diff --git a/platform/native/lui_host/lui_host.ml b/platform/native/lui_host/lui_host.ml index 156d4aa1..4664414b 100644 --- a/platform/native/lui_host/lui_host.ml +++ b/platform/native/lui_host/lui_host.ml @@ -100,6 +100,11 @@ 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; + (* Bumped by every host-side poke that can change layout output + without a store revision — [invalidate_layout], [resize], + [set_hidden], [set_ctx_anchor]. Epoch keys mixing it in stay + honest across external measure/visibility changes. *) + mutable inv_count : int; (* Virtual-list viewport feed: node id → content-space visible range (start, extent) evaluated during every layout sync. *) mutable vport : int -> (float * float) option; @@ -142,7 +147,7 @@ let create ~hooks ?layout ?(measure = no_measure) ~renderer ~width ~height ~scal paint_hints = Hashtbl.create 16; moved_hints = Hashtbl.create 16; hidden = (fun _ -> false); - ctx_anchor = (fun () -> None); + ctx_anchor = (fun () -> None); inv_count = 0; vport = (fun _ -> None) } let store h = h.store @@ -151,11 +156,13 @@ let wants_repaint h = h.dirty let set_hidden h pred = h.hidden <- (match pred with Some p -> p | None -> fun _ -> false); + h.inv_count <- h.inv_count + 1; h.dirty <- true let set_ctx_anchor h accessor = h.ctx_anchor <- - (match accessor with Some f -> f | None -> fun () -> None) + (match accessor with Some f -> f | None -> fun () -> None); + h.inv_count <- h.inv_count + 1 (* Install the virtual-list viewport feed: [f id] reports the node's content-space visible range (start, extent, device px) — scroll @@ -200,8 +207,11 @@ let layout_take_drift h id = stable identity — e.g. a font stack that finished loading — needs this poke to be seen. *) let invalidate_layout h = + h.inv_count <- h.inv_count + 1; match h.layout with Some l -> l.linvalidate () | None -> () +let invalidation_count h = h.inv_count + (* Host-side paint inputs the store cannot see (interaction state, images that finished decoding): the next repaint re-derives these fragments instead of replaying stale ops. *) @@ -226,7 +236,11 @@ let apply_batch h (batch : Lui_protocol.patch_batch) = true let resize h ~width ~height ~scale = - h.width <- width; h.height <- height; h.scale <- scale; h.dirty <- true + h.width <- width; + h.height <- height; + h.scale <- scale; + h.inv_count <- h.inv_count + 1; + h.dirty <- true let prof_t0 = Sys.time () let prof msg = diff --git a/platform/native/lui_host/lui_host.mli b/platform/native/lui_host/lui_host.mli index 6cd96362..f1b80b03 100644 --- a/platform/native/lui_host/lui_host.mli +++ b/platform/native/lui_host/lui_host.mli @@ -119,6 +119,12 @@ val invalidate_layout : t -> unit keys on, and sync always runs between batches, so the memo can never observe a half-applied tree. *) +val invalidation_count : t -> int +(** Monotonic counter bumped by every host-side input that can move + layout output without a store revision: {!invalidate_layout}, + {!resize}, {!set_hidden}, {!set_ctx_anchor}. Epoch keys that must + stay honest about resolved geometry mix it in. *) + val note_paint : t -> int -> unit (** [note_paint h id] marks a node whose host-side paint inputs changed outside the store — interaction state the hooks report, diff --git a/platform/native/lui_input/dune b/platform/native/lui_input/dune index 113e3367..64b75c08 100644 --- a/platform/native/lui_input/dune +++ b/platform/native/lui_input/dune @@ -3,5 +3,5 @@ (public_name lui.input) (modules lui_input lui_query lui_pointer lui_gesture lui_focus lui_selection lui_scroll lui_drag lui_overlay lui_text_input) - (libraries lui lui_store lui_scene lui_ime lui_paint) + (libraries lui lui_store lui_scene lui_ime lui_paint lui_spatial) (wrapped false)) diff --git a/platform/native/lui_input/lui_query.ml b/platform/native/lui_input/lui_query.ml index eed3f259..7ca05438 100644 --- a/platform/native/lui_input/lui_query.ml +++ b/platform/native/lui_input/lui_query.ml @@ -139,8 +139,13 @@ let hit_flags_of store id = display:contents pass-through, and the clip context computed with [Lui_paint.clips]'s exact rule. [rects] carries the driver's resolved boxes — fed back as placement overrides so the walk - shares the stacking/clip result without re-resolving geometry. *) -let hit_path store rects ~x ~y = + shares the stacking/clip result without re-resolving geometry. + [?spatial] is the subtree-bound index built over that same mirror + ([Lui_spatial.build] with [~rect_of:(Rects.get rects)]): with it, + the walk descends only toward subtrees that could contain the + point instead of visiting every node — O(log n + path) instead of + O(n) per pointer event. *) +let hit_path ?spatial store rects ~x ~y = let path = ref [] in (* pointer-events accumulate down the tree; the ghost pass threads the same [on] update so a promoted popup keeps its clipped-away @@ -171,13 +176,15 @@ let hit_path store rects ~x ~y = ~enter ~ghost_enter:(fun on id _contents -> flags_or on (hit_flags_of store id)) - ~leave:(fun _ _ -> ()) true; + ~leave:(fun _ _ -> ()) + ?spatial:(Option.map (fun i -> (i, Lui_spatial.Point (x, y))) spatial) + true; (* The head is the node visited last in paint order: visually topmost. *) !path -let hit store rects ~x ~y = - match hit_path store rects ~x ~y with +let hit ?spatial store rects ~x ~y = + match hit_path ?spatial store rects ~x ~y with | id :: _ -> Some id | [] -> None diff --git a/platform/native/lui_input/test/dune b/platform/native/lui_input/test/dune index 2faae869..3ff42c09 100644 --- a/platform/native/lui_input/test/dune +++ b/platform/native/lui_input/test/dune @@ -1,4 +1,4 @@ (test (name lui_input_test) (modules lui_input_test) - (libraries lui_input lui lui_store lui_scene lui_ime alcotest)) + (libraries lui_input lui lui_store lui_scene lui_ime lui_paint lui_spatial alcotest)) diff --git a/platform/native/lui_input/test/lui_input_test.ml b/platform/native/lui_input/test/lui_input_test.ml index 6f9efd9b..fbbdc0d8 100644 --- a/platform/native/lui_input/test/lui_input_test.ml +++ b/platform/native/lui_input/test/lui_input_test.ml @@ -536,6 +536,95 @@ let test_pointer_hover_press () = (function `Dispatch (PointerUp (2, _)) -> true | _ -> false) m2) +(* ---------- spatial index (P2-2) ---------- *) + +(* A tree exercising everything hit-testing must agree on: a scroll + clip with clipped-away children, a popover promoted out of that + clip, a wide list exercising the interval index, display:contents, + a hidden subtree, a spacer and z-reordered overlap. *) +let spatial_hit_store () = + store_of + ([ CreateNode (1, Root); CreateNode (2, Scroll); + CreateNode (3, Button); + CreateNode (4, Column); CreateNode (50, Popover); + CreateNode (7, ListContainer); + CreateNode (8, Column); CreateNode (9, Button); + CreateNode (10, Button); CreateNode (11, Spacer); + CreateNode (12, Button); CreateNode (13, Button); + SetProp (8, DisplayValue, StringValue "contents"); + SetProp (10, Visible, BoolValue false); + SetProp (12, ZIndex, IntValue 1); + SetProp (13, ZIndex, IntValue 2); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); InsertChild (2, 4, 1); + InsertChild (4, 50, 0); + InsertChild (1, 7, 1); + InsertChild (1, 8, 2); InsertChild (8, 9, 0); + InsertChild (1, 10, 3); InsertChild (1, 11, 4); + InsertChild (1, 12, 5); InsertChild (1, 13, 6) ] + @ List.concat_map + (fun i -> + [ CreateNode (100 + i, ListItem); + SetProp (100 + i, TextValue, StringValue "row"); + InsertChild (7, 100 + i, i) ]) + (List.init 40 Fun.id)) + +let spatial_hit_rects = + [ (1, rect 0. 0. 300. 200.); (2, rect 0. 0. 100. 80.); + (3, rect 0. 0. 100. 40.); (4, rect 0. 120. 100. 40.); + (50, rect 0. 120. 60. 20.); (7, rect 100. 0. 200. 200.); + (8, rect 100. 60. 100. 20.); (9, rect 100. 60. 100. 20.); + (10, rect 150. 100. 50. 50.); (11, rect 0. 90. 100. 10.); + (12, rect 150. 150. 50. 40.); (13, rect 150. 150. 50. 40.) ] + @ List.init 40 (fun i -> + (100 + i, rect 100. (float_of_int i *. 5.) 200. 5.)) + +let spatial_index store rects = + Lui_spatial.build Lui_paint.spatial_env store + ~rect_of:(Lui_query.Rects.get rects) + +(* Indexed and unindexed hit paths must agree on every point. *) +let test_spatial_hit_equiv () = + let store = spatial_hit_store () in + let rects = rects_of spatial_hit_rects in + let idx = spatial_index store rects in + let y = ref (-10.) in + while !y < 210. do + let x = ref (-10.) in + while !x < 310. do + let a = Lui_query.hit_path store rects ~x:!x ~y:!y in + let b = Lui_query.hit_path ~spatial:idx store rects ~x:!x ~y:!y in + if a <> b then + Alcotest.failf "hit_path diverged at (%.1f,%.1f): %s vs %s" !x !y + (String.concat "," (List.map string_of_int a)) + (String.concat "," (List.map string_of_int b)); + x := !x +. 7.5 + done; + y := !y +. 7.5 + done + +(* The promoted popover escapes its off-clip parent: indexed or not, + a click in its painted area must still land on it. *) +let test_spatial_hit_popup_escape () = + let store = spatial_hit_store () in + let rects = rects_of spatial_hit_rects in + let idx = spatial_index store rects in + Alcotest.(check (option int)) "popover hits" (Some 50) + (Lui_query.hit ~spatial:idx store rects ~x:10. ~y:130.); + Alcotest.(check (option int)) "clipped child misses" (Some 1) + (Lui_query.hit ~spatial:idx store rects ~x:10. ~y:100.) + +(* The interval index enumerates the same candidates in the same + order the unindexed walk would — a click deep in a 40-row list. *) +let test_spatial_hit_wide_list () = + let store = spatial_hit_store () in + let rects = rects_of spatial_hit_rects in + let idx = spatial_index store rects in + Alcotest.(check (option int)) "row 20 hits" (Some 120) + (Lui_query.hit ~spatial:idx store rects ~x:110. ~y:102.); + Alcotest.(check (option int)) "last row hits" (Some 139) + (Lui_query.hit ~spatial:idx store rects ~x:110. ~y:197.) + let () = Alcotest.run "lui_input" [ ( "focus", @@ -575,4 +664,11 @@ let () = [ Alcotest.test_case "flow" `Quick test_drag_flow ] ); ( "pointer", [ Alcotest.test_case "hover+press" `Quick - test_pointer_hover_press ] ) ] + test_pointer_hover_press ] ); + ( "spatial", + [ Alcotest.test_case "hit equivalence" `Quick + test_spatial_hit_equiv; + Alcotest.test_case "popup escape" `Quick + test_spatial_hit_popup_escape; + Alcotest.test_case "wide list" `Quick + test_spatial_hit_wide_list ] ) ] diff --git a/platform/native/lui_paint/dune b/platform/native/lui_paint/dune index f737c712..9416c994 100644 --- a/platform/native/lui_paint/dune +++ b/platform/native/lui_paint/dune @@ -2,7 +2,7 @@ (name lui_paint) (public_name lui.paint) (modules lui_paint) - (libraries lui_store lui_scene)) + (libraries lui_store lui_scene lui_spatial)) (library (name lui_retained) diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index 1b31b33f..02cdebfe 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -2365,6 +2365,16 @@ let node_shown store id = let unboxed store id = pstr store id "display" = Some "contents" +(* The store/paint predicates a spatial index build needs, wired to + this module's own rules so index bounds always mirror the walk's + visibility and clip-escape decisions. *) +let spatial_env : Lui_spatial.env = + { shown = node_shown; + promotes = + (fun store id -> + popup_kind (Lui_store.kind store id) && not (unboxed store id)); + ordered = ordered_children } + (* ---------- shared stacking walk ---------- *) (* One frame of the tree walk paint and hit-testing share: the node's @@ -2441,9 +2451,16 @@ let frame_of ~scale ~viewport store id kind p parent_r clip = walk switches to a ghost pass that skips [enter]/[leave] and every 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 ?(mounted = fun _ -> None) ~scale ~viewport store ~layout ~enter - ~ghost_enter ~leave ctx0 = + accumulate its clipped-away ancestors). + + [?spatial] supplies a fresh [Lui_spatial] index plus the query the + walk serves ([Point] for hit-testing, [Region] for paint culling): + children whose subtree bound cannot contribute are never entered, + and wide parents enumerate candidates through the interval index + instead of scanning every child. Pruning is a strict filter on the + unindexed walk's visit set — same order, same clip contexts. *) +let walk ?(mounted = fun _ -> None) ~scale ~viewport store ~layout ~enter ~ghost_enter ~leave + ?spatial ctx0 = let deferred = Queue.create () in (* Virtualized containers walk only their mounted window slice. *) let kids id = @@ -2460,12 +2477,12 @@ let walk ?(mounted = fun _ -> None) ~scale ~viewport store ~layout ~enter (* promoted out of the ancestor clips: painted after the whole tree with a fresh clip context *) Queue.add (fun () -> body ctx None parent_r id kind) deferred - else if dead clip then ghost ctx parent_r n + else if dead clip then ghost ctx clip parent_r n else body ctx clip parent_r id kind (* The subtree sits entirely inside a dead clip: keep walking only far enough to find promoted popups (and their containing blocks), skipping every node's enter/chrome cost. *) - and ghost ctx parent_r (n : Lui_store.node) = + and ghost ctx clip parent_r (n : Lui_store.node) = let id = Lui_store.node_id n and kind = Lui_store.node_kind n in if node_shown store id then if popup_kind kind && not (unboxed store id) then @@ -2476,24 +2493,90 @@ let walk ?(mounted = fun _ -> None) ~scale ~viewport store ~layout ~enter 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) (kids id) + (* The ghost pass owes the query only promoted escapees — + [keep] answers exactly that via [eb]/[esc]. *) + List.iter + (fun cid -> + match Lui_store.find_opt store cid with + | Some c -> ghost ctx clip child_parent c + | None -> ()) + (match mounted id, spatial with + | Some _, _ -> List.map Lui_store.node_id (kids id) + | None, None -> + List.map Lui_store.node_id (ordered_children store id) + | None, Some (idx, q) -> candidates idx q clip id) end + (* Children of [id] that could contribute to the query under clip + context [clip]: the interval index for indexed parents, the + per-child [keep] test otherwise. Returns ids in paint order. *) + and candidates idx q clip id = + match Lui_spatial.children idx ~clip q id with + | Some ids -> ids + | None -> + List.filter_map + (fun c -> + let cid = Lui_store.node_id c in + if + Lui_spatial.keep idx ~clip + ~esc: + (popup_kind (Lui_store.node_kind c) + && not (unboxed store cid)) + q cid + then Some cid + else None) + (ordered_children store id) and body ctx clip parent_r id kind = let p = layout id in let f, child_clip = frame_of ~scale ~viewport store id kind p parent_r clip in let br = enter ctx f in - if br.b_down then - List.iter (go br.b_ctx child_clip f.f_parent_r) (kids id); + if br.b_down then begin + (* Mounted virtual-list slices are already window-sized; the + spatial index only prunes ordinary children. *) + match mounted id, spatial with + | Some _, _ -> List.iter (go br.b_ctx child_clip f.f_parent_r) (kids id) + | None, None -> + List.iter (go br.b_ctx child_clip f.f_parent_r) + (ordered_children store id) + | None, Some (idx, q) -> + List.iter + (fun cid -> + match Lui_store.find_opt store cid with + | Some c -> go br.b_ctx child_clip f.f_parent_r c + | None -> ()) + (candidates idx q child_clip id) + end; if br.b_emit then leave br.b_ctx f in + let roots = + (* [root_ids] folds the whole store — with an index the snapshot + already knows the roots. *) + match spatial with + | None -> Lui_store.root_ids store + | Some (idx, _) -> Lui_spatial.roots idx + in List.iter - (fun id -> go ctx0 None viewport - (match Lui_store.find_opt store id with - | Some n -> n - | None -> invalid_arg "Lui_paint.walk: root id missing")) - (Lui_store.root_ids store); + (fun id -> + let keep = + match spatial with + | None -> true + | Some (idx, q) -> + (match Lui_store.find_opt store id with + | Some n -> + Lui_spatial.keep idx ~clip:None + ~esc: + (popup_kind (Lui_store.node_kind n) + && not (unboxed store id)) + q id + | None -> false) + in + if keep then + go ctx0 None viewport + (match Lui_store.find_opt store id with + | Some n -> n + | None -> invalid_arg "Lui_paint.walk: root id missing")) + roots; (* Promoted popups drain after the tree — nested popups enqueue during the drain and preserve preorder among popups. *) while not (Queue.is_empty deferred) do @@ -2638,8 +2721,11 @@ let paint_leave ctx store scene alpha f = emit scene (scrollbar_ops ctx store f.f_id f.f_rect alpha); if f.f_clips then emit1 scene Pop_clip -(* Full repaint: reset the scene and repaint the whole forest. *) -let paint hooks store scene ~width ~height ~scale ~clear = +(* Full repaint: reset the scene and repaint the whole forest. A + [?spatial] index fresh for the geometry about to resolve lets the + walk skip subtrees whose bounds miss the viewport instead of + visiting every mounted node — see {!Lui_spatial}. *) +let paint ?spatial hooks store scene ~width ~height ~scale ~clear = Lui_scene.reset scene ~width ~height ~scale ~clear; let ctx = { hooks; scale; @@ -2650,7 +2736,10 @@ let paint hooks store scene ~width ~height ~scale ~clear = ~mounted:hooks.mounted_children ~enter:(paint_enter ctx store scene) ~ghost_enter:(paint_ghost_enter ctx store) - ~leave:(paint_leave ctx store scene) 1.; + ~leave:(paint_leave ctx store scene) + ?spatial: + (Option.map (fun i -> (i, Lui_spatial.Region ctx.viewport)) spatial) + 1.; (* Extension renderers may enqueue their own deferred work on ctx.deferred — drain it after the walk's own popup pass. *) while not (Queue.is_empty ctx.deferred) do diff --git a/platform/native/lui_paint/test/dune b/platform/native/lui_paint/test/dune index 529f1e4f..bd44e64b 100644 --- a/platform/native/lui_paint/test/dune +++ b/platform/native/lui_paint/test/dune @@ -1,7 +1,7 @@ (test (name lui_paint_test) (modules lui_paint_test) - (libraries lui_paint lui_store lui_scene lui alcotest)) + (libraries lui_paint lui_spatial lui_store lui_scene lui alcotest)) (test (name lui_retained_test) diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index 7fc22ebd..b25eff06 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -34,9 +34,11 @@ let scene () = ~mask_atlas:(Atlas.create ~bpp:1 ~w:64 ~h:64) ~color_atlas:(Atlas.create ~bpp:4 ~w:64 ~h:64) -let paint_with h t ?(width = 200) ?(height = 200) ?(scale = 1.) () = +let paint_with h t ?(width = 200) ?(height = 200) ?(scale = 1.) + ?spatial () = let s = scene () in - Lui_paint.paint h t s ~width ~height ~scale ~clear:(color 0 0 0 0); + Lui_paint.paint ?spatial h t s ~width ~height ~scale + ~clear:(color 0 0 0 0); s let test_fill_op () = @@ -811,6 +813,142 @@ let test_coverage_table () = in check bool "all props classified" true (missing = []) +(* ---------- spatial index ---------- *) + +(* Build an index over the same rects a layout-map hook hands the + walk — integral rects so [rect_snap] is the identity. *) +let spatial_of store f = + Lui_spatial.build Lui_paint.spatial_env store + ~rect_of:(fun id -> f id) + +let ops_of s = (s.ops, s.glyphs, s.wide) + +let test_spatial_paint_equiv () = + (* Everything the cull must respect at once: a scroll clip with + off-clip children, a promoted popup inside a fully offscreen + parent, a hidden subtree, a display:contents wrapper, a zero-area + spacer and z-reordered siblings. *) + let t = store () in + apply t + [ Lui_protocol.CreateNode (1, Column); + CreateNode (2, Scroll); + CreateNode (3, Column); CreateNode (4, Column); + CreateNode (5, Popover); + CreateNode (6, Column); CreateNode (7, Spacer); + CreateNode (8, Column); CreateNode (9, Column); + CreateNode (10, Column); CreateNode (11, Column); + SetProp (2, BackgroundValue, StringValue "#101010"); + SetProp (3, BackgroundValue, StringValue "#ff0000"); + SetProp (4, BackgroundValue, StringValue "#00ff00"); + SetProp (5, BackgroundValue, StringValue "#0000ff"); + SetProp (6, Visible, BoolValue false); + SetProp (6, BackgroundValue, StringValue "#ffffff"); + SetProp (8, DisplayValue, StringValue "contents"); + SetProp (9, BackgroundValue, StringValue "#ffff00"); + SetProp (10, ZIndex, IntValue 2); + SetProp (10, BackgroundValue, StringValue "#00ffff"); + SetProp (11, ZIndex, IntValue 1); + SetProp (11, BackgroundValue, StringValue "#ff00ff"); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); InsertChild (2, 4, 1); + InsertChild (4, 5, 0); + InsertChild (1, 6, 1); InsertChild (1, 7, 2); + InsertChild (1, 8, 3); InsertChild (8, 9, 0); + InsertChild (1, 10, 4); InsertChild (1, 11, 5) ]; + let rects = function + | 1 -> rect 0. 0. 200. 200. + | 2 -> rect 0. 0. 200. 100. + | 3 -> rect 0. 0. 200. 20. + | 4 -> rect 0. 300. 200. 20. (* fully offscreen + off-clip *) + | 5 -> rect 10. 50. 60. 30. (* promoted popup back onscreen *) + | 6 -> rect 0. 100. 200. 50. + | 7 -> rect 0. 150. 0. 0. + | 8 -> rect 0. 160. 200. 40. + | 9 -> rect 0. 160. 200. 40. + | 10 -> rect 0. 20. 100. 100. + | 11 -> rect 50. 20. 100. 100. + | _ -> rect 0. 0. 0. 0. + in + let layout = layout_map rects in + let idx = spatial_of t rects in + let a = paint_with (hooks ~layout ()) t () in + let b = paint_with (hooks ~layout ()) t ~spatial:idx () in + check bool "indexed paint emits identical scene" true + (ops_of a = ops_of b) + +let test_spatial_visit_bound () = + (* A 300-row column under a 100px viewport: the interval index must + visit only the band, not every mounted row. *) + let t = store () in + let ops = ref [ Lui_protocol.InsertChild (1, 2, 0); + CreateNode (2, Column); CreateNode (1, Root) ] in + for i = 0 to 299 do + ops := + InsertChild (2, 10 + i, i) + :: CreateNode (10 + i, ViewThatFits) + :: !ops + done; + apply t (List.rev !ops); + let rects = function + | 1 -> rect 0. 0. 200. 100. + | 2 -> rect 0. 0. 200. 6000. + | id -> rect 0. (float_of_int (id - 10) *. 20.) 200. 20. + in + let idx = spatial_of t rects in + let count ?spatial () = + let n = ref 0 in + Lui_paint.walk ~scale:1. ~viewport:(rect 0. 0. 200. 100.) t + ~layout:(layout_map rects) + ~enter:(fun () _ -> incr n; + { Lui_paint.b_emit = false; b_down = true; b_ctx = () }) + ~ghost_enter:(fun () _ _ -> ()) + ~leave:(fun _ _ -> ()) + ?spatial:(Option.map + (fun i -> (i, Lui_spatial.Region (rect 0. 0. 200. 100.))) + spatial) + (); + !n + in + let full = count () and pruned = count ~spatial:idx () in + check int "unindexed visits everything" 302 full; + check bool "indexed visits the band only" true (pruned <= 12) + +let test_spatial_degenerate_kept () = + (* A zero-area child inside a wide parent must still run its + emission path — the index keeps zero-bound subtrees reachable + for region queries. *) + let t = store () in + let ops = + ref [ Lui_protocol.InsertChild (1, 2, 0); + CreateNode (2, Column); CreateNode (1, Root) ] + in + for i = 0 to 39 do + ops := + InsertChild (2, 10 + i, i) + :: CreateNode (10 + i, ViewThatFits) + :: SetProp (10 + i, BackgroundValue, StringValue "#ff0000") + :: !ops + done; + (* one zero-area box at a scrolled-off position *) + ops := + InsertChild (2, 500, 40) + :: CreateNode (500, Column) + :: SetProp (500, BackgroundValue, StringValue "#00ff00") + :: !ops; + apply t (List.rev !ops); + let rects = function + | 1 -> rect 0. 0. 200. 200. + | 2 -> rect 0. 0. 200. 2000. + | 500 -> rect 0. 5000. 0. 0. + | id -> rect 0. (float_of_int (id - 10) *. 40.) 200. 40. + in + let idx = spatial_of t rects in + let a = paint_with (hooks ~layout:(layout_map rects) ()) t () in + let b = + paint_with (hooks ~layout:(layout_map rects) ()) t ~spatial:idx () + in + check bool "degenerate ops preserved" true (ops_of a = ops_of b) + let () = run "lui_paint" [ ("color", @@ -871,5 +1009,9 @@ let () = test_case "placeholder unmasked" `Quick test_secure_placeholder_unmasked; test_case "mask units" `Quick test_secure_mask_units; test_case "masked offset map" `Quick test_secure_byte_of_masked ]); + ("spatial", + [ test_case "paint equivalence" `Quick test_spatial_paint_equiv; + test_case "visit bound" `Quick test_spatial_visit_bound; + test_case "degenerate kept" `Quick test_spatial_degenerate_kept ]); ("coverage", [ test_case "all props" `Quick test_coverage_table ]) ] diff --git a/platform/native/lui_spatial/dune b/platform/native/lui_spatial/dune new file mode 100644 index 00000000..152f5768 --- /dev/null +++ b/platform/native/lui_spatial/dune @@ -0,0 +1,5 @@ +(library + (name lui_spatial) + (public_name lui.spatial) + (modules lui_spatial) + (libraries lui_store lui_scene)) diff --git a/platform/native/lui_spatial/lui_spatial.ml b/platform/native/lui_spatial/lui_spatial.ml new file mode 100644 index 00000000..1573bba9 --- /dev/null +++ b/platform/native/lui_spatial/lui_spatial.ml @@ -0,0 +1,287 @@ +(* Spatial index over resolved node rects (P2-2): per-node subtree + bounds plus per-parent interval-ordered child arrays, shared by + hit-testing and the paint pass's viewport cull. + + The index is a pure snapshot: [build] folds the store's shown tree + over a rect source (the hit mirror's resolved, scroll-shifted and + snapped rects — the same geometry [Lui_paint.walk] produces), and + the walk consumes it to skip subtrees that cannot contribute to the + query. Point queries (hit-testing) answer "which subtree could + contain this point"; region queries (paint) answer "which subtree + could intersect the viewport". Both preserve the walk's exact + semantics — z-ordered visit, promoted popups escaping ancestor + clips, display:contents pass-through, hidden subtrees skipped — so + the index records three facts per node: + + - [b]: union of the shown subtree's non-empty resolved rects. + Every clipped subtree area is a subrect of [b] ∩ clip, so a + point outside [b] (or a region disjoint from it) prunes safely. + - [eb]: the clip-escaping part of [b] — bounds of promoted popups, + which paint and hit-test above and outside their ancestors' + clip contexts. A point inside [eb] keeps the subtree alive even + when the current clip excludes it. + - [z]: the subtree contains a zero-area node. Empty rects still + run the emission path today (degenerate ops, split-pane clip + brackets), so a region query must keep such subtrees to stay + op-identical; point queries can ignore them — an empty rect + contains nothing. + + Wide parents (>= [kid_threshold] children) additionally get an + interval index: children sorted by bound top with a prefix-max + bottom table, so a query touches O(log n + candidates) siblings + instead of every child. *) + +open Lui_scene + +(** What a consumer is looking for. *) +type query = + | Point of float * float (** hit-testing: containment semantics *) + | Region of rect (** paint culling: intersection semantics *) + +(** Per-node subtree facts; [b] and [eb] may be empty rects. *) +type info = { b : rect; eb : rect; z : bool } + +(* One child inside a wide parent's interval index. [k_esc] mirrors + the walk's promotion rule ([popup_kind && not unboxed]) so queries + decide clip escape without fetching the node; [k_rank] is the + child's position in paint order so filtered results revisit + siblings exactly as the unindexed walk would. *) +type kid = { k_id : int; k_rank : int; k_esc : bool; ki : info } + +(* [ka]: children whose subtrees carry no zero-area node, sorted by + bound top — the interval-scanned array. [kmax]: prefix max over + [ka]'s bound bottoms — the scan stops once no earlier sibling can + reach the query band. [kz]: zero-subtree children, in paint order. + Region queries keep every [kz] child unconditionally (zero-area + nodes still run the emission path — clip brackets, degenerate + fills); point queries check them linearly since a zero-descendant + says nothing about the child's own bound. The split is disjoint: + a z-child in [ka] could be lost to the band cutoff a region query + must not apply to it. *) +type kids_arr = { ka : kid array; kmax : float array; kz : kid array } + +type t = { + info : (int, info) Hashtbl.t; + kids : (int, kids_arr) Hashtbl.t; + roots : int list; + (* the forest's roots, captured at build so the walk can start + without a fresh [Lui_store.root_ids] scan — the index is a + snapshot, so its roots are exactly the ones the caller should + visit. *) +} + +let empty = + { info = Hashtbl.create 8; kids = Hashtbl.create 2; roots = [] } + +let roots t = t.roots + +(* Above this child count a parent gets the sorted interval arrays; + below it the walk falls back to a per-child [keep] lookup. *) +let kid_threshold = 32 + +(** The store/paint predicates [build] needs, supplied by the caller + so this module stays free of Lui_paint dependencies: [shown] is + the walk's visibility gate, [promotes] is its clip-escape rule + (popup kind and not display:contents), [ordered] yields children + in paint order. *) +type env = { + shown : Lui_store.t -> int -> bool; + promotes : Lui_store.t -> int -> bool; + ordered : Lui_store.t -> int -> Lui_store.node list; +} + +let info t id = Hashtbl.find_opt t.info id + +(* Union accumulators: bounds fold as raw (x0,y0,x1,y1) edges so an + untouched accumulator materializes to an empty rect. *) +type acc = { + mutable x0 : float; + mutable y0 : float; + mutable x1 : float; + mutable y1 : float; +} + +let acc_empty () = + { x0 = Float.max_float; y0 = Float.max_float; + x1 = Float.min_float; y1 = Float.min_float } + +let acc_nonvoid a = a.x1 > a.x0 && a.y1 > a.y0 + +let acc_union a b = + if acc_nonvoid b then begin + if b.x0 < a.x0 then a.x0 <- b.x0; + if b.y0 < a.y0 then a.y0 <- b.y0; + if b.x1 > a.x1 then a.x1 <- b.x1; + if b.y1 > a.y1 then a.y1 <- b.y1 + end + +let acc_add a r = + if not (rect_empty r) then begin + if r.x < a.x0 then a.x0 <- r.x; + if r.y < a.y0 then a.y0 <- r.y; + if r.x +. r.w > a.x1 then a.x1 <- r.x +. r.w; + if r.y +. r.h > a.y1 then a.y1 <- r.y +. r.h + end + +let acc_rect a = + if acc_nonvoid a then rect a.x0 a.y0 (a.x1 -. a.x0) (a.y1 -. a.y0) + else rect 0. 0. 0. 0. + +(* Post-order fold over shown nodes: [b] unions every non-empty + resolved rect in the subtree, [eb] unions the bounds of children + that promote (their whole subtree escapes this node's clip + context; non-promoting children contribute only their own + escapees upward), [z] flags zero-area rects a region query must + keep visiting. *) +let build env store ~rect_of = + let roots = Lui_store.root_ids store in + let idx = + { info = Hashtbl.create 256; kids = Hashtbl.create 16; roots } + in + let rec visit id : acc * acc * bool = + if not (env.shown store id) then (acc_empty (), acc_empty (), false) + else begin + let b = acc_empty () and eb = acc_empty () in + let z = ref false in + List.iter + (fun cid -> + let cb, ceb, cz = visit cid in + acc_union b cb; + (* A promoting child's whole bound escapes the clip context + this node's children share; a non-promoting child passes + only its own escapees upward. *) + acc_union eb (if env.promotes store cid then cb else ceb); + if cz then z := true) + (Lui_store.child_ids store id); + let r = rect_of id in + acc_add b r; + let z = !z || rect_empty r in + Hashtbl.replace idx.info id { b = acc_rect b; eb = acc_rect eb; z }; + if Lui_store.child_count store id >= kid_threshold then + build_kids id; + (b, eb, z) + end + and build_kids id = + (* Paint-order positions are ranks; [ka] sorts by bound top for + the interval scan. Hidden children never entered [info], so + they drop out of both arrays. *) + let arr = + env.ordered store id + |> List.mapi (fun rank n -> (rank, Lui_store.node_id n)) + |> List.filter_map (fun (rank, cid) -> + match Hashtbl.find_opt idx.info cid with + | None -> None + | Some ki -> + Some + { k_id = cid; k_rank = rank; + k_esc = env.promotes store cid; ki }) + |> Array.of_list + in + let ka = + let a = + Array.of_list + (List.filter (fun k -> not k.ki.z) (Array.to_list arr)) + in + Array.sort (fun x c -> Float.compare x.ki.b.y c.ki.b.y) a; + a + in + let kmax = Array.make (Array.length ka) 0. in + Array.iteri + (fun i k -> + let bot = k.ki.b.y +. k.ki.b.h in + kmax.(i) <- (if i = 0 then bot else Float.max kmax.(i - 1) bot)) + ka; + let kz = Array.of_list + (List.filter (fun k -> k.ki.z) (Array.to_list arr)) in + Hashtbl.replace idx.kids id { ka; kmax; kz } + in + List.iter (fun id -> ignore (visit id)) roots; + idx + +(* The keep-test shared by the per-child fallback and the interval + arrays. [i] is the child's subtree facts; [esc] whether the child + itself promotes (its frame then runs clip-free); [clip] the clip + context its subtree is visited under. *) +let ok i ~esc ~clip q = + match q with + | Region r -> i.z || not (rect_empty (rect_intersect i.b r)) + | Point (x, y) -> + (esc && rect_contains i.b x y) + || rect_contains i.eb x y + || (rect_contains i.b x y + && + match clip with + | Some c -> rect_contains c x y + | None -> true) + +(* May the subtree of node [id] contribute to [q] under clip context + [clip]? Absent info means a hidden subtree — the walk skips it + wholesale either way. *) +let keep t ~clip ~esc q id = + match Hashtbl.find_opt t.info id with + | None -> false + | Some i -> ok i ~esc ~clip q + +(* Count of [ka] entries whose bound top precedes [ylimit] ([<] for + regions — a touching band is an empty intersection — [<=] for + points). Entries past it start below the query band. *) +let band_end (ka : kid array) ~inclusive ylimit = + let n = Array.length ka in + let rec go lo hi = + if lo >= hi then lo + else + let mid = (lo + hi) / 2 in + if ka.(mid).ki.b.y < ylimit + || (inclusive && ka.(mid).ki.b.y = ylimit) + then go (mid + 1) hi + else go lo mid + in + go 0 n + +(* Children of [pid] that can contribute to [q], in paint order. + [None] when the parent has no interval index — the caller falls + back to per-child [keep] tests. *) +let children t ~clip q pid : int list option = + match Hashtbl.find_opt t.kids pid with + | None -> None + | Some karr -> + let hits = + match q with + | Point (_, y) -> + (* Candidates need b.y <= y < b.bottom: scan the prefix of + tops <= y while the prefix-max bottom still reaches y. *) + let hi = band_end karr.ka ~inclusive:true y in + let acc = ref [] in + let rec back i = + if i >= 0 && karr.kmax.(i) > y then begin + let k = karr.ka.(i) in + if ok k.ki ~esc:k.k_esc ~clip q then acc := k :: !acc; + back (i - 1) + end + in + back (hi - 1); + Array.fold_left + (fun acc k -> if ok k.ki ~esc:k.k_esc ~clip q then k :: acc + else acc) + !acc karr.kz + | Region r -> + let hi = band_end karr.ka ~inclusive:false (r.y +. r.h) in + let acc = ref [] in + let rec back i = + if i >= 0 && karr.kmax.(i) > r.y then begin + let k = karr.ka.(i) in + if ok k.ki ~esc:k.k_esc ~clip q then acc := k :: !acc; + back (i - 1) + end + in + back (hi - 1); + (* Zero-subtree children always stay reachable for emission; + merge them into the ranked result. *) + Array.fold_left (fun acc k -> k :: acc) !acc karr.kz + in + (* Re-establish paint order: ranks sort the candidates exactly as + ordered_children would list them. *) + let sorted = + List.sort (fun a c -> compare a.k_rank c.k_rank) hits + in + Some (List.map (fun k -> k.k_id) sorted) diff --git a/platform/native/lui_window/dune b/platform/native/lui_window/dune index 0a778c52..60bdbb92 100644 --- a/platform/native/lui_window/dune +++ b/platform/native/lui_window/dune @@ -2,7 +2,7 @@ (name lui_window) (public_name lui.window) (modules lui_window) - (libraries lui lui_store lui_scene lui_paint lui_host lui_ime lui_input)) + (libraries lui lui_store lui_scene lui_paint lui_host lui_ime lui_input lui_spatial)) ; The demo app lives in its own library (not the window library) so ; tests can drive its model/action/event paths directly. diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 5926236a..091598a9 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -860,7 +860,7 @@ module Ui = struct let store_changed t store = sync t store - let handle t store rects input = + let handle ?spatial t store rects input = let acts = ref [] in let emit_all l = acts := List.rev_append l !acts in let run m_acts = emit_all (perform_all t store m_acts) in @@ -871,7 +871,7 @@ module Ui = struct let dx, dy = x *. t.scale, y *. t.scale in if Lui_drag.active t.drag then begin (* live drop-target tracking while a drag is in flight *) - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in let d, m = Lui_drag.track store t.drag ~path in t.drag <- d; run m @@ -906,7 +906,7 @@ module Ui = struct run m | None -> t.dirty <- true) end else begin - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in let p, m = Lui_pointer.move store t.pointer ~path ~x ~y in t.pointer <- p; run m @@ -917,7 +917,7 @@ module Ui = struct (match btn with | Input.Left -> let dx, dy = x *. t.scale, y *. t.scale in - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in t.drag <- Lui_drag.note_down t.drag ~x ~y; t.gesture <- Lui_gesture.reset_typeahead t.gesture; (* a press outside the open menu closes it host-side *) @@ -987,7 +987,7 @@ module Ui = struct run m) | Input.Right -> let dx, dy = x *. t.scale, y *. t.scale in - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in let p, m = Lui_pointer.down store t.pointer ~path ~x ~y ~button:Lui_input.Right ~clicks ~mods:(conv_mods mods) @@ -1016,7 +1016,7 @@ module Ui = struct t.pointer <- p; emit_all (perform_all t store up_acts); let dx, dy = x *. t.scale, y *. t.scale in - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in let release = match Lui_query.press_target store path with | Some id -> id @@ -1223,7 +1223,7 @@ module Ui = struct | Input.Wheel (dx, dy) -> let sx, sy = Lui_pointer.position t.pointer in let path = - hit_path store rects ~x:(sx *. t.scale) + hit_path ?spatial store rects ~x:(sx *. t.scale) ~y:(sy *. t.scale) in let s', m = @@ -1235,7 +1235,7 @@ module Ui = struct | Input.Wheel_mods (dx, dy, mods) -> let sx, sy = Lui_pointer.position t.pointer in let path = - hit_path store rects ~x:(sx *. t.scale) + hit_path ?spatial store rects ~x:(sx *. t.scale) ~y:(sy *. t.scale) in let s', m = @@ -1251,7 +1251,7 @@ module Ui = struct them from internal drags *) let sx, sy = Lui_pointer.position t.pointer in let path = - hit_path store rects ~x:(sx *. t.scale) + hit_path ?spatial store rects ~x:(sx *. t.scale) ~y:(sy *. t.scale) in let d, m = @@ -1262,7 +1262,7 @@ module Ui = struct | Input.Drop_text s -> let sx, sy = Lui_pointer.position t.pointer in let path = - hit_path store rects ~x:(sx *. t.scale) + hit_path ?spatial store rects ~x:(sx *. t.scale) ~y:(sy *. t.scale) in let d, m = @@ -2041,6 +2041,9 @@ module Assembly = struct caret_rect : unit -> rect; (** Focused field's caret rect, device px — candidate-window anchor while composing. *) + spatial_hit : unit -> Lui_spatial.t option; + (** Fresh subtree-bound index for {!Ui.handle} — [None] until + the first {!populate} has produced rects. *) } let host t = t.host @@ -2051,7 +2054,8 @@ module Assembly = struct let populate t = t.populate () let caret_rect t = t.caret_rect () let store t = Lui_host.store t.host - let handle t input = Ui.handle t.ui (store t) t.rects input + let handle t input = + Ui.handle ?spatial:(t.spatial_hit ()) t.ui (store t) t.rects input let store_changed t = Ui.store_changed t.ui (store t) (* One frame's paint path: the engine re-syncs inside @@ -2310,21 +2314,53 @@ module Assembly = struct the live pointer position unconditionally would defeat the layout memo on every pointer-driven repaint, so the accessor returns [None] the rest of the time. *) - Lui_host.set_ctx_anchor host - (Some - (fun () -> - match Ui.open_menu ui with - | 0 -> None - | id when Lui_store.kind store id = "context-menu" -> - Some (Ui.pointer ui) - | _ -> None)); + let ctx_anchor () = + match Ui.open_menu ui with + | 0 -> None + | id when Lui_store.kind store id = "context-menu" -> + Some (Ui.pointer ui) + | _ -> None + in + Lui_host.set_ctx_anchor host (Some ctx_anchor); (* 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. *) + materialized; a scroll move repaints and the re-sync re-reads + this feed — the scroll-driven remount. *) Lui_host.set_vport host vport_of; + (* The spatial index over the hit mirror — see [Lui_spatial]. It + is rebuilt lazily whenever the rects or store shape it was + derived from moved on; [sp_key] records the geometry inputs + [populate] built the mirror under so [spatial_index] can tell + a stale snapshot from a fresh one. The key must cover + everything that moves resolved rects: store contents, + host-side layout invalidations (font loads, menu visibility), + scroll offsets, the live context anchor, scale and viewport + size. *) + let geom_key () = + let scn = Lui_host.scene host in + ( Lui_store.revision store, Lui_host.invalidation_count host, + Ui.scroll_epoch ui, ctx_anchor (), !scale_cell, + scn.Lui_scene.width, scn.Lui_scene.height ) + in + let sp_key = ref None in + let sp_idx = ref None in + let spatial_index () = + let rev = Lui_store.revision store in + match !sp_idx with + | Some (k, r, idx) when k = !sp_key && r = rev -> idx + | _ -> + let idx = + Lui_spatial.build Lui_paint.spatial_env store + ~rect_of:(Rects.get rects) + in + sp_idx := Some (!sp_key, rev, idx); + idx + in + let spatial_for_hit () = + match !sp_key with + | Some _ -> Some (spatial_index ()) + | None -> None + in (* 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 @@ -2423,6 +2459,9 @@ module Assembly = struct (* Rects were just rebuilt — visible-range memos key on the epoch. *) Ui.notify_layout ui; + (* Stamp the geometry inputs this mirror was built under — the + spatial index and the paint cull key off it. *) + sp_key := Some (geom_key ()); (* Anchor correction: rows that measured off-estimate above the viewport shifted everything below — fold the drift back into the scroll offset so the top row does not visibly jump. *) @@ -2496,5 +2535,6 @@ module Assembly = struct (Lui_host.layout_virtual_item host p ~index:i) | None -> r) | None -> r); - { host; ui; rects; scale_cell; engine_rect; populate; caret_rect } + { host; ui; rects; scale_cell; engine_rect; populate; caret_rect; + spatial_hit = spatial_for_hit } end diff --git a/platform/native/lui_window/lui_window.mli b/platform/native/lui_window/lui_window.mli index 502e46e0..299aed9d 100644 --- a/platform/native/lui_window/lui_window.mli +++ b/platform/native/lui_window/lui_window.mli @@ -187,11 +187,18 @@ val event_supported : (** Deepest-first id path from the root to the topmost node whose layout rect contains [(x, y)] (device pixels). Skips invisible and zero-area nodes. [pointer-enabled] only gates pointer-detail - events — plain clicks still reach Press-able nodes. *) + events — plain clicks still reach Press-able nodes. + + [?spatial] is a {!Lui_spatial.t} built over the same [rects] + mirror — the walk then visits only subtrees that could contain + the point. *) val hit_path : - Lui_store.t -> Rects.t -> x:float -> y:float -> int list + ?spatial:Lui_spatial.t -> Lui_store.t -> Rects.t -> x:float -> + y:float -> int list -val hit : Lui_store.t -> Rects.t -> x:float -> y:float -> int option +val hit : + ?spatial:Lui_spatial.t -> Lui_store.t -> Rects.t -> x:float -> + y:float -> int option (** Deterministic layout stub. Stacks children vertically inside their parent's padded content box (or horizontally for rows), @@ -380,8 +387,11 @@ module Ui : sig before each input too. *) val store_changed : t -> Lui_store.t -> action list - (** Map one input event to ordered actions. *) - val handle : t -> Lui_store.t -> Rects.t -> Input.t -> action list + (** Map one input event to ordered actions. [?spatial] accelerates + the rect hit-tests — see {!Lui_window.hit_path}. *) + val handle : + ?spatial:Lui_spatial.t -> t -> Lui_store.t -> Rects.t -> + Input.t -> action list (** [state_of] hook for the paint pipeline: folds hover/press/focus from this Ui plus selected/disabled props. *) diff --git a/platform/native/perf/PERF.md b/platform/native/perf/PERF.md index 5715dca3..d76ec91e 100644 --- a/platform/native/perf/PERF.md +++ b/platform/native/perf/PERF.md @@ -37,6 +37,7 @@ separately. | `vlist-{10,50,100}k` | mount N list-items under a `virtual-list`, then scroll at 40 px/frame for 30 frames; per-stage p50/p95, visible window, ops emitted | | `damage-5k` | flip one visible node's `background` in a 5k-row tree; damaged rect count, damage-limited vs full raster | | `text-500` | 500 text leaves through the glyph-quad engine; layout+measure, paint+shape, raster+blit | +| `hit-100k` | `hit_path` point queries over a mounted 100k list, unindexed vs `Lui_spatial`-indexed; indexed static-frame paint; index build cost | | `first-frame-10k` | one 10k-node initial patch, apply through pixels | | `rss-100k` | RSS before/after a 100k-node patch and after the first frame | diff --git a/platform/native/perf/dune b/platform/native/perf/dune index 29ced891..ab62758a 100644 --- a/platform/native/perf/dune +++ b/platform/native/perf/dune @@ -2,16 +2,17 @@ (name lui_perf) (modules harness scenarios) (libraries lui lui_store lui_scene lui_paint lui_retained lui_layout - lui_raster lui_host unix)) + lui_raster lui_host lui_input lui_spatial unix)) (executable (name main) (modules main) - (libraries lui_perf lui lui_store lui_scene lui_paint lui_layout - lui_raster lui_host unix)) + (libraries lui_perf lui lui_store lui_scene lui_paint lui_retained + lui_layout lui_raster lui_host unix)) (test (name lui_perf_test) (modules lui_perf_test) (libraries lui_perf lui lui_store lui_scene lui_paint lui_layout lui_raster lui_host alcotest unix)) + diff --git a/platform/native/perf/scenarios.ml b/platform/native/perf/scenarios.ml index 3f030676..cf527e8d 100644 --- a/platform/native/perf/scenarios.ml +++ b/platform/native/perf/scenarios.ml @@ -260,6 +260,77 @@ let memory ~n ~item_h ~width ~height = row name "RSS after first frame" (mb after_frame) "MiB"; ] +(* Pointer-event cost over a mounted virtual list: the store is + static and the hit mirror is resolved once, then every pointer + event pays one point query. The unindexed path walks the whole + tree; the spatial index descends only toward the hit band. Also + reports an indexed static-frame paint — the viewport-cull case the + same index serves. *) +let hit ~n ~item_h ~width ~height = + let name = Printf.sprintf "hit-%dk" (n / 1000) in + let t = H.create ~width ~height ~scale:1. () in + ignore (H.apply t (mount_vlist ~n ~item_h ())); + ignore (H.layout t); + ignore (H.paint t); + (* The mirror the window driver's populate fills: resolved rects — + shifted engine rects here, resolve_rect is identity for these + plain rows. *) + let rects = Lui_query.Rects.create () in + List.iter + (fun id -> + match H.rect_of t id with + | Some r -> Hashtbl.replace rects id r + | None -> ()) + (Lui_store.preorder (H.store t)); + let idx = + Lui_spatial.build Lui_paint.spatial_env (H.store t) + ~rect_of:(Lui_query.Rects.get rects) + in + (* Probe points: three depths inside the visible viewport. *) + let probes = + [ (float_of_int width *. 0.5, 20.); + (float_of_int width *. 0.5, float_of_int height *. 0.5); + (float_of_int width *. 0.9, float_of_int height -. 20.) ] + in + let iters = 30 in + let time_hits spatial = + let acc = ref [] in + for _ = 1 to iters do + let t0 = H.now () in + List.iter + (fun (x, y) -> + ignore (Lui_query.hit_path ?spatial (H.store t) rects ~x ~y)) + probes; + acc := (H.now () -. t0) *. 1000. :: !acc + done; + H.stats_of !acc + in + let unindexed = time_hits None in + let indexed = time_hits (Some idx) in + (* Static-frame paint with a warm index: what a hover-only repaint + costs once geometry is already indexed. *) + let paint_idx = + let acc = ref [] in + for _ = 1 to 10 do + let t0 = H.now () in + Lui_paint.paint ~spatial:idx (H.paint_hooks t) (H.store t) + (H.scene t) ~width ~height ~scale:1. ~clear:(Lui_scene.color 0 0 0 0); + acc := (H.now () -. t0) *. 1000. :: !acc + done; + H.stats_of !acc + in + [ + ms_row name "hit_path p50" unindexed.p50; + ms_row name "hit_path p50 indexed" indexed.p50; + ms_row name "paint static p50 indexed" paint_idx.p50; + ms_row name "index build" + (snd + (H.timed (fun () -> + ignore + (Lui_spatial.build Lui_paint.spatial_env (H.store t) + ~rect_of:(Lui_query.Rects.get rects))))); + ] + (* Retained fragments: one visible node's color flips on a static n-node tree — the P0-3 acceptance case. The fragment pass re-derives only that node; the flat scene materialize still walks ops. *) @@ -323,6 +394,8 @@ let all ?(scale_sizes = false) () = ~vary:(fun i -> if i mod 7 = 0 then Some 60. else None) ~name:"vlist-var-100k" ~width:w ~height:h ()); damage ~n:n5 ~item_h:20. ~width:w ~height:h; + hit ~n:(List.nth vlists (List.length vlists - 1)) ~item_h:28. + ~width:w ~height:h; text ~n:ntx ~len:48 ~width:w ~height:h; first_frame ~n:n10 ~item_h:20. ~width:w ~height:h; List.concat