From 7e81ac37c3060066b4014e32f90825b6b07255e5 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 05:10:21 +0800 Subject: [PATCH 1/3] perf(native): incremental forest roots and indexed child access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - root_ids now reads an incrementally maintained roots set instead of folding every node + probing each parent per call — a paint over a 100k-node store no longer pays O(n) just to enumerate roots. - child_count / child_id_at / child_index / children_range expose indexed slices of the child vector so virtualized containers can materialize a mounted window without building the full child list. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_store/lui_store.ml | 65 +++++++++++++++++++++---- platform/native/lui_store/lui_store.mli | 17 +++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/platform/native/lui_store/lui_store.ml b/platform/native/lui_store/lui_store.ml index 8b350525..b9b8d6bc 100644 --- a/platform/native/lui_store/lui_store.ml +++ b/platform/native/lui_store/lui_store.ml @@ -70,13 +70,21 @@ type node = { 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, unit) Hashtbl.t; mutable generation : int; mutable revision : int; } let create () = - { nodes = Hashtbl.create 64; dirty = Hashtbl.create 64; generation = 0; revision = 0 } + { nodes = Hashtbl.create 64; + roots = Hashtbl.create 8; + dirty = Hashtbl.create 64; + generation = 0; + revision = 0 } let node_count t = Hashtbl.length t.nodes let generation t = t.generation @@ -119,6 +127,7 @@ let rec drop t id = | None -> ()) | None -> ()); Hashtbl.remove t.nodes id; + Hashtbl.remove t.roots id; mark_dirty t id (* Insert child at index within parent, detaching it from its previous @@ -141,6 +150,7 @@ let insert_child t parent child index = | None -> ()); vec_insert_at p.children index child; c.parent <- Some parent; + Hashtbl.remove t.roots child; mark_dirty t parent; mark_dirty t child | _ -> () @@ -152,12 +162,14 @@ let apply_op t = function the new node starts with an empty child_vec — 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 t id | CreateExtension (id, identifier, _fp) -> if Hashtbl.mem t.nodes id then drop t id; 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 t id | DropNode id | DetachSubtree id -> drop t id | SetProp (id, p, v) -> ( @@ -191,6 +203,7 @@ let apply_op t = function | Some p, Some c -> vec_remove p.children child; c.parent <- None; + Hashtbl.replace t.roots child (); mark_dirty t parent; mark_dirty t child | _ -> ()) @@ -217,6 +230,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 -> @@ -234,6 +248,42 @@ let child_ids t id = | Some n -> vec_to_list n.children | None -> [] +(* Indexed access for virtualized containers: a virtual list may hold + 100k logical children whose materialized list a caller must not pay + for when it only wants a window slice. *) +let child_count t id = + match find_opt t id with Some n -> n.children.len | None -> 0 + +let child_id_at t id i = + match find_opt t id with + | Some n when i >= 0 && i < n.children.len -> n.children.arr.(i) + | _ -> -1 + +let child_index t id child = + match find_opt t id with + | Some n -> + let rec go i = + if i >= n.children.len then -1 + else if n.children.arr.(i) = child then i + else go (i + 1) + in + go 0 + | None -> -1 + +let children_range t id ~first ~last = + match find_opt t id with + | Some n -> + let first = max 0 first and last = min last n.children.len in + let rec go i acc = + if i < first then acc + else + match Hashtbl.find_opt t.nodes n.children.arr.(i) with + | Some c -> go (i - 1) (c :: acc) + | None -> go (i - 1) acc + in + go (last - 1) [] + | None -> [] + let last_child_id t id = match find_opt t id with | Some n -> vec_last n.children @@ -253,15 +303,12 @@ let rec depth t id = | Some { parent = Some p; _ } -> 1 + depth t p | _ -> 0 -(* Ids whose parent chain reaches no stored node: the forest roots. *) +(* Ids whose parent chain reaches no stored node: the forest roots. + 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 = - 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 [] + Hashtbl.fold (fun id () acc -> id :: acc) t.roots [] |> List.sort compare (* Preorder ids under [root] (or the whole forest when None). *) diff --git a/platform/native/lui_store/lui_store.mli b/platform/native/lui_store/lui_store.mli index dbc6670b..db764edc 100644 --- a/platform/native/lui_store/lui_store.mli +++ b/platform/native/lui_store/lui_store.mli @@ -35,6 +35,23 @@ 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 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. O(1); materializes nothing. *) + +val child_id_at : t -> int -> int -> int + +(** [child_index t id child] is [child]'s position among [id]'s + children, or [-1]. O(child count). *) + +val child_index : t -> int -> int -> int + +(** [children_range t id ~first ~last] is the node list of children at + indices \[first, last), clamped to bounds. Only the slice is + materialized — the accessor a virtualized container pays for. *) + +val children_range : t -> int -> first:int -> last:int -> node list 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 From 5393c3252561a212837a86a37b0d2220bcde56fd Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 05:10:26 +0800 Subject: [PATCH 2/3] =?UTF-8?q?perf(native):=20real=20virtualized=20lists?= =?UTF-8?q?=20=E2=80=94=20mounted=20window=20drives=20layout,=20paint=20an?= =?UTF-8?q?d=20hit-test=20(Milestone=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A virtual-list's store children stay the logical model; the layout engine materializes flex trees only for the mounted window of item indices (visible range + overscan): - Lui_layout: per-list extent model (Fenwick tree over stride = measured extent + gap) giving O(log n) index<->offset; the list is a leaf in the main mirror whose intrinsic main size is the virtual content extent; mounted row subtrees live in a side forest and get composed absolute rects in t.rects. Sync feeds a ?vport accessor (content-space visible range per list); on a memo hit only the rows that slid are remounted — scroll frames pay no mirror rebuild. - Config rides data-attrs (no schema change): data-item-extent, data-overscan, data-track-visible-range, data-scroll-target/ token/anchor. - Lui_paint: hooks.mounted_range slices the child walk to the mounted window; the paint pass no longer touches unmounted rows. - Lui_host: forwards the vport feed into every engine sync and exposes the virtual-list query surface (mounted range, virtual extent, item rects incl. unmounted indices, key/id -> index). - Ui/main: hit-test mirror and scroll caps slice to the mounted window and span the virtual extent; scroll-target resolves keys through the model so unmounted rows are reachable; visible-range reports answer via the fed viewport + binary search; the scrollable-id list is memoized on store revision so populate does not re-walk all rows per scroll frame. - Lui_elements.virtual_list gains ?overscan / ?item_extent. Tests: layout window mount/move/measured/gap/unwindowed, paint mounted-range slicing, Ui vlist scroll-request. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_gallery/lui_gallery.ml | 1 + platform/native/lui_host/lui_host.ml | 73 +- platform/native/lui_host/lui_host.mli | 31 + platform/native/lui_layout/lui_layout.ml | 684 +++++++++++++++++- platform/native/lui_layout/lui_layout.mli | 69 +- .../native/lui_layout/test/lui_layout_test.ml | 138 +++- platform/native/lui_paint/lui_paint.ml | 21 +- .../native/lui_paint/test/lui_paint_test.ml | 26 +- platform/native/lui_window/lui_window.ml | 112 ++- platform/native/lui_window/lui_window.mli | 14 + platform/native/lui_window/main.ml | 138 +++- .../native/lui_window/test/lui_window_test.ml | 74 +- platform/native/lui_window_linux/main.ml | 1 + .../test/lui_window_linux_test.ml | 1 + platform/native/lui_window_windows/main.ml | 1 + .../test/lui_window_windows_test.ml | 1 + src/lui_elements.ml | 20 +- src/lui_elements.mli | 8 + 18 files changed, 1366 insertions(+), 47 deletions(-) diff --git a/platform/native/lui_gallery/lui_gallery.ml b/platform/native/lui_gallery/lui_gallery.ml index 235d462b..9df2f3c0 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_range = (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 ec3f5c55..fe1e830b 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 @@ -34,6 +35,16 @@ module type LAYOUT_ENGINE = sig val layout_hook : t -> int -> Lui_paint.placement 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 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 end (* An instantiated engine: what (module LAYOUT_ENGINE) becomes once the @@ -42,10 +53,18 @@ 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; + 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; } type t = { @@ -63,11 +82,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 @@ -75,20 +98,28 @@ 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 } + lrect = L.rect engine; + llists = (fun () -> L.vlists engine); + lrange = (fun id -> L.mounted_range 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) } 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; hidden = (fun _ -> false); - ctx_anchor = (fun () -> None) } + ctx_anchor = (fun () -> None); + vport = (fun _ -> None) } let store h = h.store let scene h = h.scene @@ -102,6 +133,36 @@ 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_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 @@ -134,7 +195,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 diff --git a/platform/native/lui_host/lui_host.mli b/platform/native/lui_host/lui_host.mli index e33204c3..99d7b929 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 @@ -38,6 +39,16 @@ module type LAYOUT_ENGINE = sig val layout_hook : t -> int -> Lui_paint.placement 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 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 end type t @@ -112,4 +123,24 @@ 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_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_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index f9d7b909..78815a90 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -17,10 +17,78 @@ 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 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 *) +} + +(* 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; +} + type t = { mutable root : Lui_flex.node option; 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 *) mutable extent : float * float; (* frame extent the last layout used *) (* Memo of the inputs the current mirror+rects reflect. [synced_rev] is the store revision covered; -1 forces a rebuild. Function @@ -39,7 +107,10 @@ type t = { let create () = { root = None; nodes = Hashtbl.create 64; + applied = Hashtbl.create 64; rects = Hashtbl.create 64; + vlists = Hashtbl.create 4; + vctx = None; extent = (0., 0.); synced_rev = -1; synced_scale = 0.; @@ -583,6 +654,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" @@ -623,8 +712,232 @@ type ctx = { applied : (int, Lui_flex.style) Hashtbl.t; (* store id → applied style *) grids : (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-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; + 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; + } + 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) = + 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 Lui_store.child_id_at store v.vid 0 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 + (* [build] expands a store node into flex nodes — a singleton list for normal kinds, [] for display:none, and the lifted children for display:contents. [index] is the flattened sibling position. *) @@ -638,6 +951,18 @@ let rec build ctx store ~pkind ~pid ~index id : Lui_flex.node list = |> adjust ~pkind ~pid ~index store id in let kind = Lui_store.kind store id in + if kind = "virtual-list" && vlist_vertical store id then begin + (* Real virtualization: the list is a leaf in this mirror — only + the mounted row window gets flex trees (side forest). *) + let v = vlist_state ctx id in + vlist_configure 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; + [ node ] + end + else let cids = ordered_child_ids store id kind in let kids = build_seq ctx store ~pkind:kind ~pid:id ~first:0 cids in let text = text_of store id in @@ -720,24 +1045,6 @@ and build_seq ctx store ~pkind ~pid ~first ids : Lui_flex.node list = 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 - (* Reverse lookup: store id that produced [fnode] (physical equality — the mirror is rebuilt per sync and discarded right after). *) let store_id_of_flex ctx fnode = @@ -1073,6 +1380,243 @@ let layout_frame root ~width ~height : float * float = Lui_flex.compute_layout root ~width:ew ~height:eh (); (ew, eh) +(* ---------- 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) + +(* 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 -> + List.iter (fun id -> Hashtbl.remove v.nodes id) row.rids; + List.iter (fun id -> Hashtbl.remove t.rects id) row.rids; + Hashtbl.remove v.rows i + | None -> ()) + !stale; + for i = first to last - 1 do + if not (Hashtbl.mem v.rows i) then begin + let cid = Lui_store.child_id_at store v.vid i 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. *) + let rctx = + { ctx with + nodes = v.nodes; + applied = Hashtbl.create 8; + grids = ref []; + popups = ref [] } + in + let kids = + build rctx store ~pkind:"virtual-list" ~pid:v.vid ~index:i cid + 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); + 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) = + List.iter (fun id -> 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 + Hashtbl.replace t.rects id + { + Lui_scene.x = v.origin_cross +. (r.x -. rr.x); + y = ry +. (r.y -. rr.y); + w = r.w; + h = r.h; + }; + ids := id :: !ids + | None -> ()) + row.rids) + v.rows; + v.mounted_ids <- !ids + +let vlists_collect (t : t) = + Hashtbl.iter (fun _ v -> collect_vlist t v) t.vlists + +(* Full-sync pass: prune dead lists, learn geometry, mount the fed + windows, and repatch leaf extents when measurements moved them. *) +let vlists_prepare (t : t) ctx ~vport ~width ~height = + let store = ctx.store in + Hashtbl.filter_map_inplace + (fun id v -> + if Lui_store.kind store id = "virtual-list" && vlist_vertical store id + then Some v + else None) + t.vlists; + if Hashtbl.length t.vlists > 0 then begin + (* Row subtrees were built under the previous sync's context — + props, scale and viewport may all have changed, so the mounted + forest rebuilds from scratch against the fresh ctx. *) + Hashtbl.iter + (fun _ (v : vlist) -> + Hashtbl.reset v.rows; + Hashtbl.reset v.nodes) + t.vlists; + Hashtbl.iter (fun _ v -> vlist_geometry ctx v) t.vlists; + ignore (vlists_mount t ctx store ~vport); + if vlists_patch t ctx then begin + (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 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. *) +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; + nodes = t.nodes; + applied = t.applied; + grids = ref []; + popups = ref []; + vlists = t.vlists } + +(* 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 -> () @@ -1084,7 +1628,7 @@ let compute t ~width ~height = invalidate 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 rev = Lui_store.revision store in (* The store bumps its revision on every mutation, so a matching key means the rebuild below would produce an identical mirror — @@ -1097,10 +1641,12 @@ let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) || (not (t.synced_hidden == hidden)) then begin Hashtbl.reset t.nodes; + Hashtbl.reset t.applied; let ctx = { scale; vw = width; vh = height; tm = measure; hidden; ctx_anchor; store; nodes = t.nodes; - applied = Hashtbl.create 64; grids = ref []; popups = ref [] } + applied = t.applied; grids = ref []; popups = ref []; + vlists = t.vlists } in let kids = build_seq ctx store ~pkind:"frame" ~pid:(-1) ~first:0 @@ -1116,15 +1662,111 @@ let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) (if !(ctx.grids) <> [] || !(ctx.popups) <> [] then Lui_flex.compute_layout root ~width:ew ~height:eh ()); t.extent <- (ew, eh); + vlists_prepare t ctx ~vport ~width ~height; collect t; + vlists_collect t; t.synced_rev <- rev; t.synced_scale <- scale; t.synced_w <- width; 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 + else + (* Memo-hit: only the fed viewports may have moved — remount the + virtual-list windows that slid instead of rebuilding anything. *) + vlists_update t ~vport + +(* ---------- 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 + +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 = Lui_store.child_id_at store id 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 = Lui_store.child_id_at store id 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 diff --git a/platform/native/lui_layout/lui_layout.mli b/platform/native/lui_layout/lui_layout.mli index 8db0f82f..f7adb927 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 @@ -85,13 +93,72 @@ val measure_of : store's mutation revision (bumped by every applied op), 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. *) + that only 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 + +(** [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 41d2e5ee..6773ce01 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -329,6 +329,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) + let () = run "lui_layout" [ ("layout", @@ -347,4 +478,9 @@ let () = test_case "popup centered" `Quick test_popup_default_centered; 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 "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 ]) ] diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index f013fdbb..043d9357 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_range : int -> (int * int) option; + (* virtualized container id → mounted child-index window + [first, last): 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_range = (fun _ -> None); text_ops = (fun _ _ _ _ -> []); image_of = (fun _ -> None); image_in = (fun _ _ -> None); @@ -1963,8 +1969,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 @@ -1975,6 +1980,16 @@ let ordered_children store id = kids else kids +let ordered_children store id = z_ordered store (Lui_store.children store id) + +(* The children paint walks: a mounted window slice for virtualized + containers, the full ordered list otherwise. *) +let paint_children ctx store id = + match ctx.hooks.mounted_range id with + | Some (first, last) -> + z_ordered store (Lui_store.children_range store id ~first ~last) + | None -> ordered_children store id + let node_shown store id = (match Lui_store.prop store id "visible" with | Some (Lui_protocol.BoolValue false) -> false @@ -2131,7 +2146,7 @@ and paint_node_body ctx store scene id kind parent_r alpha = (fun c -> paint_node ctx store scene (Lui_store.node_id c) (Lui_store.node_kind c) child_parent alpha) - (ordered_children store id); + (paint_children ctx store id); if scrollable kind store id && not contents then emit scene (scrollbar_ops ctx store id r alpha); if clip then emit1 scene Pop_clip diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index 41c5160d..d3a19be1 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_range 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_range = + (fun id -> if id = 1 then Some (2, 5) 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 @@ -624,7 +647,8 @@ let () = [ test_case "opacity multiply" `Quick test_opacity_multiply; 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 "display contents" `Quick test_display_contents; + test_case "mounted range" `Quick test_mounted_range ]); ("placement", [ test_case "absolute" `Quick test_position_absolute; test_case "relative" `Quick test_position_relative; diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 2958487c..cf886693 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -657,6 +657,18 @@ module Ui = struct (Lui_store.t -> int -> string -> lx:float -> ly:float -> lw:float -> lh:float -> int) option; mutable content_rect : int -> rect; + (* 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. *) + mutable vlist_hook : vlist_hook option; + } + + and vlist_hook = { + 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; } let create () = @@ -684,13 +696,16 @@ module Ui = struct measure = (fun _store _id s -> (8. *. float (String.length s), 16.)); hit_text = None; - content_rect = (fun _ -> rect 0. 0. 0. 0.) } + content_rect = (fun _ -> rect 0. 0. 0. 0.); + vlist_hook = None } let set_scale t s = t.scale <- s let set_measure t f = t.measure <- f let set_hit_text t f = t.hit_text <- 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 let hovered t = t.hovered let pressed t = t.pressed let focused t = t.focused @@ -1267,6 +1282,46 @@ module Ui = struct Hashtbl.replace t.vrange id (f, l); [ Dispatch (VisibleRange (id, f, l)) ]) end + else if 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 (t.vlist_hook, 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, t.rects_epoch) in + (match Hashtbl.find_opt t.vrange_key id with + | Some k when k = key -> [] + | _ -> + Hashtbl.replace t.vrange_key id key; + let cr = t.content_rect id in + if rect_empty cr then [] + 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 Hashtbl.find_opt t.vrange id with + | Some (f', l') when f' = f && l' = l -> [] + | _ -> + Hashtbl.replace t.vrange id (f, l); + [ Dispatch (VisibleRange (id, f, l)) ] + end) + | _ -> []) + | _ -> [] else [] let scroll_set ?(axis = Y) t store id off = @@ -1610,7 +1665,8 @@ module Ui = struct (* scroll-target requests on list containers *) List.iter (fun id -> - if kind_name store id = "list" then + let k = kind_name store id in + if k = "list" then match Lui_store.int_prop store id "scroll-token" with | Some tok -> (match Hashtbl.find_opt t.scroll_token id with @@ -1632,7 +1688,50 @@ module Ui = struct push (Dispatch (ScrollCompleted (id, tok, "succeeded"))) | None -> push (Dispatch (ScrollCompleted (id, tok, "missing-target"))))) - | None -> ()) + | None -> () + else if k = "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 + (data_attr store id "data-scroll-token") + (fun s -> int_of_string_opt (String.trim s)), + data_attr store id "data-scroll-target", + t.vlist_hook ) + with + | Some tok, Some target, Some vh when target <> "" -> ( + match Hashtbl.find_opt t.scroll_token id with + | Some prev when prev = tok -> () + | _ -> + Hashtbl.replace t.scroll_token id tok; + (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 + data_attr store id "data-scroll-anchor" + with + | Some a -> a + | None -> "" + in + acts := + List.rev_append + (scroll_align t store id cid anchor) !acts; + push + (Dispatch (ScrollCompleted (id, tok, "succeeded"))) + end + else + push + (Dispatch + (ScrollCompleted (id, tok, "missing-target"))) + | None -> + push + (Dispatch + (ScrollCompleted (id, tok, "missing-target"))))) + | _ -> ()) (Lui_store.preorder store); (* The tracked-list ids are store-derived too — refreshed under the same gate so the per-sync report loop below only walks @@ -1640,8 +1739,11 @@ module Ui = struct t.vrange_lists <- List.filter (fun id -> - kind_name store id = "list" - && bool_prop store id "track-visible-range") + let k = kind_name store id in + (k = "list" && bool_prop store id "track-visible-range") + || (k = "virtual-list" + && data_attr store id "data-track-visible-range" + = Some "true")) (Lui_store.preorder store) end; List.iter diff --git a/platform/native/lui_window/lui_window.mli b/platform/native/lui_window/lui_window.mli index 295d9834..c86450b3 100644 --- a/platform/native/lui_window/lui_window.mli +++ b/platform/native/lui_window/lui_window.mli @@ -283,6 +283,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/main.ml b/platform/native/lui_window/main.ml index 5d5ef493..cbee7a19 100644 --- a/platform/native/lui_window/main.ml +++ b/platform/native/lui_window/main.ml @@ -754,6 +754,40 @@ let open_window ~title ~idx = (b.Lui_scene.y +. b.Lui_scene.h) -. y)) 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 = 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; @@ -764,14 +798,29 @@ let open_window ~title ~idx = match !host_cell with | Some h -> Ui.state_of ui (Lui_host.store h) id | None -> Lui_paint.state_neutral); + mounted_range = + (fun id -> + match !host_cell with + | Some h -> Lui_host.layout_mounted_range 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; @@ -875,11 +924,19 @@ let open_window ~title ~idx = | 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. *) + 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 @@ -921,27 +978,61 @@ let open_window ~title ~idx = 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_range host id with + | Some (first, last) -> + let rec go i acc = + if i < first then acc + else + let cid = Lui_store.child_id_at store id i in + go (i - 1) (if cid >= 0 then cid :: acc else acc) + in + go (last - 1) [] + | None -> Lui_store.child_ids store id + in List.iter (fun c -> visit c ~sx:sx' ~sy:sy' ~anc_clip:anc_clip' ~parent_r:child_parent) - (Lui_store.child_ids store id) + cids in let everything = Lui_scene.rect (-.1e9) (-.1e9) 2e9 2e9 in List.iter (fun id -> visit id ~sx:0. ~sy:0. ~anc_clip:everything ~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 in @@ -975,7 +1066,40 @@ let open_window ~title ~idx = Ui.set_hit_text ui (fun _store id text ~lx ~ly ~lw ~lh -> Lui_window_text.hit_text text_engine id text ~lx ~ly ~lw ~lh); - Ui.set_content_rect ui engine_rect; + (* Virtual lists: expose the extent model to Ui (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); (* The a11y forest mirrors the store: synced after every repaint, focused along with the Ui focus, dumped on request and at quit. *) let a11y = Lui_a11y.of_store store in diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index ba6e9393..fddec860 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -1443,6 +1443,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 @@ -1759,7 +1829,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/lui_window_linux/main.ml b/platform/native/lui_window_linux/main.ml index 9889a470..05495212 100644 --- a/platform/native/lui_window_linux/main.ml +++ b/platform/native/lui_window_linux/main.ml @@ -435,6 +435,7 @@ let () = | Some h -> Ui.state_of ui (Lui_host.store h) id | None -> Lui_paint.state_neutral); scroll_of = (fun _ -> None); + mounted_range = (fun _ -> None); text_ops = (fun id r fg s -> let ops = Lui_window_text.text_ops text_engine id r fg s in diff --git a/platform/native/lui_window_linux/test/lui_window_linux_test.ml b/platform/native/lui_window_linux/test/lui_window_linux_test.ml index 96e4df79..6d4851d8 100644 --- a/platform/native/lui_window_linux/test/lui_window_linux_test.ml +++ b/platform/native/lui_window_linux/test/lui_window_linux_test.ml @@ -234,6 +234,7 @@ let host_with_app () = | Some h -> Ui.state_of ui (Lui_host.store h) id | None -> Lui_paint.state_neutral); scroll_of = (fun _ -> None); + mounted_range = (fun _ -> None); text_ops = (fun id r fg s -> Lui_window_text.text_ops text_engine id r fg s); diff --git a/platform/native/lui_window_windows/main.ml b/platform/native/lui_window_windows/main.ml index 611292f9..194d426f 100644 --- a/platform/native/lui_window_windows/main.ml +++ b/platform/native/lui_window_windows/main.ml @@ -263,6 +263,7 @@ let () = | Some h -> Ui.state_of ui (Lui_host.store h) id | None -> Lui_paint.state_neutral); scroll_of = (fun _ -> None); + mounted_range = (fun _ -> None); text_ops = (fun id r fg s -> let ops = Lui_window_text.text_ops text_engine id r fg s in diff --git a/platform/native/lui_window_windows/test/lui_window_windows_test.ml b/platform/native/lui_window_windows/test/lui_window_windows_test.ml index d7a56e04..588779b4 100644 --- a/platform/native/lui_window_windows/test/lui_window_windows_test.ml +++ b/platform/native/lui_window_windows/test/lui_window_windows_test.ml @@ -306,6 +306,7 @@ let host_with_app () = | Some h -> Ui.state_of ui (Lui_host.store h) id | None -> Lui_paint.state_neutral); scroll_of = (fun _ -> None); + mounted_range = (fun _ -> None); text_ops = (fun id r fg s -> Lui_window_text.text_ops text_engine id r fg s); diff --git a/src/lui_elements.ml b/src/lui_elements.ml index a1b182fb..c658d8fb 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -862,9 +862,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 fd7e3b20..bb0c7019 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -977,8 +977,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 -> From e4e2d5a2b8cf09d6cb0173a34a7bfdaa1f44cf9f Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 05:10:29 +0800 Subject: [PATCH 3/3] perf(native): feed vlist viewports in perf harness, assert mounted bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Harness feeds each virtual-list's content-space visible range into layout sync and wires mounted_range into paint hooks — the same composition the window driver makes. - vlist scenarios report "mounted rows"; the perf test asserts the milestone bound (10k rows mount <= 200). Results: vlist-100k scroll frame p50 = 7.3ms (paint 0.13ms), mounted rows = 38 at 10k/50k/100k alike. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/perf/harness.ml | 35 +++++++++++++++++++++++++-- platform/native/perf/lui_perf_test.ml | 5 ++++ platform/native/perf/scenarios.ml | 3 +++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/platform/native/perf/harness.ml b/platform/native/perf/harness.ml index b5e86c28..e543c976 100644 --- a/platform/native/perf/harness.ml +++ b/platform/native/perf/harness.ml @@ -244,11 +244,15 @@ let visible_children t ~root ~clip = 0 let paint_hooks t = + let mounted_range = Lui_layout.mounted_range 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_range } else { t.hooks with + mounted_range; layout = (fun id -> let p = Lui_layout.layout_hook t.engine id in @@ -259,6 +263,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 = @@ -267,7 +298,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 62a81bf3..546f54eb 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"; ]