Skip to content
20 changes: 17 additions & 3 deletions platform/native/lui_host/lui_host.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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. *)
Expand All @@ -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 =
Expand Down
6 changes: 6 additions & 0 deletions platform/native/lui_host/lui_host.mli
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion platform/native/lui_input/dune
Original file line number Diff line number Diff line change
Expand Up @@ -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))
17 changes: 12 additions & 5 deletions platform/native/lui_input/lui_query.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion platform/native/lui_input/test/dune
Original file line number Diff line number Diff line change
@@ -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))
98 changes: 97 additions & 1 deletion platform/native/lui_input/test/lui_input_test.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 ] ) ]
2 changes: 1 addition & 1 deletion platform/native/lui_paint/dune
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading