From 7f9fba4e8b41a668e4d2ee0e2ab0b69360d49dbd Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 04:24:54 +0800 Subject: [PATCH 1/9] perf(ml): typed dirty flags and O(1) wide child removal (Milestone 1) Lui_store now classifies every applied op into typed dirty masks (structure/layout/paint/semantics/input, plus layout-kids for parent-driven props like grid columns, split value, edge-inset edge) and tracks a separate layout_rev bumped only by geometry-relevant ops. Lui_layout.sync keys its memo on layout_rev, so a paint-only prop change (background, opacity, border-*, z-index, ...) no longer triggers a wholesale flex mirror rebuild. The store also keeps a bounded op journal (changes_since) that incremental consumers replay instead of rescanning the tree. Children move from a compacting array to an intrusive doubly-linked list: detach is O(1) via the child's own sibling links, so removing N children from a wide parent is O(N) total instead of O(N^2); drop skips unlinking children of a doomed parent. Index inserts walk from the nearer end. Tests: masked-drain classification, layout_rev gating, journal replay, paint-only prop skips the mirror rebuild, and a 20k-child removal complexity check (linear; the array needed seconds). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_layout/lui_layout.ml | 19 +- platform/native/lui_layout/lui_layout.mli | 14 +- .../native/lui_layout/test/lui_layout_test.ml | 22 + platform/native/lui_store/lui_store.ml | 498 ++++++++++++++---- platform/native/lui_store/lui_store.mli | 40 ++ .../native/lui_store/test/lui_store_test.ml | 102 ++++ 6 files changed, 574 insertions(+), 121 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index f9d7b909..74440d2d 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -34,6 +34,10 @@ type t = { mutable synced_anchor : (float * float) option; mutable synced_measure : text_measure; mutable synced_hidden : int -> bool; + (* Instrumentation: how many times {!sync} actually rebuilt the + mirror. Lets tests assert that memoization (and later the + incremental path) skipped work instead of trusting timings. *) + mutable stat_syncs : int; } let create () = @@ -47,9 +51,11 @@ let create () = synced_h = 0.; synced_anchor = None; synced_measure = no_measure; - synced_hidden = show_all } + synced_hidden = show_all; + stat_syncs = 0 } let invalidate (t : t) = t.synced_rev <- -1 +let stat_syncs (t : t) = t.stat_syncs (* ---------- wire value access ---------- *) @@ -1085,10 +1091,12 @@ let compute t ~width ~height = let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) ?ctx_anchor ~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 — - skipped wholesale (scroll-only and idle repaints pay nothing). *) + let rev = Lui_store.layout_rev store in + (* The store bumps its layout revision on every mutation that can + move geometry — structure and layout-classified props — so a + matching key means the rebuild below would produce an identical + mirror: skipped wholesale (scroll-only frames, idle repaints and + paint-only prop churn all pay nothing). *) if t.synced_rev <> rev || t.synced_scale <> scale || t.synced_w <> width || t.synced_h <> height @@ -1096,6 +1104,7 @@ let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) || (not (t.synced_measure == measure)) || (not (t.synced_hidden == hidden)) then begin + t.stat_syncs <- t.stat_syncs + 1; Hashtbl.reset t.nodes; let ctx = { scale; vw = width; vh = height; tm = measure; hidden; diff --git a/platform/native/lui_layout/lui_layout.mli b/platform/native/lui_layout/lui_layout.mli index 8db0f82f..af503e0d 100644 --- a/platform/native/lui_layout/lui_layout.mli +++ b/platform/native/lui_layout/lui_layout.mli @@ -82,10 +82,12 @@ val measure_of : (** Memoized: a call whose inputs all match the last sync returns immediately, keeping the previous mirror and rects. The key is the - 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. *) + store's layout revision ({!Lui_store.layout_rev} — bumped only by + structure and layout-classified ops, so a paint-only prop change + like [background] never re-enters the mirror), the frame geometry, + [?ctx_anchor]'s value, and the [?measure]/[?hidden] functions' + identities. Scroll offsets are not an input — a frame that only + scrolls pays no layout at all. *) val sync : ?scale:float -> ?measure:text_measure -> ?hidden:(int -> bool) -> @@ -100,6 +102,10 @@ val sync : val invalidate : t -> unit +(** [stat_syncs t] counts the {!sync} calls that actually rebuilt the + mirror — memoized calls do not. Test instrumentation. *) +val stat_syncs : t -> int + (** [compute t ~width ~height] re-runs layout on the current mirror — the cheap path when only the frame size changed. [*-viewport] props keep the resolution from the last {!sync}; call {!sync} on diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index 41d2e5ee..16b946a1 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -189,6 +189,27 @@ let test_sync_memoized () = Lui_layout.sync ~measure ~width:200. ~height:100. t store; check bool "store mutation resyncs" true (!calls > after_invalidate) +(* A paint-only prop change never re-enters the mirror: the store's + layout revision does not move, so sync stays memoized. *) +let test_paint_prop_skips_layout () = + let store = Lui_store.create () in + apply store + [ CreateNode (1, Root); CreateNode (2, Row); CreateNode (3, Text); + InsertChild (1, 2, 0); InsertChild (2, 3, 0); + SetProp (3, TextValue, StringValue "hi"); + SetProp (3, WidthValue, IntValue 200); + SetProp (3, HeightValue, IntValue 10) ]; + let t = Lui_layout.create () in + Lui_layout.sync ~width:200. ~height:100. t store; + check int "first sync builds" 1 (Lui_layout.stat_syncs t); + apply store [ SetProp (3, BackgroundValue, StringValue "#ff0000") ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check int "paint-only prop skips rebuild" 1 (Lui_layout.stat_syncs t); + check_some_rect "rect intact" 0. 0. 200. 10. (Lui_layout.rect t 3); + apply store [ SetProp (3, WidthValue, IntValue 30) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check int "layout prop resyncs" 2 (Lui_layout.stat_syncs t) + (* Grid cells subtract the gap share: columns=2 + gap 10 in a 200-wide frame gives two 95-wide cells per row, not one. *) let test_grid_gap_fits_columns () = @@ -343,6 +364,7 @@ let () = test_case "display none" `Quick test_display_none_absent; test_case "rebuild" `Quick test_rebuild_after_mutation; test_case "sync memoized" `Quick test_sync_memoized; + test_case "paint prop skips layout" `Quick test_paint_prop_skips_layout; test_case "frame grows" `Quick test_frame_grows_to_content; test_case "popup centered" `Quick test_popup_default_centered; test_case "root popup" `Quick test_root_popup_centered_in_frame; diff --git a/platform/native/lui_store/lui_store.ml b/platform/native/lui_store/lui_store.ml index 8b350525..2f7476e0 100644 --- a/platform/native/lui_store/lui_store.ml +++ b/platform/native/lui_store/lui_store.ml @@ -5,58 +5,143 @@ can invalidate incrementally. Kinds and props are keyed by their wire names (Lui_wire_schema names), matching the convention used elsewhere so a prop lookup like [bool_prop t id "enabled"] reads the same key - every backend sees. *) + every backend sees. -open Lui_protocol - -(* Children of a node live in a growable array: patch streams append - children one op at a time and a list made every [InsertChild] an - O(fan-out) rewalk, which made 100k mounts quadratic. The vec keeps - appends O(1) amortized; readers materialize lists on demand (a - snapshot, which is also safer than sharing the live order). *) -type child_vec = { mutable arr : int array; mutable len : int } - -let vec_create () = { arr = [||]; len = 0 } - -let vec_grow v = - let cap = max 8 (2 * Array.length v.arr) in - let arr' = Array.make cap 0 in - Array.blit v.arr 0 arr' 0 v.len; - v.arr <- arr' - -let vec_insert_at v i x = - (* Out-of-range indices clamp the way insert_at on a list did: - i <= 0 prepends, i >= len appends. *) - let i = max 0 (min i v.len) in - if v.len = Array.length v.arr then vec_grow v; - Array.blit v.arr i v.arr (i + 1) (v.len - i); - v.arr.(i) <- x; - v.len <- v.len + 1 - -let vec_remove v x = - (* Compacts in place; removes every occurrence like - List.filter (fun c -> c <> x). *) - let w = ref 0 in - for i = 0 to v.len - 1 do - let c = v.arr.(i) in - if c <> x then ( - v.arr.(!w) <- c; - incr w) - done; - v.len <- !w - -let vec_to_list v = - let rec go i acc = if i < 0 then acc else go (i - 1) (v.arr.(i) :: acc) in - go (v.len - 1) [] + Children are an intrusive doubly-linked list: a child detaches in + O(1) through its own sibling links (no parent-wide scan), appends + stay O(1) through the tail pointer, and index inserts walk from the + nearer end. A wide parent losing N children one op at a time is + O(N) total instead of the O(N^2) a compacting array paid. *) -let vec_fold f acc v = - let acc = ref acc in - for i = 0 to v.len - 1 do - acc := f !acc v.arr.(i) - done; - !acc +open Lui_protocol -let vec_last v = if v.len = 0 then None else Some v.arr.(v.len - 1) +(* ---------- typed dirty masks ---------- + + Every applied op tags the nodes it touched with a bitmask naming the + pipeline stages that must see the change. [drain_dirty] keeps its + flat id-list contract for existing consumers; masked drains let + layout and paint pick only the work a change actually needs. *) + +type dirty_mask = int + +let d_structure = 0x01 (* produced child set/order changed *) +let d_layout = 0x02 (* own flex style or measure inputs changed *) +let d_paint = 0x04 (* scene ops changed *) +let d_semantics = 0x08 (* a11y tree *) +let d_input = 0x10 (* focus/selection/interactive state *) +(* A prop that flows into the styles or order of the node's *children* + (grid [columns], split [value], edge-inset [edge]): layout must + re-derive produced children, not just the node's own style. *) +let d_layout_kids = 0x20 +let d_all = 0x3f + +let mask_has m f = m land f <> 0 + +(* The props [Lui_layout] reads, directly or through a parent. Keep in + sync with style_of/adjust/text_of/ordered_child_ids/refine_popups. *) +let layout_prop_names = + [ "alignment"; "anchor"; "anchor-alignment"; "anchor-offset"; "x"; "y"; + "columns"; "container-relative-frame"; "container-relative-frame-inset"; + "cross"; "main"; "description"; "edge"; "flex-wrap"; "gap"; "row-gap"; + "column-gap"; "grow"; + "width"; "height"; "min-width"; "min-height"; "max-width"; + "max-height"; "width-viewport"; "height-viewport"; + "min-width-viewport"; "min-height-viewport"; "max-width-viewport"; + "max-height-viewport"; + "icon"; "inset"; "inset-top"; "inset-right"; "inset-bottom"; + "inset-left"; "label"; "margin"; "margin-top"; "margin-right"; + "margin-bottom"; "margin-left"; "margin-horizontal"; "margin-vertical"; + "orientation"; "overflow"; "padding"; "padding-horizontal"; + "padding-vertical"; "placeholder"; "position"; "selected"; "size"; + "text"; "title"; "value" ] + +let layout_props = + let t = Hashtbl.create 64 in + List.iter (fun n -> Hashtbl.replace t n ()) layout_prop_names; + t + +(* Props whose change ripples into produced children. [display] is the + only prop that rewrites the produced set itself (none/contents lift); + the three rest restyle or reorder the children a parent produced. *) +let child_prop_names = [ "columns"; "value"; "edge" ] +let child_props = + let t = Hashtbl.create 8 in + List.iter (fun n -> Hashtbl.replace t n ()) child_prop_names; + t + +(* Pure-paint props: consumed by paint/hit-test, never by style_of or + the measure text. Only names verified absent from [layout_props] may + live here — a wrong entry produces a stale layout, so the list is + kept deliberately narrow; anything unlisted falls through to the + conservative layout|paint default. *) +let paint_prop_names = + [ "background"; "foreground"; "opacity"; "shadow"; "corner-radius"; + "border-color"; "border-width"; "border-style"; "z-index"; + "cursor"; "caret-color"; "tint"; "image"; "name"; "src"; "fit"; + "blend-mode"; "secure"; "style-class"; "variant"; "placement"; + "autofocus"; "submit-on-enter"; "scroll-x"; "scroll-y"; + "key"; "id"; "class" ] + +let paint_props = + let t = Hashtbl.create 64 in + List.iter (fun n -> Hashtbl.replace t n ()) paint_prop_names; + t + +(* Paint props the a11y tree also mirrors (visibility, control state, + links): they repaint and re-semantize but never move geometry. *) +let paint_sem_prop_names = + [ "visible"; "checked"; "indeterminate"; "href"; "target"; "rel"; + "alt"; "draggable"; "drop-target"; "tooltip" ] + +let paint_sem_props = + let t = Hashtbl.create 16 in + List.iter (fun n -> Hashtbl.replace t n ()) paint_sem_prop_names; + t + +(* Interactive-state channels. Hover/pressed/selected/disabled state + variants are paint-only (the bare "selected" stays layout: a drawer's + selected prop moves it in and out of popup flow). *) +let state_prefixes = [ "hover-"; "pressed-"; "selected-"; "disabled-"; "focus-" ] + +let has_prefix s p = + let n = String.length p in + String.length s >= n && String.sub s 0 n = p + +(* Semantics-only props: the a11y tree cares, paint and layout do not. *) +let semantics_prop_names = + [ "accessibility-label"; "accessibility-identifier"; "role"; "expanded"; + "level"; "sort" ] + +let semantics_props = + let t = Hashtbl.create 16 in + List.iter (fun n -> Hashtbl.replace t n ()) semantics_prop_names; + t + +(* Focus/selection/input-channel props. *) +let input_prop_names = + [ "enabled"; "disabled"; "focus"; "focused"; "selection"; + "selection-start"; "selection-end"; "input-mode"; "readonly" ] + +let input_props = + let t = Hashtbl.create 16 in + List.iter (fun n -> Hashtbl.replace t n ()) input_prop_names; + t + +(* Wire prop name -> dirty mask. Unknown and extension props land on + the conservative default: a missed layout bit is a stale frame, a + superfluous one only costs a style recompute. *) +let mask_of_prop name : dirty_mask = + if name = "display" then d_structure lor d_layout lor d_paint + else if Hashtbl.mem child_props name then d_layout_kids lor d_layout lor d_paint + else if Hashtbl.mem layout_props name then d_layout lor d_paint + else if Hashtbl.mem semantics_props name then d_semantics + else if Hashtbl.mem input_props name then d_input lor d_paint lor d_semantics + else if List.exists (has_prefix name) state_prefixes then d_paint lor d_semantics + else if Hashtbl.mem paint_sem_props name then d_paint lor d_semantics + else if Hashtbl.mem paint_props name then d_paint + else d_layout lor d_paint + +(* ---------- store ---------- *) type node = { id : int; @@ -64,70 +149,210 @@ type node = { mutable ext_id : string option; props : (string, wire_value) Hashtbl.t; mutable parent : int option; - children : child_vec; + (* Intrusive child list: [first_child]/[last_child] own the endpoints, + [prev_sib]/[next_sib] let a child unlink itself in O(1). *) + mutable first_child : node option; + mutable last_child : node option; + mutable child_count : int; + mutable prev_sib : node option; + mutable next_sib : node option; mutable revision : int; (* last store revision that touched this node *) } +(* One journal entry: the store revision right after the op applied and + the op itself. Consumers replay ops since their last seen revision to + update their own mirrors incrementally. *) +type change = { crev : int; cop : patch_op } + type t = { nodes : (int, node) Hashtbl.t; - dirty : (int, unit) Hashtbl.t; + dirty : (int, dirty_mask) Hashtbl.t; mutable generation : int; mutable revision : int; + (* Bumped only by ops that can move geometry (structure, layout and + layout-kids bits). The layout memo keys on it so pure paint churn + leaves the flex mirror untouched. *) + mutable layout_rev : int; + journal : change Queue.t; + mutable journal_len : int; + (* Journal completeness: every op whose [crev] exceeds + [journal_base] is queued. When the cap drops entries the base moves + to the oldest surviving revision, so [changes_since] for an older + cursor reports [None] and the consumer rebuilds. *) + mutable journal_base : int; } +let journal_cap = 8192 + let create () = - { nodes = Hashtbl.create 64; dirty = Hashtbl.create 64; generation = 0; revision = 0 } + { nodes = Hashtbl.create 64; dirty = Hashtbl.create 64; + generation = 0; revision = 0; layout_rev = 0; + journal = Queue.create (); journal_len = 0; journal_base = -1 } let node_count t = Hashtbl.length t.nodes let generation t = t.generation let revision t = t.revision +let layout_rev t = t.layout_rev let find_opt t id = Hashtbl.find_opt t.nodes id let mem t id = Hashtbl.mem t.nodes id -let mark_dirty t id = +(* Ops newer than [rev], oldest first. [None] when the journal no + longer reaches back to [rev] — the caller must resync wholesale. + Passing the store's current revision yields [Some []]. *) +let changes_since t rev : patch_op list option = + if rev < t.journal_base then None + else Some (Queue.fold (fun acc c -> if c.crev > rev then c.cop :: acc else acc) [] t.journal |> List.rev) + +(* Every store mutation ends here: revision bumps once per mark so a + batch of marks keeps a strictly increasing clock. *) +let mark_dirty_mask t id m = t.revision <- t.revision + 1; - Hashtbl.replace t.dirty id (); + if m land (d_structure lor d_layout lor d_layout_kids) <> 0 then + t.layout_rev <- t.layout_rev + 1; + Hashtbl.replace t.dirty id + (match Hashtbl.find_opt t.dirty id with + | Some old -> old lor m + | None -> m); match Hashtbl.find_opt t.nodes id with | Some (n : node) -> n.revision <- t.revision | None -> () (* Ids touched since the last drain, in application order (unstable). *) let drain_dirty t = - let ids = Hashtbl.fold (fun id () acc -> id :: acc) t.dirty [] in + let ids = Hashtbl.fold (fun id _ acc -> id :: acc) t.dirty [] in Hashtbl.reset t.dirty; List.sort compare ids +(* Same drain, with the accumulated mask per id. *) +let drain_dirty_masked t = + let ids = Hashtbl.fold (fun id m acc -> (id, m) :: acc) t.dirty [] in + Hashtbl.reset t.dirty; + List.sort (fun a b -> compare (fst a) (fst b)) ids + let peek_dirty t id = Hashtbl.mem t.dirty id -let new_node id kind = { id; kind; ext_id = None; props = Hashtbl.create 8; parent = None; children = vec_create (); revision = 0 } +let peek_dirty_mask t id = Option.value ~default:0 (Hashtbl.find_opt t.dirty id) + +let new_node id kind = + { id; kind; ext_id = None; props = Hashtbl.create 8; parent = None; + first_child = None; last_child = None; child_count = 0; + prev_sib = None; next_sib = None; revision = 0 } + +(* ---------- intrusive child list ---------- *) + +let node_children_list n = + (* Iterative walk + one reverse: a 100k-wide sibling list must not + recurse per element. *) + let acc = ref [] in + let cur = ref n.first_child in + while !cur <> None do + let c = Option.get !cur in + acc := c :: !acc; + cur := c.next_sib + done; + List.rev !acc + +(* Detach [c] from its recorded parent's list. O(1): the sibling links + carry the position. Callers fix [c.parent] themselves — this only + cuts the links. *) +let unlink (p : node) (c : node) = + (match c.prev_sib with + | Some s -> s.next_sib <- c.next_sib + | None -> p.first_child <- c.next_sib); + (match c.next_sib with + | Some s -> s.prev_sib <- c.prev_sib + | None -> p.last_child <- c.prev_sib); + p.child_count <- p.child_count - 1; + c.prev_sib <- None; + c.next_sib <- None + +let push_back (p : node) (c : node) = + c.prev_sib <- p.last_child; + c.next_sib <- None; + (match p.last_child with + | Some l -> l.next_sib <- Some c + | None -> p.first_child <- Some c); + p.last_child <- Some c; + p.child_count <- p.child_count + 1 + +let push_front (p : node) (c : node) = + c.next_sib <- p.first_child; + c.prev_sib <- None; + (match p.first_child with + | Some f -> f.prev_sib <- Some c + | None -> p.last_child <- Some c); + p.first_child <- Some c; + p.child_count <- p.child_count + 1 + +(* Insert before the element at [index], clamped like list insert_at: + i <= 0 prepends, i >= count appends. Walks from the nearer end. *) +let insert_at (p : node) (c : node) index = + if index <= 0 then push_front p c + else if index >= p.child_count then push_back p c + else begin + let rec nth_front i n = + match (i, n) with + | 0, cur -> cur + | i, Some cur -> nth_front (i - 1) cur.next_sib + | _, None -> None + in + let rec nth_back i n = + match (i, n) with + | 0, cur -> cur + | i, Some cur -> nth_back (i + 1) cur.prev_sib + | _, None -> None + in + (* Walk to the node currently at [index]. *) + let target = + if index <= p.child_count / 2 then nth_front index p.first_child + else nth_back (index - p.child_count + 1) p.last_child + in + match target with + | Some at -> + c.prev_sib <- at.prev_sib; + c.next_sib <- Some at; + (match at.prev_sib with + | Some s -> s.next_sib <- Some c + | None -> p.first_child <- Some c); + at.prev_sib <- Some c; + p.child_count <- p.child_count + 1 + | None -> push_back p c + end (* Drop a subtree: remove every descendant from the table and unlink the node from its parent. Marks the parent's children changed and every - removed id dirty so repaint can forget it. *) + removed id dirty so repaint can forget it. Children are detached + from the doomed parent in O(1) each by clearing their parent link + before the recursive drop — the parent's list is discarded wholesale + rather than unlinked child by child. *) let rec drop t id = match Hashtbl.find_opt t.nodes id with | None -> () | Some node -> - (* Snapshot first: dropping a child unlinks it from this vec, and - mutating the vec mid-iteration would skip siblings. *) - List.iter (drop t) (vec_to_list node.children); + List.iter + (fun (c : node) -> + c.parent <- None; + drop t c.id) + (node_children_list node); + node.first_child <- None; + node.last_child <- None; + node.child_count <- 0; (match node.parent with | Some pid -> ( match Hashtbl.find_opt t.nodes pid with | Some p -> - vec_remove p.children id; - mark_dirty t pid + unlink p node; + mark_dirty_mask t pid (d_structure lor d_layout lor d_paint) | None -> ()) | None -> ()); Hashtbl.remove t.nodes id; - mark_dirty t id + mark_dirty_mask t id (d_structure lor d_paint) (* Insert child at index within parent, detaching it from its previous parent first so a move never leaves the id listed twice. - The detach consults the child's recorded parent instead of filtering - the target's children unconditionally: [c.parent = None] means the id - sits in no children vec, so the common first-attach path skips the - O(fan-out) scan entirely. *) + The detach consults the child's recorded parent instead of scanning + the target's children: [c.parent = None] means the id sits in no + child list, so the common first-attach path skips any walk. *) let insert_child t parent child index = match (Hashtbl.find_opt t.nodes parent, Hashtbl.find_opt t.nodes child) with | Some p, Some c -> @@ -135,65 +360,116 @@ let insert_child t parent child index = | Some pid -> ( match Hashtbl.find_opt t.nodes pid with | Some op -> - vec_remove op.children child; - if pid <> parent then mark_dirty t pid + unlink op c; + if pid <> parent then + mark_dirty_mask t pid (d_structure lor d_layout lor d_paint) | None -> ()) | None -> ()); - vec_insert_at p.children index child; + insert_at p c index; c.parent <- Some parent; - mark_dirty t parent; - mark_dirty t child + mark_dirty_mask t parent (d_structure lor d_layout lor d_paint); + mark_dirty_mask t child (d_structure lor d_paint) | _ -> () +let journal_push t op = + Queue.push { crev = t.revision; cop = op } t.journal; + t.journal_len <- t.journal_len + 1; + while t.journal_len > journal_cap do + let dropped = Queue.pop t.journal in + t.journal_len <- t.journal_len - 1; + (* Entries that survived are complete only back to just after the + dropped one's revision: ops that shared its revision may have + fallen out with it, so the base must sit strictly below the + oldest surviving entry but above the dropped one. *) + t.journal_base <- dropped.crev + done + let apply_op t = function - | CreateNode (id, k) -> + | CreateNode (id, k) as op -> (* Recreate must drop the previous subtree first: a bare Hashtbl.replace leaves former children with parent=Some id while - the new node starts with an empty child_vec — orphans. *) + the new node starts with an empty child list — orphans. *) if Hashtbl.mem t.nodes id then drop t id; Hashtbl.replace t.nodes id (new_node id (Lui_wire_schema.node_kind_name k)); - mark_dirty t id - | CreateExtension (id, identifier, _fp) -> + mark_dirty_mask t id d_all; + journal_push t op + | CreateExtension (id, identifier, _fp) as op -> 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; - mark_dirty t id - | DropNode id | DetachSubtree id -> drop t id - | SetProp (id, p, v) -> ( + mark_dirty_mask t id d_all; + journal_push t op + | (DropNode id | DetachSubtree id) as op -> + drop t id; + journal_push t op + | SetProp (id, p, v) as op -> ( + let name = Lui_wire_schema.property_name p in match Hashtbl.find_opt t.nodes id with | Some n -> - Hashtbl.replace n.props (Lui_wire_schema.property_name p) v; - mark_dirty t id - | None -> ()) - | RemoveProp (id, p) -> ( + Hashtbl.replace n.props name v; + mark_dirty_mask t id (mask_of_prop name) + | None -> ()); + journal_push t op + | RemoveProp (id, p) as op -> ( + let name = Lui_wire_schema.property_name p in match Hashtbl.find_opt t.nodes id with | Some n -> - Hashtbl.remove n.props (Lui_wire_schema.property_name p); - mark_dirty t id - | None -> ()) - | SetExtensionProp (id, name, v) -> ( + Hashtbl.remove n.props name; + mark_dirty_mask t id (mask_of_prop name) + | None -> ()); + journal_push t op + | SetExtensionProp (id, name, v) as op -> ( match Hashtbl.find_opt t.nodes id with | Some n -> Hashtbl.replace n.props name v; - mark_dirty t id - | None -> ()) - | RemoveExtensionProp (id, name) -> ( + mark_dirty_mask t id d_all + | None -> ()); + journal_push t op + | RemoveExtensionProp (id, name) as op -> ( match Hashtbl.find_opt t.nodes id with | Some n -> Hashtbl.remove n.props name; - mark_dirty t id - | None -> ()) - | InsertChild (parent, child, index) | MoveChild (parent, child, index) -> - insert_child t parent child index - | RemoveChild (parent, child) -> ( + mark_dirty_mask t id d_all + | None -> ()); + journal_push t op + | (InsertChild (parent, child, index) | MoveChild (parent, child, index)) as op -> + insert_child t parent child index; + journal_push t op + | RemoveChild (parent, child) as op -> ( match (Hashtbl.find_opt t.nodes parent, Hashtbl.find_opt t.nodes child) with | Some p, Some c -> - vec_remove p.children child; - c.parent <- None; - mark_dirty t parent; - mark_dirty t child - | _ -> ()) + if c.parent = Some parent then ( + unlink p c; + c.parent <- None) + else begin + (* Defensive scan: the child claims a different parent but the + op names this one — walk the list and cut any matching link + so a stale record cannot leave the id listed twice. *) + let rec scan prev cur = + match cur with + | None -> () + | Some c' -> + if c'.id = child then ( + let next = c'.next_sib in + (match prev with + | Some s -> s.next_sib <- next + | None -> p.first_child <- next); + (match next with + | Some s -> s.prev_sib <- prev + | None -> p.last_child <- prev); + p.child_count <- p.child_count - 1; + c'.prev_sib <- None; + c'.next_sib <- None) + else scan cur c'.next_sib + in + scan None p.first_child; + c.parent <- None + end; + mark_dirty_mask t parent (d_structure lor d_layout lor d_paint); + mark_dirty_mask t child (d_structure lor d_paint) + | _ -> ()); + journal_push t op let apply_batch t (batch : patch_batch) = t.generation <- batch.generation; @@ -205,9 +481,10 @@ let node_id n = n.id let node_kind n = n.kind let node_ext_id n = n.ext_id let node_parent n = n.parent -let node_children n = vec_to_list n.children +let node_children n = List.map node_id (node_children_list n) let node_prop n name = Hashtbl.find_opt n.props name let node_revision (n : node) = n.revision +let node_child_count n = n.child_count let kind t id = match find_opt t id with Some n -> n.kind | None -> "" let is_editable_text_kind = function @@ -219,25 +496,20 @@ 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 -> - vec_fold - (fun acc c -> - match Hashtbl.find_opt t.nodes c with - | Some child -> child :: acc - | None -> acc) - [] n.children - |> List.rev + | Some n -> node_children_list n | None -> [] let child_ids t id = match find_opt t id with - | Some n -> vec_to_list n.children + | Some n -> List.map node_id (node_children_list n) | None -> [] let last_child_id t id = match find_opt t id with - | Some n -> vec_last n.children + | Some n -> Option.map node_id n.last_child | None -> None +let child_count t id = + match find_opt t id with Some n -> n.child_count | None -> 0 let prop t id name = match find_opt t id with | Some n -> Hashtbl.find_opt n.props name @@ -269,7 +541,9 @@ let preorder ?root t = let rec walk id acc = match find_opt t id with | None -> acc - | Some n -> vec_fold (fun acc c -> walk c acc) (id :: acc) n.children + | Some n -> + let acc = id :: acc in + List.fold_left (fun a c -> walk c.id a) acc (node_children_list n) in let roots = match root with Some id -> [ id ] | None -> root_ids t in List.rev (List.fold_left (fun acc id -> walk id acc) [] roots) diff --git a/platform/native/lui_store/lui_store.mli b/platform/native/lui_store/lui_store.mli index dbc6670b..cb053eb3 100644 --- a/platform/native/lui_store/lui_store.mli +++ b/platform/native/lui_store/lui_store.mli @@ -8,6 +8,37 @@ val node_count : t -> int val generation : t -> int val revision : t -> int +(** {1 Typed dirty tracking} *) + +type dirty_mask = int + +val d_structure : dirty_mask (* produced child set/order changed *) +val d_layout : dirty_mask (* own flex style or measure inputs changed *) +val d_paint : dirty_mask (* scene ops changed *) +val d_semantics : dirty_mask (* a11y tree *) +val d_input : dirty_mask (* focus/selection/interactive state *) +val d_layout_kids : dirty_mask +(* a prop that rewrites the styles or order of the node's produced + children (grid [columns], split [value], edge-inset [edge]) *) +val d_all : dirty_mask + +val mask_has : dirty_mask -> dirty_mask -> bool + +(** [mask_of_prop name] classifies a wire prop name into the stages it + feeds. Unknown names return the conservative layout|paint default. *) +val mask_of_prop : string -> dirty_mask + +(** Bumped by every op that can move geometry (structure, layout or + layout-kids masks). Pure paint churn leaves it untouched — the + layout memo keys on it so a background flip never rebuilds the + flex mirror. *) +val layout_rev : t -> int + +(** [changes_since t rev] replays the ops applied after store revision + [rev], oldest first, or [None] when the bounded journal no longer + reaches that far back (consumer must resync wholesale). *) +val changes_since : t -> int -> Lui_protocol.patch_op list option + val find_opt : t -> int -> node option val mem : t -> int -> bool val node_id : node -> int @@ -17,6 +48,7 @@ val node_parent : node -> int option val node_children : node -> int list val node_prop : node -> string -> Lui_protocol.wire_value option val node_revision : node -> int +val node_child_count : node -> int val apply_batch : t -> Lui_protocol.patch_batch -> unit val apply_op : t -> Lui_protocol.patch_op -> unit @@ -24,8 +56,15 @@ val apply_op : t -> Lui_protocol.patch_op -> unit (* Incremental invalidation: ids touched since the last drain, sorted ascending. A drop marks the removed id and its old parent. *) val drain_dirty : t -> int list + +(** Same drain, with the OR-ed mask of everything that touched the id. *) +val drain_dirty_masked : t -> (int * dirty_mask) list + val peek_dirty : t -> int -> bool +(** [peek_dirty_mask t id] is the pending mask for [id] (0 when clean). *) +val peek_dirty_mask : t -> int -> dirty_mask + val kind : t -> int -> string (** Editable text kinds have an intrinsic line height even when empty. *) @@ -36,6 +75,7 @@ val parent : t -> int -> int option val children : t -> int -> node list val child_ids : t -> int -> int list val last_child_id : t -> int -> int option +val child_count : t -> int -> int val prop : t -> int -> string -> Lui_protocol.wire_value option val bool_prop : t -> int -> string -> bool val string_prop : t -> int -> string -> string option diff --git a/platform/native/lui_store/test/lui_store_test.ml b/platform/native/lui_store/test/lui_store_test.ml index e92b24e5..8ddac99f 100644 --- a/platform/native/lui_store/test/lui_store_test.ml +++ b/platform/native/lui_store/test/lui_store_test.ml @@ -87,6 +87,104 @@ let test_dirty () = apply t [ SetProp (2, TextValue, StringValue "v") ]; check (list int) "only touched" [2] (Lui_store.drain_dirty t) +(* Typed dirty masks: a paint-only prop reports only the paint bit, a + layout prop reports layout|paint, and structure ops report + structure. Consumers drain the same ids they always did — the mask + rides along. *) +let test_dirty_masks () = + let t = Lui_store.create () in + apply t [ CreateNode (1, Column); CreateNode (2, Text); InsertChild (1, 2, 0) ]; + ignore (Lui_store.drain_dirty_masked t); + apply t [ SetProp (2, BackgroundValue, StringValue "#fff") ]; + (match Lui_store.drain_dirty_masked t with + | [ (2, m) ] -> + check bool "paint bit" true (Lui_store.mask_has m Lui_store.d_paint); + check bool "no layout bit" false (Lui_store.mask_has m Lui_store.d_layout); + check bool "no structure bit" false (Lui_store.mask_has m Lui_store.d_structure) + | _ -> fail "expected one masked entry"); + apply t [ SetProp (2, WidthValue, IntValue 30) ]; + (match Lui_store.drain_dirty_masked t with + | [ (2, m) ] -> + check bool "layout bit" true (Lui_store.mask_has m Lui_store.d_layout); + check bool "paint bit" true (Lui_store.mask_has m Lui_store.d_paint) + | _ -> fail "expected one masked entry"); + apply t [ SetProp (2, DisplayValue, StringValue "none") ]; + (match Lui_store.drain_dirty_masked t with + | [ (2, m) ] -> + check bool "display is structure" true (Lui_store.mask_has m Lui_store.d_structure) + | _ -> fail "expected one masked entry"); + (* masks accumulate across ops on one id *) + apply t + [ SetProp (2, BackgroundValue, StringValue "#000"); + SetProp (2, HeightValue, IntValue 10) ]; + (match Lui_store.drain_dirty_masked t with + | [ (2, m) ] -> + check bool "merged paint" true (Lui_store.mask_has m Lui_store.d_paint); + check bool "merged layout" true (Lui_store.mask_has m Lui_store.d_layout) + | _ -> fail "expected merged entry") + +(* The layout revision moves only on geometry-relevant ops: paint churn + leaves it (and therefore the layout memo) untouched. *) +let test_layout_rev () = + let t = Lui_store.create () in + apply t [ CreateNode (1, Column); CreateNode (2, Text); InsertChild (1, 2, 0) ]; + let r0 = Lui_store.layout_rev t in + check bool "mount bumps" true (r0 > 0); + apply t [ SetProp (2, BackgroundValue, StringValue "#fff") ]; + check int "paint only" r0 (Lui_store.layout_rev t); + apply t [ SetProp (2, ZIndex, IntValue 2) ]; + check int "z-index paint only" r0 (Lui_store.layout_rev t); + apply t [ SetProp (2, AccessibilityLabel, StringValue "x") ]; + check int "a11y prop not layout" r0 (Lui_store.layout_rev t); + apply t [ SetProp (2, WidthValue, IntValue 10) ]; + check bool "width bumps" true (Lui_store.layout_rev t > r0) + +(* changes_since replays ops in order for a consumer cursor; an unknown + revision still yields everything reachable. *) +let test_changes_since () = + let t = Lui_store.create () in + apply t [ CreateNode (1, Column); CreateNode (2, Text); InsertChild (1, 2, 0) ]; + let rev = Lui_store.revision t in + (match Lui_store.changes_since t rev with + | Some [] -> () + | _ -> fail "expected no ops since head"); + apply t [ SetProp (2, TextValue, StringValue "a"); SetProp (2, WidthValue, IntValue 5) ]; + (match Lui_store.changes_since t rev with + | Some [ SetProp (2, TextValue, StringValue "a"); SetProp (2, WidthValue, IntValue 5) ] -> () + | _ -> fail "expected the two SetProps in order"); + (match Lui_store.changes_since t 0 with + | Some ops -> check bool "from scratch" true (List.length ops >= 5) + | None -> fail "fresh journal covers rev 0") + +(* O(N) wide removals: the intrusive child list detaches each child in + O(1), so dropping 20k siblings one op at a time — or dropping their + parent — stays linear. The old compacting array re-scanned the + siblings per removal and took seconds here. *) +let test_wide_removal_complexity () = + let n = 20_000 in + let t = Lui_store.create () in + apply t [ CreateNode (1, Column) ]; + apply t + (List.concat_map + (fun i -> [ CreateNode (10 + i, Text); InsertChild (1, 10 + i, i) ]) + (List.init n Fun.id)); + let t0 = Sys.time () in + apply t (List.init n (fun i -> RemoveChild (1, 10 + i))); + let remove_ms = (Sys.time () -. t0) *. 1000. in + check (list int) "all detached" [] (Lui_store.child_ids t 1); + (* Repopulate, then drop the whole subtree at once. *) + apply t + (List.concat_map + (fun i -> [ CreateNode (10 + i, Text); InsertChild (1, 10 + i, i) ]) + (List.init n Fun.id)); + let t1 = Sys.time () in + apply t [ DropNode 1 ]; + let drop_ms = (Sys.time () -. t1) *. 1000. in + check int "dropped" 0 (Lui_store.node_count t); + (* ~2ms at linear; the quadratic version needed ~seconds. *) + check bool "remove under 500ms" true (remove_ms < 500.); + check bool "drop under 500ms" true (drop_ms < 500.) + let test_root_ids () = let t = Lui_store.create () in apply t @@ -141,4 +239,8 @@ let () = test_case "extension" `Quick test_extension ]); ("dirty", [ test_case "drain" `Quick test_dirty; + test_case "typed masks" `Quick test_dirty_masks; + test_case "layout rev" `Quick test_layout_rev; + test_case "changes since" `Quick test_changes_since; + test_case "wide removal complexity" `Quick test_wide_removal_complexity; test_case "missing ids ignored" `Quick test_ops_on_missing_are_ignored ]) ] From 8834d918469da596a16d37aa9bc1f925f1d6b995 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 05:01:22 +0800 Subject: [PATCH 2/9] perf(ml): persistent incremental layout mirror (Milestone 2) The flex mirror no longer rebuilds per sync. node_id -> flex_node mappings persist across frames in a shadow of produced expansions (pkids/sparent/owner), and Lui_store.changes_since replays the op journal onto them: - SetProp/RemoveProp classified layout re-styles the node's flex node in place (new Lui_flex.set_measure/set_children swap style, measure and child arrays without recreating the node); layout-kids props restyle the produced children. - display/structure props re-expand the node's produced set in place. - Insert/Move/Remove splice the raw-parent shadow and re-set only the affected owner's flex children; contents/hidden nodes lift their produced set through the raw chain to the nearest flex owner. - The flex engine's own dirty propagation confines relayout to the touched path, and rect_dirty scopes the rect re-read to the shifted produced subtrees (top affected owner plus any later frame siblings). sync now has three lanes: paint/semantics-only churn returns early on an unmoved layout_rev, the journal replays layout-relevant ops, and a wholesale rebuild remains for input changes, invalidation, or a journal that no longer reaches our cursor. Differential tests rebuild-oracle the mirror after every op pattern (moves, reorders, display toggles, detach-to-root, drops, prop churn, two-root shifts, an 80-round seeded fuzz, and a 10k-sibling tree). Single text edit on a 10k flat column: ~73 ms -> ~17 ms (mirror rebuild eliminated; the residual is the flex line re-flow, P2-1 territory). Paint-only prop sync: 0 ms. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_flex/lui_flex.ml | 27 +- platform/native/lui_flex/lui_flex.mli | 14 + platform/native/lui_layout/lui_layout.ml | 806 +++++++++++++++--- platform/native/lui_layout/lui_layout.mli | 9 +- .../native/lui_layout/test/lui_layout_test.ml | 162 +++- 5 files changed, 884 insertions(+), 134 deletions(-) diff --git a/platform/native/lui_flex/lui_flex.ml b/platform/native/lui_flex/lui_flex.ml index 3b06461c..15a59541 100644 --- a/platform/native/lui_flex/lui_flex.ml +++ b/platform/native/lui_flex/lui_flex.ml @@ -288,11 +288,11 @@ type node = { mutable parent : node; mutable next_child : node; mutable has_new_layout : bool; - measure : (node -> width:float -> width_mode:measure_mode + mutable measure : (node -> width:float -> width_mode:measure_mode -> height:float -> height_mode:measure_mode -> dims) option; - children : node array; - children_count : int; + mutable children : node array; + mutable children_count : int; mutable is_dirty : bool; (* Auto margins resolved by the parent during its flex line layout, per physical edge. *) @@ -2563,6 +2563,7 @@ let create ?(style = default_style) ?measure (children : node list) : node = n let children (n : node) : node list = Array.to_list n.children +let child_count (n : node) : int = n.children_count let parent (n : node) : node option = if n.parent == null_node then None else Some n.parent @@ -2573,6 +2574,26 @@ let set_style (n : node) (s : style) : unit = (* Propagate the dirty flag up so the ancestors re-measure. *) mark_dirty_internal n +(* The persistent layout mirror needs mid-life child edits: swapping the + array keeps node identity (and its cached layout) while the store + splices produced children in and out. Detached children lose their + parent link so a stale pointer cannot dirty a tree they left. *) +let set_children (n : node) (kids : node list) : unit = + let arr = Array.of_list kids in + Array.iter (fun c -> if c.parent == n then c.parent <- null_node) n.children; + Array.iter (fun c -> c.parent <- n) arr; + n.children <- arr; + n.children_count <- Array.length arr; + mark_dirty_internal n + +(* Text/leaf re-measure: the mirror rebuilds the closure when a measure + input prop changes instead of recreating the node. *) +let set_measure (n : node) (m : measure option) : unit = + if m <> None && n.children_count > 0 then + invalid_arg "Lui_flex.set_measure: a measure callback is only allowed on leaf nodes"; + n.measure <- m; + mark_dirty_internal n + let has_new_layout (n : node) : bool = n.has_new_layout let clear_new_layout (n : node) : unit = n.has_new_layout <- false diff --git a/platform/native/lui_flex/lui_flex.mli b/platform/native/lui_flex/lui_flex.mli index ea335fd6..2365a229 100644 --- a/platform/native/lui_flex/lui_flex.mli +++ b/platform/native/lui_flex/lui_flex.mli @@ -166,3 +166,17 @@ val layout : node -> rect val absolute_layout : node -> rect val iter : (node -> unit) -> node -> unit + +(** [child_count node] is the number of children without materializing + the list. *) +val child_count : node -> int + +(** [set_children node kids] replaces the child array in place, fixing + parent links and marking the node dirty so ancestors re-measure. + Detached children get their parent link cleared. *) +val set_children : node -> node list -> unit + +(** [set_measure node m] swaps the leaf's measure callback, marking the + node dirty. Raises [Invalid_argument] when [m] is [Some] on a node + with children, mirroring {!create}. *) +val set_measure : node -> measure option -> unit diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index 74440d2d..3e15407a 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -17,45 +17,91 @@ 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 +(* One raw child's produced expansion inside its parent's produced + list: [gens] is the flat list of store ids that own flex nodes and + land, in order, inside the flex children of the parent's flex owner + (a normal child produces itself; a display:contents child produces + its own produced children; display:none produces nothing). *) +type pkid = { raw : int; mutable gens : int list } + +(* The pseudo store id of the synthetic frame root: store roots' parent + in the [sparent] shadow and the key of their produced list in + [pkids]. Real store ids are >= 0. *) +let frame_id = -1 + type t = { mutable root : Lui_flex.node option; + (* The mirror is persistent: maps below live across syncs and the + journal replays store ops onto them. *) 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; + (* Store ids whose produced subtree needs a rect re-read after the + incremental pass ([frame_id] = everything). *) + rect_dirty : (int, unit) Hashtbl.t; + (* [pkids p] is the produced expansion of raw parent p's children — + the shadow the incremental path edits when children move. Present + for every built node (leaves keep []) and for [frame_id]. *) + pkids : (int, pkid list) Hashtbl.t; + (* Raw parent of each built store id at mirror time (drops need it — + the store link is already gone when the op arrives). *) + sparent : (int, int) Hashtbl.t; + (* Produced store id → owner: the store id whose flex node is its + flex parent ([frame_id] for frame children). *) + owner : (int, int) Hashtbl.t; + grids : (int * Lui_flex.node * Lui_flex.style * float) list ref; + popups : (Lui_flex.node * int) list ref; 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 - inputs compare by identity — a caller whose measure/hidden - closure keeps its identity but changes its answers (e.g. a text - engine that finishes loading fonts) must {!invalidate}. *) + is the store revision the journal has been replayed through; + [synced_lrev] is the layout revision ([Lui_store.layout_rev]) — + paint-only churn leaves it still so the fast path skips entirely. + -1 forces a rebuild. Function inputs compare by identity — a + caller whose measure/hidden closure keeps its identity but + changes its answers (e.g. a text engine that finishes loading + fonts) must {!invalidate}. *) mutable synced_rev : int; + mutable synced_lrev : int; mutable synced_scale : float; mutable synced_w : float; mutable synced_h : float; mutable synced_anchor : (float * float) option; mutable synced_measure : text_measure; mutable synced_hidden : int -> bool; - (* Instrumentation: how many times {!sync} actually rebuilt the - mirror. Lets tests assert that memoization (and later the - incremental path) skipped work instead of trusting timings. *) + (* Instrumentation: [stat_syncs] counts syncs that rebuilt the mirror + wholesale, [stat_steps] syncs that applied journal ops. Lets tests + assert memoization and the incremental path instead of trusting + timings. *) mutable stat_syncs : int; + mutable stat_steps : int; } let create () = { root = None; nodes = Hashtbl.create 64; + applied = Hashtbl.create 64; rects = Hashtbl.create 64; + rect_dirty = Hashtbl.create 16; + pkids = Hashtbl.create 64; + sparent = Hashtbl.create 64; + owner = Hashtbl.create 64; + grids = ref []; + popups = ref []; extent = (0., 0.); synced_rev = -1; + synced_lrev = -1; synced_scale = 0.; synced_w = 0.; synced_h = 0.; synced_anchor = None; synced_measure = no_measure; synced_hidden = show_all; - stat_syncs = 0 } + stat_syncs = 0; + stat_steps = 0 } let invalidate (t : t) = t.synced_rev <- -1 let stat_syncs (t : t) = t.stat_syncs +let stat_steps (t : t) = t.stat_steps (* ---------- wire value access ---------- *) @@ -625,90 +671,163 @@ type ctx = { hidden : int -> bool; (* host-owned transient visibility (menus) *) ctx_anchor : (float * float) option; (* pointer point for context menus *) store : Lui_store.t; + eng : t; (* back-reference: root and rects *) nodes : (int, Lui_flex.node) Hashtbl.t; (* store id → flex node *) applied : (int, Lui_flex.style) Hashtbl.t; (* store id → applied style *) - grids : (Lui_flex.node * Lui_flex.style * float) list ref; + pkids : (int, pkid list) Hashtbl.t; + sparent : (int, int) Hashtbl.t; + owner : (int, int) Hashtbl.t; + grids : (int * Lui_flex.node * Lui_flex.style * float) list ref; popups : (Lui_flex.node * int) list ref; (* flex node, store id *) } -(* [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. *) -let rec build ctx store ~pkind ~pid ~index id : Lui_flex.node list = - if display_none store id || ctx.hidden id then [] - else if display_contents store id then - build_seq ctx store ~pkind ~pid ~first:index (Lui_store.child_ids store id) +let kind_of ctx id = if id = frame_id then "frame" else Lui_store.kind ctx.store id + +(* What a store node produces into its flex parent's children. *) +type nstatus = N_hidden | N_contents | N_normal + +let status_of ctx id = + if display_none ctx.store id || ctx.hidden id then N_hidden + else if display_contents ctx.store id then N_contents + else N_normal + +let pkids_of ctx p = Option.value ~default:[] (Hashtbl.find_opt ctx.pkids p) + +(* Produced gens from mirror state only — never builds. *) +let peek_produced ctx id = + match status_of ctx id with + | N_hidden -> [] + | N_normal -> if Hashtbl.mem ctx.nodes id then [ id ] else [] + | N_contents -> List.concat_map (fun e -> e.gens) (pkids_of ctx id) + +(* The store id owning the flex node p's produced children land in: p + itself when it has a flex node, else the nearest ancestor with one + (contents/hidden nodes lift their produced set up the raw chain). *) +let children_owner ctx p = + if Hashtbl.mem ctx.nodes p then p else - let style = - style_of ~scale:ctx.scale ~viewport:(ctx.vw, ctx.vh) store id - |> adjust ~pkind ~pid ~index store id + let rec climb x = + match Hashtbl.find_opt ctx.sparent x with + | Some par -> + if par = frame_id || Hashtbl.mem ctx.nodes par then par + else climb par + | None -> frame_id in - let kind = Lui_store.kind store id in - 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 + climb p + +(* A node whose produced children are all popup surfaces (e.g. a menu + trigger) still sizes to its own label — the popups are out of flow + and contribute nothing. *) +let popup_min_style ctx id cids kids (style : Lui_flex.style) : Lui_flex.style = + let text = text_of ctx.store id in + match (kids, text) with + | _ :: _, Some t + when List.for_all + (fun cid -> popup_kind (Lui_store.kind ctx.store cid)) + cids -> ( + match ctx.tm id t ctx.vw ctx.vh with + | Some (tw, th) -> + { style with + min_width = + (match (style.width, style.min_width) with + | Unset, Unset -> Pt tw + | _, l -> l); + min_height = + (match (style.height, style.min_height) with + | Unset, Unset -> Pt th + | _, l -> l) } + | None -> style) + | _ -> style + +let measure_for ctx id ~kids : Lui_flex.measure option = + match (kids, text_of ctx.store id) with + | [], Some t -> ( + match Lui_store.prop ctx.store id "icon" with + | Some (StringValue s) when s <> "" -> + (* labeled_content_ops paints a 16px icon slot + 6px gap — + reserve them so the label doesn't overflow the chrome *) + let icon_extra = 22. *. ctx.scale in + Some + (fun node ~width ~width_mode ~height ~height_mode -> + let d = + measure_of ctx.tm ~id ~text:t node ~width ~width_mode + ~height ~height_mode + in + { d with Lui_flex.width = d.Lui_flex.width +. icon_extra }) + | _ -> Some (measure_of ctx.tm ~id ~text:t)) + | _ -> None + +let register ctx id kind node style = + (if kind = "grid" then + match pnum ctx.store id "columns" with + | Some n when n > 0. -> + ctx.grids := (id, node, style, n) :: !(ctx.grids) + | _ -> ()); + if popup_kind kind + && not (kind = "drawer" && has_prop ctx.store id "selected") + then ctx.popups := (node, id) :: !(ctx.popups) + +let unregister ctx id = + ctx.grids := List.filter (fun (g, _, _, _) -> g <> id) !(ctx.grids); + ctx.popups := List.filter (fun (_, s) -> s <> id) !(ctx.popups) + +(* [build] expands a store node into (flex node, generator store id) + pairs — a singleton for normal kinds, [] for display:none/hidden, + the lifted produced pairs for display:contents. Every mirror map is + populated as it goes: nodes/applied/owner per built node, pkids and + sparent per expanded parent. [index] is the flattened sibling + position inside the produced children of [pid]. *) +let rec build ctx ~owner_id ~pkind ~pid ~index id : (Lui_flex.node * int) list = + match status_of ctx id with + | N_hidden -> [] + | N_contents -> + let cids = + ordered_child_ids ctx.store id (Lui_store.kind ctx.store id) + in + fst + (expand_children ctx ~owner:owner_id ~pkind ~pid ~raw_parent:id + ~first:index cids) + | N_normal -> + let kind = Lui_store.kind ctx.store id in let style = - (* A node whose built children are all popup surfaces (e.g. a - menu trigger) still sizes to its own label — the popups are - out of flow and contribute nothing. *) - match (kids, text) with - | _ :: _, Some t - when List.for_all - (fun cid -> popup_kind (Lui_store.kind store cid)) - cids -> ( - match ctx.tm id t ctx.vw ctx.vh with - | Some (tw, th) -> - { style with - min_width = - (match (style.width, style.min_width) with - | Unset, Unset -> Pt tw - | _, l -> l); - min_height = - (match (style.height, style.min_height) with - | Unset, Unset -> Pt th - | _, l -> l) } - | None -> style) - | _ -> style + style_of ~scale:ctx.scale ~viewport:(ctx.vw, ctx.vh) ctx.store id + |> adjust ~pkind ~pid ~index ctx.store id in - let m = - match (kids, text) with - | [], Some t -> ( - match Lui_store.prop store id "icon" with - | Some (StringValue s) when s <> "" -> - (* labeled_content_ops paints a 16px icon slot + 6px gap — - reserve them so the label doesn't overflow the chrome *) - let icon_extra = 22. *. ctx.scale in - Some - (fun node ~width ~width_mode ~height ~height_mode -> - let d = - measure_of ctx.tm ~id ~text:t node ~width ~width_mode - ~height ~height_mode - in - { d with Lui_flex.width = d.Lui_flex.width +. icon_extra }) - | _ -> Some (measure_of ctx.tm ~id ~text:t)) - | _ -> None + let cids = ordered_child_ids ctx.store id kind in + let kids, () = + expand_children ctx ~owner:id ~pkind:kind ~pid:id ~raw_parent:id + ~first:0 cids in + let kids = List.map fst kids in + let style = popup_min_style ctx id cids kids style in + let m = measure_for ctx id ~kids in let node = Lui_flex.create ~style ?measure:m kids in Hashtbl.replace ctx.nodes id node; Hashtbl.replace ctx.applied id style; - (if kind = "grid" then - match pnum store id "columns" with - | Some n when n > 0. -> - ctx.grids := (node, style, n) :: !(ctx.grids) - | _ -> ()); - (if popup_kind kind - && not (kind = "drawer" && has_prop store id "selected") - then ctx.popups := (node, id) :: !(ctx.popups)); - [ node ] - -and build_seq ctx store ~pkind ~pid ~first ids : Lui_flex.node list = + Hashtbl.replace ctx.owner id owner_id; + register ctx id kind node style; + [ (node, id) ] + +(* Build each raw child of [raw_parent], recording sparent per child + and pkids[raw_parent]. [owner] is the flex owner the produced pairs + belong to — for a contents raw_parent the enclosing owner's values + pass straight through, exactly like [pkind]/[pid] did at rebuild + time. *) +and expand_children ctx ~owner ~pkind ~pid ~raw_parent ~first cids = let counter = ref first in - List.concat_map + let acc = ref [] and entries = ref [] in + List.iter (fun cid -> - let ns = build ctx store ~pkind ~pid ~index:!counter cid in - counter := !counter + List.length ns; - ns) - ids + let pairs = + build ctx ~owner_id:owner ~pkind ~pid ~index:!counter cid + in + counter := !counter + List.length pairs; + Hashtbl.replace ctx.sparent cid raw_parent; + entries := { raw = cid; gens = List.map snd pairs } :: !entries; + acc := List.rev_append pairs !acc) + cids; + Hashtbl.replace ctx.pkids raw_parent (List.rev !entries); + (List.rev !acc, ()) (* ---------- two-pass refinement ---------- @@ -744,20 +863,13 @@ let edge_px ~owner named axis (e : Lui_flex.edges) : float = 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 = - let found = ref None in - Hashtbl.iter - (fun sid n -> if !found = None && n == fnode then found := Some sid) - ctx.nodes; - !found - (* Pin every flex child of a [columns = n] grid to its real share of - the row so [n] cells plus [(n-1)] gaps fit one line. *) + the row so [n] cells plus [(n-1)] gaps fit one line. The produced + children come from pkids — the mirror is persistent, so a reverse + flex→store lookup is no longer needed. *) let refine_grids ctx = List.iter - (fun (gnode, (gstyle : Lui_flex.style), ncols) -> + (fun (gsid, gnode, (gstyle : Lui_flex.style), ncols) -> let gr = Lui_flex.absolute_layout gnode in let pl = edge_px ~owner:gr.w gstyle.padding.left gstyle.padding.horizontal gstyle.padding in let pr = edge_px ~owner:gr.w gstyle.padding.right gstyle.padding.horizontal gstyle.padding in @@ -771,15 +883,18 @@ let refine_grids ctx = Float.max 0. ((inner_w -. (ncols -. 1.) *. gap) /. ncols -. 0.001) in List.iter - (fun c -> - match store_id_of_flex ctx c with - | Some sid -> ( - match Hashtbl.find_opt ctx.applied sid with - | Some s -> - Lui_flex.set_style c { s with flex_basis = Pt basis } - | None -> ()) - | None -> ()) - (Lui_flex.children gnode)) + (fun e -> + List.iter + (fun sid -> + match + (Hashtbl.find_opt ctx.applied sid, + Hashtbl.find_opt ctx.nodes sid) + with + | Some s, Some f -> + Lui_flex.set_style f { s with flex_basis = Pt basis } + | _ -> ()) + e.gens) + (pkids_of ctx gsid)) !(ctx.grids) (* Popup anchoring: [x]/[y] supply an anchor point (a zero-size anchor @@ -850,8 +965,15 @@ let refine_popups ctx = match Lui_flex.parent pnode with | None -> () | Some parent -> ( + (* The flex parent's store id is the popup's recorded owner — + [frame_id] means the popup sits directly under the root. *) + let psid_opt = + match Hashtbl.find_opt ctx.owner sid with + | Some o when o <> frame_id -> Some o + | _ -> None + in let pr, pstyle_opt = - match store_id_of_flex ctx parent with + match psid_opt with | Some psid -> ( Lui_flex.absolute_layout parent, Hashtbl.find_opt ctx.applied psid ) @@ -895,7 +1017,7 @@ let refine_popups ctx = | Some anc -> positioned anc | None -> None) in - match store_id_of_flex ctx parent with + match psid_opt with | Some psid -> (match positioned psid with | Some r -> r | None -> pr) @@ -1089,44 +1211,472 @@ let compute t ~width ~height = later {!sync} at the memoized size would wrongly reuse them. *) invalidate t -let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) - ?ctx_anchor ~width ~height (t : t) store = - let rev = Lui_store.layout_rev store in - (* The store bumps its layout revision on every mutation that can - move geometry — structure and layout-classified props — so a - matching key means the rebuild below would produce an identical - mirror: skipped wholesale (scroll-only frames, idle repaints and - paint-only prop churn all pay nothing). *) - if - t.synced_rev <> rev || t.synced_scale <> scale - || t.synced_w <> width || t.synced_h <> height - || t.synced_anchor <> ctx_anchor - || (not (t.synced_measure == measure)) - || (not (t.synced_hidden == hidden)) - then begin - t.stat_syncs <- t.stat_syncs + 1; - Hashtbl.reset t.nodes; - let ctx = - { scale; vw = width; vh = height; tm = measure; hidden; - ctx_anchor; store; nodes = t.nodes; - applied = Hashtbl.create 64; grids = ref []; popups = ref [] } +(* ---------- incremental mirror ---------- + + Between syncs the mirror is persistent: [changes_since] replays the + store's op journal onto the shadow tables (pkids/sparent/owner), the + flex engine's own dirty propagation confines relayout to the touched + paths, and [rect_dirty] scopes the rect refresh to the produced + subtrees a change can actually shift. *) + +(* produced_of is the built-state produced list: unlike peek it expands + whatever the produced set needs. [owner_id] is the flex owner a lazy + expansion belongs to — the caller knows it, an unbuilt node's own + shadow does not yet. *) +let rec produced_of ctx ~owner_id id = + match status_of ctx id with + | N_hidden -> [] + | N_normal -> [ id ] + | N_contents -> + (if not (Hashtbl.mem ctx.pkids id) then + let cids = + ordered_child_ids ctx.store id (Lui_store.kind ctx.store id) + in + ignore + (expand_children ctx ~owner:owner_id ~pkind:(kind_of ctx owner_id) + ~pid:owner_id ~raw_parent:id ~first:0 cids)); + List.concat_map (fun e -> e.gens) (pkids_of ctx id) + +(* ensure_produced builds whatever the produced expansion needs: a + never-built node mirrors its whole subtree, an unexpanded contents + node expands its children, anything already built is reused. *) +and ensure_produced ctx ~owner_id id = + match status_of ctx id with + | N_hidden -> [] + | N_normal -> + if Hashtbl.mem ctx.nodes id then [ id ] + else begin + ignore + (build ctx ~owner_id ~pkind:(kind_of ctx owner_id) ~pid:owner_id + ~index:0 id); + [ id ] + end + | N_contents -> produced_of ctx ~owner_id id + +(* Raw children in produced order: store roots for the frame + pseudo-parent, the ordered child list otherwise. *) +let raw_children ctx p = + if p = frame_id then Lui_store.root_ids ctx.store + else ordered_child_ids ctx.store p (kind_of ctx p) + +(* Align pkids[p] with the store's current child list for p: recorded + gens survive (the produced set did not change), [overrides] supply + fresh gens for spliced children, unseen raws peek — never build. *) +let sync_entries ?(overrides = []) ctx p = + let ids = raw_children ctx p in + let old = pkids_of ctx p in + let tbl = + Hashtbl.create (2 * (List.length old + List.length overrides + 1)) + in + List.iter (fun e -> Hashtbl.replace tbl e.raw e.gens) old; + List.iter (fun (id, g) -> Hashtbl.replace tbl id g) overrides; + Hashtbl.replace ctx.pkids p + (List.map + (fun id -> + { raw = id; + gens = + (match Hashtbl.find_opt tbl id with + | Some g -> g + | None -> peek_produced ctx id) }) + ids) + +let flex_children_of ctx p : Lui_flex.node list = + List.concat_map + (fun e -> List.filter_map (Hashtbl.find_opt ctx.nodes) e.gens) + (pkids_of ctx p) + +(* Update pkids[p]'s entry for [raw] to [ng]; true when the gens + actually changed (unchanged means ancestors see no difference and the + climb can stop). *) +let replace_gens ctx p raw ng = + match Hashtbl.find_opt ctx.pkids p with + | None -> false + | Some es -> + let rec loop = function + | [] -> false + | e :: rest -> + if e.raw = raw then + if e.gens = ng then false + else begin + e.gens <- ng; + true + end + else loop rest + in + loop es + +(* pkids[p] changed: re-set the flex children of the flex owner of p's + produced set — p's own flex node when it has one — and, for + hidden/contents p, push the recomputed produced list into the + parent's slot and keep climbing until a flex owner. *) +let rec commit ctx p = + if p = frame_id then ( + match ctx.eng.root with + | Some root -> Lui_flex.set_children root (flex_children_of ctx frame_id) + | None -> ()) + else if Hashtbl.mem ctx.nodes p then + Lui_flex.set_children (Hashtbl.find ctx.nodes p) (flex_children_of ctx p) + else begin + match Hashtbl.find_opt ctx.sparent p with + | Some par -> + if + replace_gens ctx par p + (produced_of ctx ~owner_id:(children_owner ctx p) p) + then commit ctx par + | None -> () + end + +(* A frame-level produced node that resized also shifted every later + frame sibling — mark their produced subtrees too. [t] is a produced + id directly under the frame. *) +let mark_frame_siblings_after ctx t = + let rec scan_entries = function + | [] -> () + | e :: rest -> + let rec mark_tail = function + | [] -> scan_entries rest + | g :: gs -> + if g = t then begin + List.iter + (fun x -> Hashtbl.replace ctx.eng.rect_dirty x ()) + gs; + List.iter + (fun e' -> + List.iter + (fun x -> Hashtbl.replace ctx.eng.rect_dirty x ()) + e'.gens) + rest + end + else mark_tail gs + in + mark_tail e.gens + in + scan_entries (pkids_of ctx frame_id) + +(* Any change can re-flow the owner's siblings, which can grow the + owner, which can re-flow the grandparent's siblings — the shifted + region walks the flex-parent chain to a store root. The produced + subtree of that root covers every mirror node under it, so marking + the chain's topmost owner is the tight bound (plus, for a resized + root, every later frame sibling) and a full collect is its cheap + realization (rect writes only — no style resolution, no flex node + churn). *) +let mark_affected ctx o = + let rec top x = + match Hashtbl.find_opt ctx.owner x with + | Some p when p <> frame_id -> top p + | _ -> x + in + let t = top o in + Hashtbl.replace ctx.eng.rect_dirty t (); + if t <> frame_id then mark_frame_siblings_after ctx t + +(* Flattened position of produced node g inside owner o's produced + children — the value [index] had when g was built. *) +let produced_index ctx o g = + let rec scan acc = function + | [] -> acc + | e :: rest -> ( + let rec find i = function + | [] -> None + | x :: xs -> if x = g then Some (acc + i) else find (i + 1) xs + in + match find 0 e.gens with + | Some p -> p + | None -> scan (acc + List.length e.gens) rest) + in + scan 0 (pkids_of ctx o) + +(* Recompute g's style and measure against its current owner and index + — the incremental equivalent of rebuilding its flex node. *) +let restyle_gen ctx o g = + match Hashtbl.find_opt ctx.nodes g with + | Some f -> + let index = produced_index ctx o g in + let style = + style_of ~scale:ctx.scale ~viewport:(ctx.vw, ctx.vh) ctx.store g + |> adjust ~pkind:(kind_of ctx o) ~pid:o ~index ctx.store g in - let kids = - build_seq ctx store ~pkind:"frame" ~pid:(-1) ~first:0 - (Lui_store.root_ids store) + let kids = Lui_flex.children f in + let cids = + ordered_child_ids ctx.store g (Lui_store.kind ctx.store g) in - let root = - Lui_flex.create ~style:(frame_style_for width (Pt height)) kids + let style = popup_min_style ctx g cids kids style in + let m = measure_for ctx g ~kids in + Lui_flex.set_style f style; + Lui_flex.set_measure f m; + Hashtbl.replace ctx.applied g style; + Hashtbl.replace ctx.owner g o; + let kind = Lui_store.kind ctx.store g in + unregister ctx g; + register ctx g kind f style + | None -> () + +(* Parent kinds whose child adjustment depends on sibling position: + membership or order churn under them rewrites sibling styles, not + just the moved node's. *) +let index_sensitive = function + | "overlay" | "stack" | "panel" | "card" | "resizable" + | "view-that-fits" | "split" | "edge-inset" -> true + | _ -> false + +let restyle_children_of ctx o = + List.iter + (fun e -> List.iter (fun g -> restyle_gen ctx o g) e.gens) + (pkids_of ctx o) + +(* Insert/MoveChild: detach the shadow entry from the old raw parent, + ensure the child's produced set exists, then let [sync_entries] + place it at the store's current position. *) +let attach_child ctx ~parent:p ~child:c = + (match Hashtbl.find_opt ctx.sparent c with + | Some op when op <> p -> + sync_entries ctx op; + commit ctx op; + mark_affected ctx (children_owner ctx op) + | _ -> ()); + let o = children_owner ctx p in + let gens = ensure_produced ctx ~owner_id:o c in + Hashtbl.replace ctx.sparent c p; + sync_entries ctx p ~overrides:[ (c, gens) ]; + (* Commit before restyling: gens indices inside the owner's produced + list only read correctly once the parent chain has propagated. *) + commit ctx p; + List.iter + (fun g -> + Hashtbl.replace ctx.owner g o; + restyle_gen ctx o g) + gens; + (if index_sensitive (kind_of ctx o) then restyle_children_of ctx o); + mark_affected ctx o + +let detach_child ctx ~parent:p ~child:c = + Hashtbl.remove ctx.sparent c; + sync_entries ctx p; + commit ctx p; + let o = children_owner ctx p in + (if index_sensitive (kind_of ctx o) then restyle_children_of ctx o); + mark_affected ctx o + +(* The store link is already gone (drop/recreate): find the old raw + parent in the shadow, drop the entry, keep the subtree for reuse. *) +let detach_from_parent ctx id = + match Hashtbl.find_opt ctx.sparent id with + | Some par -> + Hashtbl.remove ctx.sparent id; + sync_entries ctx par; + commit ctx par; + mark_affected ctx (children_owner ctx par) + | None -> () + +(* Remove id's flex node and every built descendant's mirror state. *) +let rec purge ctx id = + (match Hashtbl.find_opt ctx.pkids id with + | Some es -> + Hashtbl.remove ctx.pkids id; + List.iter (fun e -> purge ctx e.raw) es + | None -> ()); + Hashtbl.remove ctx.nodes id; + Hashtbl.remove ctx.applied id; + Hashtbl.remove ctx.sparent id; + Hashtbl.remove ctx.owner id; + Hashtbl.remove ctx.eng.rects id; + unregister ctx id + +(* display/structure churn on id: rebuild its produced expansion in + place — the subtree is re-mirrored (a rare op). *) +let reexpand ctx id = + match Hashtbl.find_opt ctx.sparent id with + | Some par -> + let o = children_owner ctx par in + purge ctx id; + (* Purge dropped the raw-parent link too — restore it so the next + structure op on id can still find its slot. *) + Hashtbl.replace ctx.sparent id par; + let pairs = + build ctx ~owner_id:o ~pkind:(kind_of ctx o) ~pid:o ~index:0 id in - t.root <- Some root; + let gens = List.map snd pairs in + sync_entries ctx par ~overrides:[ (id, gens) ]; + commit ctx par; + List.iter + (fun g -> + Hashtbl.replace ctx.owner g o; + restyle_gen ctx o g) + gens; + mark_affected ctx o + | None -> () + +let restyle_self ctx id = + match Hashtbl.find_opt ctx.owner id with + | Some o -> + restyle_gen ctx o id; + mark_affected ctx o + | None -> () + +(* A layout-kids prop (columns/value/edge) rewrites the produced + children's styles and possibly their order — restyle them all + against the current parent inputs. *) +let restyle_children ctx id = + let o = children_owner ctx id in + sync_entries ctx id; + commit ctx id; + List.iter + (fun e -> + List.iter + (fun g -> + Hashtbl.replace ctx.owner g o; + restyle_gen ctx o g) + e.gens) + (pkids_of ctx id); + mark_affected ctx o + +let apply_journal_op ctx (op : patch_op) = + match op with + | SetProp (id, p, _) | RemoveProp (id, p) -> ( + let m = + Lui_store.mask_of_prop (Lui_wire_schema.property_name p) + in + if m land (Lui_store.d_structure lor Lui_store.d_layout lor Lui_store.d_layout_kids) = 0 then () + else if Lui_store.mask_has m Lui_store.d_structure then reexpand ctx id + else begin + if Lui_store.mask_has m Lui_store.d_layout then restyle_self ctx id; + if Lui_store.mask_has m Lui_store.d_layout_kids then + restyle_children ctx id + end) + | SetExtensionProp (id, _, _) | RemoveExtensionProp (id, _) -> + (* Unknown prop semantics: restyle the node and its children. *) + restyle_self ctx id; + restyle_children ctx id + | CreateNode (id, _) | CreateExtension (id, _, _) + | DropNode id | DetachSubtree id -> + detach_from_parent ctx id; + purge ctx id + | InsertChild (p, c, _) | MoveChild (p, c, _) -> attach_child ctx ~parent:p ~child:c + | RemoveChild (p, c) -> detach_child ctx ~parent:p ~child:c + +(* Rect refresh scoped to dirty owner subtrees: every produced + descendant of a dirty owner gets its rect re-read (a resized node + moves its siblings, so the refresh root is the owner — the flex + parent — not the changed node). [frame_id] dirties everything. *) +let rec refresh_sub ctx sid = + (match Hashtbl.find_opt ctx.nodes sid with + | Some f -> + Hashtbl.replace ctx.eng.rects sid (scene_rect (Lui_flex.absolute_layout f)) + | None -> ()); + List.iter + (fun e -> List.iter (fun g -> refresh_sub ctx g) e.gens) + (pkids_of ctx sid) + +let refresh_rects ctx = + if Hashtbl.mem ctx.eng.rect_dirty frame_id then collect ctx.eng + else + Hashtbl.iter (fun sid () -> refresh_sub ctx sid) ctx.eng.rect_dirty; + Hashtbl.reset ctx.eng.rect_dirty + +let ctx_of t store ~scale ~measure ~hidden ctx_anchor ~width ~height : ctx = + { scale; vw = width; vh = height; tm = measure; hidden; ctx_anchor; + store; eng = t; nodes = t.nodes; applied = t.applied; + pkids = t.pkids; sparent = t.sparent; owner = t.owner; + grids = t.grids; popups = t.popups } + +(* Wipe the mirror and re-expand the frame: first sync, invalidate, or + a journal that no longer reaches our cursor. *) +let rebuild ctx (t : t) ~width ~height = + Hashtbl.reset t.nodes; + Hashtbl.reset t.applied; + Hashtbl.reset t.pkids; + Hashtbl.reset t.sparent; + Hashtbl.reset t.owner; + t.grids := []; + t.popups := []; + ignore + (expand_children ctx ~owner:frame_id ~pkind:"frame" ~pid:frame_id + ~raw_parent:frame_id ~first:0 (Lui_store.root_ids ctx.store)); + let root = + Lui_flex.create ~style:(frame_style_for width (Pt height)) + (flex_children_of ctx frame_id) + in + t.root <- Some root; + Hashtbl.reset t.rect_dirty; + Hashtbl.replace t.rect_dirty frame_id () + +let finish ctx (t : t) ~width ~height = + match t.root with + | None -> () + | Some root -> let ew, eh = layout_frame root ~width ~height in refine_grids ctx; refine_popups ctx; (if !(ctx.grids) <> [] || !(ctx.popups) <> [] then Lui_flex.compute_layout root ~width:ew ~height:eh ()); t.extent <- (ew, eh); - collect t; - t.synced_rev <- rev; + refresh_rects ctx + +let sync ?(scale = 1.) ?(measure = no_measure) ?(hidden = show_all) + ?ctx_anchor ~width ~height (t : t) store = + let inputs_changed = + t.synced_scale <> scale + || t.synced_w <> width || t.synced_h <> height + || t.synced_anchor <> ctx_anchor + || (not (t.synced_measure == measure)) + || (not (t.synced_hidden == hidden)) + in + (* Fast path: inputs stable and the store's layout revision + unmoved — paint-only churn, scroll-only and idle frames all skip + the mirror entirely. The store-revision cursor still advances so + the journal never re-feeds paint ops. *) + if + (not inputs_changed) && t.synced_rev >= 0 + && t.synced_lrev = Lui_store.layout_rev store + then t.synced_rev <- Lui_store.revision store + else begin + t.stat_syncs <- t.stat_syncs + 1; + let ctx = ctx_of t store ~scale ~measure ~hidden ctx_anchor ~width ~height in + let full = + if inputs_changed || t.synced_rev < 0 || t.root = None then true + else + match Lui_store.changes_since store t.synced_rev with + | None -> true + | Some ops -> + t.stat_steps <- t.stat_steps + 1; + List.iter (apply_journal_op ctx) ops; + (* Detach/drop can leave a built node as a fresh store root — + resync the frame level so new roots appear and removed + ones vanish. Membership churn dirties the whole frame. *) + let before = + List.map (fun e -> e.raw) (pkids_of ctx frame_id) + in + sync_entries ctx frame_id; + if before <> List.map (fun e -> e.raw) (pkids_of ctx frame_id) + then Hashtbl.replace t.rect_dirty frame_id (); + List.iter + (fun e -> + Hashtbl.replace ctx.sparent e.raw frame_id; + let gens = + if e.gens = [] then + ensure_produced ctx ~owner_id:frame_id e.raw + else e.gens + in + e.gens <- gens; + (* A reused node that newly became a root still carries + its old owner — restyle it against the frame. *) + List.iter + (fun g -> + match Hashtbl.find_opt ctx.owner g with + | Some o when o = frame_id -> () + | _ -> + Hashtbl.replace ctx.owner g frame_id; + restyle_gen ctx frame_id g) + gens) + (pkids_of ctx frame_id); + commit ctx frame_id; + false + in + if full then rebuild ctx t ~width ~height; + finish ctx t ~width ~height; + t.synced_rev <- Lui_store.revision store; + t.synced_lrev <- Lui_store.layout_rev store; t.synced_scale <- scale; t.synced_w <- width; t.synced_h <- height; diff --git a/platform/native/lui_layout/lui_layout.mli b/platform/native/lui_layout/lui_layout.mli index af503e0d..ce810beb 100644 --- a/platform/native/lui_layout/lui_layout.mli +++ b/platform/native/lui_layout/lui_layout.mli @@ -102,10 +102,15 @@ val sync : val invalidate : t -> unit -(** [stat_syncs t] counts the {!sync} calls that actually rebuilt the - mirror — memoized calls do not. Test instrumentation. *) +(** [stat_syncs t] counts the {!sync} calls that touched the mirror — + memoized calls do not. Test instrumentation. *) val stat_syncs : t -> int +(** [stat_steps t] counts the {!sync} calls that took the incremental + journal path rather than a wholesale rebuild. Test + instrumentation. *) +val stat_steps : t -> int + (** [compute t ~width ~height] re-runs layout on the current mirror — the cheap path when only the frame size changed. [*-viewport] props keep the resolution from the last {!sync}; call {!sync} on diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index 16b946a1..01e68524 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -350,6 +350,159 @@ let test_ac_row () = check (float eps) "text h" 10. (place p 10).h; check (float eps) "wrap h" 10. (place p 4).h +(* ---------- incremental mirror ---------- *) + +let rect_or_zero t id = + Option.value ~default:(Lui_scene.rect 0. 0. 0. 0.) + (Lui_layout.rect t id) + +(* Differential oracle: the persistent mirror must produce the same + rects as a wholesale rebuild of the same store state. *) +let check_equiv msg store t = + let p = Lui_layout.run store ~measure:no_measure ~width:200. ~height:100. in + List.iter + (fun id -> + let expect = (p id).Lui_paint.p_rect in + let got = rect_or_zero t id in + check_rect (Printf.sprintf "%s #%d" msg id) expect.x expect.y + expect.w expect.h got) + (Lui_store.preorder store) + +(* A journal replay through moves, toggles, drops and prop churn keeps + the mirror identical to a rebuild at every step. *) +let test_incremental_equivalence () = + let store = Lui_store.create () in + apply store + [ CreateNode (1, Root); CreateNode (2, Column); + CreateNode (3, Row); CreateNode (4, Text); CreateNode (5, Text); + CreateNode (6, Text); CreateNode (7, Column); + InsertChild (1, 2, 0); InsertChild (2, 3, 0); InsertChild (2, 7, 1); + InsertChild (3, 4, 0); InsertChild (3, 5, 1); InsertChild (7, 6, 0); + SetProp (4, WidthValue, IntValue 20); SetProp (4, HeightValue, IntValue 10); + SetProp (5, WidthValue, IntValue 30); SetProp (5, HeightValue, IntValue 10); + SetProp (6, WidthValue, IntValue 40); SetProp (6, HeightValue, IntValue 10); + SetProp (3, Gap, IntValue 5) ]; + let t = Lui_layout.create () in + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "init" store t; + (* Move a leaf across parents — reuse, not rebuild. *) + apply store [ MoveChild (7, 4, 0) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "move across" store t; + check bool "incremental path" true (Lui_layout.stat_steps t > 0); + (* Same-parent reorder. *) + apply store [ MoveChild (7, 4, 1) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "reorder" store t; + (* display:none then back. *) + apply store [ SetProp (6, DisplayValue, StringValue "none") ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "hidden" store t; + apply store [ RemoveProp (6, DisplayValue) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "reshown" store t; + (* RemoveChild leaves a root. *) + apply store [ RemoveChild (3, 5) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "detached root" store t; + apply store [ InsertChild (2, 5, 0) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "reattached" store t; + (* Drop a whole subtree. *) + apply store [ DropNode 7 ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "dropped subtree" store t; + (* Layout prop on a survivor. *) + apply store [ SetProp (5, HeightValue, IntValue 42) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "restyle" store t + +(* Deterministic op fuzz: leaf props, reorders, cross-parent moves, + display toggles and drops keep the mirror rebuild-equivalent. *) +let test_incremental_random () = + let store = Lui_store.create () in + let leaves = List.init 10 (fun i -> 10 + i) in + apply store + ([ CreateNode (1, Root); CreateNode (2, Column); CreateNode (3, Row); + InsertChild (1, 2, 0); InsertChild (1, 3, 1) ] + @ List.concat_map + (fun i -> + [ CreateNode (10 + i, Text); + InsertChild ((if i mod 2 = 0 then 2 else 3), 10 + i, i / 2); + SetProp (10 + i, WidthValue, IntValue (10 + i)); + SetProp (10 + i, HeightValue, IntValue 10) ]) + leaves); + let t = Lui_layout.create () in + Lui_layout.sync ~width:200. ~height:100. t store; + let st = Random.State.make [| 42 |] in + let pick l = List.nth l (Random.State.int st (List.length l)) in + for round = 1 to 80 do + let ops = + List.init (1 + Random.State.int st 3) (fun _ -> + let leaf = pick leaves in + match Random.State.int st 6 with + | 0 -> SetProp (leaf, WidthValue, IntValue (5 + Random.State.int st 60)) + | 1 -> SetProp (leaf, HeightValue, IntValue (5 + Random.State.int st 40)) + | 2 -> ( + match Lui_store.parent store leaf with + | Some p -> MoveChild (p, leaf, Random.State.int st 3) + | None -> SetProp (leaf, PaddingValue, IntValue 0)) + | 3 -> InsertChild ((if Random.State.bool st then 2 else 3), leaf, + Random.State.int st 3) + | 4 -> + if Lui_store.string_prop store leaf "display" = Some "none" + then RemoveProp (leaf, DisplayValue) + else SetProp (leaf, DisplayValue, StringValue "none") + | _ -> SetProp ((if Random.State.bool st then 2 else 3), Gap, + IntValue (Random.State.int st 8))) + in + apply store ops; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv (Printf.sprintf "round %d" round) store t + done + +(* Two store roots: growth under the first shifts the second — the + scoped rect refresh must cover the moved sibling root's subtree. *) +let test_incremental_root_shift () = + let store = Lui_store.create () in + apply store + [ CreateNode (1, Root); CreateNode (2, Root); + CreateNode (3, Text); CreateNode (4, Text); + InsertChild (1, 3, 0); InsertChild (2, 4, 0); + SetProp (3, HeightValue, IntValue 10); + SetProp (4, HeightValue, IntValue 10) ]; + let t = Lui_layout.create () in + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "roots init" store t; + apply store [ SetProp (3, HeightValue, IntValue 40) ]; + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "root shifted" store t + +(* A 10k-sibling tree stays incremental: one text prop edit replays + through the journal and relayouts only the dirty path, well under + the cost of a full mirror rebuild. *) +let test_incremental_large_tree () = + let store = Lui_store.create () in + apply store + ([ CreateNode (1, Root); CreateNode (2, Column); InsertChild (1, 2, 0) ] + @ List.concat_map + (fun i -> + [ CreateNode (10 + i, Text); + InsertChild (2, 10 + i, i); + SetProp (10 + i, HeightValue, IntValue 1) ]) + (List.init 10_000 Fun.id)); + let t = Lui_layout.create () in + Lui_layout.sync ~width:200. ~height:100. t store; + check_equiv "big init" store t; + let before_steps = Lui_layout.stat_steps t in + let t0 = Sys.time () in + apply store [ SetProp (5_000, TextValue, StringValue "x") ]; + Lui_layout.sync ~width:200. ~height:100. t store; + let dt = Sys.time () -. t0 in + check bool "journal path" true (Lui_layout.stat_steps t > before_steps); + check_equiv "big restyle" store t; + check bool "fast incremental" true (dt < 0.5) + let () = run "lui_layout" [ ("layout", @@ -369,4 +522,11 @@ 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 "incremental equivalence" `Quick + test_incremental_equivalence; + test_case "incremental random" `Quick test_incremental_random; + test_case "incremental root shift" `Quick + test_incremental_root_shift; + test_case "incremental large tree" `Quick + test_incremental_large_tree ]) ] From cf207707c379253495e047a75df65b175e167c4d Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 06:02:34 +0800 Subject: [PATCH 3/9] perf(ml): track changed rects across incremental layout refreshes take_rect_changes hands the retained paint pass the set of store ids whose absolute rect moved, so it can re-emit only moved subtrees instead of repainting everything. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_layout/lui_layout.ml | 33 ++++++++++++++++++++--- platform/native/lui_layout/lui_layout.mli | 8 ++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index 3e15407a..e6a0e3b7 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -39,6 +39,10 @@ type t = { (* Store ids whose produced subtree needs a rect re-read after the incremental pass ([frame_id] = everything). *) rect_dirty : (int, unit) Hashtbl.t; + (* Store ids whose absolute rect changed under the refresh — paint + consumes the accumulated set via {!take_rect_changes} to re-emit + only moved subtrees. *) + mutable rect_changed : (int, unit) Hashtbl.t; (* [pkids p] is the produced expansion of raw parent p's children — the shadow the incremental path edits when children move. Present for every built node (leaves keep []) and for [frame_id]. *) @@ -82,6 +86,7 @@ let create () = applied = Hashtbl.create 64; rects = Hashtbl.create 64; rect_dirty = Hashtbl.create 16; + rect_changed = Hashtbl.create 64; pkids = Hashtbl.create 64; sparent = Hashtbl.create 64; owner = Hashtbl.create 64; @@ -1175,12 +1180,28 @@ let extent_of root : float * float = let scene_rect (r : Lui_flex.rect) : Lui_scene.rect = { x = r.x; y = r.y; w = r.w; h = r.h } +(* Re-read [id]'s absolute rect into [t.rects], marking it changed when + it differs from what consumers last saw. *) +let update_rect (t : t) id r = + let r' = scene_rect r in + (match Hashtbl.find_opt t.rects id with + | Some old when old = r' -> () + | _ -> Hashtbl.replace t.rect_changed id ()); + Hashtbl.replace t.rects id r' + let collect (t : t) = - Hashtbl.reset t.rects; Hashtbl.iter - (fun id n -> Hashtbl.replace t.rects id (scene_rect (Lui_flex.absolute_layout n))) + (fun id n -> update_rect t id (Lui_flex.absolute_layout n)) t.nodes +(* The set of ids whose absolute rect differed at the last rect + refresh. Hands the table itself to the caller and installs a fresh + one — ids a later refresh moves again simply re-mark. *) +let take_rect_changes (t : t) = + let s = t.rect_changed in + t.rect_changed <- Hashtbl.create (Hashtbl.length s); + s + (* The frame style for one offered extent. *) let frame_style_for width (height : Lui_flex.length) = { Lui_flex.default_style with @@ -1561,8 +1582,7 @@ let apply_journal_op ctx (op : patch_op) = parent — not the changed node). [frame_id] dirties everything. *) let rec refresh_sub ctx sid = (match Hashtbl.find_opt ctx.nodes sid with - | Some f -> - Hashtbl.replace ctx.eng.rects sid (scene_rect (Lui_flex.absolute_layout f)) + | Some f -> update_rect ctx.eng sid (Lui_flex.absolute_layout f) | None -> ()); List.iter (fun e -> List.iter (fun g -> refresh_sub ctx g) e.gens) @@ -1585,6 +1605,11 @@ let ctx_of t store ~scale ~measure ~hidden ctx_anchor ~width ~height : ctx = let rebuild ctx (t : t) ~width ~height = Hashtbl.reset t.nodes; Hashtbl.reset t.applied; + (* Rects reset with the mirror: entries for dropped ids would + otherwise linger and answer stale rects to {!rect}. Every rect + the rebuild produces reads as changed to {!take_rect_changes} + consumers. *) + Hashtbl.reset t.rects; Hashtbl.reset t.pkids; Hashtbl.reset t.sparent; Hashtbl.reset t.owner; diff --git a/platform/native/lui_layout/lui_layout.mli b/platform/native/lui_layout/lui_layout.mli index ce810beb..d5bf5e31 100644 --- a/platform/native/lui_layout/lui_layout.mli +++ b/platform/native/lui_layout/lui_layout.mli @@ -139,6 +139,14 @@ val layout_hook : t -> int -> Lui_paint.placement val rect : t -> int -> Lui_scene.rect option +(** [take_rect_changes t] is the set of store ids whose absolute rect + differed at the last rect refresh, drained: the caller owns the + returned table and the next refresh starts a fresh one. The + retained paint pass uses it to re-emit only the subtrees whose + geometry actually moved. *) + +val take_rect_changes : t -> (int, unit) Hashtbl.t + (** [run ?scale store ~measure ~width ~height] is the one-shot hook for [Lui_host]: mirror + layout + the placement function. The [~measure] here is the id-keyed host form; the mirror picks the From ea809825f283705f6dee590b704ccc81a23a0d4c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 06:23:43 +0800 Subject: [PATCH 4/9] =?UTF-8?q?perf(ml):=20retained=20scene=20fragments=20?= =?UTF-8?q?=E2=80=94=20journal-driven=20incremental=20paint=20(P0-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Lui_retained: a per-node paint cache that keeps each node's emitted ops plus the glyph/wide/effect records they index. The store op journal (replayed at a paint-owned cursor), engine rect changes, and host state hints mark fragments for re-derivation; clean frames skip assembly entirely and a single-node update re-derives one fragment while the flat scene buffers the renderers consume are re-materialized. Wiring: Lui_host.repaint drives the retained pass with the layout engine's take_rect_changes as the moved set (purged rects now mark changed too, so hidden/dropped nodes re-derive) and a drained hint table the window fills from a Ui interaction snapshot — hover, press, focus, caret/selection/marked, menus, and per-container scroll moves (a moved offset shifts all descendant rects, so the subtree marks). Tests: 15-case suite cross-checking the retained scene against a full repaint after every op batch (random equivalence, structure, z-index, display cycles, opacity cascade, popup deferral, state hints), plus a perf scenario: 10k static tree single-color flip repaints exactly one node. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_host/dune | 2 +- platform/native/lui_host/lui_host.ml | 47 +- platform/native/lui_host/lui_host.mli | 17 + platform/native/lui_layout/lui_layout.ml | 3 + platform/native/lui_paint/dune | 6 + platform/native/lui_paint/lui_retained.ml | 426 ++++++++++++++++++ platform/native/lui_paint/test/dune | 5 + .../lui_paint/test/lui_retained_test.ml | 409 +++++++++++++++++ platform/native/lui_window/lui_window.ml | 101 ++++- .../native/lui_window/test/lui_window_test.ml | 7 + platform/native/perf/dune | 4 +- platform/native/perf/harness.ml | 15 + platform/native/perf/scenarios.ml | 36 ++ 13 files changed, 1065 insertions(+), 13 deletions(-) create mode 100644 platform/native/lui_paint/lui_retained.ml create mode 100644 platform/native/lui_paint/test/lui_retained_test.ml diff --git a/platform/native/lui_host/dune b/platform/native/lui_host/dune index 9d0cb9e9..03098187 100644 --- a/platform/native/lui_host/dune +++ b/platform/native/lui_host/dune @@ -2,4 +2,4 @@ (name lui_host) (public_name lui.host) (modules lui_host) - (libraries lui lui_store lui_scene lui_paint)) + (libraries lui lui_store lui_scene lui_paint lui_retained)) diff --git a/platform/native/lui_host/lui_host.ml b/platform/native/lui_host/lui_host.ml index ec3f5c55..2964fcb5 100644 --- a/platform/native/lui_host/lui_host.ml +++ b/platform/native/lui_host/lui_host.ml @@ -34,6 +34,10 @@ module type LAYOUT_ENGINE = sig val layout_hook : t -> int -> Lui_paint.placement val rect : t -> int -> Lui_scene.rect option + + (** Store ids whose absolute rect changed under the last {!sync}; + drained by the paint pass to re-emit only moved subtrees. *) + val take_rect_changes : t -> (int, unit) Hashtbl.t end (* An instantiated engine: what (module LAYOUT_ENGINE) becomes once the @@ -46,6 +50,7 @@ type layout_state = { linvalidate : unit -> unit; lhook : int -> Lui_paint.placement; lrect : int -> rect option; + lchanges : unit -> (int, unit) Hashtbl.t; } type t = { @@ -59,6 +64,11 @@ type t = { mutable height : int; mutable scale : float; mutable dirty : bool; (* repaint requested since last present *) + retained : Lui_retained.retained; + (* Ids whose host-side paint inputs (interaction state, async + resources) changed since the last repaint — the fragment pass's + third dirty channel, next to the store journal and moved rects. *) + paint_hints : (int, unit) Hashtbl.t; mutable hidden : int -> bool; (* host-owned transient visibility *) (* Pointer point an unanchored context-menu opens at — a thunk so the accessor reads the live position each repaint. *) @@ -80,14 +90,17 @@ let create ~hooks ?layout ?(measure = no_measure) ~renderer ~width ~height ~scal engine store); linvalidate = (fun () -> L.invalidate engine); lhook = L.layout_hook engine; - lrect = L.rect engine } + lrect = L.rect engine; + lchanges = (fun () -> L.take_rect_changes engine) } in let mask_atlas = Atlas.create ~bpp:1 ~w:512 ~h:512 in let color_atlas = Atlas.create ~bpp:4 ~w:512 ~h:512 in { store = Lui_store.create (); scene = Lui_scene.create ~mask_atlas ~color_atlas; hooks; layout; measure; renderer; width; height; scale; - dirty = false; hidden = (fun _ -> false); + dirty = false; retained = Lui_retained.create (); + paint_hints = Hashtbl.create 16; + hidden = (fun _ -> false); ctx_anchor = (fun () -> None) } let store h = h.store @@ -109,6 +122,17 @@ let set_ctx_anchor h accessor = let invalidate_layout h = match h.layout with Some l -> l.linvalidate () | None -> () +(* 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. *) +let note_paint h id = Hashtbl.replace h.paint_hints id () + +let note_paints h ids = List.iter (note_paint h) ids + +(* The retained fragment cache — tests invalidate it wholesale when + their hooks change under a stable store (e.g. a theme swap). *) +let invalidate_paint h = Lui_retained.invalidate h.retained + let apply_batch h (batch : Lui_protocol.patch_batch) = Lui_store.apply_batch h.store batch; h.dirty <- true; @@ -125,9 +149,9 @@ let prof msg = (* Paint the current tree and render; returns pixel bytes. *) let repaint h = prof "repaint begin"; - let hooks = + let hooks, moved = match h.layout with - | None -> h.hooks + | None -> (h.hooks, None) | Some l -> (* Rebuilt every repaint, never per batch: the engine must see the store as it is NOW (several batches may land between @@ -139,9 +163,12 @@ let repaint h = h.store; (* The engine hook yields to an explicitly supplied hooks.layout; the zero-rect default does. *) - if h.hooks.layout == Lui_paint.default_hooks.layout - then { h.hooks with layout = l.lhook } - else h.hooks + let hooks = + if h.hooks.layout == Lui_paint.default_hooks.layout + then { h.hooks with layout = l.lhook } + else h.hooks + in + (hooks, Some (l.lchanges ())) in prof "layout done"; (* The window clear follows the app theme's "background" token — @@ -152,8 +179,10 @@ let repaint h = | Some c -> c | None -> { r = 0; g = 0; b = 0; a = 0 } in - Lui_paint.paint hooks h.store h.scene ~width:h.width ~height:h.height - ~scale:h.scale ~clear; + Lui_retained.paint_retained h.retained hooks h.store h.scene ?moved + ~hints:h.paint_hints ~width:h.width ~height:h.height ~scale:h.scale + ~clear (); + Hashtbl.reset h.paint_hints; prof "paint done"; h.dirty <- false; let bytes = h.renderer.render h.scene in diff --git a/platform/native/lui_host/lui_host.mli b/platform/native/lui_host/lui_host.mli index e33204c3..6bc3c35a 100644 --- a/platform/native/lui_host/lui_host.mli +++ b/platform/native/lui_host/lui_host.mli @@ -38,6 +38,10 @@ module type LAYOUT_ENGINE = sig val layout_hook : t -> int -> Lui_paint.placement val rect : t -> int -> Lui_scene.rect option + + (** Store ids whose absolute rect changed under the last {!sync}; + drained by the paint pass to re-emit only moved subtrees. *) + val take_rect_changes : t -> (int, unit) Hashtbl.t end type t @@ -98,6 +102,19 @@ val invalidate_layout : t -> unit keys on, and sync always runs between batches, so the memo can never observe a half-applied tree. *) +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, + an image that finished decoding. The next {!repaint} re-derives + the fragment instead of replaying its cached ops. *) + +val note_paints : t -> int list -> unit + +val invalidate_paint : t -> unit +(** Drop every cached paint fragment — the wholesale counterpart of + {!note_paint} for inputs that moved globally (a theme swap under + a stable store). *) + val apply_batch : t -> Lui_protocol.patch_batch -> bool val resize : t -> width:int -> height:int -> scale:float -> unit diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index e6a0e3b7..a7db4e26 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -1502,6 +1502,9 @@ let rec purge ctx id = Hashtbl.remove ctx.sparent id; Hashtbl.remove ctx.owner id; Hashtbl.remove ctx.eng.rects id; + (* A dropped rect is a geometry change too: paint consumers holding + the old box must re-derive (their layout hook now answers zero). *) + Hashtbl.replace ctx.eng.rect_changed id (); unregister ctx id (* display/structure churn on id: rebuild its produced expansion in diff --git a/platform/native/lui_paint/dune b/platform/native/lui_paint/dune index 7f26a13c..f737c712 100644 --- a/platform/native/lui_paint/dune +++ b/platform/native/lui_paint/dune @@ -3,3 +3,9 @@ (public_name lui.paint) (modules lui_paint) (libraries lui_store lui_scene)) + +(library + (name lui_retained) + (public_name lui.retained) + (modules lui_retained) + (libraries lui_paint lui_store lui_scene)) diff --git a/platform/native/lui_paint/lui_retained.ml b/platform/native/lui_paint/lui_retained.ml new file mode 100644 index 00000000..b77c9ad8 --- /dev/null +++ b/platform/native/lui_paint/lui_retained.ml @@ -0,0 +1,426 @@ +(* Retained scene fragments: a per-node paint cache sitting between the + store and {!Lui_scene}. + + A full [Lui_paint.paint] re-plans every mounted node each frame — + prop lookups, state/style resolution, chrome op construction — even + when nothing changed. This module caches what each node emitted (its + own chrome ops plus the glyph/wide/effect records they index, all + local to the fragment) and replays the store's op journal plus + host-supplied move/state hints to repaint only the fragments whose + inputs actually changed. Clean frames skip assembly entirely; a + single-node update pays for that node, while the materialize pass + still walks the fragment tree to rebuild the flat scene buffers the + renderers consume. + + Correctness contract: anything a node paints derives from (a) the + store — tracked by the journal, (b) its layout hook answer — the + engine reports moved rects, and (c) host interaction state — the + caller reports touched ids via [~hints]. Hooks whose answers change + silently outside those channels must be poked through hints. *) + +open Lui_scene + +(* One node's cached emission. [pre]/[post] are the node's own ops in + paint order (before/after the point children emit), self-contained: + [Glyphs.gstart]/[gend] index [glyphs], [fwide]/[gwide2]/[swide]/ + [gwide] index [wide] (1-based, 0 = none), [edindex] indexes [fx]. + Materialize re-bases them onto the frame buffers. *) +type frag = { + mutable pre : op list; + mutable post : op list; + mutable glyphs : glyph array; + mutable wide : wide_colors array; + mutable fx : effect_op array; + (* Children in paint order, split by stacking contract: [pkids] + defer to the overlay drain like the full pass's deferred queue. *) + mutable kids : int list; + mutable pkids : int list; + (* Inherited inputs the fragment was painted under. *) + mutable alpha_in : float; + mutable parent_r : rect; + (* Results handed to children. *) + mutable alpha_out : float; + mutable child_parent : rect; + mutable show_kids : bool; + mutable born : bool; + mutable repaint : bool; + mutable rekids : bool; +} + +type retained = { + frags : (int, frag) Hashtbl.t; + (* The parent whose kids list last contained the id — lets drop/move + ops re-derive the right child list when the op only names the + child. *) + fparent : (int, int) Hashtbl.t; + (* Store revision the journal has been replayed through. *) + mutable rev : int; + mutable vp : rect; + mutable scale : float; + (* Any mark since the last materialize. *) + mutable dirty : bool; + (* Instrumentation for tests/benchmarks. *) + mutable stat_repaints : int; + mutable stat_visits : int; +} + +let create () = + { frags = Hashtbl.create 256; fparent = Hashtbl.create 256; + rev = -1; vp = rect 0. 0. 0. 0.; scale = 1.; + dirty = false; stat_repaints = 0; stat_visits = 0 } + +let invalidate (rt : retained) = + Hashtbl.reset rt.frags; + rt.dirty <- true + +let stat_repaints (rt : retained) = rt.stat_repaints +let stat_visits (rt : retained) = rt.stat_visits + +let fresh_frag () = + { pre = []; post = []; glyphs = [||]; wide = [||]; fx = [||]; + kids = []; pkids = []; alpha_in = 0.; parent_r = rect 0. 0. 0. 0.; + alpha_out = 0.; child_parent = rect 0. 0. 0. 0.; show_kids = false; + born = false; repaint = false; rekids = false } + +let frag_of (rt : retained) id = + match Hashtbl.find_opt rt.frags id with + | Some f -> f + | None -> + let f = fresh_frag () in + Hashtbl.replace rt.frags id f; + f + +let mark_repaint rt id = + let f = frag_of rt id in + f.repaint <- true; + rt.dirty <- true + +let mark_rekids rt id = + let f = frag_of rt id in + f.rekids <- true; + rt.dirty <- true + +(* The paint-parent of [id] — whoever last listed it as a child — or + the live store parent when paint never saw it. *) +let paint_parent rt store id = + match Hashtbl.find_opt rt.fparent id with + | Some p when p <> 0 -> Some p + | _ -> Lui_store.parent store id + +let mark_parent_rekids rt store id = + match paint_parent rt store id with + | Some p -> mark_rekids rt p + | None -> () + +let rec purge_frag rt id = + match Hashtbl.find_opt rt.frags id with + | Some f -> + Hashtbl.remove rt.frags id; + List.iter (purge_frag rt) f.kids; + List.iter (purge_frag rt) f.pkids + | None -> () + +(* Replay the store ops since [rt.rev], translating them into fragment + marks. Mirrors the layout engine's incremental contract: the + journal is the single source of truth for what the store saw. *) +let advance rt store = + match Lui_store.changes_since store rt.rev with + | None -> + (* Journal truncated past our cursor — resync wholesale. *) + invalidate rt; + rt.rev <- Lui_store.revision store + | Some ops -> + List.iter + (fun (op : Lui_protocol.patch_op) -> + match op with + | SetProp (id, p, _) | RemoveProp (id, p) -> ( + let name = Lui_wire_schema.property_name p in + let m = Lui_store.mask_of_prop name in + if m land Lui_store.d_paint <> 0 then mark_repaint rt id; + (* Structure-classified props (display) change the node's + deferral in the parent's child list; a z-index change + reorders it. Both re-derive the parent's kids. *) + if + m land Lui_store.d_structure <> 0 || name = "z-index" + then mark_parent_rekids rt store id) + | SetExtensionProp (id, _, _) | RemoveExtensionProp (id, _) -> + (* Unknown semantics: repaint the node and re-derive its + slot under the parent. *) + mark_repaint rt id; + mark_parent_rekids rt store id + | CreateNode (id, _) | CreateExtension (id, _, _) -> + (* A reused id may still carry a stale fragment. *) + mark_repaint rt id + | DropNode id | DetachSubtree id -> + mark_parent_rekids rt store id; + purge_frag rt id + | InsertChild (p, c, _) -> + mark_rekids rt p; + (* The store silently unlinks [c] from its old parent — the + journal names only the new one, so the recorded + paint-parent must rebuild its kid list too. *) + mark_parent_rekids rt store c + | RemoveChild (p, _) -> mark_rekids rt p + | MoveChild (p, c, _) -> + mark_rekids rt p; + mark_parent_rekids rt store c) + ops; + (* [rt.dirty] only follows real marks — a semantics-only op leaves + the frame alone. *) + rt.rev <- Lui_store.revision store + +(* The node's own emission, captured into [f]: the same decisions + [Lui_paint.paint_node_body] makes — shown/cull/state/alpha/clip — + minus the child walk, which the materialize drives itself through + [f.kids]/[f.pkids]. Ops land in the scene's swapped-in fresh buffers + so every buffer index they carry is fragment-local. *) +let repaint_frag ctx store (scene : Lui_scene.t) (f : frag) id kind ~parent_r ~alpha_in = + f.alpha_in <- alpha_in; + f.parent_r <- parent_r; + let saved_ops = scene.ops + and saved_glyphs = scene.glyphs + and saved_wide = scene.wide + and saved_fx = scene.effects in + scene.ops <- []; + scene.glyphs <- []; + scene.wide <- []; + scene.effects <- []; + let pre = ref [] and post = ref [] in + let show_kids = ref false in + let child_parent = ref parent_r and alpha_out = ref alpha_in in + if Lui_paint.node_shown store id then begin + let contents = Lui_paint.unboxed store id in + let p = ctx.Lui_paint.hooks.layout id in + let r = Lui_paint.resolve_in ctx store id kind p parent_r in + let misses_viewport = + (not contents) + && (not (rect_empty r)) + && rect_empty (rect_intersect r ctx.Lui_paint.viewport) + in + if not misses_viewport then begin + let st = Lui_paint.effective_state ctx.hooks store id in + let rs = Lui_paint.resolve_style store id st in + let alpha = + if contents then alpha_in + else alpha_in *. rs.rs_opacity *. Lui_paint.disabled_alpha store id st + in + (* display:contents generates no box: children keep the parent's + containing block and its alpha, exactly like the full pass. *) + child_parent := (if contents then parent_r else r); + alpha_out := alpha; + if alpha > 0. then begin + show_kids := true; + let clip = + (not contents) + && Lui_paint.clips kind store id + && ((not (rect_empty r)) + || (match Lui_store.parent store id with + | Some pid -> Lui_store.kind store pid = "split" + | None -> false)) + in + if clip then + Lui_paint.emit1 scene + (Push_clip + { crect = r; + cradii = corners r (Lui_paint.radii_of ctx store id) false; + ccontinuous = false }); + if not contents then + Lui_paint.emit scene + (Lui_paint.paint_own ctx store id st kind r rs alpha); + pre := List.rev scene.ops; + scene.ops <- []; + if Lui_paint.scrollable kind store id && not contents then + Lui_paint.emit scene + (Lui_paint.scrollbar_ops ctx store id r alpha); + if clip then Lui_paint.emit1 scene Pop_clip; + post := List.rev scene.ops + end + end + end; + f.pre <- !pre; + f.post <- !post; + f.glyphs <- Array.of_list (List.rev scene.glyphs); + f.wide <- Array.of_list (List.rev scene.wide); + f.fx <- Array.of_list scene.effects; + f.child_parent <- !child_parent; + f.alpha_out <- !alpha_out; + f.show_kids <- !show_kids; + f.born <- true; + f.repaint <- false; + scene.ops <- saved_ops; + scene.glyphs <- saved_glyphs; + scene.wide <- saved_wide; + scene.effects <- saved_fx + +(* Re-derive the child list from the store: z-ordered like the full + pass, popup kinds (except display:contents) split into the deferred + set so the overlay drain can paint them last. *) +let build_kids rt store (f : frag) id = + let old = f.kids @ f.pkids in + let kids, pkids = + List.fold_left + (fun (ks, ps) (c : Lui_store.node) -> + let cid = Lui_store.node_id c in + if + Lui_paint.popup_kind (Lui_store.node_kind c) + && not (Lui_paint.unboxed store cid) + then (ks, cid :: ps) + else (cid :: ks, ps)) + ([], []) (Lui_paint.ordered_children store id) + in + f.kids <- List.rev kids; + f.pkids <- List.rev pkids; + (* Re-point parent links: entries this list dropped keep whatever + parent claimed them since (a reparent op already marked it). *) + List.iter + (fun k -> + match Hashtbl.find_opt rt.fparent k with + | Some p when p = id -> Hashtbl.remove rt.fparent k + | _ -> ()) + old; + List.iter (fun k -> Hashtbl.replace rt.fparent k id) (f.kids @ f.pkids); + f.rekids <- false + +(* Materialize-threaded state: running buffer bases plus the popup + drain. *) +type mat = { + m_ctx : Lui_paint.ctx; + mutable gcount : int; + mutable wcount : int; + mutable ecount : int; + mutable gbase : int; + mutable wbase : int; + mutable ebase : int; + mutable fx_rev : effect_op list; + deferred : (int * rect * float * bool) Queue.t; +} + +(* Append a fragment's local buffers to the frame's, recording the + bases its ops need. Glyph records carry their own [gwide] link into + [scene.wide] — re-based here. *) +let push_buffers (scene : Lui_scene.t) m (f : frag) = + let wbase = m.wcount in + Array.iter (fun w -> scene.wide <- w :: scene.wide) f.wide; + m.wcount <- wbase + Array.length f.wide; + let ebase = m.ecount in + Array.iter (fun e -> m.fx_rev <- e :: m.fx_rev) f.fx; + m.ecount <- ebase + Array.length f.fx; + let gbase = m.gcount in + Array.iter + (fun g -> + scene.glyphs <- + (if g.gwide > 0 then { g with gwide = g.gwide + wbase } else g) + :: scene.glyphs) + f.glyphs; + m.gcount <- gbase + Array.length f.glyphs; + m.gbase <- gbase; + m.wbase <- wbase; + m.ebase <- ebase + +(* Re-base a fragment-local op onto the frame buffers. Ops without + buffer links are shared unchanged. *) +let rebase m (op : op) : op = + match op with + | Glyphs g -> + Glyphs + { g with + gstart = g.gstart + m.gbase; + gend = g.gend + m.gbase; + gwide2 = (if g.gwide2 > 0 then g.gwide2 + m.wbase else 0) } + | Fill f when f.fwide > 0 -> Fill { f with fwide = f.fwide + m.wbase } + | Shadow s when s.swide > 0 -> Shadow { s with swide = s.swide + m.wbase } + | Effect e -> Effect { e with edindex = e.edindex + m.ebase } + | op -> op + +let emit_ops (scene : Lui_scene.t) m ops = + List.iter (fun op -> scene.ops <- rebase m op :: scene.ops) ops + +let rec visit rt store scene m id kind ~parent_r ~alpha_in ~force = + rt.stat_visits <- rt.stat_visits + 1; + let f = frag_of rt id in + if f.rekids || not f.born then build_kids rt store f id; + if force then + (* A forcibly moved subtree (e.g. scroll): descendants under a + culled branch keep their repaint mark for when they surface. *) + List.iter + (fun k -> (frag_of rt k).repaint <- true) + (f.kids @ f.pkids); + if + force || f.repaint || (not f.born) + || f.alpha_in <> alpha_in || f.parent_r <> parent_r + then begin + rt.stat_repaints <- rt.stat_repaints + 1; + repaint_frag m.m_ctx store scene f id kind ~parent_r ~alpha_in + end; + push_buffers scene m f; + emit_ops scene m f.pre; + if f.show_kids then begin + let cp = f.child_parent and a = f.alpha_out in + List.iter + (fun k -> + visit rt store scene m k (Lui_store.kind store k) ~parent_r:cp + ~alpha_in:a ~force) + f.kids; + List.iter + (fun k -> Queue.add (k, cp, a, force) m.deferred) + f.pkids + end; + emit_ops scene m f.post + +let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints + ~width ~height ~scale ~clear () = + let vp = rect 0. 0. (float width) (float height) in + if vp <> rt.vp || scale <> rt.scale then begin + (* Culling and fixed-position resolution key on the frame. *) + invalidate rt; + rt.vp <- vp; + rt.scale <- scale + end; + if Lui_store.revision store <> rt.rev then advance rt store; + (match moved with + | Some mv -> Hashtbl.iter (fun id () -> mark_repaint rt id) mv + | None -> ()); + (match hints with + | Some h -> Hashtbl.iter (fun id () -> mark_repaint rt id) h + | None -> ()); + if rt.dirty then begin + rt.dirty <- false; + Lui_scene.reset scene ~width ~height ~scale ~clear; + (* Buffer bases + the ctx the repaints run against. The deferred + queue on the ctx is vestigial — the fragment pass defers by + classification, not emission. *) + let m = + { m_ctx = + { Lui_paint.hooks; scale; viewport = vp; + deferred = Queue.create () }; + gcount = 0; wcount = 0; ecount = 0; + gbase = 0; wbase = 0; ebase = 0; + fx_rev = []; deferred = Queue.create () } + in + List.iter + (fun id -> + Hashtbl.replace rt.fparent id 0; + visit rt store scene m id (Lui_store.kind store id) ~parent_r:vp + ~alpha_in:1. ~force:false) + (Lui_store.root_ids store); + (* Popups (and popups nested inside them) emit after the inline + forest, preserving the full pass's drain order. *) + while not (Queue.is_empty m.deferred) do + let id, parent_r, alpha_in, force = Queue.pop m.deferred in + visit rt store scene m id (Lui_store.kind store id) ~parent_r + ~alpha_in ~force + done; + scene.ops <- List.rev scene.ops; + scene.glyphs <- List.rev scene.glyphs; + scene.wide <- List.rev scene.wide; + scene.effects <- List.rev m.fx_rev + end + else begin + (* Nothing the fragments depend on moved — the buffers stand. + Frame identity still tracks the caller's size/clear so later + consumers read current dimensions. *) + scene.width <- width; + scene.height <- height; + scene.scale <- scale; + scene.clear <- clear + end diff --git a/platform/native/lui_paint/test/dune b/platform/native/lui_paint/test/dune index 48940583..529f1e4f 100644 --- a/platform/native/lui_paint/test/dune +++ b/platform/native/lui_paint/test/dune @@ -2,3 +2,8 @@ (name lui_paint_test) (modules lui_paint_test) (libraries lui_paint lui_store lui_scene lui alcotest)) + +(test + (name lui_retained_test) + (modules lui_retained_test) + (libraries lui_retained lui_paint lui_store lui_scene lui_layout lui alcotest)) diff --git a/platform/native/lui_paint/test/lui_retained_test.ml b/platform/native/lui_paint/test/lui_retained_test.ml new file mode 100644 index 00000000..df194984 --- /dev/null +++ b/platform/native/lui_paint/test/lui_retained_test.ml @@ -0,0 +1,409 @@ +open Alcotest +open Lui_scene +open Lui_protocol + +let apply t ops = Lui_store.apply_batch t { generation = 1; ops } + +(* The text hook appends one glyph per character to whichever scene + [cur] points at — the same append-then-index discipline the real + text engine follows, exercising the fragment buffer capture. *) +let text_ops cur _id (r : rect) fg s = + let scn = !cur in + let gstart = List.length scn.glyphs in + String.iter + (fun _ -> + scn.glyphs <- + { gx = r.x; gy = r.y; gw = 4.; gh = 8.; gu = 0; gv = 0; + guw = 4; gvh = 8; gcolor = fg; gwide = 0; gcolored = false; + gsubpixel = false; gthin = false } + :: scn.glyphs) + s; + let gend = List.length scn.glyphs in + if gend = gstart then [] + else + [ Glyphs + { gstart; gend; gpaint = Solid; gcolor = fg; gcolor2 = fg; + ggradient = (0., 0., 0., 0.); gwide2 = 0; gopacity = 1. } ] + +let scene () = + Lui_scene.create + ~mask_atlas:(Atlas.create ~bpp:1 ~w:64 ~h:64) + ~color_atlas:(Atlas.create ~bpp:4 ~w:64 ~h:64) + +(* ---------- scene comparison ---------- *) + +(* [Stdlib.compare] is total across NaN fields — [=] is not. *) +let ceq (a : 'a) (b : 'a) = Stdlib.compare a b = 0 + +let op_eq a b = + match (a, b) with + | Lui_scene.Image a, Lui_scene.Image b -> + a.iimage.iid = b.iimage.iid && ceq { a with iimage = b.iimage } b + | a, b -> ceq a b + +let glyph_eq (a : glyph) (b : glyph) = ceq a b + +let rec list_eq eq xs ys = + match (xs, ys) with + | [], [] -> true + | x :: xs', y :: ys' -> eq x y && list_eq eq xs' ys' + | _ -> false + +let effect_shallow (e : effect_op) = + (e.ee.ename, e.ee.ebackdrop, e.eblur, Array.to_list e.eparams) + +let show_op = function + | Fill f -> + Printf.sprintf "Fill(%.1f,%.1f,%.1f,%.1f)" f.frect.x f.frect.y + f.frect.w f.frect.h + | Shadow _ -> "Shadow" + | Glyphs g -> Printf.sprintf "Glyphs(%d,%d)" g.gstart g.gend + | Image _ -> "Image" + | Push_clip c -> + Printf.sprintf "Push_clip(%.1f,%.1f,%.1f,%.1f)" c.crect.x c.crect.y + c.crect.w c.crect.h + | Pop_clip -> "Pop_clip" + | Effect _ -> "Effect" + | Hole _ -> "Hole" + +let check_scene (a : Lui_scene.t) (b : Lui_scene.t) = + (if not (list_eq op_eq a.ops b.ops) then begin + let rec first i xs ys = + match (xs, ys) with + | x :: _, y :: _ when not (op_eq x y) -> + Printf.eprintf "first diff at %d: %s vs %s\n%!" i (show_op x) + (show_op y) + | _ :: xs', _ :: ys' -> first (i + 1) xs' ys' + | [], _ -> Printf.eprintf "kept has %d extra ops\n%!" (List.length ys) + | _, [] -> Printf.eprintf "full has %d extra ops\n%!" (List.length xs) + in + first 0 a.ops b.ops + end); + check bool "ops equal" true (list_eq op_eq a.ops b.ops); + check bool "glyphs equal" true (list_eq glyph_eq a.glyphs b.glyphs); + check bool "wide equal" true (Stdlib.compare a.wide b.wide = 0); + check bool "effects equal" true + (List.map effect_shallow a.effects + = List.map effect_shallow b.effects) + +(* ---------- driver ---------- + + One store, one layout engine, one retained cache. Each frame syncs + the engine (host contract), hands the moved-rect set to the retained + pass, and cross-checks it against a full [Lui_paint.paint]. *) +type env = { + store : Lui_store.t; + eng : Lui_layout.t; + rt : Lui_retained.retained; + cur : Lui_scene.t ref; + (* The retained pass owns its scene across frames — untouched + buffers on clean frames are the whole point. *) + kept : Lui_scene.t; + state_tbl : (int, Lui_paint.state) Hashtbl.t; +} + +let env () = + { store = Lui_store.create (); + eng = Lui_layout.create (); + rt = Lui_retained.create (); + cur = ref (scene ()); + kept = scene (); + state_tbl = Hashtbl.create 8 } + +let hooks env = + { Lui_paint.default_hooks with + text_ops = text_ops env.cur; + state_of = + (fun id -> + match Hashtbl.find_opt env.state_tbl id with + | Some st -> st + | None -> Lui_paint.state_neutral); + shadow_of = Lui_paint.parse_shadows (fun _ -> None) } + +let measure _id _text _w _h = Some (40., 16.) + +let frame env ?(width = 200) ?(height = 200) () = + Lui_layout.sync ~scale:1. ~measure ~width:(float_of_int width) + ~height:(float_of_int height) env.eng env.store; + let moved = Lui_layout.take_rect_changes env.eng in + let h = { (hooks env) with layout = Lui_layout.layout_hook env.eng } in + let full = scene () in + env.cur := full; + Lui_paint.paint h env.store full ~width ~height ~scale:1. + ~clear:(color 0 0 0 0); + env.cur := env.kept; + Lui_retained.paint_retained env.rt h env.store env.kept ~moved ~width + ~height ~scale:1. ~clear:(color 0 0 0 0) (); + check_scene full env.kept + +let build_rows env n = + let ops = + CreateNode (1, Column) + :: List.concat_map + (fun i -> + let id = i + 2 in + [ CreateNode (id, Button); + SetProp (id, TextValue, StringValue (Printf.sprintf "row %d" i)); + SetProp (id, BackgroundValue, StringValue "#112233"); + InsertChild (1, id, i) ]) + (List.init n Fun.id) + in + apply env.store ops + +let test_static_frame () = + let env = env () in + build_rows env 10; + frame env (); + let v0 = Lui_retained.stat_visits env.rt + and r0 = Lui_retained.stat_repaints env.rt in + frame env (); + frame env (); + check int "no visits" 0 (Lui_retained.stat_visits env.rt - v0); + check int "no repaints" 0 (Lui_retained.stat_repaints env.rt - r0) + +let test_single_paint_update () = + let env = env () in + build_rows env 20; + frame env (); + let r0 = Lui_retained.stat_repaints env.rt in + apply env.store + [ SetProp (7, BackgroundValue, StringValue "#ff0000") ]; + frame env (); + check int "one repaint" 1 + (Lui_retained.stat_repaints env.rt - r0) + +let test_semantics_only_skips () = + let env = env () in + build_rows env 5; + frame env (); + let v0 = Lui_retained.stat_visits env.rt in + apply env.store + [ SetProp (3, AccessibilityLabel, StringValue "third") ]; + frame env (); + check int "no visits" 0 (Lui_retained.stat_visits env.rt - v0) + +let test_text_edit () = + let env = env () in + build_rows env 8; + frame env (); + apply env.store [ SetProp (4, TextValue, StringValue "renamed") ]; + frame env () + +let test_insert_remove () = + let env = env () in + build_rows env 6; + frame env (); + apply env.store + [ CreateNode (100, Text); + SetProp (100, TextValue, StringValue "inserted"); + InsertChild (1, 100, 2) ]; + frame env (); + apply env.store [ RemoveChild (1, 4) ]; + frame env (); + apply env.store [ RemoveChild (1, 100) ]; + frame env () + +let test_move_child () = + let env = env () in + build_rows env 6; + frame env (); + apply env.store [ MoveChild (1, 6, 0) ]; + frame env () + +let test_zindex () = + let env = env () in + build_rows env 4; + frame env (); + apply env.store [ SetProp (4, ZIndex, IntValue 5) ]; + frame env (); + apply env.store [ SetProp (4, ZIndex, IntValue 0) ]; + frame env () + +let test_display_none_cycle () = + let env = env () in + build_rows env 5; + frame env (); + apply env.store [ SetProp (4, DisplayValue, StringValue "none") ]; + frame env (); + apply env.store [ RemoveProp (4, DisplayValue) ]; + frame env () + +let test_display_contents () = + let env = env () in + build_rows env 3; + apply env.store + [ CreateNode (50, ViewThatFits); + SetProp (50, DisplayValue, StringValue "contents"); + InsertChild (1, 50, 1); + CreateNode (51, Text); + SetProp (51, TextValue, StringValue "lifted"); + InsertChild (50, 51, 0) ]; + frame env (); + apply env.store [ SetProp (51, TextValue, StringValue "still lifted") ]; + frame env () + +let test_opacity_cascade () = + let env = env () in + build_rows env 4; + frame env (); + apply env.store [ SetProp (1, Opacity, FloatValue 0.5) ]; + frame env (); + apply env.store [ SetProp (1, Opacity, FloatValue 1.) ]; + frame env () + +let test_popup_deferred () = + let env = env () in + build_rows env 3; + apply env.store + [ CreateNode (60, ContextMenu); + CreateNode (61, MenuItem); + SetProp (61, TextValue, StringValue "item"); + InsertChild (60, 61, 0); + InsertChild (1, 60, 1) ]; + frame env (); + apply env.store [ SetProp (61, TextValue, StringValue "item!") ]; + frame env () + +let test_state_hint () = + let env = env () in + build_rows env 5; + frame env (); + Hashtbl.replace env.state_tbl 4 + { Lui_paint.state_neutral with hovered = true }; + let hints = Hashtbl.create 4 in + Hashtbl.replace hints 4 (); + Lui_layout.sync ~scale:1. ~measure ~width:200. ~height:200. env.eng + env.store; + let h = { (hooks env) with layout = Lui_layout.layout_hook env.eng } in + let full = scene () in + env.cur := full; + Lui_paint.paint h env.store full ~width:200 ~height:200 ~scale:1. + ~clear:(color 0 0 0 0); + env.cur := env.kept; + Lui_retained.paint_retained env.rt h env.store env.kept ~hints ~width:200 + ~height:200 ~scale:1. ~clear:(color 0 0 0 0) (); + check_scene full env.kept + +let test_drop_subtree () = + let env = env () in + build_rows env 6; + frame env (); + apply env.store [ DropNode 1 ]; + frame env (); + (* store still carries the orphaned children as roots *) + frame env () + +(* Wide-child removals: 250 removed children land on the root list + and re-emit there — one repaint per moved id, never a rebuild per + removed child. *) +let test_wide_removal () = + let env = env () in + build_rows env 300; + frame env (); + let r0 = Lui_retained.stat_repaints env.rt in + for id = 2 to 250 do + apply env.store [ RemoveChild (1, id) ] + done; + frame env (); + check bool "bounded repaints" true + (Lui_retained.stat_repaints env.rt - r0 < 320) + +(* Deterministic random op sequences: every frame cross-checks the + retained scene against a full repaint. *) +let test_random_equivalence () = + let env = env () in + let rand = Random.State.make [| 0xC0FFEE |] in + let next_id = ref 100 in + let kinds = + [| Column; Row; Text; Button; Scroll; ViewThatFits; Label; Kbd |] + in + let paint_props = + [| BackgroundValue; ForegroundValue; CornerRadius; Shadow; + HoverBackground; BorderWidth; BorderColorValue |] + in + let layout_props = + [| WidthValue; HeightValue; PaddingValue; Gap; MarginValue |] + in + let colors = [| "#ff0000"; "#00ff00"; "#0000ff"; "#11223344" |] in + let pick a = a.(Random.State.int rand (Array.length a)) in + let live = ref [ 1 ] in + let kids_of id = Lui_store.child_ids env.store id in + let rand_live () = pick (Array.of_list !live) in + let rand_op () = + match Random.State.int rand 10 with + | 0 -> ( + incr next_id; + let id = !next_id in + let p = rand_live () in + live := id :: !live; + let n = List.length (kids_of p) in + [ CreateNode (id, pick kinds); + SetProp (id, TextValue, StringValue (Printf.sprintf "n%d" id)); + InsertChild (p, id, Random.State.int rand (n + 1)) ]) + | 1 -> ( + let p = rand_live () in + match kids_of p with + | [] -> [] + | kids -> + let c = pick (Array.of_list kids) in + [ RemoveChild (p, c) ]) + | 2 -> ( + let p = rand_live () in + match kids_of p with + | [] -> [] + | kids -> + let c = pick (Array.of_list kids) in + let n = List.length (kids_of p) in + [ MoveChild (p, c, Random.State.int rand n) ]) + | 3 -> + [ SetProp (rand_live (), pick paint_props, StringValue (pick colors)) ] + | 4 -> + [ SetProp + (rand_live (), pick layout_props, + FloatValue (float_of_int (Random.State.int rand 60))) ] + | 5 -> + [ SetProp + (rand_live (), Opacity, + FloatValue (Random.State.float rand 1.)) ] + | 6 -> + [ SetProp + ( rand_live (), + DisplayValue, + StringValue (if Random.State.bool rand then "none" else "contents") ) ] + | 7 -> + [ SetProp + ( rand_live (), + Visible, + BoolValue (Random.State.bool rand) ) ] + | 8 -> + [ SetProp (rand_live (), ZIndex, IntValue (Random.State.int rand 4)) ] + | _ -> + [ SetProp (rand_live (), TextValue, StringValue (pick colors)) ] + in + apply env.store [ CreateNode (1, Column) ]; + for _step = 1 to 60 do + let ops = + List.concat_map (fun _ -> rand_op ()) (List.init 4 Fun.id) + in + if ops <> [] then apply env.store ops; + frame env () + done + +let () = + run "lui_retained" + [ ( "fragments", + [ test_case "static frame" `Quick test_static_frame; + test_case "single paint update" `Quick test_single_paint_update; + test_case "semantics-only skips" `Quick test_semantics_only_skips; + test_case "text edit" `Quick test_text_edit; + test_case "insert/remove" `Quick test_insert_remove; + test_case "move child" `Quick test_move_child; + test_case "z-index" `Quick test_zindex; + test_case "display none cycle" `Quick test_display_none_cycle; + test_case "display contents" `Quick test_display_contents; + test_case "opacity cascade" `Quick test_opacity_cascade; + test_case "popup deferred" `Quick test_popup_deferred; + test_case "state hint" `Quick test_state_hint; + test_case "drop subtree" `Quick test_drop_subtree; + test_case "wide removal" `Quick test_wide_removal; + test_case "random equivalence" `Quick test_random_equivalence ] ) ] diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index e0a2bf7d..03716471 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -729,6 +729,30 @@ module Ui = struct (Lui_store.t -> int -> string -> lw:float -> lh:float -> caret_ops) option; mutable content_rect : int -> rect; + (* Paint-hint channels for the retained fragment pass: scroll + containers whose offset moved this frame (their whole subtree + re-derives — ops embed shifted rects), containers whose + scrollable range changed (thumb metrics only), and the + interaction snapshot the last hints diff ran against. *) + scrolled : (int, unit) Hashtbl.t; + scroll_metrics : (int, unit) Hashtbl.t; + mutable paint_snap : snap; + } + + and snap = { + ps_hovered : int; + ps_pressed : int; + ps_focused : int; + ps_drop_target : int; + ps_drag_src : int; + ps_hl : int; + ps_menu : int; + ps_caret : int; + ps_anchor : int; + ps_sel_text : (int * int * int * int) option; + ps_marked : string; + ps_shadow : string; + ps_focus_vis : bool; } (* The text engine's caret model for one editable string, built @@ -771,7 +795,14 @@ module Ui = struct measure = (fun _store _id s -> (8. *. float (String.length s), 16.)); hit_text = None; caret_ops = None; - content_rect = (fun _ -> rect 0. 0. 0. 0.) } + content_rect = (fun _ -> rect 0. 0. 0. 0.); + scrolled = Hashtbl.create 8; + scroll_metrics = Hashtbl.create 8; + paint_snap = + { ps_hovered = 0; ps_pressed = 0; ps_focused = 0; + ps_drop_target = 0; ps_drag_src = 0; ps_hl = 0; ps_menu = 0; + ps_caret = 0; ps_anchor = 0; ps_sel_text = None; + ps_marked = ""; ps_shadow = ""; ps_focus_vis = false } } let set_scale t s = t.scale <- s let set_measure t f = t.measure <- f @@ -823,12 +854,16 @@ module Ui = struct (Hashtbl.find_opt (snd (scroll_tbls t axis)) id) let set_scroll_cap_axis t axis id cap = + (* A new range moves the scrollbar thumb — metric-only hint. *) + if scroll_cap_axis t axis id <> cap then + Hashtbl.replace t.scroll_metrics id (); Hashtbl.replace (snd (scroll_tbls t axis)) id cap; (* A shrinking cap (content removed, window grown) clamps any offset it left dangling past the new range. *) let off = scroll_offset_axis t axis id in if off > cap then begin Hashtbl.replace (fst (scroll_tbls t axis)) id cap; + Hashtbl.replace t.scrolled id (); t.scroll_epoch <- t.scroll_epoch + 1; t.dirty <- true end @@ -1363,6 +1398,7 @@ module Ui = struct if Float.abs (off' -. scroll_offset_axis t axis id) > 0.001 then begin Hashtbl.replace (fst (scroll_tbls t axis)) id off'; + Hashtbl.replace t.scrolled id (); t.scroll_epoch <- t.scroll_epoch + 1; t.dirty <- true end; @@ -2034,6 +2070,7 @@ module Ui = struct in if off' <> off then begin Hashtbl.replace (fst (scroll_tbls t axis)) id off'; + Hashtbl.replace t.scrolled id (); t.scroll_epoch <- t.scroll_epoch + 1; t.dirty <- true end; @@ -2540,6 +2577,63 @@ module Ui = struct selected = bool_prop store id "selected" || t.hl_item = id; disabled = not (node_enabled store id) } + (* Ids whose paint inputs moved outside the store journal: the + interaction-state slots [state_of] reads, the editing session + behind the focused node's text ops, menu visibility, and every + scroll container that moved — a moved offset shifts all + descendant rects (ops embed them), so the whole subtree + re-derives. Drains the scroll tables and rolls the snapshot. *) + let paint_hints t store = + let h = Hashtbl.create 16 in + let mark id = if id <> 0 then Hashtbl.replace h id () in + let s = t.paint_snap in + let drag_src = + match t.drag with Some (id, _) -> id | None -> 0 + in + if s.ps_hovered <> t.hovered then (mark s.ps_hovered; mark t.hovered); + if s.ps_pressed <> t.pressed then (mark s.ps_pressed; mark t.pressed); + if s.ps_focused <> t.focused then (mark s.ps_focused; mark t.focused); + if s.ps_drop_target <> t.drop_target then + (mark s.ps_drop_target; mark t.drop_target); + if s.ps_drag_src <> drag_src then (mark s.ps_drag_src; mark drag_src); + if s.ps_hl <> t.hl_item then (mark s.ps_hl; mark t.hl_item); + if s.ps_menu <> t.open_menu then (mark s.ps_menu; mark t.open_menu); + (* Caret/selection/marked/shadow ride the focused node's text + ops; the ring channel reads focus_visible. *) + if + s.ps_caret <> t.caret || s.ps_anchor <> t.sel_anchor + || s.ps_marked <> marked t || s.ps_shadow <> t.shadow + || s.ps_focus_vis <> t.focus_visible + then mark t.focused; + if s.ps_sel_text <> t.sel_text then begin + (match s.ps_sel_text with + | Some (a, _, b, _) -> mark a; mark b + | None -> ()); + (match t.sel_text with + | Some (a, _, b, _) -> mark a; mark b + | None -> ()) + end; + (* Moved offset: the container's thumb plus every descendant's + shifted rect. *) + Hashtbl.iter + (fun id () -> + mark id; + List.iter mark (Lui_store.preorder ~root:id store)) + t.scrolled; + Hashtbl.reset t.scrolled; + (* Range-only change: the thumb metrics, nothing below. *) + Hashtbl.iter (fun id () -> mark id) t.scroll_metrics; + Hashtbl.reset t.scroll_metrics; + t.paint_snap <- + { ps_hovered = t.hovered; ps_pressed = t.pressed; + ps_focused = t.focused; ps_drop_target = t.drop_target; + ps_drag_src = drag_src; ps_hl = t.hl_item; + ps_menu = t.open_menu; ps_caret = t.caret; + ps_anchor = t.sel_anchor; ps_sel_text = t.sel_text; + ps_marked = marked t; ps_shadow = t.shadow; + ps_focus_vis = t.focus_visible }; + h + let want_frame t host = if t.laid_out_menu <> t.open_menu then begin Lui_host.invalidate_layout host; @@ -3061,6 +3155,11 @@ module Assembly = struct rebuilt from the fresh rects — always in this order, on every platform. *) let repaint t = + (* Interaction-state diffs reach the fragment pass through the + host's hint table — the store journal cannot see them. *) + Hashtbl.iter + (fun id () -> Lui_host.note_paint t.host id) + (Ui.paint_hints t.ui (store t)); let px = Lui_host.repaint t.host in t.populate (); px diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index 9c0dd0b5..118e63c2 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2043,6 +2043,13 @@ module Fake_layout = struct (Option.value ~default:Rects.zero (Hashtbl.find_opt t id)) let rect t id = Hashtbl.find_opt t id + + (* The stub re-places the whole tree every sync — every rect is + potentially fresh. *) + let take_rect_changes t = + let m = Hashtbl.create (Hashtbl.length t) in + Hashtbl.iter (fun id _ -> Hashtbl.replace m id ()) t; + m end let assembly_text : Assembly.text = diff --git a/platform/native/perf/dune b/platform/native/perf/dune index 92260433..29ced891 100644 --- a/platform/native/perf/dune +++ b/platform/native/perf/dune @@ -1,8 +1,8 @@ (library (name lui_perf) (modules harness scenarios) - (libraries lui lui_store lui_scene lui_paint lui_layout lui_raster - lui_host unix)) + (libraries lui lui_store lui_scene lui_paint lui_retained lui_layout + lui_raster lui_host unix)) (executable (name main) diff --git a/platform/native/perf/harness.ml b/platform/native/perf/harness.ml index b5e86c28..4da68e71 100644 --- a/platform/native/perf/harness.ml +++ b/platform/native/perf/harness.ml @@ -69,6 +69,7 @@ type t = { scroll : (int, float) Hashtbl.t; (* node id -> scroll offset, device px *) renderer : Lui_raster.Renderer.t; mutable hooks : Lui_paint.hooks; + retained : Lui_retained.retained; gcount : int ref; (* glyph records appended this paint (Quads engine) *) rev_glyphs : bool; (* Quads prepends records; [paint] unreverses once *) width : int; @@ -188,6 +189,7 @@ let create ?(text_engine = Stub) ~width ~height ~scale () = scroll = Hashtbl.create 8; renderer = Lui_raster.Renderer.create (); hooks; + retained = Lui_retained.create (); gcount; rev_glyphs = (text_engine = Quads); width; @@ -294,6 +296,19 @@ let raster_full t = let ops_count t = List.length (scene t).ops +(* Retained fragment pass — same frame inputs as {!paint}, driven the + way [Lui_host.repaint] wires it: engine rect changes as [~moved]. *) +let paint_retained t = + snd + (timed (fun () -> + Lui_retained.paint_retained t.retained (paint_hooks t) (store t) + (scene t) ~moved:(Lui_layout.take_rect_changes t.engine) + ~width:t.width ~height:t.height ~scale:t.scale + ~clear:(color 0 0 0 0) ())) + +let retained_repaints t = Lui_retained.stat_repaints t.retained +let retained_visits t = Lui_retained.stat_visits t.retained + (* ---------- resident memory ---------- *) (* Current RSS in KiB: /proc on Linux, `ps` elsewhere (macOS/BSD). diff --git a/platform/native/perf/scenarios.ml b/platform/native/perf/scenarios.ml index 62a81bf3..28dbf46d 100644 --- a/platform/native/perf/scenarios.ml +++ b/platform/native/perf/scenarios.ml @@ -248,6 +248,38 @@ let memory ~n ~item_h ~width ~height = row name "RSS after first frame" (mb after_frame) "MiB"; ] +(* 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. *) +let retained ~n ~item_h ~width ~height = + let name = Printf.sprintf "retained-%dk" (n / 1000) in + let t = H.create ~width ~height ~scale:1. () in + ignore (H.apply t (mount_rows ~n ~item_h ~text:(Some "row"))); + ignore (H.layout t); + ignore (H.paint_retained t); + let flips = 10 in + let target = 10 + min 20 (n - 1) in + let pa, rp = (ref [], ref []) in + for i = 0 to flips - 1 do + let c = if i land 1 = 0 then "#ff5030" else "#2a2d33" in + ignore + (H.apply t + { generation = 2 + i; + ops = [ SetProp (target, BackgroundValue, StringValue c) ] }); + let r0 = H.retained_repaints t in + pa := H.paint_retained t :: !pa; + rp := float_of_int (H.retained_repaints t - r0) :: !rp + done; + let full = H.paint t in + [ + ms_row name "full paint (same frame)" full; + ms_row name "retained paint p50" (H.stats_of !pa).p50; + ms_row name "retained paint p95" (H.stats_of !pa).p95; + row name "nodes repainted/frame" + (f2 (H.stats_of !rp).mean) + "nodes"; + ] + (* ---------- suite ---------- *) let default_width = 1280 @@ -274,6 +306,10 @@ let all ?(scale_sizes = false) () = 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; + List.concat + (List.map + (fun n -> retained ~n ~item_h:20. ~width:w ~height:h) + (if scale_sizes then [ 10_000 ] else [ 10_000; 100_000 ])); memory ~n:nrss ~item_h:28. ~width:w ~height:h; ] From 7b8570889a09630974da787fa4193cfe6b5cb2a6 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 06:41:39 +0800 Subject: [PATCH 5/9] =?UTF-8?q?perf(ml):=20subtree-piece=20segments=20+=20?= =?UTF-8?q?cached=20root=20set=20=E2=80=94=20O(dirty)=20retained=20paint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the retained engine's materialize from a per-node visit (~34 ms at 100k) to piece references: each frag's seg is a list of own ops + Sub references to emitting children, so a child's content edit never touches ancestor lists. Ancestors re-derive only on emit-status flips or child-set changes, driven by a deepest-first worklist (pend_repaint / pend_kids / pend_segs) instead of a tree walk; marks under culled branches persist until the branch surfaces. Also memoizes Lui_store.root_ids on layout_rev — the forest scan was an O(N log N) hidden cost inside every frame (helps layout too). Bench (perf scenario retained-*): 100k static tree, one color flip — 104.9 ms full -> 0.04 ms p50 / 0.40 ms p95, 1 node repainted. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_paint/lui_retained.ml | 495 +++++++++++++++------- platform/native/lui_store/lui_store.ml | 29 +- 2 files changed, 363 insertions(+), 161 deletions(-) diff --git a/platform/native/lui_paint/lui_retained.ml b/platform/native/lui_paint/lui_retained.ml index b77c9ad8..467e4e9b 100644 --- a/platform/native/lui_paint/lui_retained.ml +++ b/platform/native/lui_paint/lui_retained.ml @@ -1,50 +1,73 @@ -(* Retained scene fragments: a per-node paint cache sitting between the - store and {!Lui_scene}. +(* Retained scene fragments: a per-node paint cache sitting between + the store and {!Lui_scene}. A full [Lui_paint.paint] re-plans every mounted node each frame — prop lookups, state/style resolution, chrome op construction — even - when nothing changed. This module caches what each node emitted (its - own chrome ops plus the glyph/wide/effect records they index, all - local to the fragment) and replays the store's op journal plus - host-supplied move/state hints to repaint only the fragments whose - inputs actually changed. Clean frames skip assembly entirely; a - single-node update pays for that node, while the materialize pass - still walks the fragment tree to rebuild the flat scene buffers the - renderers consume. - - Correctness contract: anything a node paints derives from (a) the - store — tracked by the journal, (b) its layout hook answer — the - engine reports moved rects, and (c) host interaction state — the - caller reports touched ids via [~hints]. Hooks whose answers change - silently outside those channels must be poked through hints. *) + when nothing changed. This module caches, per node: + + - its own emitted ops (split around the point children paint), + self-contained against fragment-local glyph/wide/effect buffers; + - [kids]: the store children in paint order, popup kinds flagged + for the overlay drain; + - [seg]: the subtree's emission as a piece list — own ops inline, + children as [Sub] references — so a child's content change never + touches its ancestors' segment lists. Ancestors re-derive only + when a child's *emit-status* flips (shown ↔ silent) or the child + set/order changes; + - [sub_pops]: the popup nodes this subtree contributes to the + overlay drain, in preorder. + + Marks come from three channels: the store op journal (replayed at + a paint-owned cursor), engine rect changes ([~moved]), and host + interaction hints ([~hints]). A dirty frame processes three + worklists — kid lists, repaints, then segment rebuilds deepest + first — and materializes the flat buffers at O(emitted ops). + Clean frames skip assembly entirely. + + Correctness contract: hooks whose answers change outside those + channels (a theme swap, a decoded image) must be poked through + hints or {!invalidate}. *) open Lui_scene -(* One node's cached emission. [pre]/[post] are the node's own ops in - paint order (before/after the point children emit), self-contained: - [Glyphs.gstart]/[gend] index [glyphs], [fwide]/[gwide2]/[swide]/ - [gwide] index [wide] (1-based, 0 = none), [edindex] indexes [fx]. - Materialize re-bases them onto the frame buffers. *) -type frag = { +(* A subtree piece: an op of the owning frag, or a reference to a + child's memoized segment. References keep a child's content edits + out of the parent's list — only emit-status flips and membership + churn rebuild it. *) +type piece = Own of frag * op | Sub of frag + +and frag = { + self : int; + mutable kind : string; mutable pre : op list; mutable post : op list; mutable glyphs : glyph array; mutable wide : wide_colors array; mutable fx : effect_op array; - (* Children in paint order, split by stacking contract: [pkids] - defer to the overlay drain like the full pass's deferred queue. *) - mutable kids : int list; - mutable pkids : int list; - (* Inherited inputs the fragment was painted under. *) + (* Store children in paint order as (id, defers-to-overlay). *) + mutable kids : (int * bool) list; + (* Memoized subtree emission (inline children only) and the popup + nodes below it, both rebuilt on [seg_dirty]. [emitted] is + [seg <> []] at last build — the emit-status the parent folded. *) + mutable seg : piece list; + mutable sub_pops : frag list; + mutable emitted : bool; + (* Inherited inputs the fragment was painted under, and the results + children resolve against. *) mutable alpha_in : float; mutable parent_r : rect; - (* Results handed to children. *) mutable alpha_out : float; mutable child_parent : rect; mutable show_kids : bool; mutable born : bool; mutable repaint : bool; mutable rekids : bool; + mutable seg_dirty : bool; + (* Buffer bases assigned while materializing the current frame. *) + mutable gen : int; + mutable gbase : int; + mutable wbase : int; + mutable ebase : int; } type retained = { @@ -53,12 +76,19 @@ type retained = { ops re-derive the right child list when the op only names the child. *) fparent : (int, int) Hashtbl.t; + (* Open work the next materialize must process. *) + pend_repaint : (int, unit) Hashtbl.t; + pend_kids : (int, unit) Hashtbl.t; + pend_segs : (int, unit) Hashtbl.t; (* Store revision the journal has been replayed through. *) mutable rev : int; mutable vp : rect; mutable scale : float; (* Any mark since the last materialize. *) mutable dirty : bool; + (* Materialize generation — fragments record which frame last + pushed their buffers. *) + mutable mgen : int; (* Instrumentation for tests/benchmarks. *) mutable stat_repaints : int; mutable stat_visits : int; @@ -66,8 +96,10 @@ type retained = { let create () = { frags = Hashtbl.create 256; fparent = Hashtbl.create 256; + pend_repaint = Hashtbl.create 64; pend_kids = Hashtbl.create 16; + pend_segs = Hashtbl.create 64; rev = -1; vp = rect 0. 0. 0. 0.; scale = 1.; - dirty = false; stat_repaints = 0; stat_visits = 0 } + dirty = false; mgen = 0; stat_repaints = 0; stat_visits = 0 } let invalidate (rt : retained) = Hashtbl.reset rt.frags; @@ -76,48 +108,76 @@ let invalidate (rt : retained) = let stat_repaints (rt : retained) = rt.stat_repaints let stat_visits (rt : retained) = rt.stat_visits -let fresh_frag () = - { pre = []; post = []; glyphs = [||]; wide = [||]; fx = [||]; - kids = []; pkids = []; alpha_in = 0.; parent_r = rect 0. 0. 0. 0.; - alpha_out = 0.; child_parent = rect 0. 0. 0. 0.; show_kids = false; - born = false; repaint = false; rekids = false } +let fresh_frag id = + { self = id; kind = ""; pre = []; post = []; + glyphs = [||]; wide = [||]; fx = [||]; + kids = []; seg = []; sub_pops = []; emitted = false; + alpha_in = 0.; parent_r = rect 0. 0. 0. 0.; + alpha_out = 0.; child_parent = rect 0. 0. 0. 0.; + show_kids = false; born = false; + repaint = false; rekids = false; seg_dirty = true; + gen = -1; gbase = 0; wbase = 0; ebase = 0 } let frag_of (rt : retained) id = match Hashtbl.find_opt rt.frags id with | Some f -> f | None -> - let f = fresh_frag () in + let f = fresh_frag id in Hashtbl.replace rt.frags id f; f +(* The paint-parent of [id] — whoever last listed it as a child — or + the live store parent when paint never saw it. *) +let paint_parent rt store id = + match Hashtbl.find_opt rt.fparent id with + | Some p when p <> 0 -> Some p + | _ -> Lui_store.parent store id + +let mark_seg rt id = + let f = frag_of rt id in + if not f.seg_dirty then begin + f.seg_dirty <- true; + Hashtbl.replace rt.pend_segs id () + end + let mark_repaint rt id = let f = frag_of rt id in - f.repaint <- true; + if not f.repaint then begin + f.repaint <- true; + Hashtbl.replace rt.pend_repaint id () + end; + mark_seg rt id; rt.dirty <- true let mark_rekids rt id = let f = frag_of rt id in - f.rekids <- true; + if not f.rekids then begin + f.rekids <- true; + Hashtbl.replace rt.pend_kids id () + end; + mark_seg rt id; rt.dirty <- true -(* The paint-parent of [id] — whoever last listed it as a child — or - the live store parent when paint never saw it. *) -let paint_parent rt store id = - match Hashtbl.find_opt rt.fparent id with - | Some p when p <> 0 -> Some p - | _ -> Lui_store.parent store id - let mark_parent_rekids rt store id = match paint_parent rt store id with | Some p -> mark_rekids rt p | None -> () +(* [f]'s emit-status moved — its slot in the parent's piece list is + stale. Culled parents carry no child slots to update: when they + surface, their own rebuild enumerates children fresh. *) +let flip_to_parent rt store f = + match paint_parent rt store f.self with + | Some p -> + let pf = frag_of rt p in + if pf.show_kids then mark_seg rt p + | None -> () + let rec purge_frag rt id = match Hashtbl.find_opt rt.frags id with | Some f -> Hashtbl.remove rt.frags id; - List.iter (purge_frag rt) f.kids; - List.iter (purge_frag rt) f.pkids + List.iter (fun (k, _) -> purge_frag rt k) f.kids | None -> () (* Replay the store ops since [rt.rev], translating them into fragment @@ -169,14 +229,43 @@ let advance rt store = the frame alone. *) rt.rev <- Lui_store.revision store +(* Re-derive the child list from the store: z-ordered like the full + pass, popup kinds (except display:contents) flagged for the overlay + drain. *) +let build_kids rt store (f : frag) id = + let old = f.kids in + f.kids <- + List.map + (fun (c : Lui_store.node) -> + let cid = Lui_store.node_id c in + ( cid, + Lui_paint.popup_kind (Lui_store.node_kind c) + && not (Lui_paint.unboxed store cid) )) + (Lui_paint.ordered_children store id); + (* Re-point parent links: entries this list dropped keep whatever + parent claimed them since (a reparent op already marked it). *) + List.iter + (fun (k, _) -> + match Hashtbl.find_opt rt.fparent k with + | Some p when p = id -> Hashtbl.remove rt.fparent k + | _ -> ()) + old; + List.iter (fun (k, _) -> Hashtbl.replace rt.fparent k id) f.kids; + f.rekids <- false + (* The node's own emission, captured into [f]: the same decisions [Lui_paint.paint_node_body] makes — shown/cull/state/alpha/clip — - minus the child walk, which the materialize drives itself through - [f.kids]/[f.pkids]. Ops land in the scene's swapped-in fresh buffers - so every buffer index they carry is fragment-local. *) -let repaint_frag ctx store (scene : Lui_scene.t) (f : frag) id kind ~parent_r ~alpha_in = + minus the child walk, which the segment build drives through + [f.kids]. Ops land in the scene's swapped-in fresh buffers so every + buffer index they carry is fragment-local. Afterwards, children + whose inherited context actually moved get re-marked (the alpha + and containing-block cascades). *) +let repaint_frag rt ctx store scene (f : frag) id kind ~parent_r + ~alpha_in = + let old_alpha = f.alpha_out and old_cp = f.child_parent in f.alpha_in <- alpha_in; f.parent_r <- parent_r; + f.kind <- kind; let saved_ops = scene.ops and saved_glyphs = scene.glyphs and saved_wide = scene.wide @@ -202,10 +291,13 @@ let repaint_frag ctx store (scene : Lui_scene.t) (f : frag) id kind ~parent_r ~a let rs = Lui_paint.resolve_style store id st in let alpha = if contents then alpha_in - else alpha_in *. rs.rs_opacity *. Lui_paint.disabled_alpha store id st + else + alpha_in *. rs.rs_opacity + *. Lui_paint.disabled_alpha store id st in - (* display:contents generates no box: children keep the parent's - containing block and its alpha, exactly like the full pass. *) + (* display:contents generates no box: children keep the + parent's containing block and its alpha, exactly like the + full pass. *) child_parent := (if contents then parent_r else r); alpha_out := alpha; if alpha > 0. then begin @@ -222,7 +314,8 @@ let repaint_frag ctx store (scene : Lui_scene.t) (f : frag) id kind ~parent_r ~a Lui_paint.emit1 scene (Push_clip { crect = r; - cradii = corners r (Lui_paint.radii_of ctx store id) false; + cradii = + corners r (Lui_paint.radii_of ctx store id) false; ccontinuous = false }); if not contents then Lui_paint.emit scene @@ -247,54 +340,111 @@ let repaint_frag ctx store (scene : Lui_scene.t) (f : frag) id kind ~parent_r ~a f.show_kids <- !show_kids; f.born <- true; f.repaint <- false; + rt.stat_repaints <- rt.stat_repaints + 1; scene.ops <- saved_ops; scene.glyphs <- saved_glyphs; scene.wide <- saved_wide; - scene.effects <- saved_fx + scene.effects <- saved_fx; + (* Inherited-context cascade: children painted under the old values + must re-derive when either moved. *) + if f.alpha_out <> old_alpha || f.child_parent <> old_cp then + List.iter (fun (k, _) -> mark_repaint rt k) f.kids -(* Re-derive the child list from the store: z-ordered like the full - pass, popup kinds (except display:contents) split into the deferred - set so the overlay drain can paint them last. *) -let build_kids rt store (f : frag) id = - let old = f.kids @ f.pkids in - let kids, pkids = - List.fold_left - (fun (ks, ps) (c : Lui_store.node) -> - let cid = Lui_store.node_id c in - if - Lui_paint.popup_kind (Lui_store.node_kind c) - && not (Lui_paint.unboxed store cid) - then (ks, cid :: ps) - else (cid :: ks, ps)) - ([], []) (Lui_paint.ordered_children store id) - in - f.kids <- List.rev kids; - f.pkids <- List.rev pkids; - (* Re-point parent links: entries this list dropped keep whatever - parent claimed them since (a reparent op already marked it). *) - List.iter - (fun k -> - match Hashtbl.find_opt rt.fparent k with - | Some p when p = id -> Hashtbl.remove rt.fparent k - | _ -> ()) - old; - List.iter (fun k -> Hashtbl.replace rt.fparent k id) (f.kids @ f.pkids); - f.rekids <- false +(* Parent context for a pending repaint, painting the parent first so + the inherited alpha/containing-block are current. [None] while the + parent's subtree is culled — the child keeps its marks for when + the branch surfaces. *) +let rec parent_ctx rt store ctx scene vp id = + match paint_parent rt store id with + | Some p -> ( + let pf = frag_of rt p in + ignore (ensure_painted rt store ctx scene vp pf); + if pf.show_kids then Some (pf.child_parent, pf.alpha_out) else None) + | None -> Some (vp, 1.) + +(* Paint [f] if it is marked or unborn — parent context first so the + inherited values are current. Returns whether [f] holds fresh ops + afterwards (false only while an ancestor culls it). *) +and ensure_painted rt store ctx scene vp (f : frag) : bool = + if f.repaint || not f.born then begin + if not (Lui_store.mem store f.self) then begin + (* Marked after the node died — drop the fragment. *) + Hashtbl.remove rt.frags f.self; + false + end + else + match parent_ctx rt store ctx scene vp f.self with + | None -> false + | Some (parent_r, alpha_in) -> + repaint_frag rt ctx store scene f f.self + (Lui_store.kind store f.self) ~parent_r ~alpha_in; + true + end + else f.born -(* Materialize-threaded state: running buffer bases plus the popup - drain. *) +(* Materialize-threaded state: running buffer bases plus the frame + generation that makes each fragment's buffers append once. *) type mat = { m_ctx : Lui_paint.ctx; + m_gen : int; mutable gcount : int; mutable wcount : int; mutable ecount : int; - mutable gbase : int; - mutable wbase : int; - mutable ebase : int; mutable fx_rev : effect_op list; - deferred : (int * rect * float * bool) Queue.t; } +(* Bring one child current — repaint under this parent's resolved + context, rebuild its own segment — then let the fold read its + emit-status. Culled parents skip this entirely: the marks persist + and surface when the branch does. *) +let rec ensure_kid rt store scene m (f : frag) (k, _d) = + let kf = frag_of rt k in + if kf.rekids then build_kids rt store kf k; + if kf.repaint || not kf.born then begin + if Lui_store.mem store k then + repaint_frag rt m.m_ctx store scene kf k + (Lui_store.kind store k) + ~parent_r:f.child_parent ~alpha_in:f.alpha_out + end; + if kf.born && kf.seg_dirty then rebuild_seg rt store scene m kf + +(* Rebuild [f.seg]/[f.sub_pops] from the current pieces, descending + into dirty children first so every kid's emit-status is fresh when + the fold reads it. Clean kids contribute their memoized pieces for + free; culled kids are not enumerated at all. *) +and rebuild_seg rt store scene m (f : frag) = + rt.stat_visits <- rt.stat_visits + 1; + f.seg_dirty <- false; + let own ops = List.map (fun o -> Own (f, o)) ops in + if f.show_kids then begin + List.iter (ensure_kid rt store scene m f) f.kids; + f.seg <- + own f.pre + @ List.filter_map + (fun (k, deferred) -> + let kf = frag_of rt k in + if deferred || not kf.emitted then None + else Some (Sub kf)) + f.kids + @ own f.post; + f.sub_pops <- + List.concat_map + (fun (k, deferred) -> + let kf = frag_of rt k in + if deferred then kf :: kf.sub_pops else kf.sub_pops) + f.kids + end + else begin + f.seg <- own f.pre @ own f.post; + f.sub_pops <- [] + end; + (* Emit-status flips re-derive the parent's piece list. *) + let e = f.seg <> [] in + if e <> f.emitted then begin + f.emitted <- e; + flip_to_parent rt store f + end + (* Append a fragment's local buffers to the frame's, recording the bases its ops need. Glyph records carry their own [gwide] link into [scene.wide] — re-based here. *) @@ -313,59 +463,39 @@ let push_buffers (scene : Lui_scene.t) m (f : frag) = :: scene.glyphs) f.glyphs; m.gcount <- gbase + Array.length f.glyphs; - m.gbase <- gbase; - m.wbase <- wbase; - m.ebase <- ebase + f.gbase <- gbase; + f.wbase <- wbase; + f.ebase <- ebase; + f.gen <- m.m_gen (* Re-base a fragment-local op onto the frame buffers. Ops without buffer links are shared unchanged. *) -let rebase m (op : op) : op = +let rebase (f : frag) (op : op) : op = match op with | Glyphs g -> Glyphs { g with - gstart = g.gstart + m.gbase; - gend = g.gend + m.gbase; - gwide2 = (if g.gwide2 > 0 then g.gwide2 + m.wbase else 0) } - | Fill f when f.fwide > 0 -> Fill { f with fwide = f.fwide + m.wbase } - | Shadow s when s.swide > 0 -> Shadow { s with swide = s.swide + m.wbase } - | Effect e -> Effect { e with edindex = e.edindex + m.ebase } + gstart = g.gstart + f.gbase; + gend = g.gend + f.gbase; + gwide2 = (if g.gwide2 > 0 then g.gwide2 + f.wbase else 0) } + | Fill fl when fl.fwide > 0 -> + Fill { fl with fwide = fl.fwide + f.wbase } + | Shadow s when s.swide > 0 -> + Shadow { s with swide = s.swide + f.wbase } + | Effect e -> Effect { e with edindex = e.edindex + f.ebase } | op -> op -let emit_ops (scene : Lui_scene.t) m ops = - List.iter (fun op -> scene.ops <- rebase m op :: scene.ops) ops - -let rec visit rt store scene m id kind ~parent_r ~alpha_in ~force = - rt.stat_visits <- rt.stat_visits + 1; - let f = frag_of rt id in - if f.rekids || not f.born then build_kids rt store f id; - if force then - (* A forcibly moved subtree (e.g. scroll): descendants under a - culled branch keep their repaint mark for when they surface. *) - List.iter - (fun k -> (frag_of rt k).repaint <- true) - (f.kids @ f.pkids); - if - force || f.repaint || (not f.born) - || f.alpha_in <> alpha_in || f.parent_r <> parent_r - then begin - rt.stat_repaints <- rt.stat_repaints + 1; - repaint_frag m.m_ctx store scene f id kind ~parent_r ~alpha_in - end; - push_buffers scene m f; - emit_ops scene m f.pre; - if f.show_kids then begin - let cp = f.child_parent and a = f.alpha_out in - List.iter - (fun k -> - visit rt store scene m k (Lui_store.kind store k) ~parent_r:cp - ~alpha_in:a ~force) - f.kids; - List.iter - (fun k -> Queue.add (k, cp, a, force) m.deferred) - f.pkids - end; - emit_ops scene m f.post +(* Emit a segment: each [Sub] reference descends into the child's own + memoized pieces — only emitting nodes are ever visited. *) +let rec emit_seg (scene : Lui_scene.t) m (f : frag) = + List.iter + (fun piece -> + match piece with + | Own (g, op) -> + if g.gen <> m.m_gen then push_buffers scene m g; + scene.ops <- rebase g op :: scene.ops + | Sub g -> emit_seg scene m g) + f.seg let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ~width ~height ~scale ~clear () = @@ -384,36 +514,97 @@ let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints | Some h -> Hashtbl.iter (fun id () -> mark_repaint rt id) h | None -> ()); if rt.dirty then begin - rt.dirty <- false; - Lui_scene.reset scene ~width ~height ~scale ~clear; - (* Buffer bases + the ctx the repaints run against. The deferred - queue on the ctx is vestigial — the fragment pass defers by - classification, not emission. *) let m = { m_ctx = { Lui_paint.hooks; scale; viewport = vp; deferred = Queue.create () }; - gcount = 0; wcount = 0; ecount = 0; - gbase = 0; wbase = 0; ebase = 0; - fx_rev = []; deferred = Queue.create () } + m_gen = + (rt.mgen <- rt.mgen + 1; rt.mgen); + gcount = 0; wcount = 0; ecount = 0; fx_rev = [] } in + Lui_scene.reset scene ~width ~height ~scale ~clear; + (* Kid lists first: parent links must be current before repaints + resolve their context. *) + Hashtbl.iter + (fun id () -> + match Hashtbl.find_opt rt.frags id with + | Some f when f.rekids -> build_kids rt store f id + | _ -> ()) + rt.pend_kids; + (* Repaints can re-mark children (alpha/containing-block moves) — + drain until the worklist empties. *) + while Hashtbl.length rt.pend_repaint > 0 do + let ids = + Hashtbl.fold (fun id () acc -> id :: acc) rt.pend_repaint [] + in + Hashtbl.reset rt.pend_repaint; + List.iter + (fun id -> + match Hashtbl.find_opt rt.frags id with + | Some f -> + ignore (ensure_painted rt store m.m_ctx scene vp f) + | None -> ()) + ids + done; + (* Roots anchor the paint tree — link them. Dirty subtrees + rebuild through the worklist; emit-status flips re-mark + parents into it. Marks under a culled branch are skipped — + their flags persist and the ancestor's own rebuild descends + into them when the branch surfaces. *) + let roots = Lui_store.root_ids store in + List.iter (fun id -> Hashtbl.replace rt.fparent id 0) roots; + while Hashtbl.length rt.pend_segs > 0 do + let ids = + Hashtbl.fold (fun id () acc -> id :: acc) rt.pend_segs [] + in + Hashtbl.reset rt.pend_segs; + List.iter + (fun id -> + match Hashtbl.find_opt rt.frags id with + | Some f when f.seg_dirty -> ( + match paint_parent rt store id with + | Some p -> + let pf = frag_of rt p in + if (not pf.born) || pf.show_kids then + if ensure_painted rt store m.m_ctx scene vp f then + rebuild_seg rt store scene m f + | None -> + if ensure_painted rt store m.m_ctx scene vp f then + rebuild_seg rt store scene m f) + | _ -> ()) + ids + done; + (* Clean roots need no rebuild — their piece lists hold [Sub] + references that already point at the fresh child segments. *) + List.iter + (fun id -> + let f = frag_of rt id in + if f.seg_dirty || f.repaint || not f.born then + if ensure_painted rt store m.m_ctx scene vp f then + rebuild_seg rt store scene m f) + roots; + List.iter (emit_seg scene m) (List.map (frag_of rt) roots); + (* Overlay drain: subtree popups in preorder — each emits its own + memoized segment. *) List.iter (fun id -> - Hashtbl.replace rt.fparent id 0; - visit rt store scene m id (Lui_store.kind store id) ~parent_r:vp - ~alpha_in:1. ~force:false) - (Lui_store.root_ids store); - (* Popups (and popups nested inside them) emit after the inline - forest, preserving the full pass's drain order. *) - while not (Queue.is_empty m.deferred) do - let id, parent_r, alpha_in, force = Queue.pop m.deferred in - visit rt store scene m id (Lui_store.kind store id) ~parent_r - ~alpha_in ~force + List.iter (emit_seg scene m) (frag_of rt id).sub_pops) + roots; + while not (Queue.is_empty m.m_ctx.Lui_paint.deferred) do + (Queue.pop m.m_ctx.Lui_paint.deferred) () done; scene.ops <- List.rev scene.ops; scene.glyphs <- List.rev scene.glyphs; scene.wide <- List.rev scene.wide; - scene.effects <- List.rev m.fx_rev + scene.effects <- List.rev m.fx_rev; + (* Marks raised while materializing were consumed through the + same frame's worklists — nothing may leak a phantom dirty into + the next frame. Fragment flags persist independently for + culled branches. *) + Hashtbl.reset rt.pend_repaint; + Hashtbl.reset rt.pend_kids; + Hashtbl.reset rt.pend_segs; + rt.dirty <- false end else begin (* Nothing the fragments depend on moved — the buffers stand. diff --git a/platform/native/lui_store/lui_store.ml b/platform/native/lui_store/lui_store.ml index 2f7476e0..131d4480 100644 --- a/platform/native/lui_store/lui_store.ml +++ b/platform/native/lui_store/lui_store.ml @@ -173,6 +173,8 @@ type t = { layout-kids bits). The layout memo keys on it so pure paint churn leaves the flex mirror untouched. *) mutable layout_rev : int; + mutable roots_cache : int list; + mutable roots_rev : int; journal : change Queue.t; mutable journal_len : int; (* Journal completeness: every op whose [crev] exceeds @@ -187,6 +189,7 @@ let journal_cap = 8192 let create () = { nodes = Hashtbl.create 64; dirty = Hashtbl.create 64; generation = 0; revision = 0; layout_rev = 0; + roots_cache = []; roots_rev = -1; journal = Queue.create (); journal_len = 0; journal_base = -1 } let node_count t = Hashtbl.length t.nodes @@ -525,16 +528,24 @@ 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 only moves when structure ops run, so the scan+sort is + memoized on [layout_rev] — callers on per-frame paths stay O(roots) + between them. *) 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 [] - |> List.sort compare + if t.roots_rev <> t.layout_rev then begin + t.roots_cache <- + Hashtbl.fold + (fun id n acc -> + match n.parent with + | None -> id :: acc + | Some pid when not (Hashtbl.mem t.nodes pid) -> id :: acc + | Some _ -> acc) + t.nodes [] + |> List.sort compare; + t.roots_rev <- t.layout_rev + end; + t.roots_cache (* Preorder ids under [root] (or the whole forest when None). *) let preorder ?root t = From 4fd2068edbfd86ca6576db308de477b9940369ec Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 06:58:51 +0800 Subject: [PATCH 6/9] perf(ml): scene revision + Lui_gpu batch cache skeleton (P2-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a paint revision to Lui_scene (bumped by reset; touch for out-of-band writers) and Lui_gpu.batch_cache: memoized batch list plus a grow-only packed instance buffer, keyed on the scene's rev and the inputs build folds into instances (wide flag, frame size, text coverage params, atlas dims). A paint that changed nothing — the retained pass's clean frames — reuses the batches and the staging buffer wholesale. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_gpu/lui_gpu.ml | 95 ++++++++++++++++++++++++++ platform/native/lui_gpu/lui_gpu.mli | 32 +++++++++ platform/native/lui_scene/lui_scene.ml | 13 +++- 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/platform/native/lui_gpu/lui_gpu.ml b/platform/native/lui_gpu/lui_gpu.ml index 371800b4..f6c42a11 100644 --- a/platform/native/lui_gpu/lui_gpu.ml +++ b/platform/native/lui_gpu/lui_gpu.ml @@ -559,4 +559,99 @@ let to_float32_array (i : instance) : float array = pack_instance i b ~ofs:0; Array.init instance_floats (fun j -> Bigarray.Array1.get b j) +(* {1 Scene batch cache} + + A renderer compiles a scene to a batch list and a packed instance + buffer every frame, even when the display list did not move — the + retained paint pass already reports that: an unchanged scene keeps + its revision. The cache memoizes both artifacts under a key of what + [build] folds into instances, so a clean frame reuses the batch + structure, the CPU staging buffer and the GPU-side contents it was + uploaded to — renderers skip the re-upload on a hit. *) + +type f32_ba = + (float, Bigarray.float32_elt, Bigarray.c_layout) Bigarray.Array1.t + +(* The buffer a renderer uploads: [data] is the grow-only staging, + [floats] the live prefix, [spans] each batch's (first, count) in + instances. *) +type pack = { data : f32_ba; floats : int; spans : (int * int) array } + +(* The inputs [build] folds into instances besides the display list: + the revision identifying it, [wide] selecting the gamut, the frame + size (the root scissor), the text coverage params, and the atlas + dimensions glyph UVs divide by. A change to any recompiles. *) +type key = + int * bool * int * int * text_params * (int * int) * (int * int) + +type batch_cache = { + mutable c_scene : t option; + mutable c_key : key option; + mutable c_batches : batch list; + mutable c_pack : pack; + mutable c_rebuilds : int; + mutable c_reuses : int; +} + +let batch_cache () = + { c_scene = None; c_key = None; c_batches = []; + c_pack = + { data = Bigarray.(Array1.create float32 c_layout 0); + floats = 0; spans = [||] }; + c_rebuilds = 0; c_reuses = 0 } + +let stat_rebuilds c = c.c_rebuilds +let stat_reuses c = c.c_reuses + +(* Snapshot the key; the text params are copied so a later in-place + edit cannot mutate the stored key along with them. *) +let key_of ~wide (s : t) : key = + ( s.rev, wide, s.width, s.height, + { gamma_ratios = s.text.gamma_ratios; + contrast = s.text.contrast; + subpixel_contrast = s.text.subpixel_contrast }, + (s.mask_atlas.w, s.mask_atlas.h), + (s.color_atlas.w, s.color_atlas.h) ) + +let rebuild c ~wide s = + let batches = build ~wide s in + let total = + List.fold_left (fun n b -> n + List.length b.instances) 0 batches + in + let floats = total * instance_floats in + let data = + if Bigarray.Array1.dim c.c_pack.data >= floats then c.c_pack.data + else + Bigarray.(Array1.create float32 c_layout (floats + floats / 2)) + in + let spans = Array.make (List.length batches) (0, 0) in + let ofs = ref 0 in + List.iteri + (fun bi b -> + let start = !ofs / instance_floats in + List.iter + (fun inst -> + pack_instance inst data ~ofs:!ofs; + ofs := !ofs + instance_floats) + b.instances; + spans.(bi) <- (start, List.length b.instances)) + batches; + c.c_scene <- Some s; + c.c_key <- Some (key_of ~wide s); + c.c_batches <- batches; + c.c_pack <- { data; floats; spans }; + c.c_rebuilds <- c.c_rebuilds + 1 + +(* The scene's compiled batches and packed buffer, rebuilt only when + the key moved; [rebuilt] tells the renderer whether the GPU-side + buffer contents are stale. *) +let frame ?(wide = false) c (s : t) : batch list * pack * bool = + match (c.c_scene, c.c_key) with + | Some sc, Some k when sc == s && k = key_of ~wide s -> + c.c_reuses <- c.c_reuses + 1; + (c.c_batches, c.c_pack, false) + | _ -> + rebuild c ~wide s; + (c.c_batches, c.c_pack, true) + let shader_source = Shader_source.source diff --git a/platform/native/lui_gpu/lui_gpu.mli b/platform/native/lui_gpu/lui_gpu.mli index 639dddc0..7edf29bd 100644 --- a/platform/native/lui_gpu/lui_gpu.mli +++ b/platform/native/lui_gpu/lui_gpu.mli @@ -139,5 +139,37 @@ val to_float32_array : instance -> float array params, clip2, clip2_radii, clip3, clip3_radii — rounded to float32. *) +(** {1 Scene batch cache} *) + +type f32_ba = + (float, Bigarray.float32_elt, Bigarray.c_layout) Bigarray.Array1.t + +(** The buffer a renderer uploads: [data] is the grow-only staging, + [floats] the live prefix, [spans] each batch's (first, count) + instance offset in that prefix. *) +type pack = { data : f32_ba; floats : int; spans : (int * int) array } + +type batch_cache +(** A renderer's memoized batches and instance buffer for one scene. + The key is the scene's revision plus the other inputs {!build} + folds into instances: the wide-gamut flag, frame size, text + coverage parameters, and atlas dimensions. A paint that changed + nothing — the retained pass's clean frames — reuses the batches, + the staging buffer, and the GPU-side contents it was uploaded to, + so the renderer may skip the re-upload entirely. *) + +val batch_cache : unit -> batch_cache + +val frame : + ?wide:bool -> batch_cache -> Lui_scene.t -> batch list * pack * bool +(** The scene's compiled batches and packed instance buffer, rebuilt + only when the cache key moved. The flag tells whether this call + rebuilt — on [false] the returned [pack]'s GPU-side contents are + still current and the renderer skips re-uploading them. *) + +val stat_rebuilds : batch_cache -> int +val stat_reuses : batch_cache -> int +(** Cache accounting for tests and benchmarks. *) + val shader_source : string (** The shader of shader.glsl, verbatim, as data. *) diff --git a/platform/native/lui_scene/lui_scene.ml b/platform/native/lui_scene/lui_scene.ml index c7359b5d..dc7ef3d2 100644 --- a/platform/native/lui_scene/lui_scene.ml +++ b/platform/native/lui_scene/lui_scene.ml @@ -482,7 +482,10 @@ and effect_draw = { edrect : rect; edradii : float * float * float * float; edco and hole_op = { hrect : rect; hradii : float * float * float * float; hcontinuous : bool; hopacity : float } -(* Scene: one frame's display list. *) +(* Scene: one frame's display list. [rev] identifies its contents: + {!reset} bumps it, and a writer mutating the buffers outside a + reset–seal window calls {!touch}, so a renderer can tell a scene it + has already compiled from one whose display list moved. *) type t = { mutable width : int; mutable height : int; @@ -493,6 +496,7 @@ type t = { mutable effects : effect_op list; mutable wide : wide_colors list; mutable text : text_params; + mutable rev : int; mask_atlas : Atlas.t; color_atlas : Atlas.t; } @@ -500,10 +504,13 @@ type t = { let create ~mask_atlas ~color_atlas = { width = 0; height = 0; scale = 1.; clear = color 0 0 0 0; ops = []; glyphs = []; effects = []; wide = []; - text = default_text_params; mask_atlas; color_atlas } + text = default_text_params; rev = 0; mask_atlas; color_atlas } + +let touch s = s.rev <- s.rev + 1 let reset s ~width ~height ~scale ~clear = s.width <- width; s.height <- height; s.scale <- scale; s.clear <- clear; - s.ops <- []; s.glyphs <- []; s.effects <- []; s.wide <- [] + s.ops <- []; s.glyphs <- []; s.effects <- []; s.wide <- []; + s.rev <- s.rev + 1 let add_wide s wc = s.wide <- wc :: s.wide; List.length s.wide From ba183182239c963a3cc0eea38834f47a5d9f86c9 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 07:06:48 +0800 Subject: [PATCH 7/9] perf(ml): wire scene batch cache into all four GPU renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each renderer now holds a Lui_gpu.batch_cache and calls Lui_gpu.frame for its batches and packed instances instead of rebuilding them frame-locally. On a hit — a paint that changed nothing — GL skips glBufferData, Vulkan and D3D11 skip the instance-buffer memcpy (their persistent vertex buffers already hold the contents), and Metal reuses the OCaml-side compile+pack (its stub still stages the buffer each render; noted as follow-up). Per-batch draw state (scissor, image, pipeline, attribute pointers) still re-issues every frame: pointers encode the batch offset, they are draw setup not one-time state. Writers mutating scene buffers outside the reset–seal window now bump the revision (Lui_scene.touch): the glass and terminal plugin emit helpers, the fuzz testkit's Maker.change, and the backdrop damage parity case. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_d3d11/lui_d3d11.ml | 43 ++--- platform/native/lui_d3d11/lui_d3d11.mli | 9 - platform/native/lui_gl/lui_gl.ml | 54 ++---- platform/native/lui_gl/lui_gl.mli | 12 -- platform/native/lui_gl/test/lui_gl_test.ml | 4 +- platform/native/lui_gpu/lui_gpu.ml | 36 ++-- platform/native/lui_gpu/lui_gpu.mli | 5 + platform/native/lui_gpu/test/lui_gpu_test.ml | 168 +++++++++++++++++- platform/native/lui_metal/lui_metal.ml | 39 ++-- platform/native/lui_metal/lui_metal.mli | 12 -- .../lui_plugin_glass/lui_plugin_glass.ml | 6 +- .../lui_plugin_terminal/terminal_render.ml | 3 +- platform/native/lui_raster/test/testkit.ml | 3 + platform/native/lui_vulkan/lui_vulkan.ml | 41 ++--- platform/native/lui_vulkan/lui_vulkan.mli | 12 -- platform/native/parity/test/parity_test.ml | 3 +- 16 files changed, 263 insertions(+), 187 deletions(-) diff --git a/platform/native/lui_d3d11/lui_d3d11.ml b/platform/native/lui_d3d11/lui_d3d11.ml index 579fdbb2..fbb10fd2 100644 --- a/platform/native/lui_d3d11/lui_d3d11.ml +++ b/platform/native/lui_d3d11/lui_d3d11.ml @@ -174,36 +174,12 @@ let effect_source eglsl = (* {1 Instance packing} *) -type pack = { data : f32_ba; spans : (int * int) array } - let attribute_count = Lui_gpu.instance_floats / 4 let instance_bytes = Lui_gpu.instance_floats * 4 let attribute_offset start attr = start * instance_bytes + attr * 16 -let pack_batches batches = - let total = - List.fold_left (fun n b -> n + List.length b.Lui_gpu.instances) 0 batches - in - let data = - A1.create Bigarray.float32 Bigarray.c_layout - (total * Lui_gpu.instance_floats) - in - let spans = Array.make (List.length batches) (0, 0) in - let ofs = ref 0 in - List.iteri - (fun bi b -> - let start = !ofs / Lui_gpu.instance_floats in - List.iter - (fun inst -> - Lui_gpu.pack_instance inst data ~ofs:!ofs; - ofs := !ofs + Lui_gpu.instance_floats) - b.Lui_gpu.instances; - spans.(bi) <- (start, List.length b.Lui_gpu.instances)) - batches; - { data; spans } - (* {1 Textures} *) (* What a texture tracking an atlas must do to catch up. *) @@ -234,6 +210,10 @@ type t = { mutable grab : int * int; mutable backdrop_tex : int * int; mutable effects : (fx * int) list; + (* The scene's compiled batches and packed staging; a frame whose + display list did not move reuses them and skips the instance + upload — the vertex buffer still holds what was drawn. *) + fc : Lui_gpu.batch_cache; } and texture = { mutable gen : int; mutable ver : int; mutable w : int; @@ -376,7 +356,7 @@ let read_backdrop r (bk : backdrop) = (* {1 Drawing} *) let draw_batch r s pack bi (b : Lui_gpu.batch) bound program hole = - let start, count = pack.spans.(bi) in + let start, count = pack.Lui_gpu.spans.(bi) in let sc = clamp_scissor b.Lui_gpu.scissor ~w:s.width ~h:s.height in if count > 0 && not (Lui_gpu.scissor_empty sc) then ( let want = @@ -418,13 +398,15 @@ let render r s = r.frame <- r.frame + 1; sync_atlas r 0 r.mask s.mask_atlas; sync_atlas r 1 r.color s.color_atlas; - let batches = Lui_gpu.build s in + let batches, pack, rebuilt = Lui_gpu.frame r.fc s in fit_backdrop r batches; let cr, cg, cb, ca = premul s.clear 1. in c_begin r.ctx w h cr cg cb ca; - let pack = pack_batches batches in - if A1.dim pack.data > 0 then ( - c_instances r.ctx pack.data; + if pack.Lui_gpu.floats > 0 then ( + (* A scene the cache already compiled reuses the vertex + buffer's contents as uploaded — no second upload. *) + if rebuilt then + c_instances r.ctx (A1.sub pack.data 0 pack.floats); let bound = ref (-1) and program = ref 0 and hole = ref false in List.iteri (fun i b -> draw_batch r s pack i b bound program hole) batches); @@ -450,7 +432,8 @@ let init hwnd ~w ~h = images = Hashtbl.create 16; grab = (0, 0); backdrop_tex = (0, 0); - effects = [] } + effects = []; + fc = Lui_gpu.batch_cache () } let init_offscreen ~w ~h = init 0n ~w ~h diff --git a/platform/native/lui_d3d11/lui_d3d11.mli b/platform/native/lui_d3d11/lui_d3d11.mli index 3e6ab0d1..39b9f933 100644 --- a/platform/native/lui_d3d11/lui_d3d11.mli +++ b/platform/native/lui_d3d11/lui_d3d11.mli @@ -67,15 +67,6 @@ val attribute_offset : int -> int -> int (** [attribute_offset index attribute] is the byte offset of [attribute] of instance [index]. *) -type pack = { - data : (float, Bigarray.float32_elt, Bigarray.c_layout) Bigarray.Array1.t; - spans : (int * int) array; (** instance (start, count) per batch *) -} - -val pack_batches : Lui_gpu.batch list -> pack -(** The instance data of [batches], packed as the vertex shader reads - them, plus each batch's span. *) - val clamp_scissor : Lui_gpu.scissor -> w:int -> h:int -> Lui_gpu.scissor type atlas_plan = Recreate | Upload of Lui_scene.irect list | Nothing diff --git a/platform/native/lui_gl/lui_gl.ml b/platform/native/lui_gl/lui_gl.ml index 92d195aa..22549fb0 100644 --- a/platform/native/lui_gl/lui_gl.ml +++ b/platform/native/lui_gl/lui_gl.ml @@ -26,8 +26,6 @@ module A1 = Bigarray.Array1 type char_ba = (char, Bigarray.int8_unsigned_elt, Bigarray.c_layout) Bigarray.Array1.t -type f32_ba = - (float, Bigarray.float32_elt, Bigarray.c_layout) Bigarray.Array1.t external blit_bytes_to_ba : Bytes.t -> int -> char_ba -> int -> int -> unit = "lui_gl_blit_bytes_to_ba" @@ -284,10 +282,12 @@ type t = { backdrop_fb : int array; header : string; mutable effects : (fx * effect_program) list; + (* The scene's compiled batches and packed staging; a frame whose + display list did not move reuses them and skips the instance + upload — [buf] still holds what was drawn. *) + fc : Lui_gpu.batch_cache; } -type pack = { data : f32_ba; spans : (int * int) array } - (* {1 Instance packing} *) let attribute_count = Lui_gpu.instance_floats / 4 @@ -296,28 +296,6 @@ let instance_bytes = Lui_gpu.instance_floats * 4 let attribute_offset start attr = start * instance_bytes + attr * 16 -let pack_batches batches = - let total = - List.fold_left (fun n b -> n + List.length b.Lui_gpu.instances) 0 batches - in - let data = - A1.create Bigarray.float32 Bigarray.c_layout - (total * Lui_gpu.instance_floats) - in - let spans = Array.make (List.length batches) (0, 0) in - let ofs = ref 0 in - List.iteri - (fun bi b -> - let start = !ofs / Lui_gpu.instance_floats in - List.iter - (fun inst -> - Lui_gpu.pack_instance inst data ~ofs:!ofs; - ofs := !ofs + Lui_gpu.instance_floats) - b.Lui_gpu.instances; - spans.(bi) <- (start, List.length b.Lui_gpu.instances)) - batches; - { data; spans } - (* {1 Textures} *) let scratch = ref (A1.create Bigarray.char Bigarray.c_layout 0) @@ -577,7 +555,8 @@ let init () = backdrop_tex = [| texture (); texture () |]; backdrop_fb = [| 0; 0 |]; header; - effects = [] })))) + effects = []; + fc = Lui_gpu.batch_cache () })))) (* {1 Backdrop passes} *) @@ -710,7 +689,7 @@ let bind_state r s = Gl.enable Gl.scissor_test let draw_batch r s fb pack bi (b : Lui_gpu.batch) bound program hole = - let start, count = pack.spans.(bi) in + let start, count = pack.Lui_gpu.spans.(bi) in let sc = clamp_scissor b.Lui_gpu.scissor ~w:s.width ~h:s.height in if count > 0 && not (Lui_gpu.scissor_empty sc) then ( let want = @@ -796,7 +775,7 @@ let render r s = Gl.active_texture Gl.texture0; sync_atlas r r.mask s.mask_atlas ~internal:Gl.r8 ~format:Gl.red; sync_atlas r r.color s.color_atlas ~internal:Gl.rgba8 ~format:Gl.rgba; - let batches = Lui_gpu.build s in + let batches, pack, rebuilt = Lui_gpu.frame r.fc s in fit_backdrop r batches; (* The framebuffer drawn into, which the backdrop passes leave for their own. *) @@ -806,17 +785,20 @@ let render r s = let cr, cg, cb, ca = premul s.clear 1. in Gl.clear_color cr cg cb ca; Gl.clear Gl.color_buffer_bit; - let pack = pack_batches batches in - if A1.dim pack.data > 0 then ( - Gl.bind_buffer Gl.array_buffer r.buf; - Gl.buffer_data Gl.array_buffer - (Gl.bigarray_byte_size pack.data) - (Some pack.data) Gl.stream_draw; + if pack.Lui_gpu.floats > 0 then ( + (* A scene the cache already compiled reuses the instance + buffer's contents as uploaded — no second upload. *) + if rebuilt then ( + let live = A1.sub pack.data 0 pack.floats in + Gl.bind_buffer Gl.array_buffer r.buf; + Gl.buffer_data Gl.array_buffer + (Gl.bigarray_byte_size live) (Some live) Gl.stream_draw); bind_state r s; let bound = ref r.empty and program = ref r.program and hole = ref false in - List.iteri (fun bi b -> draw_batch r s fb pack bi b bound program hole) + List.iteri + (fun bi b -> draw_batch r s fb pack bi b bound program hole) batches; teardown (); gc_images r); diff --git a/platform/native/lui_gl/lui_gl.mli b/platform/native/lui_gl/lui_gl.mli index a7e14bbc..e176c614 100644 --- a/platform/native/lui_gl/lui_gl.mli +++ b/platform/native/lui_gl/lui_gl.mli @@ -59,18 +59,6 @@ val attribute_offset : int -> int -> int [attr]'s first component in the instance buffer, for a batch whose instances start at index [start]. *) -type pack = { - data : (float, Bigarray.float32_elt, Bigarray.c_layout) Bigarray.Array1.t; - spans : (int * int) array; -} -(** A frame's instances packed for one buffer upload: [data] is the - float32 buffer and [spans.(i)] is the (first index, count) of - batch [i]'s instances in it. *) - -val pack_batches : Lui_gpu.batch list -> pack -(** [pack_batches batches] packs every batch's instances into one - float32 bigarray. *) - val clamp_scissor : Lui_gpu.scissor -> w:int -> h:int -> Lui_gpu.scissor (** [clamp_scissor sc ~w ~h] is [sc] clamped to the frame's pixels. *) diff --git a/platform/native/lui_gl/test/lui_gl_test.ml b/platform/native/lui_gl/test/lui_gl_test.ml index 18f8193e..caa91bdb 100644 --- a/platform/native/lui_gl/test/lui_gl_test.ml +++ b/platform/native/lui_gl/test/lui_gl_test.ml @@ -48,7 +48,7 @@ let test_pack_batches () = let s = make_scene ~w:64 ~h:64 in s.ops <- [ fill_op () ]; let batches = Lui_gpu.build s in - let pack = Lui_gl.pack_batches batches in + let pack = Lui_gpu.pack_batches batches in Alcotest.(check int) "one batch" 1 (List.length batches); Alcotest.(check int) "one instance" 60 (Bigarray.Array1.dim pack.data); @@ -76,7 +76,7 @@ let test_pack_spans () = Lui_scene.Pop_clip; fill_op ~x:40. ~y:40. ~w:8. ~h:8. () ]; let batches = Lui_gpu.build s in - let pack = Lui_gl.pack_batches batches in + let pack = Lui_gpu.pack_batches batches in Alcotest.(check int) "batches" 3 (List.length batches); Alcotest.(check int) "spans" 3 (Array.length pack.spans); Alcotest.(check (pair int int)) "span 0" (0, 1) pack.spans.(0); diff --git a/platform/native/lui_gpu/lui_gpu.ml b/platform/native/lui_gpu/lui_gpu.ml index f6c42a11..5a6c1381 100644 --- a/platform/native/lui_gpu/lui_gpu.ml +++ b/platform/native/lui_gpu/lui_gpu.ml @@ -613,17 +613,7 @@ let key_of ~wide (s : t) : key = (s.mask_atlas.w, s.mask_atlas.h), (s.color_atlas.w, s.color_atlas.h) ) -let rebuild c ~wide s = - let batches = build ~wide s in - let total = - List.fold_left (fun n b -> n + List.length b.instances) 0 batches - in - let floats = total * instance_floats in - let data = - if Bigarray.Array1.dim c.c_pack.data >= floats then c.c_pack.data - else - Bigarray.(Array1.create float32 c_layout (floats + floats / 2)) - in +let pack_into (batches : batch list) (data : f32_ba) = let spans = Array.make (List.length batches) (0, 0) in let ofs = ref 0 in List.iteri @@ -636,6 +626,30 @@ let rebuild c ~wide s = b.instances; spans.(bi) <- (start, List.length b.instances)) batches; + spans + +(* The one-shot packer: an exact-size buffer, for callers without a + cache to grow into (tests). *) +let pack_batches (batches : batch list) : pack = + let total = + List.fold_left (fun n b -> n + List.length b.instances) 0 batches + in + let floats = total * instance_floats in + let data = Bigarray.(Array1.create float32 c_layout floats) in + { data; floats; spans = pack_into batches data } + +let rebuild c ~wide s = + let batches = build ~wide s in + let total = + List.fold_left (fun n b -> n + List.length b.instances) 0 batches + in + let floats = total * instance_floats in + let data = + if Bigarray.Array1.dim c.c_pack.data >= floats then c.c_pack.data + else + Bigarray.(Array1.create float32 c_layout (floats + floats / 2)) + in + let spans = pack_into batches data in c.c_scene <- Some s; c.c_key <- Some (key_of ~wide s); c.c_batches <- batches; diff --git a/platform/native/lui_gpu/lui_gpu.mli b/platform/native/lui_gpu/lui_gpu.mli index 7edf29bd..f0c60a6b 100644 --- a/platform/native/lui_gpu/lui_gpu.mli +++ b/platform/native/lui_gpu/lui_gpu.mli @@ -149,6 +149,11 @@ type f32_ba = instance offset in that prefix. *) type pack = { data : f32_ba; floats : int; spans : (int * int) array } +val pack_batches : batch list -> pack +(** [pack_batches batches] packs every batch's instances into one + exact-size float32 buffer — the one-shot packer for callers + without a cache to grow into (tests). *) + type batch_cache (** A renderer's memoized batches and instance buffer for one scene. The key is the scene's revision plus the other inputs {!build} diff --git a/platform/native/lui_gpu/test/lui_gpu_test.ml b/platform/native/lui_gpu/test/lui_gpu_test.ml index 81ca3bdb..9f4ce52e 100644 --- a/platform/native/lui_gpu/test/lui_gpu_test.ml +++ b/platform/native/lui_gpu/test/lui_gpu_test.ml @@ -437,6 +437,165 @@ let test_shader_source () = (String.length G.shader_source > 1000 && String.sub G.shader_source 0 2 = "//") +(* ---------- scene batch cache ---------- *) + +let ops_scene ?(w = 200) ?(h = 100) ops = + let s = scene ~w ~h () in + s.ops <- ops; + s + +let frame3 c ?(wide = false) s = G.frame ~wide c s + +let test_cache_hit () = + let s = ops_scene [ fill () ] in + let c = G.batch_cache () in + let b1, p1, r1 = frame3 c s in + check bool "first builds" true r1; + let b2, p2, r2 = frame3 c s in + check bool "second reuses" false r2; + check bool "same batches" true (b1 == b2); + check bool "same buffer" true (p1.data == p2.data); + check int "rebuilds" 1 (G.stat_rebuilds c); + check int "reuses" 1 (G.stat_reuses c) + +let test_cache_touch () = + let s = ops_scene [ fill () ] in + let c = G.batch_cache () in + let b1, _, _ = frame3 c s in + s.ops <- [ fill ~r:(rect 5. 5. 20. 20.) () ]; + (* Writers that mutate outside reset must touch: the cache only + rebuilds because the revision moved. *) + Lui_scene.touch s; + let b2, _, r = frame3 c s in + check bool "touch rebuilds" true r; + check bool "fresh batches" true (b1 != b2); + check int "rebuilds" 2 (G.stat_rebuilds c) + +(* A scene object the cache has never compiled always rebuilds, even + one byte-identical to the compiled scene — the key pairs identity + with revision. *) +let test_cache_identity () = + let ops () = [ fill () ] in + let s1 = ops_scene (ops ()) and s2 = ops_scene (ops ()) in + let c = G.batch_cache () in + ignore (frame3 c s1); + let _, _, r2 = frame3 c s2 in + check bool "another scene rebuilds" true r2; + let _, _, r3 = frame3 c s2 in + check bool "then reuses" false r3; + let _, _, r4 = frame3 c s1 in + check bool "back to first rebuilds" true r4 + +(* The inputs build folds into instances that the revision does not + capture — frame size, text coverage, atlas size, the gamut flag — + are in the key: each change recompiles. *) +let test_cache_key_inputs () = + let s = scene ~w:200 ~h:100 () in + s.glyphs <- [ glyph () ]; + s.ops <- + [ Glyphs + { gstart = 0; gend = 1; gpaint = Solid; gcolor = red; + gcolor2 = red; ggradient = (0., 0., 0., 0.); gwide2 = 0; + gopacity = 0. } ]; + let c = G.batch_cache () in + let _, _, r0 = frame3 c s in + check bool "build" true r0; + s.width <- 300; + let _, _, r1 = frame3 c s in + check bool "width rebuilds" true r1; + let _, _, r1b = frame3 c s in + check bool "width then reuses" false r1b; + s.text <- { s.text with contrast = 1.5 }; + let _, _, r2 = frame3 c s in + check bool "text params rebuild" true r2; + ignore (Atlas.grow s.mask_atlas); + let _, p, r3 = frame3 c s in + check bool "atlas growth rebuilds" true r3; + (* Glyph UVs are baked over the doubled atlas: 16/128 = 0.125. + [uv] is the instance's eighth float4, floats 28-31. *) + check (float 1e-3) "uv re-based" 0.125 p.data.{28}; + let _, _, r4 = frame3 ~wide:true c s in + check bool "wide flag rebuilds" true r4; + let _, _, r5 = frame3 ~wide:true c s in + check bool "wide then reuses" false r5 + +(* The staging buffer is grow-only: a smaller later scene reuses it + with a shorter live prefix. *) +let test_cache_buffer_growth () = + let s = ops_scene [ fill (); fill (); fill () ] in + let c = G.batch_cache () in + let _, p1, _ = frame3 c s in + check int "three instances" (3 * G.instance_floats) p1.floats; + let cap = Bigarray.Array1.dim p1.data in + s.ops <- [ fill () ]; + Lui_scene.touch s; + let _, p2, _ = frame3 c s in + check int "one instance" G.instance_floats p2.floats; + check bool "same buffer" true (p1.data == p2.data); + check bool "capacity kept" true (Bigarray.Array1.dim p2.data = cap) + +(* ---------- cache benchmark ---------- *) + +(* What the GPU marshalling pays per frame on a glyph-heavy scene, the + text-500 profile: 500 Glyphs ops indexing 24k glyph records. + Reports uncached build+pack against cached frames, hit and miss. *) +let bench_cache () = + let n_ops, n_glyphs = 500, 48 in + let s = scene ~w:1280 ~h:800 () in + s.glyphs <- + List.init (n_ops * n_glyphs) (fun i -> + glyph ~x:(Float.of_int (i mod 1000)) + ~y:(Float.of_int ((i / 50) mod 700)) ()); + s.ops <- + List.init n_ops (fun i -> + Glyphs + { gstart = i * n_glyphs; gend = (i + 1) * n_glyphs; + gpaint = Solid; gcolor = red; gcolor2 = red; + ggradient = (0., 0., 0., 0.); gwide2 = 0; gopacity = 0. }); + let insts = + List.fold_left + (fun n (b : G.batch) -> n + List.length b.instances) + 0 (G.build s) + in + let iters = 200 in + let timed f = + let t0 = Sys.time () in + ignore (f ()); + (Sys.time () -. t0) *. 1000. /. Float.of_int iters + in + let uncached = + timed (fun () -> + for _ = 1 to iters do + ignore (G.pack_batches (G.build s)) + done) + in + let c = G.batch_cache () in + ignore (frame3 c s); + let hit = + timed (fun () -> + for _ = 1 to iters do + ignore (frame3 c s) + done) + in + let c2 = G.batch_cache () in + let miss = + timed (fun () -> + for _ = 1 to iters do + (* Every frame a repaint: touch, compile, pack. *) + Lui_scene.touch s; + ignore (frame3 c2 s) + done) + in + Printf.printf + "\n gpu-frame cache, %d instances/frame\n\ + \ build+pack uncached : %8.3f ms\n\ + \ cache hit : %8.4f ms\n\ + \ cache miss : %8.3f ms\n\ + %!" + insts uncached hit miss; + check int "one hit per frame" iters (G.stat_reuses c); + check int "misses rebuild" iters (G.stat_rebuilds c2) + let () = run "lui_gpu" [ ( "build", @@ -451,4 +610,11 @@ let () = test_case "wide gamut" `Quick test_wide ] ); ( "packing", [ test_case "to_float32_array" `Quick test_float32_packing; - test_case "shader source" `Quick test_shader_source ] ) ] + test_case "shader source" `Quick test_shader_source ] ); + ( "batch cache", + [ test_case "hit reuses" `Quick test_cache_hit; + test_case "touch rebuilds" `Quick test_cache_touch; + test_case "scene identity" `Quick test_cache_identity; + test_case "key inputs" `Quick test_cache_key_inputs; + test_case "buffer growth" `Quick test_cache_buffer_growth; + test_case "bench" `Quick bench_cache ] ) ] diff --git a/platform/native/lui_metal/lui_metal.ml b/platform/native/lui_metal/lui_metal.ml index 524c258d..6755c650 100644 --- a/platform/native/lui_metal/lui_metal.ml +++ b/platform/native/lui_metal/lui_metal.ml @@ -50,6 +50,11 @@ type t = { images : (int, mtex) Hashtbl.t; mutable frame : int; mutable effects : (fx * effect_prog) list; + (* The scene's compiled batches and packed staging; a frame whose + display list did not move reuses them — the stub still copies + the staging each render, so the hit saves the OCaml compile + and pack. *) + fc : Lui_gpu.batch_cache; } (* An atlas or image's texture: gen/version track the source's, so uploads are incremental; last_frame lets render release textures @@ -116,30 +121,6 @@ let clamp_scissor (s : Lui_gpu.scissor) ~w ~h = (* A frame's instances packed for one buffer upload: data is the whole array, spans the (first, count) instance of each batch. *) -type pack = { data : f32_ba; spans : (int * int) array } - -let pack_batches (batches : Lui_gpu.batch list) = - let spans = - Array.of_list - (List.map (fun (b : Lui_gpu.batch) -> (0, List.length b.instances)) batches) - in - let total = Array.fold_left (fun n (_, c) -> n + c) 0 spans in - let data = - A1.create Bigarray.float32 Bigarray.c_layout (total * Lui_gpu.instance_floats) - in - let ofs = ref 0 in - List.iteri - (fun i (b : Lui_gpu.batch) -> - let start = !ofs / Lui_gpu.instance_floats in - List.iter - (fun inst -> - Lui_gpu.pack_instance inst data ~ofs:!ofs; - ofs := !ofs + Lui_gpu.instance_floats) - b.instances; - spans.(i) <- (start, !ofs / Lui_gpu.instance_floats - start)) - batches; - { data; spans } - (* Atlas texture sync: recreate when the atlas grew, upload the changed rects when the same atlas was drawn on, skip the upload when it is unchanged. *) @@ -297,7 +278,8 @@ let renderer ctx ~dual ~lib ~pipe_main ~pipe_hole ~pipe_down ~pipe_blur = { ctx; dual; lib; pipe_main; pipe_hole; pipe_down; pipe_blur; empty = make_empty ctx; mask = new_mtex (); color = new_mtex (); - images = Hashtbl.create 17; frame = 0; effects = [] } + images = Hashtbl.create 17; frame = 0; effects = []; + fc = Lui_gpu.batch_cache () } (* With dual-source blending, subpixel glyphs blend each destination channel by the coverage of the matching source channel; without it @@ -380,8 +362,7 @@ let render (t : t) (s : Lui_scene.t) : unit = t.frame <- t.frame + 1; sync_atlas t t.mask s.mask_atlas ~fmt:0; sync_atlas t t.color s.color_atlas ~fmt:1; - let batches = Lui_gpu.build s in - let pack = pack_batches batches in + let batches, pack, _rebuilt = Lui_gpu.frame t.fc s in (* Drop the image textures unseen for 64 frames. *) Hashtbl.filter_map_inplace (fun _i (m : mtex) -> @@ -394,7 +375,7 @@ let render (t : t) (s : Lui_scene.t) : unit = Array.of_list (List.mapi (fun bi (b : Lui_gpu.batch) -> - let first, count = pack.spans.(bi) in + let first, count = pack.Lui_gpu.spans.(bi) in let sc = clamp_scissor b.scissor ~w ~h in let pipe = match b.fx with @@ -422,7 +403,7 @@ let render (t : t) (s : Lui_scene.t) : unit = let mask_tex = match t.mask.tex with Some x -> x | None -> t.empty in let color_tex = match t.color.tex with Some x -> x | None -> t.empty in metal_render t.ctx - ( w, h, clear, pack.data, draws, + ( w, h, clear, A1.sub pack.data 0 pack.floats, draws, mask_tex, color_tex, t.empty, t.pipe_down, t.pipe_blur ) end diff --git a/platform/native/lui_metal/lui_metal.mli b/platform/native/lui_metal/lui_metal.mli index c785559c..c794d2c4 100644 --- a/platform/native/lui_metal/lui_metal.mli +++ b/platform/native/lui_metal/lui_metal.mli @@ -65,18 +65,6 @@ val attribute_offset : int -> int -> int [attr]'s first component in the instance buffer, for a batch whose instances start at index [start]. *) -type pack = { - data : (float, Bigarray.float32_elt, Bigarray.c_layout) Bigarray.Array1.t; - spans : (int * int) array; -} -(** A frame's instances packed for one buffer upload: [data] is the - float32 buffer and [spans.(i)] is the (first index, count) of - batch [i]'s instances in it. *) - -val pack_batches : Lui_gpu.batch list -> pack -(** [pack_batches batches] packs every batch's instances into one - float32 bigarray. *) - val clamp_scissor : Lui_gpu.scissor -> w:int -> h:int -> Lui_gpu.scissor (** [clamp_scissor sc ~w ~h] is [sc] clamped to the frame's pixels. *) diff --git a/platform/native/lui_plugin_glass/lui_plugin_glass.ml b/platform/native/lui_plugin_glass/lui_plugin_glass.ml index 96a91d3b..61f1e445 100644 --- a/platform/native/lui_plugin_glass/lui_plugin_glass.ml +++ b/platform/native/lui_plugin_glass/lui_plugin_glass.ml @@ -552,7 +552,8 @@ let emit s ee ~eblur eparams ?(radii = (0., 0., 0., 0.)) s.ops @ [ Effect { edrect = rc; edradii = radii; edcontinuous = continuous; - edindex = List.length s.effects - 1; edopacity = opacity } ] + edindex = List.length s.effects - 1; edopacity = opacity } ]; + Lui_scene.touch s let emit_fill s ?(radii = (0., 0., 0., 0.)) ?(continuous = false) ?(paint_ = Solid) ?(c2 = transparent) ?(g = (0., 0., 0., 0.)) rc c1 = @@ -562,7 +563,8 @@ let emit_fill s ?(radii = (0., 0., 0., 0.)) ?(continuous = false) { frect = rc; fradii = radii; fcontinuous = continuous; fcolor = c1; fpaint = paint_; fcolor2 = c2; fgradient = g; fborder = (0., 0., 0., 0.); fborder_color = transparent; - fdashed = false; fwide = 0; fopacity = 0. } ] + fdashed = false; fwide = 0; fopacity = 0. } ]; + Lui_scene.touch s (* A pane of glass over rect, rounded by radii: what was painted under it shows through, blurred and lensed and rim-lit per the material. diff --git a/platform/native/lui_plugin_terminal/terminal_render.ml b/platform/native/lui_plugin_terminal/terminal_render.ml index 7208459b..6f95d780 100644 --- a/platform/native/lui_plugin_terminal/terminal_render.ml +++ b/platform/native/lui_plugin_terminal/terminal_render.ml @@ -457,4 +457,5 @@ let render ?(blink_on = true) t grid scene ~w ~h = render_scrollbar t grid ~w ~h ops; (* scene.ops is reversed paint order; !ops already holds ours newest first, so a plain append keeps the invariant *) - scene.Lui_scene.ops <- !ops @ scene.Lui_scene.ops + scene.Lui_scene.ops <- !ops @ scene.Lui_scene.ops; + Lui_scene.touch scene diff --git a/platform/native/lui_raster/test/testkit.ml b/platform/native/lui_raster/test/testkit.ml index 47f7c195..a54d9d86 100644 --- a/platform/native/lui_raster/test/testkit.ml +++ b/platform/native/lui_raster/test/testkit.ml @@ -470,6 +470,9 @@ module Maker = struct done; image_changed m.img | _ -> ()); + (* The mutations above bypass reset — the renderer-facing + revision must move with them. *) + touch s; s end diff --git a/platform/native/lui_vulkan/lui_vulkan.ml b/platform/native/lui_vulkan/lui_vulkan.ml index de9dbb44..b228d11d 100644 --- a/platform/native/lui_vulkan/lui_vulkan.ml +++ b/platform/native/lui_vulkan/lui_vulkan.ml @@ -84,10 +84,12 @@ type t = { backdrop_tex : texture array; (* two: the blur passes ping-pong *) mutable effects : ((fx * bool) * int) list; (* effect pipelines keyed by the effect and the hole blend *) + (* The scene's compiled batches and packed staging; a frame whose + display list did not move reuses them and skips the instance + upload — the vertex buffer still holds what was drawn. *) + fc : Lui_gpu.batch_cache; } -type pack = { data : f32_ba; spans : (int * int) array } - (* {1 Instance packing} *) let attribute_count = Lui_gpu.instance_floats / 4 @@ -96,28 +98,6 @@ let instance_bytes = Lui_gpu.instance_floats * 4 let attribute_offset start attr = start * instance_bytes + attr * 16 -let pack_batches batches = - let total = - List.fold_left (fun n b -> n + List.length b.Lui_gpu.instances) 0 batches - in - let data = - A1.create Bigarray.float32 Bigarray.c_layout - (total * Lui_gpu.instance_floats) - in - let spans = Array.make (List.length batches) (0, 0) in - let ofs = ref 0 in - List.iteri - (fun bi b -> - let start = !ofs / Lui_gpu.instance_floats in - List.iter - (fun inst -> - Lui_gpu.pack_instance inst data ~ofs:!ofs; - ofs := !ofs + Lui_gpu.instance_floats) - b.Lui_gpu.instances; - spans.(bi) <- (start, List.length b.Lui_gpu.instances)) - batches; - { data; spans } - let clamp_scissor (sc : Lui_gpu.scissor) ~w ~h : Lui_gpu.scissor = { left = max sc.left 0; top = max sc.top 0; @@ -349,7 +329,7 @@ let fit_backdrop r batches = (* Draw batch bi. ds_sel: 0 uses ds_id's own set (an image's), 1 the backdrop set. *) let draw_batch r s pack bi (b : Lui_gpu.batch) = - let start, count = pack.spans.(bi) in + let start, count = pack.Lui_gpu.spans.(bi) in let sc = clamp_scissor b.Lui_gpu.scissor ~w:s.width ~h:s.height in if count > 0 && not (Lui_gpu.scissor_empty sc) then ( let hole = b.Lui_gpu.hole in @@ -399,7 +379,7 @@ let render r s = c_begin r.ctx; sync_atlas r r.mask s.mask_atlas ~fmt:tex_r8 ~slot:0; sync_atlas r r.color s.color_atlas ~fmt:tex_rgba8 ~slot:1; - let batches = Lui_gpu.build s in + let batches, pack, rebuilt = Lui_gpu.frame r.fc s in (* Uploads record into the frame's command buffer, so every image the batches draw is uploaded before the render pass begins. *) List.iter @@ -408,8 +388,10 @@ let render r s = | None -> ()) batches; fit_backdrop r batches; - let pack = pack_batches batches in - if A1.dim pack.data > 0 then c_instances r.ctx pack.data; + (* A scene the cache already compiled reuses the vertex buffer's + contents as uploaded — no second upload. *) + if rebuilt && pack.Lui_gpu.floats > 0 then + c_instances r.ctx (A1.sub pack.data 0 pack.floats); let cr, cg, cb, ca = premul s.clear 1. in c_frame r.ctx cr cg cb ca; List.iteri (fun bi b -> draw_batch r s pack bi b) batches; @@ -470,7 +452,8 @@ let init ~w ~h = frame = 0; grab = texture (); backdrop_tex = [| texture (); texture () |]; - effects = [] } + effects = []; + fc = Lui_gpu.batch_cache () } with | Failure m -> c_destroy ctx; diff --git a/platform/native/lui_vulkan/lui_vulkan.mli b/platform/native/lui_vulkan/lui_vulkan.mli index 045ff157..d113e8ff 100644 --- a/platform/native/lui_vulkan/lui_vulkan.mli +++ b/platform/native/lui_vulkan/lui_vulkan.mli @@ -76,18 +76,6 @@ val attribute_offset : int -> int -> int [attr]'s first component in the instance buffer, for a batch whose instances start at index [start]. *) -type pack = { - data : (float, Bigarray.float32_elt, Bigarray.c_layout) Bigarray.Array1.t; - spans : (int * int) array; -} -(** A frame's instances packed for one buffer upload: [data] is the - float32 buffer and [spans.(i)] is the (first index, count) of - batch [i]'s instances in it. *) - -val pack_batches : Lui_gpu.batch list -> pack -(** [pack_batches batches] packs every batch's instances into one - float32 bigarray. *) - val clamp_scissor : Lui_gpu.scissor -> w:int -> h:int -> Lui_gpu.scissor (** [clamp_scissor sc ~w ~h] is [sc] clamped to the frame's pixels. *) diff --git a/platform/native/parity/test/parity_test.ml b/platform/native/parity/test/parity_test.ml index 3a9f4a1a..ad421e28 100644 --- a/platform/native/parity/test/parity_test.ml +++ b/platform/native/parity/test/parity_test.ml @@ -306,11 +306,12 @@ let test_damage_backdrop () = (fun () -> for step = 0 to 1 do (* step 1 changes the fill inside the backdrop *) - if step = 1 then + if step = 1 then ( s.ops <- [ Tk.ifill (rect 0. 0. 96. 64.) (color 37 99 235 255); Tk.ifill (rect 20. 10. 40. 30.) (color 220 38 38 255); Tk.ieffect ~radii:(Tk.radii 6.) (rect 12. 8. 60. 40.) 0 ]; + Lui_scene.touch s); ignore (Lui_raster.Renderer.render dr s); let cpu = (Lui_raster.Renderer.image dr).Lui_raster.Image.pix From 67b368924ff9b34246480cf19823128a265647cd Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 07:07:30 +0800 Subject: [PATCH 8/9] docs: frame compiles the instances in the GL renderer header Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_gl/lui_gl.ml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/native/lui_gl/lui_gl.ml b/platform/native/lui_gl/lui_gl.ml index 22549fb0..1e622f35 100644 --- a/platform/native/lui_gl/lui_gl.ml +++ b/platform/native/lui_gl/lui_gl.ml @@ -1,6 +1,6 @@ (* OpenGL renderer for Lui_scene display lists, through tgls: every op of a scene is an instanced quad drawn by the one shader - (Lui_gpu.shader_source) from the instances Lui_gpu.build produces; + (Lui_gpu.shader_source) from the instances Lui_gpu.frame compiles; clips are scissor rectangles, with the innermost rounded clip computed in the shader. From 21b58d7168409acf9c4250c15e84798af2cd9e55 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 07:51:22 +0800 Subject: [PATCH 9/9] fix(ml): restore Lui_paint.resolve_in dropped by #224's squash merge feat/ml-backend @ 206eff8a does not compile: the squash merge kept lui_retained.ml's resolve_in call sites but lost the three-line ctx-wrapper in lui_paint.ml when the surrounding resolve_rect changed under it (C2/C4 shared walk + rect_snap). Re-add the wrapper delegating to the new snapping resolve_rect. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_layout/lui_layout.ml | 9 --------- platform/native/lui_paint/lui_paint.ml | 4 ++++ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index 30f32c25..8313e66c 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -1193,15 +1193,6 @@ let update_rect (t : t) id r = | _ -> Hashtbl.replace t.rect_changed id ()); Hashtbl.replace t.rects id r' -(* Re-read [id]'s absolute rect into [t.rects], marking it changed when - it differs from what consumers last saw. *) -let update_rect (t : t) id r = - let r' = scene_rect r in - (match Hashtbl.find_opt t.rects id with - | Some old when old = r' -> () - | _ -> Hashtbl.replace t.rect_changed id ()); - Hashtbl.replace t.rects id r' - let collect (t : t) = Hashtbl.iter (fun id n -> update_rect t id (Lui_flex.absolute_layout n)) diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index f5bfcb71..e51dfe14 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -2313,6 +2313,10 @@ let resolve_rect ~scale ~viewport store id kind p parent_r = in rect_snap r +(* The ctx-taking form {!Lui_retained} calls; the labeled-args one + above is the shared entry point. *) +let resolve_in ctx = resolve_rect ~scale:ctx.scale ~viewport:ctx.viewport + let zindex store id = match Lui_store.prop store id "z-index" with | Some (Lui_protocol.IntValue i) -> i