diff --git a/platform/native/lui_host/lui_host.ml b/platform/native/lui_host/lui_host.ml index dc21a8c1..156d4aa1 100644 --- a/platform/native/lui_host/lui_host.ml +++ b/platform/native/lui_host/lui_host.ml @@ -46,6 +46,7 @@ module type LAYOUT_ENGINE = sig 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 + val virtual_take_drift : t -> int -> float (** Store ids whose absolute rect changed under the last {!sync}; drained by the paint pass to re-emit only moved subtrees. *) @@ -71,6 +72,7 @@ type layout_state = { lkey : int -> string -> int option; lid : int -> int -> int option; lview : int -> (float * float) option; + ldrift : int -> float; lchanges : unit -> (int, unit) Hashtbl.t; } @@ -128,6 +130,7 @@ let create ~hooks ?layout ?(measure = no_measure) ~renderer ~width ~height ~scal 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); + ldrift = (fun id -> L.virtual_take_drift engine id); lchanges = (fun () -> L.take_rect_changes engine) } in let mask_atlas = Atlas.create ~bpp:1 ~w:512 ~h:512 in @@ -187,6 +190,11 @@ let layout_virtual_index_of_id h id cid = let layout_virtual_view h id = match h.layout with Some l -> l.lview id | None -> None +(* Consume-on-read scroll correction from virtual-list measurement + drift — the driver adds it to the list's scroll offset. *) +let layout_take_drift h id = + match h.layout with Some l -> l.ldrift id | None -> 0. + (* 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 diff --git a/platform/native/lui_host/lui_host.mli b/platform/native/lui_host/lui_host.mli index 9665bb89..6cd96362 100644 --- a/platform/native/lui_host/lui_host.mli +++ b/platform/native/lui_host/lui_host.mli @@ -51,6 +51,11 @@ module type LAYOUT_ENGINE = sig val virtual_index_of_id : t -> Lui_store.t -> int -> int -> int option val virtual_view : t -> int -> (float * float) option + (** Pending anchor correction in device px, consume-on-read: how far + the row at the viewport top moved because rows above it measured + off-estimate. Add it to the list's scroll offset. *) + val virtual_take_drift : t -> int -> float + (** Store ids whose absolute rect changed under the last {!sync}; drained by the paint pass to re-emit only moved subtrees. *) val take_rect_changes : t -> (int, unit) Hashtbl.t @@ -170,4 +175,9 @@ 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 +(** Consume-on-read anchor correction in device px; the driver adds it + to the list's scroll offset so the top row keeps its screen + position while variable heights settle. *) +val layout_take_drift : t -> int -> float + 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 cb98593b..3de06ff4 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -63,6 +63,7 @@ type vlist = { mutable vs : float; (* last viewport fed (content space) *) mutable ve : float; mutable windowed : bool; (* a viewport was supplied → slice *) + mutable drift : float; (* pending anchor correction, device px *) 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 *) @@ -879,6 +880,7 @@ let vlist_state ctx id = vs = 0.; ve = 0.; windowed = false; + drift = 0.; rows = Hashtbl.create 16; nodes = Hashtbl.create 64; mounted_ids = []; @@ -1737,9 +1739,27 @@ let vlists_mount (t : t) ctx store ~vport = let f, l = vlist_range v in if f <> v.first || l <> v.last || Hashtbl.length v.rows < l - f then begin + (* Anchor the item at the viewport top: measuring freshly + mounted overscan rows above it shifts every offset below — + record the prefix delta as drift the scroller applies so + the anchored row keeps its screen position. *) + let anchor = + if v.windowed && v.count > 0 then + let ai = + min (v.count - 1) + (bit_lower v.bit + (Float.max 0. (v.vs -. v.bor0 -. v.pad0))) + in + Some (ai, bit_sum v.bit ai) + else None + in v.first <- f; v.last <- l; build_vrows t ctx store v ~first:f ~last:l; + (match anchor with + | Some (ai, y0) -> + v.drift <- v.drift +. (bit_sum v.bit ai -. y0) + | None -> ()); changed := true end) t.vlists; @@ -1916,7 +1936,11 @@ let vlists_update (t : t) ~vport = in t.extent <- (ew, eh); Hashtbl.iter (fun _ v -> vlist_geometry ctx v) t.vlists; - collect t + collect t; + (* Freshly measured rows can widen the window the first + pass computed — a second mount covers what the + post-measure range now reaches. *) + ignore (vlists_mount t ctx sc.sc_store ~vport) | None -> ()) end; vlists_collect t @@ -2557,6 +2581,18 @@ let virtual_view (t : t) id = | Some v when v.windowed -> Some (v.vs, v.ve) | _ -> None +(* Pending scroll correction from measurement drift, device px — + consume-on-read. The scroller adds it to the list's offset so the + row at the viewport top keeps its screen position when rows above + measure off-estimate. *) +let virtual_take_drift (t : t) id = + match Hashtbl.find_opt t.vlists id with + | Some v -> + let d = v.drift in + v.drift <- 0.; + d + | None -> 0. + (* 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 diff --git a/platform/native/lui_layout/lui_layout.mli b/platform/native/lui_layout/lui_layout.mli index 813b5041..50d55ac1 100644 --- a/platform/native/lui_layout/lui_layout.mli +++ b/platform/native/lui_layout/lui_layout.mli @@ -159,6 +159,12 @@ val virtual_item_rect : t -> int -> index:int -> Lui_scene.rect option what the interaction layer reports as the visible range. *) val virtual_view : t -> int -> viewport option +(** [virtual_take_drift t id] is the pending anchor correction in + device px — how far the row at the viewport top moved because + rows above it measured off-estimate. Consume-on-read: the caller + (the scroller) adds it to the list's scroll offset. *) +val virtual_take_drift : t -> int -> float + (** [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 diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index df897bec..2befd368 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -481,6 +481,63 @@ let test_vlist_unwindowed () = check int "mounted" 50 (Lui_layout.mounted_count t 2); check_some_rect "row49" 0. 980. 100. 20. (Lui_layout.rect t 59) +(* The mounted window's store ids — what paint and hit-test slice to. *) +let test_vlist_mounted_children () = + 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 500. 200.) t store; + let f, l = Option.get (Lui_layout.mounted_range t 2) in + let ids = Option.get (Lui_layout.mounted_children t 2) in + check int "window size" (l - f) (List.length ids); + check int "first id" (10 + f) (List.hd ids); + check int "last id" (10 + l - 1) (List.nth ids (l - f - 1)) + +(* Variable heights settle through measured extents: overscan rows + above the anchor that differ from the estimate shift the prefix + sums — the drift the scroller folds back into the offset so the + anchored row does not visibly jump. *) +let test_vlist_anchor_drift () = + let store = Lui_store.create () in + let ops = + ref + [ InsertChild (1, 2, 0); + SetProp + (2, DataAttrs, + StringValue + (Lui_protocol.data_attrs_encode + [ ("data-item-extent", "20") ])); + CreateNode (2, VirtualList); CreateNode (1, Root) ] + in + for i = 0 to 99 do + let id = 10 + i in + (* Rows 46-49 are really 60 px against the 20-px estimate. *) + ops := + InsertChild (2, id, i) + :: SetProp + (id, HeightValue, + FloatValue (if i >= 46 && i < 50 then 60. 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. 300.) t store; + (* Anchor is item 0 — nothing above it can move. *) + check (float eps) "no drift at top" 0. + (Lui_layout.virtual_take_drift t 2); + (* Jump mid-list: rows 46-49 mount in the overscan band and measure + 60 instead of the 20 estimate — prefix(anchor=50) grew by 160. *) + Lui_layout.sync ~measure:no_measure4 ~width:100. ~height:800. + ~vport:(vport 1000. 300.) t store; + check (float eps) "drift" 160. (Lui_layout.virtual_take_drift t 2); + (* consume-on-read *) + check (float eps) "consumed" 0. (Lui_layout.virtual_take_drift t 2); + (* the model reports corrected positions: item 50 sits at + 46*20 + 4*60 = 1160 *) + check_some_rect "item50" 0. 1160. 100. 20. + (Lui_layout.virtual_item_rect t 2 ~index:50) + (* Pixel snapping contract: at a fractional backing scale the exported rects land on device-pixel edges, and siblings stacked without a gap share their boundary exactly — the same snapped geometry feeds @@ -714,6 +771,9 @@ let () = test_case "vlist measured" `Quick test_vlist_measured_extents; test_case "vlist gap" `Quick test_vlist_gap; test_case "vlist unwindowed" `Quick test_vlist_unwindowed; + test_case "vlist mounted children" `Quick + test_vlist_mounted_children; + test_case "vlist anchor drift" `Quick test_vlist_anchor_drift; test_case "vlist incremental child" `Quick test_vlist_incremental_child; test_case "snapped edges" `Quick test_snapped_edges; diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index c578d809..5926236a 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -2033,7 +2033,11 @@ module Assembly = struct engine_rect : int -> rect; (** Unshifted engine rect — content-space input for [scroll_show]/visible-range. *) - populate : unit -> unit; (** rebuild the hit mirror + scroll caps *) + populate : unit -> action list; + (** rebuild the hit mirror + scroll caps, fold measured anchor + drift back into scroll offsets — the VisibleRange + dispatches those corrections produce come back for the + driver to apply *) caret_rect : unit -> rect; (** Focused field's caret rect, device px — candidate-window anchor while composing. *) @@ -2053,7 +2057,8 @@ module Assembly = struct (* One frame's paint path: the engine re-syncs inside [Lui_host.repaint], then the hit mirror and scroll caps are rebuilt from the fresh rects — always in this order, on every - platform. *) + platform. Returns the frame bytes plus the actions populate's + anchor-drift corrections produced. *) let repaint t = (* Interaction-state diffs reach the fragment pass through the host's hint table — the store journal cannot see them. *) @@ -2065,8 +2070,8 @@ module Assembly = struct (fun id () -> Lui_host.note_moved t.host id) soft; let px = Lui_host.repaint t.host in - t.populate (); - px + let acts = t.populate () in + (px, acts) let backend t profile = Lui_host.backend t.host profile @@ -2417,7 +2422,17 @@ module Assembly = struct sids; (* Rects were just rebuilt — visible-range memos key on the epoch. *) - Ui.notify_layout ui + Ui.notify_layout ui; + (* 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. *) + List.concat_map + (fun id -> + let d = Lui_host.layout_take_drift host id in + if Float.abs d > 0.001 then + Ui.scroll_to ui store id (Ui.scroll_offset ui id +. d) + else []) + (Lui_host.layout_vlists host) in (* Caret rect of the focused field, device px: the node's layout rect shifted to the caret's real position inside the laid-out diff --git a/platform/native/lui_window/lui_window.mli b/platform/native/lui_window/lui_window.mli index 63fa29bb..502e46e0 100644 --- a/platform/native/lui_window/lui_window.mli +++ b/platform/native/lui_window/lui_window.mli @@ -781,15 +781,18 @@ module Assembly : sig (** {!Ui.store_changed} over this surface — run it after every [Lui_app.flush]. *) - val populate : t -> unit + val populate : t -> action list (** Rebuild the hit mirror and the scroll caps from the last layout - sync. {!repaint} already calls it; expose only for drivers that - keep paint and hit-refresh on separate schedules. *) + sync, then fold each virtual list's measured anchor drift back + into its scroll offset. {!repaint} already calls it; expose only + for drivers that keep paint and hit-refresh on separate + schedules. The returned actions are the VisibleRange dispatches + the corrections produced — apply them like {!handle}'s. *) - val repaint : t -> Bytes.t + val repaint : t -> Bytes.t * action list (** One frame's paint path: [Lui_host.repaint] (which re-syncs the engine) then {!populate} — always in this order, on every - platform. *) + platform. Returns the frame bytes and populate's actions. *) val caret_rect : t -> rect (** Focused field's caret rect, device px — the IME candidate-window diff --git a/platform/native/lui_window/main.ml b/platform/native/lui_window/main.ml index 5095e175..51eab4f7 100644 --- a/platform/native/lui_window/main.ml +++ b/platform/native/lui_window/main.ml @@ -439,7 +439,9 @@ let tick (s : surf) ~t0 = (match decision with | Frame.Present -> (* Assembly.repaint repaints and refreshes the hit-test mirror *) - ignore (Assembly.repaint s.driver.asm); + (* populate's anchor-drift corrections surface as VisibleRange + dispatches — apply them like any other Ui action *) + apply_actions s (snd (Assembly.repaint s.driver.asm)); ignore (Lui_a11y.sync d.a11y); if !(d.gl_live_cell) then begin (match !(d.gctx_cell) with diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index b25e985e..7296f757 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2429,6 +2429,7 @@ module Fake_layout = struct let virtual_index_of_key _ _ _ _ = None let virtual_index_of_id _ _ _ _ = None let virtual_view _ _ = None + let virtual_take_drift _ _ = 0. (* The stub re-places the whole tree every sync — every rect is potentially fresh. *) diff --git a/platform/native/lui_window_linux/main.ml b/platform/native/lui_window_linux/main.ml index aba5303a..01b147af 100644 --- a/platform/native/lui_window_linux/main.ml +++ b/platform/native/lui_window_linux/main.ml @@ -650,7 +650,9 @@ let () = the frame counter and checksum actually run. *) let dirty = headless || Ui.want_frame ui host in if dirty then begin - ignore (Assembly.repaint asm); + (* populate's anchor-drift corrections surface as VisibleRange + dispatches — apply them like any other Ui action *) + apply_actions (snd (Assembly.repaint asm)); if gl_live () then Sdl.gl_swap_window !win; Ui.frame_done ui; incr frame; diff --git a/platform/native/lui_window_windows/main.ml b/platform/native/lui_window_windows/main.ml index a0e1ea49..11514997 100644 --- a/platform/native/lui_window_windows/main.ml +++ b/platform/native/lui_window_windows/main.ml @@ -510,7 +510,9 @@ let () = the frame counter and checksum actually run. *) let dirty = headless || Ui.want_frame ui host in if dirty then begin - ignore (Assembly.repaint asm); + (* populate's anchor-drift corrections surface as VisibleRange + dispatches — apply them like any other Ui action *) + apply_actions (snd (Assembly.repaint asm)); Ui.frame_done ui; incr frame; Checksum.add_scene checksum (Lui_host.scene host); diff --git a/platform/native/perf/harness.ml b/platform/native/perf/harness.ml index 0dc0c9e2..09a48718 100644 --- a/platform/native/perf/harness.ml +++ b/platform/native/perf/harness.ml @@ -225,6 +225,15 @@ let scroll_shift t id = let set_scroll t id off = Hashtbl.replace t.scroll id off +(* Anchor correction, the way the window driver applies it in + populate: measurement drift folds back into the scroll offset so + the top row keeps its screen position. *) +let apply_vlist_drift t id = + let d = Lui_layout.virtual_take_drift t.engine id in + if Float.abs d > 0.001 then + set_scroll t id + (Option.value ~default:0. (Hashtbl.find_opt t.scroll id) +. d) + (* Engine rect shifted by ancestor scroll offsets — what paint and hit tests see. *) let rect_of t id = diff --git a/platform/native/perf/scenarios.ml b/platform/native/perf/scenarios.ml index 9ff80b4b..3f030676 100644 --- a/platform/native/perf/scenarios.ml +++ b/platform/native/perf/scenarios.ml @@ -28,7 +28,7 @@ let row_bg i = if i land 1 = 0 then "#2a2d33" else "#23262b" here is the frame itself, same as a real window. Children are inserted at ascending indices — the same op stream a frontend emits — so the apply stage reflects the store's real mount cost. *) -let mount_vlist ~n ~item_h = +let mount_vlist ~n ~item_h ?(vary = fun _ -> None) () = let ops = ref [ @@ -42,7 +42,8 @@ let mount_vlist ~n ~item_h = (* Ops are pushed tail-first onto [!ops] so [List.rev] emits CreateNode > SetProps > InsertChild. *) ops := CreateNode (id, ListItem) :: !ops; - ops := SetProp (id, HeightValue, FloatValue item_h) :: !ops; + let h = match vary i with Some h -> h | None -> item_h in + ops := SetProp (id, HeightValue, FloatValue h) :: !ops; ops := SetProp (id, TextValue, StringValue (Printf.sprintf "row %d" i)) :: !ops; @@ -98,10 +99,15 @@ let mount_texts ~n ~len = apply is zero, and the memoized [Lui_layout.sync] returns immediately on a scroll frame (the production model too: repaint re-syncs every frame, the memo is what makes that cheap). *) -let vlist ~n ~frames ~velocity ~item_h ~width ~height = - let name = Printf.sprintf "vlist-%dk" (n / 1000) in +let vlist ~n ~frames ~velocity ~item_h ?(vary = fun _ -> None) + ?name ~width ~height () = + let name = + match name with + | Some s -> s + | None -> Printf.sprintf "vlist-%dk" (n / 1000) + in let t = H.create ~width ~height ~scale:1. () in - let mount = mount_vlist ~n ~item_h in + let mount = mount_vlist ~n ~item_h ~vary () in let mount_ms = H.apply t mount in let mlayout = H.layout t in let mpaint = H.paint t in @@ -111,6 +117,9 @@ let vlist ~n ~frames ~velocity ~item_h ~width ~height = H.set_scroll t 2 (velocity *. float_of_int f); let t0 = H.now () in la := H.layout t :: !la; + (* Measured-extent drift folds into the scroll offset — the same + correction the window driver applies each populate. *) + H.apply_vlist_drift t 2; pa := H.paint t :: !pa; let _, rms = H.raster t in ra := rms :: !ra; @@ -231,7 +240,7 @@ let memory ~n ~item_h ~width ~height = | None -> "n/a" in let t = H.create ~width ~height ~scale:1. () in - let batch = mount_vlist ~n ~item_h in + let batch = mount_vlist ~n ~item_h () in let before = H.rss_kb () in let apply_ms = H.apply t batch in let after_apply = H.rss_kb () in @@ -304,8 +313,15 @@ let all ?(scale_sizes = false) () = List.concat (List.map (fun n -> - vlist ~n ~frames ~velocity:40. ~item_h:28. ~width:w ~height:h) + vlist ~n ~frames ~velocity:40. ~item_h:28. ~width:w + ~height:h ()) vlists); + (* Variable-height scroll: every 7th row is ~2.1x the estimate, + so scrolling measures fresh rows and exercises the + Fenwick/extent-model + anchor-drift path. *) + (vlist ~n:100_000 ~frames ~velocity:40. ~item_h:28. + ~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; text ~n:ntx ~len:48 ~width:w ~height:h; first_frame ~n:n10 ~item_h:20. ~width:w ~height:h;