From d62bb7efa9843d5b45bc347ebfef0b0bea43396c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 15 Oct 2026 22:12:20 +0800 Subject: [PATCH 001/163] fix(native): avoid full-tree lists during input sync Stream store scans, retain unchanged lifecycle sets, and read event admission properties directly. Add allocation and ordering regressions. --- platform/native/lui_input/lui_focus.ml | 56 ++++--- platform/native/lui_input/lui_overlay.ml | 15 +- platform/native/lui_input/lui_query.ml | 16 +- platform/native/lui_input/lui_scroll.ml | 25 ++-- .../native/lui_input/test/lui_input_test.ml | 139 +++++++++++++++++- src/lui_protocol.ml | 20 ++- src/lui_protocol.mli | 1 + 7 files changed, 196 insertions(+), 76 deletions(-) diff --git a/platform/native/lui_input/lui_focus.ml b/platform/native/lui_input/lui_focus.ml index c8ff9ed9..531beb80 100644 --- a/platform/native/lui_input/lui_focus.ml +++ b/platform/native/lui_input/lui_focus.ml @@ -91,43 +91,37 @@ let sync_dead store st = then [ `Focus (0, false) ] else [] +let refresh_seen previous ids = + if List.length ids = IntSet.cardinal previous + && List.for_all (fun id -> IntSet.mem id previous) ids + then previous + else IntSet.of_list ids + (* Store-sync half B: first not-yet-seen visible autofocus control in the active overlay scope. The seen set is refreshed to whatever carries autofocus now (it is a mount-lifecycle record). *) let scan_autofocus store st ~scope:ov_id = - let autofocus_now = - List.filter - (fun id -> - Lui_store.bool_prop store id "autofocus" && chain_visible store id) - (Lui_store.preorder store) - in - let cand = - List.find_opt - (fun id -> - not (IntSet.mem id st.autofocus_seen) + let now = ref [] and candidate = ref None in + Lui_store.iter_preorder store (fun id -> + if Lui_store.bool_prop store id "autofocus" && chain_visible store id then begin + now := id :: !now; + if !candidate = None && not (IntSet.mem id st.autofocus_seen) && Lui_query.node_enabled store id && (admits_text store id || activatable store id) - && (ov_id = 0 || id = ov_id || Lui_query.is_desc store id ov_id)) - autofocus_now - in - ( { st with autofocus_seen = IntSet.of_list autofocus_now }, - cand ) + && (ov_id = 0 || id = ov_id || Lui_query.is_desc store id ov_id) + then candidate := Some id + end); + ({ st with autofocus_seen = refresh_seen st.autofocus_seen !now }, !candidate) (* Store-sync half C: appear-enabled nodes announce their mount once. *) let scan_appear store st = - let now = - List.filter - (fun id -> - Lui_query.node_enabled store id - && Lui_store.bool_prop store id "appear-enabled" - && Lui_query.event_supported store id (Appear id)) - (Lui_store.preorder store) - in - let acts = - List.filter_map - (fun id -> - if IntSet.mem id st.appear_seen then None - else Some (`Dispatch (Appear id))) - now - in - ({ st with appear_seen = IntSet.of_list now }, acts) + let now = ref [] and actions = ref [] in + Lui_store.iter_preorder store (fun id -> + if Lui_query.node_enabled store id + && Lui_store.bool_prop store id "appear-enabled" + && Lui_query.event_supported store id (Appear id) then begin + now := id :: !now; + if not (IntSet.mem id st.appear_seen) then + actions := `Dispatch (Appear id) :: !actions + end); + ({ st with appear_seen = refresh_seen st.appear_seen !now }, List.rev !actions) diff --git a/platform/native/lui_input/lui_overlay.ml b/platform/native/lui_input/lui_overlay.ml index e1cb0f02..90c620ea 100644 --- a/platform/native/lui_input/lui_overlay.ml +++ b/platform/native/lui_input/lui_overlay.ml @@ -73,14 +73,13 @@ let overlay_dismissible store id = (* Topmost overlay = last overlay-kind node in paint (preorder) order; returns (id, modal, dismiss-admitted). *) let top_scan store ~open_menu = - List.fold_left - (fun acc id -> - match overlay_class store id with - | `none -> acc - | _ when trigger_owned store id && id <> open_menu -> acc - | cls -> - Some (id, cls = `modal, overlay_dismissible store id)) - None (Lui_store.preorder store) + let top = ref None in + Lui_store.iter_preorder store (fun id -> + match overlay_class store id with + | `none -> () + | _ when trigger_owned store id && id <> open_menu -> () + | cls -> top := Some (id, cls = `modal, overlay_dismissible store id)); + !top let inside store ov path = List.exists (fun i -> i = ov || Lui_query.is_desc store i ov) path diff --git a/platform/native/lui_input/lui_query.ml b/platform/native/lui_input/lui_query.ml index 7ca05438..26d2bb70 100644 --- a/platform/native/lui_input/lui_query.ml +++ b/platform/native/lui_input/lui_query.ml @@ -46,24 +46,12 @@ let string_prop store id name = | Some s -> s | None -> "" -(* The only properties dispatch admission consults. *) -let gate_properties = - [ AppearEnabled; PressEnabled; PointerEnabled; ChangeEnabled; - ToggleEnabled; RoleValue ] - let event_supported store id event = match kind_of store id with | None -> false | Some kind -> - let props = - List.fold_left - (fun acc p -> - match Lui_store.prop store id (Lui_wire_schema.property_name p) with - | Some v -> Property_map.add p v acc - | None -> acc) - Property_map.empty gate_properties - in - Lui_protocol.event_supported_for_properties kind props event + Lui_protocol.event_supported_for_lookup kind + (fun property -> Lui_store.prop store id (Lui_wire_schema.property_name property)) event (* Everything [hit_path] needs from the store for one node on top of the shared walk's stacking/clip frame: overlay presentation, the diff --git a/platform/native/lui_input/lui_scroll.ml b/platform/native/lui_input/lui_scroll.ml index 945a88cd..632aefc6 100644 --- a/platform/native/lui_input/lui_scroll.ml +++ b/platform/native/lui_input/lui_scroll.ml @@ -322,7 +322,7 @@ let wheel env store st ~path ~scale ~dx ~dy ~(mods : Lui_input.mods) = let sync env store st = let st = ref st in let acts = ref [] in - List.iter + Lui_store.iter_preorder store (fun id -> if Lui_query.kind_name store id = "list" then match Lui_store.int_prop store id "scroll-token" with @@ -400,23 +400,18 @@ let sync env store st = acts := `Dispatch (ScrollCompleted (id, tok, "missing-target")) :: !acts)) - | _ -> ()) - (Lui_store.preorder store); + | _ -> ()); (* The tracked-list ids are store-derived too — refreshed under the same gate so the per-sync report loop only walks actual VisibleRange producers, not the whole tree. *) - st := - { !st with - lists = - List.filter - (fun id -> - let k = Lui_query.kind_name store id in - (k = "list" - && Lui_store.bool_prop store id "track-visible-range") - || (k = "virtual-list" - && Lui_query.data_attr store id "data-track-visible-range" - = Some "true")) - (Lui_store.preorder store) }; + let lists = ref [] in + Lui_store.iter_preorder store (fun id -> + let kind = Lui_query.kind_name store id in + if (kind = "list" && Lui_store.bool_prop store id "track-visible-range") + || (kind = "virtual-list" + && Lui_query.data_attr store id "data-track-visible-range" = Some "true") + then lists := id :: !lists); + st := { !st with lists = List.rev !lists }; (!st, List.rev !acts) (* Visible-range reports follow scroll offsets, which live here diff --git a/platform/native/lui_input/test/lui_input_test.ml b/platform/native/lui_input/test/lui_input_test.ml index fbbdc0d8..f8905a34 100644 --- a/platform/native/lui_input/test/lui_input_test.ml +++ b/platform/native/lui_input/test/lui_input_test.ml @@ -625,9 +625,146 @@ let test_spatial_hit_wide_list () = Alcotest.(check (option int)) "last row hits" (Some 139) (Lui_query.hit ~spatial:idx store rects ~x:110. ~y:197.) +let wide_sync_store () = + let ops = ref [CreateNode (1, Root)] in + for id = 2 to 6001 do + ops := InsertChild (1, id, id - 2) :: CreateNode (id, Column) :: !ops + done; + store_of (List.rev !ops) + +let test_sync_scan_allocation scan () = + let store = wide_sync_store () in + scan store; + Gc.full_major (); + let before = Gc.allocated_bytes () in + for _ = 1 to 10 do scan store done; + let per_scan = (Gc.allocated_bytes () -. before) /. 10. in + Printf.printf "Interaction scan allocation: %.0f bytes\n%!" per_scan; + if per_scan > 16_384. then + Alcotest.failf "Scanning unchanged controls allocates %.0f bytes" per_scan + +let scan_focus store = + ignore (Lui_focus.scan_autofocus store Lui_focus.initial ~scope:0) + +let scan_appear store = + ignore (Lui_focus.scan_appear store Lui_focus.initial) + +let scan_overlay store = ignore (Lui_overlay.top_scan store ~open_menu:0) + +let scan_scroll store = + let env = Lui_scroll.{content_rect=(fun _ -> Lui_scene.rect 0. 0. 0. 0.); + rects_epoch=0; vlist=None} in + ignore (Lui_scroll.sync env store Lui_scroll.initial) + +let test_native_event_admission () = + let events = [Appear 1; Press 1; Change 1; ToggleChanged (1,true); + PointerEnter 1; PointerLeave 1; Dismiss 1; Load 1] in + let gates = [AppearEnabled; PressEnabled; PointerEnabled; ChangeEnabled; ToggleEnabled] in + List.iter (fun kind -> + for mask = 0 to 31 do + List.iter (fun role -> + let props = List.mapi (fun bit property -> + property, BoolValue (mask land (1 lsl bit) <> 0)) gates in + let props = (RoleValue, StringValue role) :: props in + let store = store_of (CreateNode (1,kind) :: List.map (fun (p,v) -> SetProp (1,p,v)) props) in + let map = List.fold_left (fun acc (p,v) -> Property_map.add p v acc) Property_map.empty props in + List.iter (fun event -> + if event_supported_for_properties kind map event + <> Lui_query.event_supported store 1 event then + Alcotest.failf "Native admission diverged: kind=%s role=%s mask=%d" + (Lui_wire_schema.node_kind_name kind) role mask) events) + ["";"treeitem"] + done) Lui_wire_schema.all_node_kinds; + let store = store_of [CreateExtension (1,"test",""); + SetExtensionProp (1,"appear-enabled",BoolValue true)] in + Alcotest.(check bool) "unknown extension cannot admit a standard event" false + (Lui_query.event_supported store 1 (Appear 1)) + +let test_dense_lifecycle_allocation () = + let store = wide_sync_store () in + for id = 2 to 6001 do + Lui_store.apply_op store (SetProp (id, AppearEnabled, BoolValue true)) + done; + let state, actions = Lui_focus.scan_appear store Lui_focus.initial in + Alcotest.(check int) "every eligible node appears" 6000 (List.length actions); + Gc.full_major (); + let before = Gc.allocated_bytes () in + let _, actions = Lui_focus.scan_appear store state in + let allocated = Gc.allocated_bytes () -. before in + Alcotest.(check int) "existing nodes do not reappear" 0 (List.length actions); + Printf.printf "Dense appearance scan allocation: %.0f bytes\n%!" allocated; + if allocated > 1_000_000. then + Alcotest.failf "A dense appearance scan allocates %.0f bytes" allocated + +let test_sync_scan_order () = + let store = store_of [ + CreateNode (1, Root); CreateNode (2, Column); + CreateNode (30, TextField); CreateNode (20, TextField); + CreateNode (10, TextField); CreateNode (9, TextField); + CreateNode (50, Dialog); CreateNode (6, TextField); + InsertChild (1, 2, 0); InsertChild (2, 30, 0); + InsertChild (2, 20, 1); InsertChild (2, 10, 2); + InsertChild (2, 9, 3); InsertChild (1, 50, 1); + InsertChild (50, 6, 0); + SetProp (30, Autofocus, BoolValue true); + SetProp (30, Enabled, BoolValue false); + SetProp (20, Autofocus, BoolValue true); + SetProp (20, Visible, BoolValue false); + SetProp (10, Autofocus, BoolValue true); + SetProp (9, Autofocus, BoolValue true); + SetProp (6, Autofocus, BoolValue true); + SetProp (10, AppearEnabled, BoolValue true); + SetProp (9, AppearEnabled, BoolValue true); + SetProp (6, AppearEnabled, BoolValue true)] in + let state, candidate = Lui_focus.scan_autofocus store Lui_focus.initial ~scope:0 in + Alcotest.(check (option int)) "first eligible control in tree order" (Some 10) candidate; + let _, candidate = Lui_focus.scan_autofocus store state ~scope:0 in + Alcotest.(check (option int)) "seen controls do not autofocus again" None candidate; + let _, candidate = Lui_focus.scan_autofocus store Lui_focus.initial ~scope:50 in + Alcotest.(check (option int)) "autofocus remains in the overlay scope" (Some 6) candidate; + let state, actions = Lui_focus.scan_appear store Lui_focus.initial in + let appeared actions = List.map (function `Dispatch (Appear id) -> id + | _ -> Alcotest.fail "unexpected appearance intent") actions in + Alcotest.(check (list int)) "appearance follows tree order" [10;9;6] (appeared actions); + let _, actions = Lui_focus.scan_appear store state in + Alcotest.(check int) "appearance is emitted once per mount" 0 (List.length actions); + Lui_store.apply_batch store {generation=2; ops=[RemoveProp (9, AppearEnabled)]}; + let state, _ = Lui_focus.scan_appear store state in + Lui_store.apply_batch store {generation=3; ops=[SetProp (9, AppearEnabled, BoolValue true)]}; + let _, actions = Lui_focus.scan_appear store state in + Alcotest.(check (list int)) "remounted appearance is emitted again" [9] (appeared actions); + let before, _ = Lui_focus.scan_appear store Lui_focus.initial in + Lui_store.apply_batch store {generation=4; ops=[ + RemoveProp (9, AppearEnabled); CreateNode (7, Column); + InsertChild (2, 7, 4); SetProp (7, AppearEnabled, BoolValue true)]}; + let after, actions = Lui_focus.scan_appear store before in + Alcotest.(check (list int)) "equal-size membership replacement appears" [7] (appeared actions); + let _, actions = Lui_focus.scan_appear store after in + Alcotest.(check int) "replacement is remembered" 0 (List.length actions); + Lui_store.apply_batch store {generation=5; ops=[ + CreateNode (40, Dialog); InsertChild (1, 40, 2); + CreateNode (60, ListContainer); CreateNode (55, ListContainer); + InsertChild (2, 60, 4); InsertChild (2, 55, 5); + SetProp (60, TrackVisibleRange, BoolValue true); + SetProp (55, TrackVisibleRange, BoolValue true)]}; + Alcotest.(check (option (triple int bool bool))) "last overlay in paint order wins" + (Some (40,true,true)) (Lui_overlay.top_scan store ~open_menu:0); + let env = Lui_scroll.{content_rect=(fun _ -> Lui_scene.rect 0. 0. 0. 0.); + rects_epoch=0; vlist=None} in + let state, _ = Lui_scroll.sync env store Lui_scroll.initial in + Alcotest.(check (list int)) "tracked lists keep tree order" [60;55] state.Lui_scroll.lists + let () = Alcotest.run "lui_input" - [ ( "focus", + [ ( "store sync", + [ Alcotest.test_case "autofocus allocation" `Quick (test_sync_scan_allocation scan_focus); + Alcotest.test_case "appearance allocation" `Quick (test_sync_scan_allocation scan_appear); + Alcotest.test_case "overlay allocation" `Quick (test_sync_scan_allocation scan_overlay); + Alcotest.test_case "scroll allocation" `Quick (test_sync_scan_allocation scan_scroll); + Alcotest.test_case "native event admission" `Quick test_native_event_admission; + Alcotest.test_case "dense lifecycle allocation" `Quick test_dense_lifecycle_allocation; + Alcotest.test_case "tree order and lifecycle" `Quick test_sync_scan_order ] ); + ( "focus", [ Alcotest.test_case "step" `Quick test_focus_step; Alcotest.test_case "dead" `Quick test_focus_dead ] ); ( "overlay", diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index cef199ac..90ef65ff 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -552,24 +552,30 @@ let treeitem_properties properties = | Some (StringValue "treeitem") -> true | _ -> false -let event_supported_for_properties kind properties event = +let event_supported_for_lookup kind lookup event = + let enabled property = + match lookup property with Some (BoolValue true) -> true | _ -> false + in if match event with - | Appear _ -> true_property properties AppearEnabled + | Appear _ -> enabled AppearEnabled | _ -> false then true - else if treeitem_properties properties then + else if lookup RoleValue = Some (StringValue "treeitem") then match event with | Press _ | PressModifiers _ -> - true_property properties PressEnabled + enabled PressEnabled | PressDetail _ | PointerDown _ | PointerUp _ | PointerEnter _ | PointerLeave _ | ContextMenuPress _ -> - true_property properties PointerEnabled - | Change _ -> true_property properties ChangeEnabled - | ToggleChanged _ -> true_property properties ToggleEnabled + enabled PointerEnabled + | Change _ -> enabled ChangeEnabled + | ToggleChanged _ -> enabled ToggleEnabled | _ -> event_supported kind event else event_supported kind event +let event_supported_for_properties kind properties event = + event_supported_for_lookup kind (fun property -> Property_map.find_opt property properties) event + let container_relative_frame_supported value = value = "horizontal" || value = "vertical" diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index cb83829b..723bace1 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -358,6 +358,7 @@ val context_menu_leaf_host_kind : node_kind -> bool val event_supported : node_kind -> event -> bool val true_property : wire_value Property_map.t -> property -> bool val treeitem_properties : wire_value Property_map.t -> bool +val event_supported_for_lookup : node_kind -> (property -> wire_value option) -> event -> bool val event_supported_for_properties : node_kind -> wire_value Property_map.t -> event -> bool val container_relative_frame_supported : string -> bool val orientation_supported : string -> bool From 0247464c03d0275bf4228b9294ad43a4bc91ee14 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 15 Oct 2026 22:42:42 +0800 Subject: [PATCH 002/163] fix: bind typed properties without intermediate signals Convert values in scoped property observers to reduce retained allocations while preserving borrowed sources and disposal behavior. Add retention, batching, derived-source, and queued-disposal regressions. --- src/lui_runtime.ml | 7 ++- src/lui_ui.ml | 69 +++++++++----------- test/test_audit.ml | 154 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 185 insertions(+), 45 deletions(-) diff --git a/src/lui_runtime.ml b/src/lui_runtime.ml index 0b541480..23048df0 100644 --- a/src/lui_runtime.ml +++ b/src/lui_runtime.ml @@ -2033,11 +2033,14 @@ let move_child application parent child index = outlive its node: a dyn remount dropped mid-round disposes the scope lazily, so the callback still fires on a stale id. Live nodes keep their writes; dropped ones are skipped rather than failing on the host's behalf. *) -let bind_prop scope application node property source = +let bind_prop_map scope application node property convert source = Signal.own scope (Signal.observe source (fun value -> if node_live application node then - set_prop application node property value)) + set_prop application node property (convert value))) + +let bind_prop scope application node property source = + bind_prop_map scope application node property Fun.id source let bind_extension_prop scope application node property source = Signal.own scope diff --git a/src/lui_ui.ml b/src/lui_ui.ml index 13fa33b0..725b204e 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -173,10 +173,8 @@ let switch_control context = create context SwitchControl let bind_float_prop context node property source = ignore - (Lui_runtime.bind_prop context.ui_scope context.ui_application node - property - (Signal.own_signal context.ui_scope - (Signal.map (fun value -> FloatValue value) source))) + (Lui_runtime.bind_prop_map context.ui_scope context.ui_application node + property (fun value -> FloatValue value) source) let split context source = let node = create context Split in @@ -211,10 +209,8 @@ let heading context level text = let bind_string_prop context node property source = ignore - (Lui_runtime.bind_prop context.ui_scope context.ui_application node - property - (Signal.own_signal context.ui_scope - (Signal.map (fun text -> StringValue text) source))) + (Lui_runtime.bind_prop_map context.ui_scope context.ui_application node + property (fun text -> StringValue text) source) let heading_value context level source = let node = create context Heading in @@ -226,9 +222,10 @@ let heading_value context level source = node let heading_signal context level source = - heading_value context level - (Signal.own_signal context.ui_scope - (Signal.map (fun text -> StringValue text) source)) + let node = create context Heading in + Lui_runtime.set_prop context.ui_application node HeadingLevel (IntValue level); + bind_string_prop context node TextValue source; + node let paragraph context text = let node = create context Paragraph in @@ -244,9 +241,9 @@ let paragraph_value context source = node let paragraph_signal context source = - paragraph_value context - (Signal.own_signal context.ui_scope - (Signal.map (fun text -> StringValue text) source)) + let node = create context Paragraph in + bind_string_prop context node TextValue source; + node let label context text = let node = create context Label in @@ -262,9 +259,9 @@ let label_value context source = node let label_signal context source = - label_value context - (Signal.own_signal context.ui_scope - (Signal.map (fun text -> StringValue text) source)) + let node = create context Label in + bind_string_prop context node TextValue source; + node let text_value context source = let node = create context Text in @@ -274,9 +271,9 @@ let text_value context source = node let text_signal context source = - text_value context - (Signal.own_signal context.ui_scope - (Signal.map (fun text -> StringValue text) source)) + let node = create context Text in + bind_string_prop context node TextValue source; + node let slider context source = let node = create context Slider in @@ -302,28 +299,20 @@ let bool_property context node property value = let bool_property_signal context node property source = ignore - (Lui_runtime.bind_prop context.ui_scope context.ui_application node - property - (Signal.own_signal context.ui_scope - (Signal.map (fun value -> BoolValue value) source))) + (Lui_runtime.bind_prop_map context.ui_scope context.ui_application node + property (fun value -> BoolValue value) source) let float_property context node property value = Lui_runtime.set_prop context.ui_application node property (FloatValue value) let float_property_signal context node property source = - ignore - (Lui_runtime.bind_prop context.ui_scope context.ui_application node - property - (Signal.own_signal context.ui_scope - (Signal.map (fun value -> FloatValue value) source))) + bind_float_prop context node property source let int_property_signal context node property source = ignore - (Lui_runtime.bind_prop context.ui_scope context.ui_application node - property - (Signal.own_signal context.ui_scope - (Signal.map (fun value -> IntValue value) source))) + (Lui_runtime.bind_prop_map context.ui_scope context.ui_application node + property (fun value -> IntValue value) source) let int_property context node property value = Lui_runtime.set_prop context.ui_application node property @@ -342,10 +331,8 @@ let disabled context node disabled = let disabled_signal context node source = ignore - (Lui_runtime.bind_prop context.ui_scope context.ui_application node - Enabled - (Signal.own_signal context.ui_scope - (Signal.map (fun disabled -> BoolValue (not disabled)) source))) + (Lui_runtime.bind_prop_map context.ui_scope context.ui_application node + Enabled (fun disabled -> BoolValue (not disabled)) source) let checked_signal context node source = bool_property_signal context node Checked source @@ -542,12 +529,12 @@ let theme context node tokens = string_property context node ThemeValue (theme_tokens_json tokens) let theme_signal context node source = - string_property_signal context node ThemeValue - (Signal.own_signal context.ui_scope (Signal.map theme_tokens_json source)) + ignore (Lui_runtime.bind_prop_map context.ui_scope context.ui_application node + ThemeValue (fun tokens -> StringValue (theme_tokens_json tokens)) source) let theme_mode context node mode = string_property context node ThemeMode (theme_mode_value mode) let theme_mode_signal context node source = - string_property_signal context node ThemeMode - (Signal.own_signal context.ui_scope (Signal.map theme_mode_value source)) + ignore (Lui_runtime.bind_prop_map context.ui_scope context.ui_application node + ThemeMode (fun mode -> StringValue (theme_mode_value mode)) source) diff --git a/test/test_audit.ml b/test/test_audit.ml index 9b94c66d..96d4a7bb 100644 --- a/test/test_audit.ml +++ b/test/test_audit.ml @@ -410,7 +410,7 @@ let review_library_derived_ownership () = for index = 1 to 20 do Signal.set branch index; ignore (Lui_runtime.flush app) done; Signal.set source ["accent", Lui_ui.Fixed "green"]; Signal.stabilize context.ui_scheduler; - Alcotest.(check int) "only current derivations recompute" 3 + Alcotest.(check int) "property conversion does not add reactive computations" 1 (Signal.last_stabilization context.ui_scheduler).stabilization_dirty_tasks; Signal.dispose_scope owner; Signal.set source ["accent", Lui_ui.Fixed "red"]; @@ -569,6 +569,151 @@ let review_wide_reorder () = Alcotest.(check bool) "wide reconciliation scales subquadratically" true (large < small *. 9. +. 0.03) +let property_binding_retention () = + let retained dynamic = + Gc.full_major (); + let before = (Gc.stat ()).Gc.live_words * 8 in + let app = runtime () in + let scope = Signal.scope "property-retention" in + let context = Lui_ui.context app scope in + let source = Signal.state context.ui_scheduler "initial" in + for _ = 1 to 2000 do + if dynamic then ignore (Lui_ui.text_signal context (Signal.value source)) + else ignore (Lui_ui.text context "initial") + done; + ignore (Lui_runtime.flush app); + Gc.full_major (); + let bytes = (Gc.stat ()).Gc.live_words * 8 - before in + ignore (Sys.opaque_identity (app, context, source)); + Signal.dispose_scope scope; + bytes in + let plain = retained false in + let bound = retained true in + let per_binding = float (bound - plain) /. 2000. in + Printf.printf "Reactive property retained overhead: %.0f bytes/binding\n%!" per_binding; + if per_binding > 768. then + Alcotest.failf "A scalar property binding retains %.0f extra bytes" per_binding + +let property_value app node property = + Property_map.find property (Hashtbl.find app.Lui_runtime.runtime_properties node) + +let property_binding_values () = + let batches = ref [] in + let app = Lui_runtime.create (Signal.scheduler ()) + (backend (fun batch -> batches := batch :: !batches; true)) in + let scope = Signal.scope "typed-property-values" in + let context = Lui_ui.context app scope in + let text = Signal.state context.ui_scheduler "initial" in + let enabled = Signal.state context.ui_scheduler true in + let number = Signal.state context.ui_scheduler 0.25 in + let level = Signal.state context.ui_scheduler 1 in + let mode = Signal.state context.ui_scheduler `light in + let tokens = Signal.state context.ui_scheduler ["accent", Lui_ui.Fixed "blue"] in + let texts = [Lui_ui.text_signal context (Signal.value text); + Lui_ui.heading_signal context 2 (Signal.value text); + Lui_ui.paragraph_signal context (Signal.value text); + Lui_ui.label_signal context (Signal.value text)] in + let button = Lui_ui.button context and disabled = Lui_ui.button context in + Lui_ui.bool_property_signal context button Enabled (Signal.value enabled); + Lui_ui.disabled_signal context disabled (Signal.value enabled); + let panel = Lui_ui.column context in + Lui_ui.float_property_signal context panel Opacity (Signal.value number); + Lui_ui.theme_mode_signal context panel (Signal.value mode); + Lui_ui.theme_signal context panel (Signal.value tokens); + let heading = Lui_ui.heading context 1 "heading" in + Lui_ui.int_property_signal context heading HeadingLevel (Signal.value level); + let slider = Lui_ui.slider context (Signal.value number) in + ignore (Lui_runtime.flush app); + List.iter (fun id -> Alcotest.(check bool) "initial text is delivered" true + (property_value app id TextValue = StringValue "initial")) texts; + Alcotest.(check bool) "initial disabled conversion" true + (property_value app disabled Enabled = BoolValue false); + batches := []; + Signal.set text "intermediate"; + Signal.set text "final"; + Signal.set enabled false; + Signal.set number 0.75; + Signal.set level 3; + Signal.set mode `dark; + Signal.set tokens ["accent", Lui_ui.Fixed "green"]; + ignore (Lui_runtime.flush app); + List.iter (fun id -> + Alcotest.(check bool) "latest text is delivered" true + (property_value app id TextValue = StringValue "final"); + let updates = List.concat_map (fun (batch:patch_batch) -> batch.ops) !batches + |> List.filter (function SetProp (node, TextValue, _) -> node=id | _ -> false) in + Alcotest.(check int) "a batched property emits one final update" 1 (List.length updates)) texts; + List.iter (fun (node,property,value) -> + Alcotest.(check bool) "typed property updates" true (property_value app node property = value)) + [button,Enabled,BoolValue false; disabled,Enabled,BoolValue true; + panel,Opacity,FloatValue 0.75; heading,HeadingLevel,IntValue 3; + slider,ProgressValue,FloatValue 0.75; panel,ThemeMode,StringValue "dark"; + panel,ThemeValue,StringValue (Lui_ui.theme_tokens_json ["accent", Lui_ui.Fixed "green"])]; + Signal.dispose_scope scope + +let property_binding_borrowed_source () = + let app = runtime () in + let first = Signal.scope "first-property-reader" and second = Signal.scope "second-property-reader" in + let a = Lui_ui.context app first and b = Lui_ui.context app second in + let source = Signal.state a.ui_scheduler "initial" in + let left = Lui_ui.text_signal a (Signal.value source) in + let right = Lui_ui.text_signal b (Signal.value source) in + ignore (Lui_runtime.flush app); + Signal.dispose_scope first; + Signal.set source "updated"; + ignore (Lui_runtime.flush app); + Alcotest.(check bool) "disposed binding stops writing" true + (property_value app left TextValue = StringValue "initial"); + Alcotest.(check bool) "shared source still updates its other reader" true + (property_value app right TextValue = StringValue "updated"); + Signal.dispose_scope second; + Signal.set source "after disposal"; + Signal.stabilize a.ui_scheduler; + Alcotest.(check int) "disposed bindings retain no computations" 1 + (Signal.last_stabilization a.ui_scheduler).stabilization_dirty_tasks; + Alcotest.(check bool) "the source is borrowed" false (Signal.signal_is_disposed (Signal.value source)) + +let property_binding_diamond () = + let updates = ref [] in + let app = Lui_runtime.create (Signal.scheduler ()) (backend (fun batch -> + updates := List.rev_append batch.ops !updates; true)) in + let caller = Signal.scope "property-source-owner" and scope = Signal.scope "property-diamond" in + let context = Lui_ui.context app scope in + let left = Signal.state context.ui_scheduler "a" and right = Signal.state context.ui_scheduler "b" in + let joined = Signal.own_signal caller (Signal.map2 ( ^ ) (Signal.value left) (Signal.value right)) in + let node = Lui_ui.text_signal context joined in + ignore (Lui_runtime.flush app); + updates := []; + Signal.set left "A"; Signal.set right "B"; + ignore (Lui_runtime.flush app); + Alcotest.(check bool) "a derived source publishes the settled value" true + (property_value app node TextValue = StringValue "AB"); + let texts = List.filter_map (function SetProp (id,TextValue,StringValue text) when id=node -> Some text + | _ -> None) !updates in + Alcotest.(check (list string)) "a diamond emits no intermediate property values" ["AB"] (List.rev texts); + Signal.dispose_scope scope; + Alcotest.(check bool) "the derived source belongs to its caller" false (Signal.signal_is_disposed joined); + Signal.dispose_scope caller + +let property_binding_queued_disposal () = + List.iter (fun drop -> + let app = runtime () in + let scope = Signal.scope "queued-property-reader" in + let context = Lui_ui.context app scope in + let source = Signal.state context.ui_scheduler "initial" in + let node = Lui_ui.text context "initial" in + let retiring = Signal.subscribe ~emit_initial:false (Signal.value source) (fun _ -> + if drop then Lui_runtime.drop_node app node else Signal.dispose_scope scope) in + Lui_ui.string_property_signal context node TextValue (Signal.value source); + ignore (Lui_runtime.flush app); + Signal.set source "queued"; + ignore (Lui_runtime.flush app); + if drop then Alcotest.(check bool) "retired node is not revived" false (Lui_runtime.node_live app node) + else Alcotest.(check bool) "retired scope discards its queued write" true + (property_value app node TextValue = StringValue "initial"); + Signal.dispose_subscription retiring; + Signal.dispose_scope scope) [false;true] + let tests = existing_tests @ List.map (fun (name, test) -> Alcotest.test_case name `Quick test) ["review wide child reorder", review_wide_reorder; "review sibling owner identity", review_sibling_state; @@ -582,4 +727,9 @@ let tests = existing_tests @ List.map (fun (name, test) -> Alcotest.test_case na "review keyed locality", review_keyed_locality; "review unchanged keyed publication", review_keyed_unchanged; "keyed retained subtree allocation", keyed_retained_subtree_allocation; - "keyed removed subtree rollback", keyed_removed_subtree_rollback] + "keyed removed subtree rollback", keyed_removed_subtree_rollback; + "property binding retention", property_binding_retention; + "property binding values", property_binding_values; + "property binding borrowed source", property_binding_borrowed_source; + "property binding queued disposal", property_binding_queued_disposal; + "property binding diamond", property_binding_diamond] From 4b8cc1d8c62669363791216720083ca4346baaa5 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 03:33:34 +0800 Subject: [PATCH 003/163] fix: release retired paint fragments Prune disposed fragments and parent links at structural journal boundaries. Ignore stale geometry and interaction hints, and cover viewport teardown, root removal, remounting, and paint equivalence. --- platform/native/lui_paint/lui_retained.ml | 32 +++++++++- .../lui_paint/test/lui_retained_test.ml | 60 ++++++++++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/platform/native/lui_paint/lui_retained.ml b/platform/native/lui_paint/lui_retained.ml index 0178fce5..9480c898 100644 --- a/platform/native/lui_paint/lui_retained.ml +++ b/platform/native/lui_paint/lui_retained.ml @@ -124,6 +124,9 @@ let create () = let invalidate (rt : retained) = Hashtbl.reset rt.frags; + Hashtbl.reset rt.fparent; + Hashtbl.reset rt.pend_kids; + Hashtbl.reset rt.pend_segs; Hashtbl.reset rt.vwin; (* Fresh fragments start with no kid list and nothing marks them: the next paint must re-derive every node's children, or the tree @@ -253,6 +256,22 @@ let mark_inherited rt store id name = | _ -> false) then List.iter (mark_repaint rt) (Lui_store.child_ids store id) +(* Cached child lists describe the last painted window, not the complete + disposal topology. A coalesced teardown or a virtual-window change can + leave retired descendants outside those lists. Prune at structural + journal boundaries against the authoritative final store. *) +let prune_retired rt store = + let prune table = Hashtbl.filter_map_inplace (fun id item -> + if Lui_store.mem store id then Some item else None) table in + prune rt.frags; + prune rt.pend_kids; + prune rt.pend_segs; + prune rt.vwin; + Hashtbl.filter_map_inplace (fun id parent -> + if Lui_store.mem store id && (parent=0 || Lui_store.mem store parent) + then Some parent else None) rt.fparent + + (* 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. *) @@ -263,6 +282,7 @@ let advance rt store = invalidate rt; rt.rev <- Lui_store.revision store | Some ops -> + let retired = ref false in List.iter (fun (op : Lui_protocol.patch_op) -> match op with @@ -284,9 +304,12 @@ let advance rt store = List.iter (mark_repaint rt) (Lui_store.preorder ~root:id store); mark_parent_rekids rt store id | CreateNode (id, _) | CreateExtension (id, _, _) -> - (* A reused id may still carry a stale fragment. *) + (* A reused id can retire descendants without an explicit drop. *) + retired := true; mark_repaint rt id | DropNode id | DetachSubtree id -> + retired := true; + rt.dirty <- true; mark_parent_rekids rt store id; purge_frag rt id | InsertChild (p, c, _) -> @@ -313,6 +336,7 @@ let advance rt store = mark_repaint rt c; mark_parent_rekids rt store c) ops; + if !retired then prune_retired rt store; (* [rt.dirty] only follows real marks — a semantics-only op leaves the frame alone. *) rt.rev <- Lui_store.revision store @@ -724,10 +748,12 @@ let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ?vlists ids | None -> ()); (match moved with - | Some mv -> Hashtbl.iter (fun id () -> mark_geom rt id) mv + | Some mv -> Hashtbl.iter (fun id () -> + if Lui_store.mem store id then mark_geom rt id) mv | None -> ()); (match hints with - | Some h -> Hashtbl.iter (fun id () -> mark_repaint rt id) h + | Some h -> Hashtbl.iter (fun id () -> + if Lui_store.mem store id then mark_repaint rt id) h | None -> ()); if rt.dirty then begin let m = diff --git a/platform/native/lui_paint/test/lui_retained_test.ml b/platform/native/lui_paint/test/lui_retained_test.ml index fd0e7c19..9515350d 100644 --- a/platform/native/lui_paint/test/lui_retained_test.ml +++ b/platform/native/lui_paint/test/lui_retained_test.ml @@ -314,9 +314,65 @@ let test_drop_subtree () = frame env (); apply env.store [ DropNode 1 ]; frame env (); - (* store still carries the orphaned children as roots *) + (* A second clean frame must preserve the empty forest. *) frame env () +let test_disposed_fragments () = + List.iter (fun painted -> + let env = env () in + apply env.store [CreateNode (1, Column)]; + frame env (); + let baseline = (Gc.stat ()).live_words in + for turn=0 to 99 do + let parent=1000+turn*4 in + apply env.store [CreateNode (parent,Column); InsertChild (1,parent,0); + CreateNode (parent+1,Column); InsertChild (parent,parent+1,0); + CreateNode (parent+2,Button); SetProp (parent+2,TextValue,StringValue "temporary"); + InsertChild (parent+1,parent+2,0); + CreateNode (parent+3,Button); InsertChild (parent,parent+3,1)]; + if painted then frame env (); + (* Native viewport teardown can dispose an inner scope and then its + enclosing row before the next materialization. *) + apply env.store [RemoveChild (parent,parent+1); DetachSubtree (parent+1); + DetachSubtree parent]; + frame env () + done; + Gc.full_major (); Gc.full_major (); + let retained = ((Gc.stat ()).live_words-baseline)*(Sys.word_size/8) in + Printf.printf "Disposed paint fragments (painted=%b): fragments=%d parents=%d retained=%d bytes\n%!" + painted (Hashtbl.length env.rt.frags) (Hashtbl.length env.rt.fparent) retained; + check bool "Disposed viewport rows retain paint fragments" + true (Hashtbl.length env.rt.frags <= Lui_store.node_count env.store); + check bool "Disposed viewport rows retain paint parent links" + true (Hashtbl.length env.rt.fparent <= Lui_store.node_count env.store); + check bool "Disposed viewport rows retain virtual paint windows" + true (Hashtbl.length env.rt.vwin <= Lui_store.node_count env.store); + check bool "Disposed rows retain pending paint work" + true (Hashtbl.length env.rt.pend_segs=0 && Hashtbl.length env.rt.pend_kids=0) + ) [false;true] + +let test_removed_paint_hints () = + let env = env () in + build_rows env 4; + frame env (); + apply env.store [DetachSubtree 1]; + let moved=Hashtbl.create 4 and hints=Hashtbl.create 4 in + for id=1 to 5 do Hashtbl.add moved id (); Hashtbl.add hints id () done; + let h=hooks env in + Lui_retained.paint_retained env.rt h env.store env.kept ~moved ~hints + ~width:200 ~height:200 ~scale:1. ~clear:(color 0 0 0 0) (); + check int "Deleted geometry and hover hints recreate paint resources" + 0 (Hashtbl.length env.rt.frags); + check int "Deleted geometry leaves parent links" 0 (Hashtbl.length env.rt.fparent); + apply env.store [CreateNode (1,Column); CreateNode (2,Button); + SetProp (2,TextValue,StringValue "replacement"); InsertChild (1,2,0)]; + frame env (); + Lui_retained.invalidate env.rt; + frame env (); + check bool "Invalidation retains obsolete parent links" + true (Hashtbl.length env.rt.fparent <= Lui_store.node_count env.store) + + (* 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. *) @@ -576,6 +632,8 @@ let () = 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 "disposed fragments" `Quick test_disposed_fragments; + test_case "removed paint hints" `Quick test_removed_paint_hints; test_case "wide removal" `Quick test_wide_removal; test_case "vlist scroll" `Quick test_vlist_scroll; test_case "random equivalence" `Quick test_random_equivalence; From d44dc83c6b4196e58a1d9a0b0f9b8284608d905c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 04:01:25 +0800 Subject: [PATCH 004/163] perf: avoid per-glyph damage allocations Accumulate glyph bounds numerically and skip atlas overlap closures when no atlas pixels changed. Cover allocation limits, fractional and empty bounds, clipping, and mask and color atlas updates. --- platform/native/lui_raster/lui_raster.ml | 22 +++- .../native/lui_raster/test/lui_raster_test.ml | 107 +++++++++++++++++- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/platform/native/lui_raster/lui_raster.ml b/platform/native/lui_raster/lui_raster.ml index 0d156cb1..221dfdfc 100644 --- a/platform/native/lui_raster/lui_raster.ml +++ b/platform/native/lui_raster/lui_raster.ml @@ -1579,12 +1579,21 @@ let op_bounds s = if f.sinset then outset f.scast 1. else outset f.srect (1.5 *. f.sblur +. 1.) | Glyphs f -> let gend = min f.gend (Array.length glyphs) in - let b = ref (irect 0 0 0 0) in + let x0=ref 0 and y0=ref 0 and x1=ref 0 and y1=ref 0 in for gi = max f.gstart 0 to gend - 1 do let g = glyphs.(gi) in - b := irect_union !b (outset (rect g.gx g.gy g.gw g.gh) 1.) + let left=ifloor (g.gx -. 1.) and top=ifloor (g.gy -. 1.) + and right=iceil (g.gx +. g.gw +. 1.) + and bottom=iceil (g.gy +. g.gh +. 1.) in + if left < right && top < bottom then + if !x1 <= !x0 || !y1 <= !y0 then begin + x0 := left; y0 := top; x1 := right; y1 := bottom + end else begin + x0 := min !x0 left; y0 := min !y0 top; + x1 := max !x1 right; y1 := max !y1 bottom + end done; - !b + irect !x0 !y0 !x1 !y1 | Push_clip f -> (* A clip that changes changes everything it cuts. *) let b = outset f.crect 1. in @@ -1696,9 +1705,10 @@ let op_same prev_effects d ops_a i _s ops_b j glyphs_a glyphs_b fxs_b mask_rects && ga.gu = gb.gu && ga.gv = gb.gv && ga.guw = gb.guw && ga.gvh = gb.gvh && ga.gcolor = gb.gcolor && ga.gcolored = gb.gcolored && ga.gsubpixel = gb.gsubpixel && ga.gthin = gb.gthin - && (let at = irect gb.gu gb.gv (gb.gu + gb.guw) (gb.gv + gb.gvh) in - let rects = if gb.gcolored then color_rects else mask_rects in - not (List.exists (fun c -> irect_overlaps c at) rects)) + && (let rects = if gb.gcolored then color_rects else mask_rects in + rects=[] || + let at = irect gb.gu gb.gv (gb.gu + gb.guw) (gb.gv + gb.gvh) in + not (List.exists (fun c -> irect_overlaps c at) rects)) && loop (k + 1) end in diff --git a/platform/native/lui_raster/test/lui_raster_test.ml b/platform/native/lui_raster/test/lui_raster_test.ml index 848b5f98..25d98dd6 100644 --- a/platform/native/lui_raster/test/lui_raster_test.ml +++ b/platform/native/lui_raster/test/lui_raster_test.ml @@ -793,6 +793,108 @@ let test_image_fractional_coverage () = assert_pixels_close "Bilinear image fractional edge coverage" expected actual ) [false;true]) [0.;0.5] +let test_glyph_damage_allocation () = + let glyphs = + List.init 1000 (fun i -> + glyph + ~x:(float (i mod 40) *. 10.) + ~y:(float (i / 40) *. 20.) + ~w:8. ~h:16. ~u:0 ~v:0 ~uw:8 ~vh:16 black) + in + let mask = Atlas.create ~bpp:1 ~w:8 ~h:16 in + Atlas.put mask ~x:0 ~y:0 ~w:8 ~h:16 ~src:(Bytes.make 128 '\128') ~stride:8; + let color_atlas = Atlas.create ~bpp:4 ~w:8 ~h:16 in + let scenes = + Array.init 31 (fun n -> + scene ~w:512 ~h:640 ~mask ~color_atlas ~glyphs + [ iglyphs 0 1000; ifill (rect 450. 8. 20. 16.) (color n 0 0 255) ]) + in + let damage = Lui_raster.Damage.create () in + ignore (Lui_raster.Damage.update damage scenes.(0)); + let before = Gc.allocated_bytes () in + for frame = 1 to 100 do + ignore + (Lui_raster.Damage.update damage scenes.(frame mod Array.length scenes)) + done; + let allocated = (Gc.allocated_bytes () -. before) /. 100. in + Printf.printf "Retained glyph damage allocated %.0f bytes/frame\n%!" allocated; + Alcotest.(check bool) + "Damage checks allocate per-glyph rectangles and closures" true + (allocated < 49_152.); + Alcotest.(check bool) + "A changed panel forces the whole static body to redraw" false + (Lui_raster.Damage.whole damage) + +let test_glyph_damage_bounds () = + let glyphs = + List.map + (fun (x, y, w, h) -> glyph ~x ~y ~w ~h ~u:0 ~v:0 ~uw:8 ~vh:16 black) + [ + (-0.25, 2.75, 7.5, 9.25); + (31.75, -0.5, 0., 0.); + (20.125, 20.875, -3., 8.); + ] + in + let damage = Lui_raster.Damage.create () in + let s = scene ~w:64 ~h:64 ~glyphs [ iglyphs 0 3 ] in + ignore (Lui_raster.Damage.update damage s); + let fields r = [ r.x0; r.y0; r.x1; r.y1 ] in + Alcotest.(check (list int)) + "Fractional, zero and empty glyph bounds" [ 0; 0; 33; 13 ] + (fields (Lui_raster.Damage.bounds damage).(0)); + s.ops <- + [ + iclip (rect 1.25 2.25 20.5 10.5); + iclip (rect 5.5 5.5 8. 8.); + iglyphs 0 3; + Pop_clip; + Pop_clip; + iglyphs 0 0; + ]; + ignore (Lui_raster.Damage.update damage s); + Alcotest.(check (list int)) + "Glyph bounds preserve nested clip intersections" [ 4; 4; 15; 13 ] + (fields (Lui_raster.Damage.bounds damage).(2)); + Alcotest.(check bool) + "Empty glyph range became visible" true + (irect_empty (Lui_raster.Damage.bounds damage).(5)) + +let test_glyph_atlas_damage () = + List.iter + (fun colored -> + let mask = Atlas.create ~bpp:1 ~w:8 ~h:16 + and color_atlas = Atlas.create ~bpp:4 ~w:8 ~h:16 in + let glyphs = + [ + glyph ~colored ~x:4.25 ~y:4.75 ~w:8. ~h:16. ~u:0 ~v:0 ~uw:8 ~vh:16 + black; + ] + in + let s = scene ~w:64 ~h:32 ~mask ~color_atlas ~glyphs [ iglyphs 0 1 ] in + let renderer = Lui_raster.Renderer.create () in + ignore (Lui_raster.Renderer.render renderer s); + let before = Bytes.copy (Lui_raster.Renderer.image renderer).pix in + let atlas, bpp = if colored then (color_atlas, 4) else (mask, 1) in + let source = + Bytes.init (128 * bpp) (fun i -> + if bpp = 1 || i mod 4 = 3 then '\255' + else Char.chr (17 + (i mod 3 * 31))) + in + Atlas.put atlas ~x:0 ~y:0 ~w:8 ~h:16 ~src:source ~stride:(8 * bpp); + let dirty = Lui_raster.Renderer.render renderer s in + let actual = (Lui_raster.Renderer.image renderer).pix in + let expected = (Lui_raster.render ~scene:s ()).pix in + Alcotest.(check bool) + "Unchanged glyphs ignore changed atlas pixels" true (dirty <> []); + Alcotest.(check bool) + "Atlas changes did not repaint visible glyph pixels" false + (Bytes.equal before actual); + Alcotest.(check bool) + "Atlas damage differs from a complete redraw" true + (Bytes.equal actual expected); + Lui_raster.Renderer.release renderer) + [ false; true ] + let redraw_allocation scene = let renderer=Lui_raster.Renderer.create () in ignore (Lui_raster.Renderer.render ~workers:1 renderer scene); @@ -933,7 +1035,10 @@ let () = Alcotest.test_case "redraws effects" `Slow test_damage_redraws_effects; Alcotest.test_case "skips unchanged" `Quick test_damage_skips; Alcotest.test_case "merge cap" `Quick test_damage_merge; - Alcotest.test_case "damage bands" `Slow test_damage_bands ] ) + Alcotest.test_case "damage bands" `Slow test_damage_bands; + Alcotest.test_case "glyph allocation" `Quick test_glyph_damage_allocation; + Alcotest.test_case "glyph bounds" `Quick test_glyph_damage_bounds; + Alcotest.test_case "glyph atlas updates" `Quick test_glyph_atlas_damage ] ) ; ("window pixels", [ Alcotest.test_case "staging retention" `Slow test_window_pixel_retention; Alcotest.test_case "bands and resize" `Slow test_window_tiles_match_whole; From 1ec113239f6482c8378383c57f9e46cc7c04abf5 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 04:40:43 +0800 Subject: [PATCH 005/163] perf: avoid allocating retained fragment lookup wrappers Return cached paint fragments directly and cover lookup identity and allocation with a regression test. --- platform/native/lui_paint/lui_retained.ml | 4 +--- .../lui_paint/test/lui_retained_test.ml | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/platform/native/lui_paint/lui_retained.ml b/platform/native/lui_paint/lui_retained.ml index 9480c898..0a7d1073 100644 --- a/platform/native/lui_paint/lui_retained.ml +++ b/platform/native/lui_paint/lui_retained.ml @@ -150,9 +150,7 @@ let fresh_frag id = 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 -> + try Hashtbl.find rt.frags id with Not_found -> let f = fresh_frag id in Hashtbl.replace rt.frags id f; f diff --git a/platform/native/lui_paint/test/lui_retained_test.ml b/platform/native/lui_paint/test/lui_retained_test.ml index 9515350d..915f4460 100644 --- a/platform/native/lui_paint/test/lui_retained_test.ml +++ b/platform/native/lui_paint/test/lui_retained_test.ml @@ -158,6 +158,22 @@ let build_rows env n = in apply env.store ops +let test_fragment_lookup_allocation () = + let retained = Lui_retained.create () in + let originals = Array.init 1000 (fun id -> Lui_retained.frag_of retained id) in + let identities_match = ref true in + let before = Gc.allocated_bytes () in + for _ = 1 to 100 do + Array.iteri + (fun id expected -> + if Lui_retained.frag_of retained id != expected then identities_match := false) + originals + done; + let allocated = (Gc.allocated_bytes () -. before) /. 100. in + check bool "Existing fragments preserve physical identity" true !identities_match; + Printf.printf "Retained 1000-fragment lookup allocated %.0f bytes/pass\n%!" allocated; + check bool "Looking up live paint fragments allocates wrappers" true (allocated < 4096.) + let test_static_frame () = let env = env () in build_rows env 10; @@ -618,7 +634,8 @@ let test_invalidate_rebuilds_kids () = let () = run "lui_retained" [ ( "fragments", - [ test_case "static frame" `Quick test_static_frame; + [ test_case "fragment lookup allocation" `Quick test_fragment_lookup_allocation; + test_case "static frame" `Quick test_static_frame; test_case "single paint update" `Quick test_single_paint_update; test_case "geom shift translates" `Quick test_geom_shift_translates; test_case "semantics-only skips" `Quick test_semantics_only_skips; From 640d7db2a0775fe7f5c667e2a3bd3cfaf2885e2c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 04:49:21 +0800 Subject: [PATCH 006/163] perf: deduplicate accessibility ancestor traversal Walk each current and previous ancestor chain once per update. Cover allocation, reordered dirty nodes, hidden ancestry, and reparenting against a fresh accessibility tree. --- platform/native/lui_a11y/lui_a11y.ml | 12 ++++- .../native/lui_a11y/test/lui_a11y_test.ml | 47 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/platform/native/lui_a11y/lui_a11y.ml b/platform/native/lui_a11y/lui_a11y.ml index 31c5f890..b42b4052 100644 --- a/platform/native/lui_a11y/lui_a11y.ml +++ b/platform/native/lui_a11y/lui_a11y.ml @@ -511,6 +511,9 @@ let flatten t = from descendant text. *) let update t dirty = let work = ref ISet.empty in + (* Reparenting can give the two trees different ancestor chains. Each + chain is walked once per update, independently of dirty-list order. *) + let store_ancestors = ref ISet.empty and a11y_ancestors = ref ISet.empty in let add id = work := ISet.add id !work in let add_a11y_subtree id = let rec walk i = @@ -525,12 +528,17 @@ let update t dirty = in let rec add_store_ancestors id = match Lui_store.parent t.store id with - | Some p -> add p; add_store_ancestors p + | Some p when not (ISet.mem p !store_ancestors) -> + store_ancestors := ISet.add p !store_ancestors; + add p; add_store_ancestors p + | Some _ -> () | None -> () in let rec add_a11y_ancestors id = match Hashtbl.find_opt t.nodes id with - | Some { parent = Some p; _ } -> add p; add_a11y_ancestors p + | Some { parent = Some p; _ } when not (ISet.mem p !a11y_ancestors) -> + a11y_ancestors := ISet.add p !a11y_ancestors; + add p; add_a11y_ancestors p | _ -> () in List.iter diff --git a/platform/native/lui_a11y/test/lui_a11y_test.ml b/platform/native/lui_a11y/test/lui_a11y_test.ml index 7b64ada6..4ba1afc6 100644 --- a/platform/native/lui_a11y/test/lui_a11y_test.ml +++ b/platform/native/lui_a11y/test/lui_a11y_test.ml @@ -415,6 +415,52 @@ let test_incremental_insert_allocation () = check (option string) "inserted text is accessible" (Some "new") (name_of tree 6002); check bool "insertion avoids rebuilding unchanged sibling records" true (allocated <= 524288.) +let test_shared_ancestry_allocation () = + let store = Lui_store.create () in + apply store [ CreateNode (1, Button); CreateNode (1000, Column) ]; + for id = 2 to 80 do + apply store [ CreateNode (id, Column); InsertChild (id - 1, id, 0) ] + done; + for id = 81 to 480 do + apply store + [ + CreateNode (id, Text); + InsertChild (80, id, id - 81); + SetProp (id, TextValue, StringValue "before"); + ] + done; + let tree = Lui_a11y.of_store store in + ignore (Lui_store.drain_dirty store); + for id = 81 to 480 do + apply store [ SetProp (id, TextValue, StringValue "after") ] + done; + let dirty = Lui_store.drain_dirty store in + let before = Gc.allocated_bytes () in + let changed = Lui_a11y.update tree (List.rev dirty @ dirty) in + let allocated = Gc.allocated_bytes () -. before in + let rebuilt = Lui_a11y.of_store store in + check bool "Shared ancestry retains the complete accessible tree" true + (Lui_a11y.flatten tree = Lui_a11y.flatten rebuilt); + check int "Changed leaf names and composite ancestor are reported" 401 + (List.length changed); + Printf.printf + "Accessibility 400 changes under 80 ancestors allocated %.0f bytes\n%!" + allocated; + check bool "Accessibility repeats shared ancestor traversal" true + (allocated < 1048576.); + apply store + [ SetProp (1, Visible, BoolValue false); InsertChild (1000, 480, 0) ]; + let dirty = Lui_store.drain_dirty store in + ignore (Lui_a11y.update tree (List.rev dirty @ dirty)); + check bool "Visibility and old/new ancestry remain current" true + (Lui_a11y.flatten tree = Lui_a11y.flatten (Lui_a11y.of_store store)); + check (option string) "Moved descendant escapes hidden ancestry" + (Some "after") (name_of tree 480); + apply store [ SetProp (1, Visible, BoolValue true) ]; + ignore (Lui_a11y.sync tree); + check bool "Visibility results do not survive their update batch" true + (Lui_a11y.flatten tree = Lui_a11y.flatten (Lui_a11y.of_store store)) + let test_incremental_visibility () = let store = Lui_store.create () in apply store [CreateNode (1,Column); CreateNode (2,Column); CreateNode (3,Column); @@ -498,5 +544,6 @@ let () = test_case "structure" `Quick test_update_structure; test_case "visibility and reparenting" `Quick test_overlapping_dirty_trees; test_case "incremental insertion allocation" `Quick test_incremental_insert_allocation; + test_case "shared ancestry allocation" `Quick test_shared_ancestry_allocation; test_case "inherited visibility" `Quick test_incremental_visibility; test_case "focus" `Quick test_focus_tracking ] ) ] From d70977cafaca191db5b9f1748ad6adbcd3b5b8d4 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 06:21:55 +0800 Subject: [PATCH 007/163] perf: share raster frame snapshots Share one operation and glyph snapshot across bounds, damage detection, and tile drawing. Preserve mutable effect parameters and cover scene mutations with pixel and allocation tests. --- platform/native/lui_raster/lui_raster.ml | 32 ++++++++++--------- .../native/lui_raster/test/lui_raster_test.ml | 29 +++++++++++++++-- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/platform/native/lui_raster/lui_raster.ml b/platform/native/lui_raster/lui_raster.ml index 221dfdfc..c836ad2d 100644 --- a/platform/native/lui_raster/lui_raster.ml +++ b/platform/native/lui_raster/lui_raster.ml @@ -1561,9 +1561,7 @@ let draw d dst s area bounds workers = (* ---------- op bounds ---------- *) (* Where each operation of s draws, within the clips around it. *) -let op_bounds s = - let ops = Array.of_list s.ops in - let glyphs = Array.of_list s.glyphs in +let op_bounds_arrays s ops glyphs = let n = Array.length ops in let out = Array.make n (irect 0 0 0 0) in let clip = ref (irect 0 0 s.width s.height) in @@ -1612,6 +1610,8 @@ let op_bounds s = done; out +let op_bounds s = op_bounds_arrays s (Array.of_list s.ops) (Array.of_list s.glyphs) + (* ---------- damage: what of the next scene to repaint ---------- *) (* The state of an atlas a scene drew from. *) @@ -1784,16 +1784,14 @@ let add_backdrops d s ops fxs = (* The damage between the last scene and s, and false when s must be drawn whole. *) -let diff d s = +let diff d s ops glyphs_b fxs_b = let mask_rects, ok = mark_changes d.mask_mark s.mask_atlas in if not ok then false else begin let color_rects, ok = mark_changes d.color_mark s.color_atlas in if not ok then false else begin - let ops = Array.of_list s.ops in - let glyphs_a = d.prev_glyphs and glyphs_b = Array.of_list s.glyphs in - let fxs_b = Array.of_list s.effects in + let glyphs_a = d.prev_glyphs in let same i j = op_same d.prev_effects d d.prev_ops i s ops j glyphs_a glyphs_b fxs_b mask_rects color_rects in @@ -1829,15 +1827,15 @@ let diff d s = end (* Keep what the next scene is compared with. *) -let remember d s = +let remember d s ops glyphs effects = d.valid <- true; d.dw <- s.width; d.dh <- s.height; d.dclear <- s.clear; - d.prev_ops <- Array.of_list s.ops; - d.prev_glyphs <- Array.of_list s.glyphs; + d.prev_ops <- ops; + d.prev_glyphs <- glyphs; d.prev_effects <- - Array.map (fun e -> { e with eparams = Array.copy e.eparams }) (Array.of_list s.effects); + Array.map (fun e -> { e with eparams = Array.copy e.eparams }) effects; let t = d.bounds in d.bounds <- d.next; d.next <- t; @@ -1864,14 +1862,18 @@ module Damage = struct (* s against the last scene: the rectangles of it that changed, a full-size one when it must be drawn whole. *) let update d s = - d.next <- op_bounds s; + (* Bounds, comparison, and drawing use one snapshot of this frame. + Effect parameters remain copied when remembered because they are mutable. *) + let ops = Array.of_list s.ops and glyphs = Array.of_list s.glyphs in + let effects = Array.of_list s.effects in + d.next <- op_bounds_arrays s ops glyphs; let whole = (not d.valid) || s.width <> d.dw || s.height <> d.dh || s.clear <> d.dclear - || not (diff d s) + || not (diff d s ops glyphs effects) in d.is_whole <- whole; if whole then d.damage <- [ irect 0 0 s.width s.height ]; - remember d s; + remember d s ops glyphs effects; d.damage end @@ -2082,7 +2084,7 @@ module Tile_renderer = struct invalid_arg "Tile rendering does not support shader effects"; let damage = Damage.update t.damage s in if damage <> [] && s.width > 0 && s.height > 0 then begin - let ops = Array.of_list s.ops and glyphs = Array.of_list s.glyphs in + let ops = t.damage.prev_ops and glyphs = t.damage.prev_glyphs in let bounds = Damage.bounds t.damage in let count = (s.height + band_height - 1) / band_height in let changed = Array.make count false in diff --git a/platform/native/lui_raster/test/lui_raster_test.ml b/platform/native/lui_raster/test/lui_raster_test.ml index 25d98dd6..4bff37f8 100644 --- a/platform/native/lui_raster/test/lui_raster_test.ml +++ b/platform/native/lui_raster/test/lui_raster_test.ml @@ -819,12 +819,36 @@ let test_glyph_damage_allocation () = let allocated = (Gc.allocated_bytes () -. before) /. 100. in Printf.printf "Retained glyph damage allocated %.0f bytes/frame\n%!" allocated; Alcotest.(check bool) - "Damage checks allocate per-glyph rectangles and closures" true - (allocated < 49_152.); + "Damage checks copy the same glyph snapshot repeatedly" true + (allocated < 16_384.); Alcotest.(check bool) "A changed panel forces the whole static body to redraw" false (Lui_raster.Damage.whole damage) +let test_mutable_scene_snapshots () = + let mask = Atlas.create ~bpp:1 ~w:8 ~h:16 in + Atlas.put mask ~x:0 ~y:0 ~w:8 ~h:16 ~src:(Bytes.make 128 '\128') ~stride:8; + let s = scene ~w:64 ~h:265 ~mask [] in + let output = Lui_raster.Image.create ~w:64 ~h:265 in + let paint,release = window_painter () in + Fun.protect ~finally:release (fun () -> + List.iteri (fun frame positions -> + s.glyphs <- List.map (fun (x,y) -> + glyph ~x ~y ~w:8. ~h:16. ~u:0 ~v:0 ~uw:8 ~vh:16 + (color (30*frame) 17 93 255)) positions; + s.ops <- [iclip (rect 1. 1. 61. 263.); iglyphs 0 (List.length positions); Pop_clip; + ifill (rect (float frame) 240. 8. 8.) black]; + if frame=2 then Atlas.put mask ~x:0 ~y:0 ~w:8 ~h:16 + ~src:(Bytes.make 128 '\255') ~stride:8; + paint s (fun (area : irect) (tile : Lui_raster.Image.t) -> + for y=area.y0 to area.y1-1 do + Bytes.blit tile.pix ((y-area.y0)*tile.stride) output.pix (y*output.stride) (4*s.width) + done); + let expected = Lui_raster.render ~scene:s () in + Alcotest.(check bool) "Changed scene lists and atlas pixels replace prior snapshots" true + (Bytes.equal output.pix expected.pix)) + [[2.,120.]; [19.,128.; 40.,250.]; [7.,-4.]; []; [31.,129.]]) + let test_glyph_damage_bounds () = let glyphs = List.map @@ -1037,6 +1061,7 @@ let () = Alcotest.test_case "merge cap" `Quick test_damage_merge; Alcotest.test_case "damage bands" `Slow test_damage_bands; Alcotest.test_case "glyph allocation" `Quick test_glyph_damage_allocation; + Alcotest.test_case "mutable scene snapshots" `Quick test_mutable_scene_snapshots; Alcotest.test_case "glyph bounds" `Quick test_glyph_damage_bounds; Alcotest.test_case "glyph atlas updates" `Quick test_glyph_atlas_damage ] ) ; ("window pixels", [ From 0e3d9517b6831787ffe68d0f29e8e4507c6f1349 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 08:27:11 +0800 Subject: [PATCH 008/163] fix: invalidate measurements when resolved edges change Discard obsolete measurement generations when percentage edges resolve against a new owner size. Cover retained resize history, fixed offers, unbounded owners, and unchanged measurement reuse. --- platform/native/lui_flex/lui_flex.ml | 39 ++++++------ .../native/lui_flex/test/lui_flex_test.ml | 60 ++++++++++++++++++- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/platform/native/lui_flex/lui_flex.ml b/platform/native/lui_flex/lui_flex.ml index fd12c032..03f23c50 100644 --- a/platform/native/lui_flex/lui_flex.ml +++ b/platform/native/lui_flex/lui_flex.ml @@ -587,6 +587,19 @@ let fresh_layout () : ilayout = { height = undefined; } +let clear_measurement_offers (layout : ilayout) = + Measurement_cache.clear layout.offer_cache; + layout.natural_cache <- None; + layout.natural_height <- None + +let invalidate_measurements (layout : ilayout) = + layout.next_cached_measurements_index <- 0; + layout.cached_layout.width_measure_mode <- Measure_invalid; + layout.cached_layout.height_measure_mode <- Measure_invalid; + layout.cached_layout.computed_width <- negativeOne; + layout.cached_layout.computed_height <- negativeOne; + clear_measurement_offers layout + let style_of_spec (s : style) : istyle = (* Scalar fields start unresolved; [resolve_specs] fills them in on every layout pass once the owning node's dimensions are known. @@ -1017,6 +1030,9 @@ let resolve_specs (n : node) ~owner_w ~owner_h ~owner_main : bool = n.res_owner_main <- owner_main; n.res_valid <- true; n.res_gen <- n.res_gen + 1; + (* Old offers and the measurement ring describe the previous resolved + edges, including when the offered dimensions themselves did not move. *) + invalidate_measurements n.layout; (* The stored position seeds derive from the resolved scalars just overwritten — re-seeding is mandatory before the clean lane may use them again. *) @@ -1511,23 +1527,12 @@ let rec layout_node_internal (node.is_dirty && layout.generation_count <> !g_current_generation_count) || layout.last_parent_direction <> Some parent_direction in - if need_to_visit_node then begin - (* Invalidate the cached results. *) - layout.next_cached_measurements_index <- 0; - layout.cached_layout.width_measure_mode <- Measure_invalid; - layout.cached_layout.height_measure_mode <- Measure_invalid; - layout.cached_layout.computed_width <- negativeOne; - layout.cached_layout.computed_height <- negativeOne - end; - (* Keep every distinct offer within a pass and retain a bounded cache - between passes. Dirty nodes discard all previous measurements. *) - if need_to_visit_node - || (layout.generation_count <> !g_current_generation_count - && Measurement_cache.length layout.offer_cache > 32) then begin - Measurement_cache.clear layout.offer_cache; - layout.natural_cache <- None; - layout.natural_height <- None - end; + (* A new resolution has already discarded its old offers. Dirty nodes + also invalidate the ring; unchanged nodes retain their bounded history. *) + if need_to_visit_node then invalidate_measurements layout + else if layout.generation_count <> !g_current_generation_count + && Measurement_cache.length layout.offer_cache > 32 then + clear_measurement_offers layout; let key_dim d = if Float.is_nan d then Float.nan else d in let offer = (key_dim available_width, key_dim available_height, width_measure_mode, height_measure_mode, node.res_gen) in diff --git a/platform/native/lui_flex/test/lui_flex_test.ml b/platform/native/lui_flex/test/lui_flex_test.ml index 1c682256..dbca20ec 100644 --- a/platform/native/lui_flex/test/lui_flex_test.ml +++ b/platform/native/lui_flex/test/lui_flex_test.ml @@ -575,6 +575,62 @@ let test_measure_fits_offer () = check_rect "new wrap width reshapes text" (r 0. 0. 60. 20.) child; Alcotest.(check bool) "changed wrap width was measured" true (!calls > before) +let test_percent_measurement_owner_change () = + List.iter (fun border -> + let calls=ref 0 in + let edges={default_style.padding with all=Percent 1.} in + let style=if border then {default_style with width=Pt 100.;border=edges} + else {default_style with width=Pt 100.;padding=edges} in + let measure height _ ~width:_ ~width_mode:_ ~height:_ ~height_mode:_ = + incr calls; {width=100.;height} in + let child=create ~style ~measure:(measure 24.) [] in + let root=box [child] in + let verify width height = + run root ~w:width ~h:undefined; + Alcotest.(check (float eps)) "resolved edges affect measured height" height (layout child).h; + let before= !calls in + run root ~w:width ~h:undefined; + Alcotest.(check int) "unchanged owner reuses measurements" before !calls in + verify 1000. 44.; + verify 1200. 48.; + verify 1000. 44.; + run root ~w:undefined ~h:undefined; + let before= !calls and geometry=layout child in + run root ~w:undefined ~h:undefined; + check_rect "unchanged unbounded owner keeps its geometry" geometry child; + Alcotest.(check int) "unbounded owner reuses measurements" before !calls; + set_measure child (Some (measure 40.)); + verify 1000. 60.; + verify 1200. 64. + ) [false;true] + +let test_percent_measurement_retention () = + let calls=ref 0 in + let measure _ ~width ~width_mode:_ ~height:_ ~height_mode:_ = + incr calls; + {width=(if Float.is_nan width then 100. else min 100. width);height=24.} in + let kids=List.init 2048 (fun _ -> create ~style:{default_style with + width=Percent 100.;padding={default_style.padding with all=Percent 1.}} ~measure []) in + let root=box kids in + let retained () = + Gc.full_major (); + Gc.full_major (); + let bytes=(Gc.stat ()).live_words*(Sys.word_size/8) in + ignore (Sys.opaque_identity root); + bytes in + run root ~w:1000. ~h:undefined; + let before=retained () in + for index=1 to 12 do run root ~w:(1000.+.float index*.20.) ~h:undefined done; + run root ~w:1000. ~h:undefined; + let growth=retained ()-before in + Printf.printf "Percentage layout retained %d extra bytes after resize\n%!" growth; + Alcotest.(check bool) "obsolete resolved offers remain bounded" true (growth<=262144); + Alcotest.(check (float eps)) "resized tree retains its measured content" + (2048.*.44.) (layout root).h; + let measured= !calls in + run root ~w:1000. ~h:undefined; + Alcotest.(check int) "unchanged resized tree avoids new measurement" measured !calls + let () = Alcotest.run "lui_flex" [ "axis", @@ -621,5 +677,7 @@ let () = Alcotest.test_case "nested owner measurements" `Quick test_nested_owner_measurements; Alcotest.test_case "nested height measurement budget" `Quick test_nested_height_measurement_budget; Alcotest.test_case "incremental block budget" `Quick test_incremental_block_budget; - Alcotest.test_case "fits_offer" `Quick test_measure_fits_offer ]; + Alcotest.test_case "fits_offer" `Quick test_measure_fits_offer; + Alcotest.test_case "percent owner invalidation" `Quick test_percent_measurement_owner_change; + Alcotest.test_case "percent cache retention" `Quick test_percent_measurement_retention ]; ] From d4736cad9b8fdf8ed836b430061ff7ef20d782e9 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 09:31:36 +0800 Subject: [PATCH 009/163] fix: preserve measurements across unrelated owner changes Track the owner axes used by percentage specs. Reuse unchanged measurement offers while preserving edge, position, gap, and constraint invalidation. --- platform/native/lui_flex/lui_flex.ml | 43 ++++---- .../native/lui_flex/test/lui_flex_test.ml | 98 ++++++++++++++++++- 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/platform/native/lui_flex/lui_flex.ml b/platform/native/lui_flex/lui_flex.ml index 03f23c50..63cd0e86 100644 --- a/platform/native/lui_flex/lui_flex.ml +++ b/platform/native/lui_flex/lui_flex.ml @@ -315,10 +315,10 @@ type node = { mutable res_margin_bottom : float; (* Resolved-spec fingerprint: owner inputs the current [style] scalars reflect. A percent-free spec resolves identically under - any owner dims, so [res_uses_owner] exempts it from the dims - compare. *) + any owner dims. [res_owner_axes] records only the scalar inputs + used by percentage specs: width=1, height=2, main axis=4. *) mutable res_valid : bool; - mutable res_uses_owner : bool; + mutable res_owner_axes : int; mutable res_owner_w : float; mutable res_owner_h : float; mutable res_owner_main : float; @@ -516,7 +516,7 @@ let rec null_node : node = { res_margin_right = zero; res_margin_bottom = zero; res_valid = false; - res_uses_owner = false; + res_owner_axes = 0; res_owner_w = zero; res_owner_h = zero; res_owner_main = zero; @@ -923,25 +923,34 @@ let uses_owner_edges (e : edges) : bool = || uses_owner_len e.horizontal || uses_owner_len e.vertical || uses_owner_len e.all +let spec_owner_axes (s : style) = + let position = s.position in + let width = + uses_owner_len s.width || uses_owner_len s.min_width + || uses_owner_len s.max_width || uses_owner_edges s.margin + || uses_owner_edges s.padding || uses_owner_edges s.border + || uses_owner_len position.all || uses_owner_len position.horizontal + || uses_owner_len position.left || uses_owner_len position.right + || uses_owner_len position.start || uses_owner_len position.end_ in + let height = + uses_owner_len s.height || uses_owner_len s.min_height + || uses_owner_len s.max_height || uses_owner_len position.vertical + || uses_owner_len position.top || uses_owner_len position.bottom in + (if width then 1 else 0) lor (if height then 2 else 0) + lor (if uses_owner_len s.flex_basis then 4 else 0) + let spec_uses_owner (s : style) : bool = - uses_owner_len s.flex_basis || uses_owner_len s.width - || uses_owner_len s.height || uses_owner_len s.min_width - || uses_owner_len s.min_height || uses_owner_len s.max_width - || uses_owner_len s.max_height || uses_owner_len s.row_gap - || uses_owner_len s.column_gap - || uses_owner_edges s.margin || uses_owner_edges s.padding - || uses_owner_edges s.border || uses_owner_edges s.position + spec_owner_axes s <> 0 || uses_owner_len s.row_gap || uses_owner_len s.column_gap (* Resolve a node's specs against the owner's inner dims. Returns [true] when the resolved scalars were (re)written, [false] when the previous resolution still applies — a percent-free spec never - needs re-resolution, a percent spec only when an owner dim moved. *) + needs re-resolution, a percent spec only when its owner axis moved. *) let resolve_specs (n : node) ~owner_w ~owner_h ~owner_main : bool = if n.res_valid - && ((not n.res_uses_owner) - || (same_dim n.res_owner_w owner_w - && same_dim n.res_owner_h owner_h - && same_dim n.res_owner_main owner_main)) + && (n.res_owner_axes land 1 = 0 || same_dim n.res_owner_w owner_w) + && (n.res_owner_axes land 2 = 0 || same_dim n.res_owner_h owner_h) + && (n.res_owner_axes land 4 = 0 || same_dim n.res_owner_main owner_main) then false else begin let s = n.spec in @@ -1024,7 +1033,7 @@ let resolve_specs (n : node) ~owner_w ~owner_h ~owner_main : bool = (if st.margin_auto_top then zero else computed_edge_margin st Top zero) +. (if st.margin_auto_bottom then zero else computed_edge_margin st Bottom zero); - n.res_uses_owner <- spec_uses_owner s; + n.res_owner_axes <- spec_owner_axes s; n.res_owner_w <- owner_w; n.res_owner_h <- owner_h; n.res_owner_main <- owner_main; diff --git a/platform/native/lui_flex/test/lui_flex_test.ml b/platform/native/lui_flex/test/lui_flex_test.ml index dbca20ec..546b55ac 100644 --- a/platform/native/lui_flex/test/lui_flex_test.ml +++ b/platform/native/lui_flex/test/lui_flex_test.ml @@ -631,6 +631,97 @@ let test_percent_measurement_retention () = run root ~w:1000. ~h:undefined; Alcotest.(check int) "unchanged resized tree avoids new measurement" measured !calls +let test_percent_owner_axis_reuse () = + let cases = + [ "width",Row,{default_style with width=Percent 50.;max_height=Pt 80.} + ; "padding",Row,{default_style with width=Pt 100.;max_height=Pt 80.;padding=edges_all (Percent 1.)} + ; "border",Row,{default_style with width=Pt 100.;max_height=Pt 80.;border=edges_all (Percent 1.)} + ; "margin",Row,{default_style with width=Pt 100.;max_height=Pt 80.;margin=edges_all (Percent 1.)} + ; "height",Column,{default_style with height=Percent 50.;max_width=Pt 80.} + ; "row basis",Row,{default_style with flex_basis=Percent 50.;max_height=Pt 80.} + ; "column basis",Column,{default_style with flex_basis=Percent 50.;max_width=Pt 80.} + ] in + List.iter (fun (name,axis,input_style) -> + let calls=ref 0 in + let child=create ~style:{input_style with flex_shrink=0.} + ~measure:(fun _ ~width ~width_mode:_ ~height:_ ~height_mode:_ -> + incr calls; {width=(if Float.is_nan width then 100. else min 100. width);height=24.}) [] in + let root=box ~style:{default_style with flex_direction=axis;align_items=Align_flex_start} [child] in + let resize cross = + let width,height=if axis=Row then 1000.,cross else cross,200. in + run root ~w:width ~h:height in + resize 200.; + let geometry=layout child and measured= !calls in + Alcotest.(check bool) (name ^ " has a measured intrinsic dimension") true (measured>0); + List.iter (fun cross -> + resize cross; + check_rect (name ^ " keeps geometry under irrelevant owner changes") geometry child; + Alcotest.(check int) (name ^ " reuses the same measurement offer") measured !calls) + [240.;280.;undefined;undefined;200.]) cases + +let test_percent_offer_constraints () = + let calls=ref 0 in + let child=create ~style:{default_style with width=Percent 50.;max_height=Pt 80.;flex_shrink=0.} + ~measure:(fun _ ~width ~width_mode:_ ~height ~height_mode:_ -> + incr calls; + let natural=if width<550. then 24. else 36. in + {width;height=(if Float.is_nan height then natural else min natural height)}) [] in + let root=box ~style:{default_style with flex_direction=Row;align_items=Align_flex_start} [child] in + run root ~w:1000. ~h:200.; + check_rect "initial intrinsic height" (r 0. 0. 500. 24.) child; + let measured= !calls in + run root ~w:1200. ~h:200.; + check_rect "relevant owner width changes wrapping" (r 0. 0. 600. 36.) child; + Alcotest.(check bool) "relevant width reshapes text" true (!calls>measured); + let widened= !calls in + run root ~w:1200. ~h:10.; + check_rect "a tighter actual offer constrains content" (r 0. 0. 600. 10.) child; + Alcotest.(check bool) "actual height constraints still remeasure" true (!calls>widened) + +let test_percent_position_axes () = + let child position=create ~style:{default_style with position_type=Absolute; + width=Pt 10.;height=Pt 10.;position} [] in + let both=child {edges_auto with left=Percent 10.;top=Percent 10.} + and far=child {edges_auto with right=Percent 10.;bottom=Percent 10.} + and all=child (edges_all (Percent 10.)) + and vertical=child {edges_auto with vertical=Percent 10.} in + let root=box [both;far;all;vertical] in + List.iter (fun (width,height) -> + run root ~w:width ~h:height; + check_rect "physical position axes" (r (width*.0.1) (height*.0.1) 10. 10.) both; + check_rect "far position axes" (r (width*.0.9-.10.) (height*.0.9-.10.) 10. 10.) far; + check_rect "all-position shorthand uses owner width" (r (width*.0.1) (width*.0.1) 10. 10.) all; + check_rect "vertical-position shorthand uses owner height" (r 0. (height*.0.1) 10. 10.) vertical) + [1000.,200.;1000.,300.;1200.,300.] + +let test_percent_gap_axes () = + List.iter (fun axis -> + let first=leaf ~style:{default_style with width=Pt 10.;height=Pt 10.} () + and second=leaf ~style:{default_style with width=Pt 10.;height=Pt 10.} () in + let root=box ~style:{default_style with flex_direction=axis; + row_gap=Percent 10.;column_gap=Percent 10.} [first;second] in + List.iter (fun extent -> + run root ~w:extent ~h:extent; + let offset=10.+.extent*.0.1 in + check_rect "percentage gap uses the container content box" + (if axis=Row then r offset 0. 10. 10. else r 0. offset 10. 10.) second) + [100.;200.]) [Row;Column] + +let test_percent_owner_axis_allocation () = + let calls=ref 0 in + let children=List.init 2048 (fun _ -> + create ~style:{default_style with width=Percent 50.;max_height=Pt 80.;flex_shrink=0.} + ~measure:(fun _ ~width ~width_mode:_ ~height:_ ~height_mode:_ -> + incr calls; {width;height=24.}) []) in + let root=box ~style:{default_style with flex_direction=Row;align_items=Align_flex_start} children in + run root ~w:1000. ~h:200.; + let measured= !calls and before=Gc.allocated_bytes () in + for index=1 to 12 do run root ~w:1000. ~h:(200.+.float index*.20.) done; + let allocated=Gc.allocated_bytes ()-.before in + Printf.printf "Irrelevant owner resizing allocated %.0f bytes; measured %d extra leaves\n%!" + allocated (!calls-measured); + Alcotest.(check int) "large unchanged offers reuse measured leaves" measured !calls + let () = Alcotest.run "lui_flex" [ "axis", @@ -679,5 +770,10 @@ let () = Alcotest.test_case "incremental block budget" `Quick test_incremental_block_budget; Alcotest.test_case "fits_offer" `Quick test_measure_fits_offer; Alcotest.test_case "percent owner invalidation" `Quick test_percent_measurement_owner_change; - Alcotest.test_case "percent cache retention" `Quick test_percent_measurement_retention ]; + Alcotest.test_case "percent cache retention" `Quick test_percent_measurement_retention; + Alcotest.test_case "percent owner axes" `Quick test_percent_owner_axis_reuse; + Alcotest.test_case "percent offer constraints" `Quick test_percent_offer_constraints; + Alcotest.test_case "percent position axes" `Quick test_percent_position_axes; + Alcotest.test_case "percent gap axes" `Quick test_percent_gap_axes; + Alcotest.test_case "percent owner allocation" `Quick test_percent_owner_axis_allocation ]; ] From 6aa0fed13c8ecbc8511c04d93ef7b2b3f7d271b8 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 11:40:41 +0800 Subject: [PATCH 010/163] fix: reuse unchanged raster prefix bounds Reuse clipped bounds after the unchanged prefix comparison. Preserve clip traversal for the changed tail and cover allocation limits, atlas updates, resize, and pixel parity. --- platform/native/lui_raster/lui_raster.ml | 59 +++++++++------- .../native/lui_raster/test/lui_raster_test.ml | 69 +++++++++++++++++++ 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/platform/native/lui_raster/lui_raster.ml b/platform/native/lui_raster/lui_raster.ml index c836ad2d..406b9d8f 100644 --- a/platform/native/lui_raster/lui_raster.ml +++ b/platform/native/lui_raster/lui_raster.ml @@ -1561,7 +1561,7 @@ let draw d dst s area bounds workers = (* ---------- op bounds ---------- *) (* Where each operation of s draws, within the clips around it. *) -let op_bounds_arrays s ops glyphs = +let op_bounds_arrays ~prefix ~previous s ops glyphs = let n = Array.length ops in let out = Array.make n (irect 0 0 0 0) in let clip = ref (irect 0 0 s.width s.height) in @@ -1569,6 +1569,20 @@ let op_bounds_arrays s ops glyphs = for i = 0 to n - 1 do let b = match ops.(i) with + | Push_clip f -> + (* Even a retained prefix must establish clipping for the changed tail. *) + let b = outset f.crect 1. in + stack := !clip :: !stack; + clip := irect_intersect !clip b; + b + | Pop_clip -> ( + match !stack with + | c :: rest -> + clip := c; + stack := rest; + irect 0 0 0 0 + | [] -> irect 0 0 0 0) + | _ when i < prefix -> previous.(i) | Fill f -> outset f.frect 1. | Image f -> outset f.irect2 1. | Effect f -> outset f.edrect 1. @@ -1592,25 +1606,14 @@ let op_bounds_arrays s ops glyphs = end done; irect !x0 !y0 !x1 !y1 - | Push_clip f -> - (* A clip that changes changes everything it cuts. *) - let b = outset f.crect 1. in - stack := !clip :: !stack; - clip := irect_intersect !clip b; - b - | Pop_clip -> ( - match !stack with - | c :: rest -> - clip := c; - stack := rest; - irect 0 0 0 0 - | [] -> irect 0 0 0 0) in - out.(i) <- irect_intersect b !clip + out.(i) <- if i < prefix then previous.(i) else irect_intersect b !clip done; out -let op_bounds s = op_bounds_arrays s (Array.of_list s.ops) (Array.of_list s.glyphs) +let op_bounds s = + op_bounds_arrays ~prefix:0 ~previous:[||] s + (Array.of_list s.ops) (Array.of_list s.glyphs) (* ---------- damage: what of the next scene to repaint ---------- *) @@ -1782,14 +1785,13 @@ let add_backdrops d s ops fxs = done end -(* The damage between the last scene and s, and false when s must be - drawn whole. *) +(* Compare a compatible frame, or return None when its atlas history expired. *) let diff d s ops glyphs_b fxs_b = let mask_rects, ok = mark_changes d.mask_mark s.mask_atlas in - if not ok then false + if not ok then None else begin let color_rects, ok = mark_changes d.color_mark s.color_atlas in - if not ok then false + if not ok then None else begin let glyphs_a = d.prev_glyphs in let same i j = @@ -1800,6 +1802,7 @@ let diff d s ops glyphs_b fxs_b = while !pre < n && !pre < m && same !pre !pre do incr pre done; + d.next <- op_bounds_arrays ~prefix:!pre ~previous:d.bounds s ops glyphs_b; let suf = ref 0 in while !suf < n - !pre && !suf < m - !pre && same (n - 1 - !suf) (m - 1 - !suf) do incr suf @@ -1822,7 +1825,7 @@ let diff d s ops glyphs_b fxs_b = end; add_backdrops d s ops fxs_b; (* Past half the window, drawing it whole costs about the same. *) - area_sum d.damage * 2 < s.width * s.height + Some (area_sum d.damage * 2 < s.width * s.height) end end @@ -1866,10 +1869,16 @@ module Damage = struct Effect parameters remain copied when remembered because they are mutable. *) let ops = Array.of_list s.ops and glyphs = Array.of_list s.glyphs in let effects = Array.of_list s.effects in - d.next <- op_bounds_arrays s ops glyphs; - let whole = - (not d.valid) || s.width <> d.dw || s.height <> d.dh || s.clear <> d.dclear - || not (diff d s ops glyphs effects) + let partial = + if d.valid && s.width = d.dw && s.height = d.dh && s.clear = d.dclear then + diff d s ops glyphs effects + else None + in + let whole = match partial with + | Some partial -> not partial + | None -> + d.next <- op_bounds_arrays ~prefix:0 ~previous:[||] s ops glyphs; + true in d.is_whole <- whole; if whole then d.damage <- [ irect 0 0 s.width s.height ]; diff --git a/platform/native/lui_raster/test/lui_raster_test.ml b/platform/native/lui_raster/test/lui_raster_test.ml index 4bff37f8..94796afb 100644 --- a/platform/native/lui_raster/test/lui_raster_test.ml +++ b/platform/native/lui_raster/test/lui_raster_test.ml @@ -825,6 +825,73 @@ let test_glyph_damage_allocation () = "A changed panel forces the whole static body to redraw" false (Lui_raster.Damage.whole damage) +let test_prefix_bounds_allocation () = + let body = List.init 1000 (fun i -> + ifill (rect (float (i mod 40) *. 10.) (float (i / 40) *. 20.) 8. 16.) black) in + let mask, color_atlas = atlases () in + let scenes = Array.init 31 (fun n -> + scene ~w:512 ~h:640 ~mask ~color_atlas + (iclip (rect 0. 0. 512. 640.) :: body @ + [ifill (rect 450. 8. 20. 16.) (color n 0 0 255); Pop_clip])) in + let damage = Lui_raster.Damage.create () in + ignore (Lui_raster.Damage.update damage scenes.(0)); + let before = Gc.allocated_bytes () in + for frame = 1 to 100 do + ignore (Lui_raster.Damage.update damage scenes.(frame mod Array.length scenes)) + done; + let allocated = (Gc.allocated_bytes () -. before) /. 100. in + Printf.printf "Unchanged prefix bounds allocated %.0f bytes/frame\n%!" allocated; + Alcotest.(check bool) "Unchanged body bounds stay below 40 KiB per frame" true + (allocated < 40_960.); + Alcotest.(check bool) "Only the changed overlay needs repainting" false + (Lui_raster.Damage.whole damage) + +let test_prefix_bounds_pixels () = + let mask = Atlas.create ~bpp:1 ~w:8 ~h:16 in + let put_mask value = Atlas.put mask ~x:0 ~y:0 ~w:8 ~h:16 + ~src:(Bytes.make 128 value) ~stride:8 in + put_mask '\128'; + let color_atlas = Atlas.create ~bpp:4 ~w:8 ~h:16 in + let damage = Lui_raster.Damage.create () in + let renderer = Lui_raster.Renderer.create () in + let glyph_at x = glyph ~x ~y:12. ~w:8. ~h:16. + ~u:0 ~v:0 ~uw:8 ~vh:16 black in + let body = ifill (rect 2. 3. 22. 27.) (color 37 99 235 255) in + let tail n = ifill (rect (float n) 39. 10. 8.) black in + let frame ?(w=64) ?(clear=white) ?(x=13.) clip ops = + scene ~w ~h:64 ~mask ~color_atlas ~clear ~glyphs:[glyph_at x] + (iclip clip :: body :: iclip (rect 9. 8. 31. 30.) :: iglyphs 0 1 :: + Pop_clip :: ops @ [Pop_clip]) in + let clip = rect 1. 1. 55. 55. in + let steps = [ + frame clip [tail 30]; + frame clip [tail 31]; + frame (rect 5. 5. 22. 39.) [tail 32]; + frame ~x:23. clip [tail 33]; + frame clip [tail 34; tail 45]; + frame clip []; + frame ~w:72 clip [tail 35]; + frame ~w:72 ~clear:(color 19 31 73 255) clip [tail 36]; + ] in + let verify s = + ignore (Lui_raster.Damage.update damage s); + let fresh = Lui_raster.Damage.create () in + ignore (Lui_raster.Damage.update fresh s); + Alcotest.(check bool) "Retained bounds equal a fresh clipped traversal" true + (Lui_raster.Damage.bounds damage = Lui_raster.Damage.bounds fresh); + ignore (Lui_raster.Renderer.render renderer s); + let expected = Lui_raster.render ~scene:s () in + Alcotest.(check bool) "Partial repaint equals a full render" true + (Bytes.equal (Lui_raster.Renderer.image renderer).pix expected.pix) + in + List.iter verify steps; + verify (List.hd steps); + put_mask '\255'; + verify (List.hd steps); + Lui_raster.Damage.invalidate damage; + Lui_raster.Renderer.invalidate renderer; + verify (List.hd steps) + let test_mutable_scene_snapshots () = let mask = Atlas.create ~bpp:1 ~w:8 ~h:16 in Atlas.put mask ~x:0 ~y:0 ~w:8 ~h:16 ~src:(Bytes.make 128 '\128') ~stride:8; @@ -1061,6 +1128,8 @@ let () = Alcotest.test_case "merge cap" `Quick test_damage_merge; Alcotest.test_case "damage bands" `Slow test_damage_bands; Alcotest.test_case "glyph allocation" `Quick test_glyph_damage_allocation; + Alcotest.test_case "prefix bounds allocation" `Quick test_prefix_bounds_allocation; + Alcotest.test_case "prefix bounds pixels" `Quick test_prefix_bounds_pixels; Alcotest.test_case "mutable scene snapshots" `Quick test_mutable_scene_snapshots; Alcotest.test_case "glyph bounds" `Quick test_glyph_damage_bounds; Alcotest.test_case "glyph atlas updates" `Quick test_glyph_atlas_damage ] ) From c5ce3f1eeb3c2bdc0f15ec40953c867fcea6cc62 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 11:51:26 +0800 Subject: [PATCH 011/163] fix: avoid unused opaque glyph color allocation Construct floating tint only for general glyph blending and compare gamma components without a temporary tuple. Add an allocation regression for integer mask text. --- platform/native/lui_raster/lui_raster.ml | 10 ++++++---- platform/native/lui_raster/test/lui_raster_test.ml | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/platform/native/lui_raster/lui_raster.ml b/platform/native/lui_raster/lui_raster.ml index 406b9d8f..9b30b008 100644 --- a/platform/native/lui_raster/lui_raster.ml +++ b/platform/native/lui_raster/lui_raster.ml @@ -1066,15 +1066,16 @@ let r_glyphs r (op : glyphs_op) s glyphs = let x0 = Int.max gx r.rx0 and y0 = Int.max gy r.ry0 in let x1 = Int.min (gx + g.guw) r.rx1 and y1 = Int.min (gy + g.gvh) r.ry1 in if x0 < x1 && y0 < y1 then begin - let tint0 = premul g.gcolor 1. in - let alpha = float g.gcolor.a /. 255. in let contrast = if g.gsubpixel then text.subpixel_contrast else text.contrast in let boost = if g.gthin then thin_boost else 0. in let (g0, g1, g2, g3) = text.gamma_ratios in - let correct = contrast <> 0. || boost <> 0. || (g0, g1, g2, g3) <> (0., 0., 0., 0.) in + let correct = contrast <> 0. || boost <> 0. + || g0 <> 0. || g1 <> 0. || g2 <> 0. || g3 <> 0. in if (not g.gcolored) && (not g.gsubpixel) && Option.is_none pt && (not correct) && g.gcolor.a = 255 then mask_opaque r atlas g gx gy x0 y0 x1 y1 - else + else begin + let tint0 = premul g.gcolor 1. in + let alpha = float g.gcolor.a /. 255. in for y = y0 to y1 - 1 do let row = (y - r.origin_y) * r.dst.Image.stride in let clip_index = clip_row r y in @@ -1131,6 +1132,7 @@ let r_glyphs r (op : glyphs_op) s glyphs = end done done + end end done diff --git a/platform/native/lui_raster/test/lui_raster_test.ml b/platform/native/lui_raster/test/lui_raster_test.ml index 94796afb..387868e0 100644 --- a/platform/native/lui_raster/test/lui_raster_test.ml +++ b/platform/native/lui_raster/test/lui_raster_test.ml @@ -998,6 +998,18 @@ let redraw_allocation scene = Lui_raster.Renderer.release renderer; allocated +let test_opaque_glyph_allocation () = + let mask = Atlas.create ~bpp:1 ~w:1 ~h:1 in + Atlas.put mask ~x:0 ~y:0 ~w:1 ~h:1 ~src:(Bytes.make 1 '\128') ~stride:1; + let glyphs = List.init 1000 (fun i -> + glyph ~x:(float (i mod 40) *. 2.) ~y:(float (i / 40) *. 2.) + ~w:1. ~h:1. ~u:0 ~v:0 ~uw:1 ~vh:1 (color 37 99 235 255)) in + let s = scene ~w:80 ~h:50 ~mask ~glyphs [iglyphs 0 1000] in + let allocated = redraw_allocation s in + Printf.printf "Opaque glyph redraw allocated %.0f bytes/frame\n%!" allocated; + Alcotest.(check bool) "Integer mask text stays below 32 KiB per frame" true + (allocated <= 32_768.) + let test_shared_clip_rows_allocation () = let mask=Atlas.create ~bpp:1 ~w:8 ~h:16 in Atlas.put mask ~x:0 ~y:0 ~w:8 ~h:16 ~src:(Bytes.make 128 '\128') ~stride:8; @@ -1149,6 +1161,7 @@ let () = Alcotest.test_case "translucent rounding" `Slow test_translucent_byte_rounding; Alcotest.test_case "span allocation" `Quick test_translucent_span_allocation; Alcotest.test_case "opaque span edges" `Quick test_opaque_span_edges; + Alcotest.test_case "opaque glyph allocation" `Quick test_opaque_glyph_allocation; Alcotest.test_case "translucent span channels" `Quick test_translucent_span_channels; Alcotest.test_case "image sampling allocation" `Quick test_image_sampling_allocation; Alcotest.test_case "empty inset allocation" `Quick test_empty_inset_allocation; From 973ebd895aedd1bb66dbcf9637b36fd24b32e3ca Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 12:31:20 +0800 Subject: [PATCH 012/163] fix: traverse restyled child positions once Pass consecutive produced positions through batch restyling instead of rescanning sibling prefixes for each child. Cover linear allocation growth and transparent, hidden, and reordered child expansions. --- platform/native/lui_layout/lui_layout.ml | 34 +++++++---- .../native/lui_layout/test/lui_layout_test.ml | 61 +++++++++++++++++++ 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index de82c73b..be4590f8 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -2152,10 +2152,9 @@ let produced_index ctx o g = (* 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 = +let restyle_gen_at ctx o g index = 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 @@ -2189,6 +2188,22 @@ let restyle_gen ctx o g = register ctx g kind f style) | None -> () +let restyle_gen ctx o g = + restyle_gen_at ctx o g (produced_index ctx o g) + +(* An expansion occupies consecutive positions in its flex owner's produced + children, including when transparent raw parents lift those children. *) +let iter_produced_indices ctx o entries f = + let rec first_index = function + | [] -> 0 + | {gens=[]; _} :: rest -> first_index rest + | {gens=g::_; _} :: _ -> produced_index ctx o g + in + let index = ref (first_index entries) in + List.iter (fun e -> List.iter (fun g -> + f g !index; + incr index) e.gens) entries + (* Parent kinds whose child adjustment depends on sibling position: membership or order churn under them rewrites sibling styles, not just the moved node's. *) @@ -2198,9 +2213,7 @@ let index_sensitive = function | _ -> 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) + iter_produced_indices ctx o (pkids_of ctx o) (restyle_gen_at ctx o) (* Insert/MoveChild: detach the shadow entry from the old raw parent, ensure the child's produced set exists, then let [sync_entries] @@ -2286,14 +2299,9 @@ 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); + iter_produced_indices ctx o (pkids_of ctx id) (fun g index -> + Hashtbl.replace ctx.owner g o; + restyle_gen_at ctx o g index); mark_affected ctx o (* A journal op landed on a live virtual list or inside one: rows diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index 9b057074..d02d13cf 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -605,6 +605,65 @@ let check_equiv msg store t = expect.w expect.h got) (Lui_store.preorder store) +let test_child_restyle_allocation () = + let allocation count = + let store = Lui_store.create () in + apply store ([CreateNode (1, Root); CreateNode (2, Grid); + InsertChild (1, 2, 0); SetProp (2, GridColumns, IntValue 2)] @ + List.concat_map (fun i -> [CreateNode (i+10, Text); + InsertChild (2, i+10, i); SetProp (i+10, HeightValue, IntValue 10)]) + (List.init count Fun.id)); + let layout = Lui_layout.create () in + let sync () = Lui_layout.sync ~width:200. ~height:100. layout store in + sync (); + let change columns = apply store [SetProp (2, GridColumns, IntValue columns)]; sync () in + change 3; change 2; + let before = Gc.allocated_bytes () in + for step=1 to 4 do change (if step mod 2=0 then 2 else 3) done; + let bytes = (Gc.allocated_bytes () -. before) /. 4. in + check bool "Restyling retains incremental replay" true (Lui_layout.stat_steps layout >= 6); + check_some_rect "First grid cell keeps its width" 0. 0. 100. 10. + (Lui_layout.rect layout 10); + bytes + in + let small = allocation 64 and large = allocation 1024 in + Printf.printf "Child restyle allocation: 64=%.0f, 1024=%.0f bytes/update\n%!" small large; + check bool "Restyling 16 times as many children exceeds 1.3 times linear allocation" true + (large <= small *. 16. *. 1.3) + +let test_child_restyle_produced_order () = + let store = Lui_store.create () in + apply store [CreateNode (1, Root); CreateNode (2, Overlay); + CreateNode (3, Text); CreateNode (4, Box); CreateNode (5, Text); + CreateNode (6, Box); CreateNode (7, Text); CreateNode (8, Box); + CreateNode (9, Text); CreateNode (10, Text); + InsertChild (1,2,0); InsertChild (2,3,0); InsertChild (2,4,1); + InsertChild (4,5,0); InsertChild (4,6,1); InsertChild (6,7,0); + InsertChild (2,8,2); InsertChild (8,9,0); InsertChild (2,10,3); + SetProp (4,DisplayValue,StringValue "contents"); + SetProp (6,DisplayValue,StringValue "contents"); + SetProp (8,DisplayValue,StringValue "none"); + SetProp (3,WidthValue,IntValue 40); SetProp (3,HeightValue,IntValue 20); + SetProp (5,HeightValue,IntValue 10); SetProp (7,HeightValue,IntValue 15); + SetProp (9,HeightValue,IntValue 30); SetProp (10,HeightValue,IntValue 25)]; + let layout = Lui_layout.create () in + let verify ops = + apply store ops; + Lui_layout.sync ~width:200. ~height:100. layout store; + check_equiv "Produced child restyle" store layout + in + List.iter verify [ []; + [SetProp (4,ProgressValue,IntValue 1)]; + [SetProp (6,ProgressValue,IntValue 2)]; + [MoveChild (2,4,0)]; + [MoveChild (4,5,1)]; + [SetProp (8,DisplayValue,StringValue "contents"); MoveChild (2,8,0)]; + [SetProp (4,ProgressValue,IntValue 3)]; + [SetProp (4,DisplayValue,StringValue "none")]; + [RemoveProp (4,DisplayValue)]; + [SetProp (4,DisplayValue,StringValue "contents")]; + [SetProp (2,ProgressValue,IntValue 4)] ] + (* A journal replay through moves, toggles, drops and prop churn keeps the mirror identical to a rebuild at every step. *) let test_incremental_equivalence () = @@ -978,6 +1037,8 @@ let () = test_case "transient links before disposal" `Quick test_transient_links_before_disposal; test_case "nested disposal purges geometry" `Quick test_nested_disposal_purges_geometry; test_case "fractional ancestor offsets" `Quick test_fractional_ancestor_offsets; + test_case "child restyle allocation" `Quick test_child_restyle_allocation; + test_case "child restyle produced order" `Quick test_child_restyle_produced_order; test_case "deep rect refresh budget" `Quick test_deep_rect_refresh_budget; test_case "incremental large tree" `Quick test_incremental_large_tree ]) ] From 5f11c3ad69dd6f7ff3cdd0424497d9c9d54cae39 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 13:41:42 +0800 Subject: [PATCH 013/163] feat: handle native split divider dragging Capture divider pointer input, mirror scaled pane geometry immediately, and dispatch resize values through the application. Cover nested tracks, release coordinates, clamping, modal admission, and removed nodes. --- platform/native/lui_input/lui_pointer.ml | 25 +++++- platform/native/lui_window/lui_window.ml | 49 +++++++++++- .../native/lui_window/test/lui_window_test.ml | 77 +++++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) diff --git a/platform/native/lui_input/lui_pointer.ml b/platform/native/lui_input/lui_pointer.ml index f56f1bfc..784223b7 100644 --- a/platform/native/lui_input/lui_pointer.ml +++ b/platform/native/lui_input/lui_pointer.ml @@ -5,7 +5,10 @@ open Lui_protocol +type split_resize = { node : int; origin : float; fraction : float; span : float } + type state = { + resize : split_resize option; x : float; (* last pointer position, logical px *) y : float; hovered : int; (* topmost hit node under the pointer *) @@ -15,7 +18,7 @@ type state = { } let initial = - { x = 0.; y = 0.; hovered = 0; detail = 0; pressed = 0; + { resize = None; x = 0.; y = 0.; hovered = 0; detail = 0; pressed = 0; press_detail = 0 } let position st = (st.x, st.y) @@ -25,6 +28,24 @@ let pressed st = st.pressed let press_detail st = st.press_detail let note_pos st ~x ~y = { st with x; y } +let resizing st = Option.is_some st.resize + +let start_resize st ~node ~x ~fraction ~span = + if not (Float.is_finite span) || span <= 0. then + invalid_arg "Split resize requires a positive finite track"; + { st with pressed = 0; press_detail = 0; + resize = Some {node; origin=x; fraction; span} } + +let resize store st ~x = + match st.resize with + | None -> st,None + | Some drag when not (Lui_store.mem store drag.node) + || not (Lui_query.node_enabled store drag.node) -> {st with resize=None},None + | Some drag -> + let value = max 0. (min 1. (drag.fraction +. (x -. drag.origin) /. drag.span)) in + if Lui_store.float_prop store drag.node "value" = Some value then st,None + else st,Some (drag.node,value) + (* Hover move: update position, hovered node and the PointerEnter target, emitting Leave/Enter on the boundary. *) let move store st ~path ~x ~y = @@ -123,4 +144,4 @@ let up store st ~x ~y ~button ~mods = (* Release press capture without emitting — after click handling, or on a completed/cancelled drag. *) -let clear_press st = { st with pressed = 0; press_detail = 0 } +let clear_press st = { st with pressed = 0; press_detail = 0; resize = None } diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index cc416543..05ad2aea 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -888,16 +888,45 @@ module Ui = struct let store_changed t store = sync t store + let resizing_split t = Lui_pointer.resizing t.pointer + + let split_handle t store rects id ~x ~y = + Lui_store.kind store id = "split" && Lui_query.node_enabled store id + && Option.is_some (Lui_store.float_prop store id "value") + && match Lui_store.child_ids store id with + | first :: second :: _ -> + let a=Rects.get rects first and b=Rects.get rects second in + let px=x*.t.scale and py=y*.t.scale in + a.w >= t.scale && b.w >= t.scale + && px >= a.x+.a.w && px <= b.x + && py >= max a.y b.y && py <= min (a.y+.a.h) (b.y+.b.h) + | _ -> false + + let split_target t store rects path ~x ~y = + if List.exists (Lui_query.presented_overlay store) path then None + else List.find_opt (fun id -> split_handle t store rects id ~x ~y) path + let handle ?spatial t store rects input = let acts = ref [] in let emit_all l = acts := List.rev_append l !acts in let run m_acts = emit_all (perform_all t store m_acts) in + let resize_split x = + let pointer,update=Lui_pointer.resize store t.pointer ~x in + t.pointer <- pointer; + match update with + | None -> () + | Some (id,value) -> + Lui_store.apply_op store (SetProp (id,ProgressValue,FloatValue value)); + t.dirty <- true; + emit_all [Dispatch (ValueChanged (id,value))] + in emit_all (sync t store); (match input with | Input.Move (x, y) -> t.pointer <- Lui_pointer.note_pos t.pointer ~x ~y; let dx, dy = x *. t.scale, y *. t.scale in - if Lui_drag.active t.drag then begin + if resizing_split t then resize_split x + else if Lui_drag.active t.drag then begin (* live drop-target tracking while a drag is in flight *) let path = hit_path ?spatial store rects ~x:dx ~y:dy in let d, m = Lui_drag.track store t.drag ~path in @@ -962,6 +991,18 @@ module Ui = struct | ov', `Proceed dismiss -> t.overlay <- ov'; emit_all (perform_all t store dismiss); + match split_target t store rects path ~x ~y with + | Some id -> + let first,second=match Lui_store.child_ids store id with + | first :: second :: _ -> Rects.get rects first,Rects.get rects second + | _ -> assert false in + let span=(second.x+.second.w-.first.x)/.t.scale in + let fraction=Option.get (Lui_store.float_prop store id "value") in + t.pointer <- Lui_pointer.start_resize t.pointer ~node:id ~x ~fraction ~span; + t.drag <- Lui_drag.clear_arm t.drag; + t.selection <- Lui_selection.clear_drag t.selection; + t.dirty <- true + | None -> let focus = match Lui_query.focus_target store path with | Some id -> id @@ -1026,6 +1067,12 @@ module Ui = struct | Input.Button_up (x, y, btn, mods) -> t.pointer <- Lui_pointer.note_pos t.pointer ~x ~y; (match btn with + | Input.Left when resizing_split t -> + resize_split x; + t.pointer <- Lui_pointer.clear_press t.pointer; + t.drag <- Lui_drag.clear_arm t.drag; + t.selection <- Lui_selection.clear_drag t.selection; + t.dirty <- true | Input.Left when Lui_drag.active t.drag -> (* the drop completes on the accepting target under the pointer; the click it rode on is consumed *) diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index 3b6730d2..67010ad8 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2731,6 +2731,79 @@ let test_idle_scrollbar_allocations () = let bytes = Gc.allocated_bytes () -. before in Alcotest.(check bool) "Hidden scrollbar polling exceeds 16 bytes per poll" true (bytes < 160000.) +let split_fixture () = + let store = store_of [CreateNode (1,Split);CreateNode (2,Column);CreateNode (3,Column); + SetProp (1,ProgressValue,FloatValue 0.25);SetProp (1,Gap,IntValue 3); + InsertChild (1,2,0);InsertChild (1,3,1)] in + let rects = Rects.create () in + List.iter (fun (id,rect) -> Hashtbl.add rects id rect) + [1,rect 50. 40. 1000. 600.;2,rect 50. 40. 250. 600.;3,rect 306. 40. 744. 600.]; + let ui=Ui.create () in + Ui.set_scale ui 2.; + store,rects,ui + +let split_fraction store id = Option.get (Lui_store.float_prop store id "value") +let resized id acts = List.find_map (function + | Dispatch (ValueChanged (node,value)) when node=id -> Some value + | _ -> None) acts + +let test_split_pointer_resize () = + let store,rects,ui=split_fixture () in + let input event=Ui.handle ui store rects event in + ignore (input (Input.Move (151.5,100.))); + Alcotest.(check (float 0.00001)) "hover does not resize" 0.25 (split_fraction store 1); + ignore (input (Input.Button_down (151.5,100.,Input.Left,1,Input.mods_none))); + Alcotest.(check (float 0.00001)) "grabbing the divider does not jump" 0.25 (split_fraction store 1); + let acts=input (Input.Move (201.5,100.)) in + Alcotest.(check (option (float 0.00001))) "drag dispatches the new fraction" (Some 0.35) (resized 1 acts); + Alcotest.(check (float 0.00001)) "drag mirrors geometry before application feedback" 0.35 (split_fraction store 1); + ignore (input (Input.Move (-500.,-100.))); + Alcotest.(check (float 0.00001)) "captured drag clamps at the first endpoint" 0. (split_fraction store 1); + ignore (input (Input.Move (2000.,1000.))); + Alcotest.(check (float 0.00001)) "captured drag clamps at the second endpoint" 1. (split_fraction store 1); + let acts=input (Input.Button_up (226.5,100.,Input.Left,Input.mods_none)) in + Alcotest.(check (option (float 0.00001))) "release uses its final pointer coordinate" (Some 0.4) (resized 1 acts); + ignore (input (Input.Move (500.,100.))); + Alcotest.(check (float 0.00001)) "release ends resize capture" 0.4 (split_fraction store 1) + +let test_split_pointer_admission () = + let store,rects,ui=split_fixture () in + let input event=Ui.handle ui store rects event in + let down button x=ignore (input (Input.Button_down (x,100.,button,1,Input.mods_none))) in + let up button x=ignore (input (Input.Button_up (x,100.,button,Input.mods_none))) in + List.iter (fun (button,x) -> down button x;ignore (input (Input.Move (201.5,100.)));up button 201.5) + [Input.Right,151.5;Input.Middle,151.5;Input.Left,100.]; + Alcotest.(check (float 0.00001)) "other buttons and pane content do not resize" 0.25 (split_fraction store 1); + Lui_store.apply_op store (SetProp (1,Enabled,BoolValue false)); + down Input.Left 151.5;ignore (input (Input.Move (201.5,100.)));up Input.Left 201.5; + Alcotest.(check (float 0.00001)) "disabled split cannot capture a drag" 0.25 (split_fraction store 1); + Lui_store.apply_op store (SetProp (1,Enabled,BoolValue true)); + Lui_store.apply_op store (CreateNode (9,Dialog)); + Hashtbl.add rects 9 (rect 250. 40. 200. 600.); + down Input.Left 151.5;ignore (input (Input.Move (201.5,100.)));up Input.Left 201.5; + Alcotest.(check (float 0.00001)) "modal overlay masks a divider behind it" 0.25 (split_fraction store 1); + Lui_store.apply_op store (DropNode 9); + down Input.Left 151.5; + Lui_store.apply_op store (DropNode 1); + Alcotest.(check (option (float 0.00001))) "removed split emits no captured resize" None + (resized 1 (input (Input.Move (201.5,100.)))); + up Input.Left 201.5 + +let test_nested_split_pointer_resize () = + let store,rects,ui=split_fixture () in + List.iter (Lui_store.apply_op store) + [DropNode 3;CreateNode (3,Split);SetProp (3,ProgressValue,FloatValue 0.5); + CreateNode (5,Column);CreateNode (6,Column);InsertChild (1,3,1); + InsertChild (3,5,0);InsertChild (3,6,1)]; + Hashtbl.replace rects 3 (rect 306. 40. 744. 600.); + Hashtbl.add rects 5 (rect 306. 40. 400. 600.); + Hashtbl.add rects 6 (rect 712. 40. 338. 600.); + ignore (Ui.handle ui store rects (Input.Button_down (354.5,100.,Input.Left,1,Input.mods_none))); + let acts=Ui.handle ui store rects (Input.Move (364.5,100.)) in + Alcotest.(check (option (float 0.00001))) "innermost divider uses its own scaled track" (Some (0.5+.20./.744.)) + (resized 3 acts); + Alcotest.(check (float 0.00001)) "nested resize leaves the outer split unchanged" 0.25 (split_fraction store 1) + let () = Alcotest.run "lui_window" [ ("config", [ Alcotest.test_case "parse_args" `Quick test_parse_args ]); @@ -2809,6 +2882,10 @@ let () = ( "clipboard", [ Alcotest.test_case "cut/copy/paste" `Quick test_clipboard_cycle; Alcotest.test_case "undo/redo" `Quick test_undo_redo ] ); + ( "split resize", + [ Alcotest.test_case "pointer feedback" `Quick test_split_pointer_resize; + Alcotest.test_case "capture admission" `Quick test_split_pointer_admission; + Alcotest.test_case "nested scaled track" `Quick test_nested_split_pointer_resize ] ); ( "keys", [ Alcotest.test_case "tab activation" `Quick test_tab_activation; Alcotest.test_case "control keys" `Quick test_control_keys ] ); From ff82ad644ed521e520d9715bf68a4a31bbedf05b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 13:48:39 +0800 Subject: [PATCH 014/163] feat: expose native split resize state Expose divider hit testing and capture state for host cursor feedback. --- platform/native/lui_window/lui_window.mli | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/platform/native/lui_window/lui_window.mli b/platform/native/lui_window/lui_window.mli index e16ef2cc..a839ca1f 100644 --- a/platform/native/lui_window/lui_window.mli +++ b/platform/native/lui_window/lui_window.mli @@ -271,6 +271,14 @@ module Ui : sig (** A data-attrs drag session is in flight. *) val drag_active : t -> bool + (** A split divider owns the current pointer capture. *) + val resizing_split : t -> bool + + (** Whether logical pointer coordinates lie on this split's divider. + [rects] contains device-pixel pane bounds at the current scale. *) + val split_handle : + t -> Lui_store.t -> Rects.t -> int -> x:float -> y:float -> bool + (** Topmost overlay-kind node currently tracked (0 = none). *) val overlay_top : t -> int From a05db507b114c10e2b42fbb133a414799563e47a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 13:52:01 +0800 Subject: [PATCH 015/163] fix: flip native popups at viewport edges Choose the opposite anchor side when it provides more room, then clamp remaining overflow. Cover all four directions, preferred-side stability, oversized panels, and display scaling. --- platform/native/lui_layout/lui_layout.ml | 16 ++++++--- .../native/lui_layout/test/lui_layout_test.ml | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index be4590f8..a888184f 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -1255,11 +1255,17 @@ let anchored_xy ctx store sid ar (prp : Lui_flex.rect) = in let vertical = side = "above" || side = "below" in let main = - match side with - | "above" -> ar.y -. offset -. prp.h - | "left" -> ar.x -. offset -. prp.w - | "right" -> ar.x +. ar.w +. offset - | _ -> ar.y +. ar.h +. offset + let start, finish, extent, viewport = + if vertical then ar.y, ar.y +. ar.h, prp.h, ctx.vh + else ar.x, ar.x +. ar.w, prp.w, ctx.vw in + let before = start -. offset and after = viewport -. finish -. offset in + let preferred_before = side = "above" || side = "left" in + let preferred, opposite = + if preferred_before then before, after else after, before in + let place_before = + if extent > preferred && opposite > preferred + then not preferred_before else preferred_before in + if place_before then start -. offset -. extent else finish +. offset in let cross = if vertical then diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index d02d13cf..d2b24f3e 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -303,6 +303,39 @@ let test_root_popup_centered_in_frame () = let p = Lui_layout.run store ~measure:no_measure ~width:200. ~height:100. in check_rect "root popup" 75. 35. 50. 30. (place p 2) +let test_popup_collision_flip () = + let store = Lui_store.create () in + apply store + [ CreateNode (1, Root); CreateNode (2, Popover); + InsertChild (1, 2, 0); + SetProp (2, WidthValue, IntValue 60); + SetProp (2, HeightValue, IntValue 40); + SetProp (2, AnchorOffset, FloatValue 4.) ]; + let check_at ~scale side x y expected_x expected_y = + apply store [SetProp (2, AnchorValue, StringValue side); + SetProp (2, PopupX, FloatValue x); SetProp (2, PopupY, FloatValue y)]; + let p = Lui_layout.run ~scale store ~measure:no_measure + ~width:(200. *. scale) ~height:(160. *. scale) in + check_rect (side ^ " popup") (expected_x *. scale) (expected_y *. scale) + (60. *. scale) (40. *. scale) (place p 2) + in + List.iter (fun scale -> + check_at ~scale "below" 80. 140. 80. 96.; + check_at ~scale "above" 80. 20. 80. 24.; + check_at ~scale "right" 180. 60. 116. 60.; + check_at ~scale "left" 20. 60. 24. 60.; + check_at ~scale "below" 80. 40. 80. 44.; + check_at ~scale "above" 80. 100. 80. 56.; + check_at ~scale "right" 80. 60. 84. 60.; + check_at ~scale "left" 100. 60. 36. 60.; + (* Cross-axis overflow shifts without changing the preferred side. *) + check_at ~scale "below" 190. 40. 140. 44.) [1.; 2.]; + (* Neither side fits: use the side with more room, then clamp. *) + apply store [SetProp (2, HeightValue, IntValue 140); + SetProp (2, PopupY, FloatValue 100.)]; + let p = Lui_layout.run store ~measure:no_measure ~width:200. ~height:160. in + check_rect "oversized popup stays in frame" 140. 0. 60. 140. (place p 2) + (* A menu-trigger's dropdown panel: hidden until opened, then anchored below the trigger with its items stacked inside. *) let test_menu_panel () = @@ -1013,6 +1046,7 @@ let () = 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; + test_case "popup collision flip" `Quick test_popup_collision_flip; test_case "menu panel" `Quick test_menu_panel; test_case "ac row" `Quick test_ac_row; test_case "vlist window" `Quick test_vlist_window_mounts_slice; From 6969adb94a8f65e83e63bad35cc3c6fc6fabbea0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 14:11:53 +0800 Subject: [PATCH 016/163] fix: preserve inset content in native scroll ranges Measure scroll extents from the viewport origin so padded content remains reachable on both axes. Verify final edges after scrolling through the shared native assembly. --- platform/native/lui_window/lui_window.ml | 10 +++---- .../native/lui_window/test/lui_window_test.ml | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 05ad2aea..acc7acb5 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -2331,8 +2331,8 @@ module Assembly = struct with | Some ext -> Float.max ext 0.001 | None -> - let _, top, _, bot = content_box id in - Float.max (bot -. top) 0.001 + let _, _, _, bot = content_box id in + Float.max (bot -. cr.y) 0.001 in Some Lui_paint. @@ -2563,11 +2563,11 @@ module Assembly = struct (Float.max 0. (ext -. cr.Lui_scene.h)); Ui.set_scroll_cap_x ui id 0. | None -> - let l, t, rgt, b = content_box id in + let _, _, rgt, b = content_box id in Ui.set_scroll_cap ui id - (Float.max 0. (b -. t -. cr.Lui_scene.h)); + (Float.max 0. (b -. cr.Lui_scene.y -. cr.Lui_scene.h)); Ui.set_scroll_cap_x ui id - (Float.max 0. (rgt -. l -. cr.Lui_scene.w))) + (Float.max 0. (rgt -. cr.Lui_scene.x -. cr.Lui_scene.w))) sids; (* Rects were just rebuilt — visible-range memos key on the epoch. *) diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index 67010ad8..cd5b83b3 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2633,6 +2633,35 @@ let test_assembly_scroll_shows_in_paint () = in Alcotest.(check bool) "scrollbar ops" true (scroll_ops > plain_ops) +let test_assembly_inset_scroll_caps () = + let module Inset_layout = struct + include Fake_layout + let sync ?scale:_ ?measure:_ ?hidden:_ ?ctx_anchor:_ ?vport:_ + ~width:_ ~height:_ t _store = + Hashtbl.reset t; + List.iter (fun (id,r) -> Hashtbl.add t id r) + [1,Lui_scene.rect 0. 0. 200. 200.;2,Lui_scene.rect 20. 30. 100. 100.; + 3,Lui_scene.rect 28. 38. 180. 200.] + end in + let asm=Assembly.create ~layout:(module Inset_layout) + ~scale_cell:(ref 1.) ~text:assembly_text ~renderer:assembly_renderer + ~width:200 ~height:200 () in + Lui_store.apply_batch (Assembly.store asm) {generation=1;ops= + [CreateNode (1,Root);CreateNode (2,Scroll);CreateNode (3,Box); + InsertChild (1,2,0);InsertChild (2,3,0)]}; + ignore (Assembly.repaint asm); + let ui=Assembly.ui asm in + Alcotest.(check (float 0.001)) "vertical inset remains reachable" 108. + (Ui.scroll_cap ui 2); + Alcotest.(check (float 0.001)) "horizontal inset remains reachable" 88. + (Ui.scroll_cap_x ui 2); + ignore (Ui.scroll_to ui (Assembly.store asm) 2 1000.); + ignore (Ui.scroll_to ~axis:X ui (Assembly.store asm) 2 1000.); + ignore (Assembly.repaint asm); + let last=Rects.get (Assembly.rects asm) 3 in + Alcotest.(check (float 0.001)) "last content bottom is visible" 130. (last.y+.last.h); + Alcotest.(check (float 0.001)) "last content right is visible" 120. (last.x+.last.w) + let test_assembly_autofocus () = let asm = assembly_of @@ -2922,6 +2951,7 @@ let () = ( "assembly", [ Alcotest.test_case "scroll caps" `Quick test_assembly_scroll_caps; + Alcotest.test_case "inset scroll caps" `Quick test_assembly_inset_scroll_caps; Alcotest.test_case "scrollbar paint" `Quick test_assembly_scroll_shows_in_paint; Alcotest.test_case "autofocus" `Quick test_assembly_autofocus ] ); From 47675b744801e6e8310fc848baa51dcb11c4a1d1 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 14:27:01 +0800 Subject: [PATCH 017/163] fix: anchor nested popups after ancestor placement Resolve viewport collisions using final ancestor positions before the final flex pass. Cover menus inside centered dialogs across display scales and window resizing. --- platform/native/lui_layout/lui_layout.ml | 30 +++++++++++++++-- .../native/lui_layout/test/lui_layout_test.ml | 33 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index a888184f..f1ced2a2 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -1295,6 +1295,29 @@ let refine_popups ctx = (* Bottom y of the last placed toast per containing block, so a viewport's toasts stack downward like the web viewport. *) let toast_next = Hashtbl.create 2 in + (* Ancestor surfaces are positioned before the final flex pass. Resolve + descendant anchors in that final coordinate space, especially when a + viewport collision clamps a menu inside a newly centered dialog. *) + let placed = Hashtbl.create 4 in + let final_rect sid node = + match Hashtbl.find_opt placed sid with + | Some r -> r + | None -> + let r = Lui_flex.absolute_layout node in + let rec displacement id = + match Hashtbl.find_opt ctx.owner id with + | None -> 0.,0. + | Some parent when parent = frame_id -> 0.,0. + | Some parent -> + match Hashtbl.find_opt placed parent with + | None -> displacement parent + | Some target -> + let before = Lui_flex.absolute_layout (Hashtbl.find ctx.nodes parent) in + target.Lui_flex.x -. before.x, target.y -. before.y + in + let dx,dy = displacement sid in + {r with Lui_flex.x=r.x+.dx; y=r.y+.dy} + in List.iter (fun (pnode, sid) -> match Lui_flex.parent pnode with @@ -1310,7 +1333,7 @@ let refine_popups ctx = let pr, pstyle_opt = match psid_opt with | Some psid -> - ( Lui_flex.absolute_layout parent, + ( final_rect psid parent, Hashtbl.find_opt ctx.applied psid ) | None -> (* The frame root is no store node's containing block — @@ -1345,7 +1368,7 @@ let refine_popups ctx = | Some (a : Lui_flex.style) when a.position_type = Lui_flex.Absolute -> ( match Hashtbl.find_opt ctx.nodes id with - | Some n -> Some (Lui_flex.absolute_layout n) + | Some n -> Some (final_rect id n) | None -> None) | _ -> ( match Lui_store.parent ctx.store id with @@ -1485,6 +1508,9 @@ let refine_popups ctx = | h -> h) | _ -> Pt prp.h in + Hashtbl.replace placed sid + {prp with Lui_flex.x=pr.x+.bor_l+.ml+.left; + y=pr.y+.bor_t+.mt+.top}; Lui_flex.set_style pnode { pstyle with width = Pt prp.w; diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index d2b24f3e..f4f39025 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -336,6 +336,38 @@ let test_popup_collision_flip () = let p = Lui_layout.run store ~measure:no_measure ~width:200. ~height:160. in check_rect "oversized popup stays in frame" 140. 0. 60. 140. (place p 2) +let test_popup_inside_centered_dialog () = + List.iter (fun scale -> + let store=Lui_store.create () and layout=Lui_layout.create () in + apply store [CreateNode (1,Root);CreateNode (2,Dialog);CreateNode (3,Column); + CreateNode (4,Spacer);CreateNode (5,Box);InsertChild (1,2,0); + InsertChild (2,3,0);InsertChild (3,4,0);InsertChild (3,5,1); + SetProp (2,WidthValue,IntValue 600);SetProp (2,HeightValue,IntValue 400); + SetProp (2,PaddingValue,IntValue 24);SetProp (4,HeightValue,IntValue 100); + SetProp (5,WidthValue,IntValue 200);SetProp (5,HeightValue,IntValue 32)]; + let sync width height = Lui_layout.sync ~scale ~width:(width*.scale) + ~height:(height*.scale) layout store in + sync 800. 600.; + apply store [CreateNode (6,DropdownMenu);InsertChild (5,6,0); + SetProp (6,WidthValue,IntValue 180);SetProp (6,HeightValue,IntValue 120); + SetProp (6,AnchorValue,StringValue "below")]; + sync 800. 600.; + let rect id=Option.get (Lui_layout.rect layout id) in + let anchor=rect 5 and popup=rect 6 in + check (float eps) "nested popup follows final trigger x" anchor.x popup.x; + check (float eps) "nested popup follows final trigger y" + (anchor.y+.anchor.h+.4.*.scale) popup.y; + apply store [SetProp (6,HeightValue,IntValue 500)]; + sync 800. 600.; + let popup=rect 6 in + check bool "nested tall popup stays in viewport" true + (popup.y>=0. && popup.y+.popup.h<=600.*.scale+.eps); + sync 640. 480.; + let popup=rect 6 in + check bool "nested popup remains bounded after resize" true + (popup.x>=0. && popup.y>=0. && popup.x+.popup.w<=640.*.scale+.eps + && popup.y+.popup.h<=480.*.scale+.eps)) [1.;2.] + (* A menu-trigger's dropdown panel: hidden until opened, then anchored below the trigger with its items stacked inside. *) let test_menu_panel () = @@ -1047,6 +1079,7 @@ let () = test_case "popup centered" `Quick test_popup_default_centered; test_case "root popup" `Quick test_root_popup_centered_in_frame; test_case "popup collision flip" `Quick test_popup_collision_flip; + test_case "popup in centered dialog" `Quick test_popup_inside_centered_dialog; test_case "menu panel" `Quick test_menu_panel; test_case "ac row" `Quick test_ac_row; test_case "vlist window" `Quick test_vlist_window_mounts_slice; From ceb2fad9b6ea66f459ed8d30d1c11c129f755441 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 14:32:10 +0800 Subject: [PATCH 018/163] fix: place parent popups before nested surfaces Normalize full-rebuild and incremental registration order without reordering unrelated surfaces. Verify an open nested menu remains in bounds after rebuilding. --- platform/native/lui_layout/lui_layout.ml | 33 +++++++++++++++---- .../native/lui_layout/test/lui_layout_test.ml | 6 ++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index f1ced2a2..2eb2570d 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -1318,8 +1318,7 @@ let refine_popups ctx = let dx,dy = displacement sid in {r with Lui_flex.x=r.x+.dx; y=r.y+.dy} in - List.iter - (fun (pnode, sid) -> + let refine (pnode, sid) = match Lui_flex.parent pnode with | None -> () | Some parent -> ( @@ -1516,10 +1515,32 @@ let refine_popups ctx = width = Pt prp.w; height; position = - { pstyle.position with left = Pt left; top = Pt top } })) - (* Mount order: the list is prepended during build; oldest first so - a toast stack grows downward like the web viewport. *) - (List.rev !(ctx.popups)) + { pstyle.position with left = Pt left; top = Pt top } }) + in + let ordered = List.rev !(ctx.popups) in + let pending = Hashtbl.create (List.length ordered) + and visited = Hashtbl.create (List.length ordered) in + List.iter (fun ((_,id) as popup) -> Hashtbl.add pending id popup) ordered; + let rec visit ((_,sid) as popup) = + if not (Hashtbl.mem visited sid) then begin + let rec ancestor id = + match Hashtbl.find_opt ctx.owner id with + | None -> () + | Some parent when parent = frame_id -> () + | Some parent -> + (match Hashtbl.find_opt pending parent with + | Some popup -> visit popup + | None -> ancestor parent) + in + ancestor sid; + Hashtbl.add visited sid (); + refine popup + end + in + (* Full rebuilds register children before parents; incremental mounts + can do the reverse. Respect ancestor dependencies in both cases, + preserving registration order for unrelated surfaces and toasts. *) + List.iter visit ordered (* Union of the frame children's absolute rect edges — the extent the laid-out content actually occupies. *) diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index f4f39025..82e62765 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -365,6 +365,12 @@ let test_popup_inside_centered_dialog () = sync 640. 480.; let popup=rect 6 in check bool "nested popup remains bounded after resize" true + (popup.x>=0. && popup.y>=0. && popup.x+.popup.w<=640.*.scale+.eps + && popup.y+.popup.h<=480.*.scale+.eps); + Lui_layout.invalidate layout; + sync 640. 480.; + let popup=rect 6 in + check bool "nested popup remains bounded after rebuilding the open tree" true (popup.x>=0. && popup.y>=0. && popup.x+.popup.w<=640.*.scale+.eps && popup.y+.popup.h<=480.*.scale+.eps)) [1.;2.] From e30fed0d7d893136eea403e7163df834a9815214 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 14:50:31 +0800 Subject: [PATCH 019/163] fix: read live native scroll state in retained callbacks Keep stored offset and range readers attached to the current scroll state. Cover both axes, range updates, clamping, and reversal. --- platform/native/lui_window/lui_window.ml | 9 +++--- .../native/lui_window/test/lui_window_test.ml | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index acc7acb5..05f790ff 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -615,11 +615,12 @@ module Ui = struct type axis = X | Y let conv_axis = function X -> Lui_scroll.X | Y -> Lui_scroll.Y - let scroll_offset t = Lui_scroll.offset t.scroll Lui_scroll.Y + (* Retained callbacks read the current state when supplied an id. *) + let scroll_offset t id = Lui_scroll.offset t.scroll Lui_scroll.Y id let scroll_epoch t = Lui_scroll.epoch t.scroll - let scroll_cap t = Lui_scroll.cap t.scroll Lui_scroll.Y - let scroll_offset_x t = Lui_scroll.offset t.scroll Lui_scroll.X - let scroll_cap_x t = Lui_scroll.cap t.scroll Lui_scroll.X + let scroll_cap t id = Lui_scroll.cap t.scroll Lui_scroll.Y id + let scroll_offset_x t id = Lui_scroll.offset t.scroll Lui_scroll.X id + let scroll_cap_x t id = Lui_scroll.cap t.scroll Lui_scroll.X id let tick_scrollbars t ~now_ms = if not (Float.is_finite now_ms) || now_ms < t.scroll_clock_ms then diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index cd5b83b3..73ee706b 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -742,6 +742,35 @@ let test_scroll () = Ui.set_scroll_cap ui 2 30.; Alcotest.(check (float 0.001)) "cap shrink" 0. (Ui.scroll_offset ui 2) +let test_live_scroll_readers () = + let store = + store_of + [ CreateNode (1, Root); CreateNode (2, Scroll); InsertChild (1, 2, 0) ] + in + let ui = Ui.create () in + (* Drivers retain these callbacks before the first layout and wheel. *) + let offset_y = Ui.scroll_offset ui and offset_x = Ui.scroll_offset_x ui in + let cap_y = Ui.scroll_cap ui and cap_x = Ui.scroll_cap_x ui in + List.iter + (fun (axis, set_cap, offset, cap) -> + List.iter + (fun range -> + set_cap ui 2 range; + Alcotest.(check (float 0.001)) + "retained reader sees new range" range (cap 2); + List.iter + (fun target -> + ignore (Ui.scroll_to ~axis ui store 2 target); + Alcotest.(check (float 0.001)) + "retained reader sees movement and reversal" + (Float.min range target) (offset 2)) + [ 0.; 44.; 200.; 12.; 0. ]) + [ 80.; 30.; 0. ]) + [ + (Ui.Y, Ui.set_scroll_cap, offset_y, cap_y); + (Ui.X, Ui.set_scroll_cap_x, offset_x, cap_x); + ] + let test_wheel_x () = (* a scroll container with horizontal range: wheel-x deltas and shift+wheel move the x offset; the y offset is untouched *) @@ -2853,6 +2882,7 @@ let () = ( "events", [ Alcotest.test_case "idle allocations" `Quick test_idle_sync_allocations; Alcotest.test_case "idle scrollbar allocations" `Quick test_idle_scrollbar_allocations; + Alcotest.test_case "live scroll readers" `Quick test_live_scroll_readers; Alcotest.test_case "click" `Quick test_click_press; Alcotest.test_case "release elsewhere" `Quick test_click_release_elsewhere; From 82e6b74c9998a0bf8f4bafd8226b90922c8a5d06 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 15:49:30 +0800 Subject: [PATCH 020/163] fix: preserve native surface motion and split geometry Animate promoted surface lifecycles with inert exit snapshots. Keep dependent split dragging immediate and enforce pane minimum widths against the available track. Add motion, geometry, pointer, and raster budget regressions. --- platform/native/lui_host/lui_host.ml | 9 +- platform/native/lui_host/lui_host.mli | 9 +- .../native/lui_host/test/lui_host_test.ml | 137 ++++++++++- platform/native/lui_layout/lui_layout.ml | 6 +- .../native/lui_layout/test/lui_layout_test.ml | 32 +++ platform/native/lui_paint/lui_retained.ml | 230 +++++++++++++++++- .../lui_paint/test/lui_retained_test.ml | 54 ++++ .../native/lui_raster/test/lui_raster_test.ml | 53 ++++ platform/native/lui_window/lui_window.ml | 21 +- .../native/lui_window/test/lui_window_test.ml | 45 ++++ src/lui_elements.ml | 3 +- src/lui_elements.mli | 1 + 12 files changed, 585 insertions(+), 15 deletions(-) diff --git a/platform/native/lui_host/lui_host.ml b/platform/native/lui_host/lui_host.ml index db79eeff..28505f09 100644 --- a/platform/native/lui_host/lui_host.ml +++ b/platform/native/lui_host/lui_host.ml @@ -165,6 +165,7 @@ let create ~hooks ?layout ?(measure = no_measure) ~renderer ~width ~height ~scal let store h = h.store let scene h = h.scene let wants_repaint h = h.dirty || Hashtbl.length h.transitions > 0 + || Lui_retained.animating h.retained let set_hidden h pred = h.hidden <- (match pred with Some p -> p | None -> fun _ -> false); @@ -273,6 +274,7 @@ let tick h ~now = | Some previous when now < previous -> invalid_arg "Native animation clock moved backwards" | _ -> ()); h.animation_time <- Some now; + if Lui_retained.tick h.retained ~now then h.dirty <- true; if Hashtbl.length h.transitions > 0 then Hashtbl.filter_map_inplace (fun id motion -> if not (Lui_store.mem h.store id) then None @@ -286,6 +288,8 @@ let tick h ~now = end) h.transitions let set_reduced_motion h enabled = + if h.reduced_motion <> enabled then h.dirty <- true; + Lui_retained.set_reduced_motion h.retained enabled; h.reduced_motion <- enabled; if enabled && Hashtbl.length h.transitions > 0 then begin Hashtbl.iter (fun id motion -> @@ -293,7 +297,7 @@ let set_reduced_motion h enabled = Hashtbl.clear h.transitions end -let apply_batch h (batch : Lui_protocol.patch_batch) = +let apply_batch ?(animate_resize=true) h (batch : Lui_protocol.patch_batch) = let previous = Hashtbl.create 4 and created = Hashtbl.create 4 in List.iter (function | Lui_protocol.CreateNode (id,_) | CreateExtension (id,_,_) @@ -303,6 +307,7 @@ let apply_batch h (batch : Lui_protocol.patch_batch) = Hashtbl.remove h.transitions id | SetProp (id, ProgressValue, _) when not (Hashtbl.mem created id) && Lui_store.kind h.store id = "split" -> + if not animate_resize then Hashtbl.remove h.transitions id; if not (Hashtbl.mem previous id) then (match Lui_store.prop h.store id "value" with | Some (FloatValue value) -> Hashtbl.add previous id value @@ -315,7 +320,7 @@ let apply_batch h (batch : Lui_protocol.patch_batch) = Hashtbl.iter (fun id from_value -> match h.animation_time, Lui_store.prop h.store id "value" with | Some started, Some (Lui_protocol.FloatValue target) - when not h.reduced_motion && from_value <> target -> + when animate_resize && not h.reduced_motion && from_value <> target -> let duration = Option.value ~default:0 (Lui_store.int_prop h.store id "resize-duration") in if duration > 0 then begin let easing = Option.value ~default:"standard" diff --git a/platform/native/lui_host/lui_host.mli b/platform/native/lui_host/lui_host.mli index 89255a23..b895f15f 100644 --- a/platform/native/lui_host/lui_host.mli +++ b/platform/native/lui_host/lui_host.mli @@ -64,6 +64,11 @@ end type t val tick : t -> now:float -> unit +(** Advance the monotonic presentation clock before input and repaint. + Promoted surfaces fade and zoom in over 150 ms (200 ms for modal + surfaces); dismissal retains only an inert display-list snapshot until + exit finishes. Split transitions share this clock. *) + val set_reduced_motion : t -> bool -> unit val create : @@ -149,7 +154,9 @@ val invalidate_paint : t -> unit {!note_paint} for inputs that moved globally (a theme swap under a stable store). *) -val apply_batch : t -> Lui_protocol.patch_batch -> bool +val apply_batch : ?animate_resize:bool -> t -> Lui_protocol.patch_batch -> bool +(** [animate_resize=false] applies direct manipulation feedback, including + dependent split corrections, without scheduling eased catch-up. *) val resize : t -> width:int -> height:int -> scale:float -> unit diff --git a/platform/native/lui_host/test/lui_host_test.ml b/platform/native/lui_host/test/lui_host_test.ml index 053f81a1..aa8b7a0b 100644 --- a/platform/native/lui_host/test/lui_host_test.ml +++ b/platform/native/lui_host/test/lui_host_test.ml @@ -196,9 +196,144 @@ let test_split_reduced_motion_and_drag () = SetProp (1, ProgressValue, FloatValue 0.4)])); check (float 0.00001) "zero duration updates immediately" 0.4 (split_value h) +let popup_fill h = + List.find_map + (function + | Lui_scene.Fill fill when fill.fcolor = Lui_scene.color 255 0 0 255 -> + Some fill + | _ -> None) + (Lui_host.scene h).Lui_scene.ops + +let popup_alpha h = + match popup_fill h with Some fill -> fill.fopacity | None -> 0. + +let test_popup_motion () = + List.iter + (fun kind -> + let h = popup_host () in + ignore (Lui_host.apply_batch h (batch [ DropNode 5 ])); + (* Recreate the surface to exercise each native presentation kind. *) + ignore + (Lui_host.apply_batch h + (batch + [ + CreateNode (3, kind); + InsertChild (1, 3, 1); + CreateNode (4, Box); + InsertChild (3, 4, 0); + SetProp (4, BackgroundValue, StringValue "#ff0000"); + ])); + Lui_host.tick h ~now:10.; + ignore (Lui_host.repaint h); + check (float 0.00001) "surface starts transparent" 0. (popup_alpha h); + check bool "entrance schedules frames" true (Lui_host.wants_repaint h); + Lui_host.tick h ~now:10.075; + ignore (Lui_host.repaint h); + let alpha = popup_alpha h in + check bool "surface fades during entrance" true (alpha > 0. && alpha < 1.); + (match popup_fill h with + | Some fill -> + check bool "surface zoom stays near final geometry" true + (fill.frect.w > 22.8 && fill.frect.w < 24.) + | None -> fail "entering surface has no visual shell"); + Lui_host.tick h ~now:10.25; + ignore (Lui_host.repaint h); + check (float 0.00001) "entrance settles exactly" 1. (popup_alpha h); + check bool "settled surface leaves host idle" false + (Lui_host.wants_repaint h); + ignore (Lui_host.apply_batch h (batch [ DetachSubtree 3 ])); + ignore (Lui_host.repaint h); + check bool "exit does not retain interactive store nodes" false + (Lui_store.mem (Lui_host.store h) 3); + check (float 0.00001) "exit keeps its initial visual shell" 1. + (popup_alpha h); + Lui_host.tick h ~now:10.325; + ignore (Lui_host.repaint h); + check bool "removed surface fades out" true + (popup_alpha h > 0. && popup_alpha h < 1.); + Lui_host.tick h ~now:10.5; + ignore (Lui_host.repaint h); + check bool "exit releases visual shell" true (popup_fill h = None); + check bool "exit leaves no scheduled frames" false + (Lui_host.wants_repaint h)) + [ Popover; DropdownMenu; ContextMenu; Dialog ] + +let test_popup_motion_reversal () = + let h = popup_host () in + Lui_host.tick h ~now:0.; + ignore (Lui_host.repaint h); + Lui_host.tick h ~now:0.08; + ignore (Lui_host.repaint h); + let before = popup_alpha h in + Lui_host.set_hidden h (Some (fun id -> id = 3)); + ignore (Lui_host.repaint h); + check (float 0.00001) "closing starts from displayed opacity" before + (popup_alpha h); + Lui_host.tick h ~now:0.12; + ignore (Lui_host.repaint h); + let closing = popup_alpha h in + check bool "interrupted entrance fades down" true + (closing > 0. && closing < before); + Lui_host.set_hidden h None; + ignore (Lui_host.repaint h); + check (float 0.00001) "reopen reverses without flashing" closing + (popup_alpha h); + Lui_host.resize h ~width:100 ~height:80 ~scale:1.; + Lui_host.tick h ~now:0.16; + ignore (Lui_host.repaint h); + check bool "resizing preserves motion progress" true (popup_alpha h > closing); + Lui_host.tick h ~now:1.; + ignore (Lui_host.repaint h); + check (float 0.00001) "reversal finishes opaque" 1. (popup_alpha h); + check bool "reversal settles" false (Lui_host.wants_repaint h) + +let test_popup_motion_reduced () = + let h = popup_host () in + Lui_host.tick h ~now:0.; + ignore (Lui_host.repaint h); + Lui_host.tick h ~now:0.05; + ignore (Lui_host.repaint h); + Lui_host.set_reduced_motion h true; + ignore (Lui_host.repaint h); + check (float 0.00001) "reduced motion completes entrance" 1. (popup_alpha h); + check bool "reduced motion stops frames" false (Lui_host.wants_repaint h); + Lui_host.set_hidden h (Some (fun id -> id = 3)); + ignore (Lui_host.repaint h); + check bool "reduced motion removes shell immediately" true + (popup_fill h = None); + Lui_host.set_hidden h None; + ignore (Lui_host.repaint h); + check (float 0.00001) "reduced motion opens immediately" 1. (popup_alpha h) + +let test_popup_reused_id () = + let h = popup_host () in + Lui_host.tick h ~now:0.; + ignore (Lui_host.repaint h); + Lui_host.tick h ~now:1.; + ignore (Lui_host.repaint h); + ignore (Lui_host.apply_batch h (batch [ DetachSubtree 3 ])); + ignore (Lui_host.repaint h); + ignore + (Lui_host.apply_batch h + (batch + [ + CreateNode (3, Popover); + InsertChild (1, 3, 1); + CreateNode (4, Box); + InsertChild (3, 4, 0); + SetProp (4, BackgroundValue, StringValue "#ff0000"); + ])); + ignore (Lui_host.repaint h); + check (float 0.00001) "reused id starts a fresh presentation" 0. + (popup_alpha h) + let () = run "lui_host" - [ ("motion", [test_case "split transition" `Quick test_split_transition; + [ ("motion", [test_case "popup enter and exit" `Quick test_popup_motion; + test_case "popup reversal and resize" `Quick test_popup_motion_reversal; + test_case "popup reduced motion" `Quick test_popup_motion_reduced; + test_case "popup reused id" `Quick test_popup_reused_id; + test_case "split transition" `Quick test_split_transition; test_case "split reversal and removal" `Quick test_split_reversal_and_removal; test_case "reduced motion and drag" `Quick test_split_reduced_motion_and_drag]); ( "dirty", diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index 2eb2570d..8514ca58 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -661,7 +661,7 @@ let adjust ~pkind ~pid ~index store id (style : Lui_flex.style) : Lui_flex.style the divider. *) match Lui_store.child_ids store pid with | first :: second :: _ -> - if id = first then ( + if id = first || id = second then ( match pnum store pid "value" with | Some v -> (* Panes clip like the web resizer: a collapsed track @@ -669,11 +669,9 @@ let adjust ~pkind ~pid ~index store id (style : Lui_flex.style) : Lui_flex.style sibling pane, and min-width:0 lets the track actually reach the fraction instead of its content's floor. *) { style with - flex_basis = Lui_flex.Percent (v *. 100.); flex_grow = 0.; + flex_basis = Pt 0.; flex_grow = (if id = first then v else 1. -. v); min_width = Pt 0.; overflow = Hidden } | None -> style) - else if id = second then - { style with flex_grow = 1.; min_width = Pt 0.; overflow = Hidden } else style | _ -> style else style diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index 82e62765..4bfde5be 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -1065,10 +1065,42 @@ let test_fractional_ancestor_offsets () = Lui_layout.sync ~width:100. ~height:40. layout store; check_some_rect "fractional ancestor update" 2. 0. 40. 10. (Lui_layout.rect layout 3) +let test_split_gap_fraction () = + let store = Lui_store.create () in + apply store + [ + CreateNode (1, Split); + CreateNode (2, Column); + CreateNode (3, Column); + SetProp (1, Gap, IntValue 3); + InsertChild (1, 2, 0); + InsertChild (1, 3, 1); + ]; + let layout = Lui_layout.create () in + List.iter + (fun scale -> + List.iter + (fun value -> + apply store [ SetProp (1, ProgressValue, FloatValue value) ]; + Lui_layout.sync ~scale ~width:1000. ~height:400. layout store; + let first = Option.get (Lui_layout.rect layout 2) + and second = Option.get (Lui_layout.rect layout 3) in + let available = 1000. -. (3. *. scale) in + check (float 0.6) "first pane uses the track excluding its divider" + (available *. value) first.w; + check (float 0.6) "second pane uses the remaining track" + (available *. (1. -. value)) + second.w; + check (float 0.6) "divider keeps its logical width" (3. *. scale) + (second.x -. first.x -. first.w)) + [ 0.; 0.25; 0.7; 1. ]) + [ 1.; 2. ] + let () = run "lui_layout" [ ("layout", [ test_case "column padding/gap" `Quick test_column_padding_gap; + test_case "split divider fraction" `Quick test_split_gap_fraction; test_case "grid wraps" `Quick test_grid_wraps; test_case "grid gap fits" `Quick test_grid_gap_fits_columns; test_case "absolute inset" `Quick test_absolute_inset; diff --git a/platform/native/lui_paint/lui_retained.ml b/platform/native/lui_paint/lui_retained.ml index 0a7d1073..293d9554 100644 --- a/platform/native/lui_paint/lui_retained.ml +++ b/platform/native/lui_paint/lui_retained.ml @@ -84,8 +84,22 @@ and frag = { mutable ebase : int; } +type surface_motion = { + mutable snapshot : frag; + mutable bounds : rect; + mutable from_alpha : float; + mutable target_alpha : float; + mutable started : float; + duration : float; +} + type retained = { frags : (int, frag) Hashtbl.t; + surfaces : (int, surface_motion) Hashtbl.t; + mutable surface_order : int list; + mutable now : float option; + mutable reduced_motion : bool; + mutable motion_dirty : bool; (* 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. *) @@ -116,12 +130,55 @@ type retained = { } let create () = - { frags = Hashtbl.create 256; fparent = Hashtbl.create 256; + { surfaces = Hashtbl.create 4; surface_order = []; now = None; + reduced_motion = false; motion_dirty = false; + frags = Hashtbl.create 256; fparent = Hashtbl.create 256; pend_kids = Hashtbl.create 16; pend_segs = Hashtbl.create 64; rev = -1; vp = rect 0. 0. 0. 0.; scale = 1.; dirty = false; resync = false; mgen = 0; stat_repaints = 0; stat_visits = 0; vwin = Hashtbl.create 4; mounted = (fun _ -> None) } +let surface_alpha now motion = + let progress = Float.min 1. ((now -. motion.started) /. motion.duration) in + if progress >= 1. then motion.target_alpha + else begin + (* CSS ease-out: cubic-bezier(0, 0, 0.58, 1). *) + let low = ref 0. and high = ref 1. in + for _ = 1 to 16 do + let t = (!low +. !high) /. 2. in + let x = (1.74 *. (1. -. t) *. t *. t) +. (t *. t *. t) in + if x < progress then low := t else high := t + done; + let t = (!low +. !high) /. 2. in + let eased = (3. *. (1. -. t) *. t *. t) +. (t *. t *. t) in + motion.from_alpha +. ((motion.target_alpha -. motion.from_alpha) *. eased) + end + +let animating rt = + match rt.now with + | None -> false + | Some now -> + Hashtbl.fold + (fun _ motion active -> + active || surface_alpha now motion <> motion.target_alpha) + rt.surfaces false + +let tick rt ~now = + let was_animating = animating rt in + rt.now <- Some now; + if was_animating then rt.motion_dirty <- true; + was_animating + +let set_reduced_motion rt enabled = + if rt.reduced_motion <> enabled then begin + rt.reduced_motion <- enabled; + if enabled && Hashtbl.length rt.surfaces > 0 then begin + Hashtbl.clear rt.surfaces; + rt.surface_order <- []; + rt.motion_dirty <- true + end + end + let invalidate (rt : retained) = Hashtbl.reset rt.frags; Hashtbl.reset rt.fparent; @@ -304,6 +361,7 @@ let advance rt store = | CreateNode (id, _) | CreateExtension (id, _, _) -> (* A reused id can retire descendants without an explicit drop. *) retired := true; + Hashtbl.remove rt.surfaces id; mark_repaint rt id | DropNode id | DetachSubtree id -> retired := true; @@ -715,6 +773,166 @@ let rec emit_seg (scene : Lui_scene.t) m (f : frag) = | Sub g -> emit_seg scene m g) f.seg +(* Snapshot only one promoted surface's display list, sharing atlas pixels. + An exiting snapshot has no store nodes, handlers, or hit-test presence. *) +let capture_surface rt scene m surface = + let capture = + Lui_scene.create ~mask_atlas:scene.mask_atlas ~color_atlas:scene.color_atlas + in + rt.mgen <- rt.mgen + 1; + let local = + { m with m_gen = rt.mgen; gcount = 0; wcount = 0; ecount = 0; fx_rev = [] } + in + emit_seg capture local surface; + let snapshot = fresh_frag surface.self in + snapshot.pre <- List.rev capture.ops; + snapshot.glyphs <- Array.of_list (List.rev capture.glyphs); + snapshot.wide <- Array.of_list (List.rev capture.wide); + snapshot.fx <- Array.of_list (List.rev local.fx_rev); + snapshot + +let emit_surface scene m viewport motion alpha = + let zoom = 0.95 +. (0.05 *. alpha) in + let cx = motion.bounds.x +. (motion.bounds.w /. 2.) + and cy = motion.bounds.y +. (motion.bounds.h /. 2.) in + let point_x x = cx +. ((x -. cx) *. zoom) + and point_y y = cy +. ((y -. cy) *. zoom) in + let box r = + { x = point_x r.x; y = point_y r.y; w = r.w *. zoom; h = r.h *. zoom } + in + let radii (a, b, c, d) = (a *. zoom, b *. zoom, c *. zoom, d *. zoom) in + let transform = function + | Fill f -> + Fill + { + f with + frect = (if f.frect = viewport then f.frect else box f.frect); + fradii = radii f.fradii; + fborder = radii f.fborder; + fopacity = f.fopacity *. alpha; + } + | Shadow s -> + Shadow + { + s with + srect = box s.srect; + scast = box s.scast; + sradii = radii s.sradii; + scast_radii = radii s.scast_radii; + sblur = s.sblur *. zoom; + sopacity = s.sopacity *. alpha; + } + | Image i -> + Image + { + i with + irect2 = box i.irect2; + iradii = radii i.iradii; + iopacity = i.iopacity *. alpha; + } + | Glyphs g -> Glyphs { g with gopacity = g.gopacity *. alpha } + | Push_clip c -> + Push_clip { c with crect = box c.crect; cradii = radii c.cradii } + | Effect e -> + Effect + { + e with + edrect = box e.edrect; + edradii = radii e.edradii; + edopacity = e.edopacity *. alpha; + } + | Hole h -> + Hole + { + h with + hrect = box h.hrect; + hradii = radii h.hradii; + hopacity = h.hopacity *. alpha; + } + | Pop_clip -> Pop_clip + in + let snapshot = motion.snapshot in + let visual = + if alpha = 1. then snapshot + else + { + snapshot with + glyphs = + Array.map + (fun g -> + { + g with + gx = point_x g.gx; + gy = point_y g.gy; + gw = g.gw *. zoom; + gh = g.gh *. zoom; + }) + snapshot.glyphs; + } + in + push_buffers scene m visual; + List.iter + (fun op -> + let op = if alpha = 1. then op else transform op in + scene.ops <- rebase visual op :: scene.ops) + snapshot.pre + +let present_surfaces rt scene m viewport ~refresh live = + let now = Option.get rt.now in + let seen = Hashtbl.create (List.length live) in + List.iter + (fun (surface : frag) -> + Hashtbl.add seen surface.self (); + match Hashtbl.find_opt rt.surfaces surface.self with + | None -> + let snapshot = capture_surface rt scene m surface in + let duration = + match surface.kind with + | "dialog" | "alert" | "sheet" | "drawer" -> 0.2 + | _ -> 0.15 + in + Hashtbl.add rt.surfaces surface.self + { + snapshot; + bounds = surface.resolved; + from_alpha = 0.; + target_alpha = 1.; + started = now; + duration; + } + | Some motion -> + if refresh then motion.snapshot <- capture_surface rt scene m surface; + motion.bounds <- surface.resolved; + if motion.target_alpha <> 1. then begin + motion.from_alpha <- surface_alpha now motion; + motion.target_alpha <- 1.; + motion.started <- now + end) + live; + Hashtbl.filter_map_inplace + (fun id motion -> + if (not (Hashtbl.mem seen id)) && motion.target_alpha <> 0. then begin + motion.from_alpha <- surface_alpha now motion; + motion.target_alpha <- 0.; + motion.started <- now + end; + if motion.target_alpha = 0. && surface_alpha now motion = 0. then None + else Some motion) + rt.surfaces; + (* Exiting shells remain behind the current live overlay stack. *) + let closing = + List.filter + (fun id -> (not (Hashtbl.mem seen id)) && Hashtbl.mem rt.surfaces id) + rt.surface_order + in + rt.surface_order <- + closing @ List.map (fun (surface : frag) -> surface.self) live; + List.iter + (fun id -> + let motion = Hashtbl.find rt.surfaces id in + emit_surface scene m viewport motion (surface_alpha now motion)) + rt.surface_order + let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ?vlists ~width ~height ~scale ~clear () = let vp = rect 0. 0. (float width) (float height) in @@ -753,7 +971,9 @@ let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ?vlists | Some h -> Hashtbl.iter (fun id () -> if Lui_store.mem store id then mark_repaint rt id) h | None -> ()); - if rt.dirty then begin + if rt.dirty || rt.motion_dirty then begin + let refresh_surfaces = rt.dirty in + rt.motion_dirty <- false; let m = { m_ctx = { Lui_paint.hooks; scale; viewport = vp; @@ -827,11 +1047,15 @@ let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ?vlists if promotes f then Queue.add f drain else List.iter (fun g -> Queue.add g drain) f.sub_pops) root_frags; + let live = ref [] in + let motion_enabled = rt.now <> None && not rt.reduced_motion in while not (Queue.is_empty drain) do let g = Queue.pop drain in - emit_seg scene m g; + if motion_enabled then (if g.emitted then live := g :: !live) + else emit_seg scene m g; List.iter (fun h -> Queue.add h drain) g.sub_pops done; + if motion_enabled then present_surfaces rt scene m vp ~refresh:refresh_surfaces (List.rev !live); while not (Queue.is_empty m.m_ctx.Lui_paint.deferred) do (Queue.pop m.m_ctx.Lui_paint.deferred) () done; diff --git a/platform/native/lui_paint/test/lui_retained_test.ml b/platform/native/lui_paint/test/lui_retained_test.ml index 915f4460..1dd43424 100644 --- a/platform/native/lui_paint/test/lui_retained_test.ml +++ b/platform/native/lui_paint/test/lui_retained_test.ml @@ -304,6 +304,59 @@ let test_popup_deferred () = apply env.store [ SetProp (61, TextValue, StringValue "item!") ]; frame env () +let test_popup_motion_buffers () = + let env = env () in + apply env.store + [ + CreateNode (1, Column); + CreateNode (60, Dialog); + InsertChild (1, 60, 0); + CreateNode (61, Text); + SetProp (61, TextValue, StringValue "dialog"); + InsertChild (60, 61, 0); + CreateNode (62, Popover); + InsertChild (60, 62, 1); + CreateNode (63, Text); + SetProp (63, TextValue, StringValue "menu"); + InsertChild (62, 63, 0); + ]; + let paint now = + ignore (Lui_retained.tick env.rt ~now); + Lui_layout.sync ~scale:1. ~measure ~width:200. ~height:200. 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 + env.cur := env.kept; + Lui_retained.paint_retained env.rt h env.store env.kept ~moved ~width:200 + ~height:200 ~scale:1. ~clear:(color 0 0 0 0) (); + List.iter + (function + | Glyphs g -> + check bool "animated glyph references remain in range" true + (g.gstart >= 0 && g.gend <= List.length env.kept.glyphs) + | _ -> ()) + env.kept.ops + in + paint 0.; + let planned = Lui_retained.stat_repaints env.rt in + paint 0.075; + check int "motion reuses the painted fragments" planned + (Lui_retained.stat_repaints env.rt); + check bool "nested glyphs fade" true + (List.exists + (function Glyphs g -> g.gopacity > 0. && g.gopacity < 1. | _ -> false) + env.kept.ops); + paint 0.25; + frame env (); + apply env.store [ DetachSubtree 60 ]; + paint 0.25; + check bool "removed nested surface retains text during exit" true + (env.kept.glyphs <> []); + paint 0.5; + frame env (); + check bool "exit releases glyph buffers" true (env.kept.glyphs = []); + check bool "all popup motion is settled" false (Lui_retained.animating env.rt) + let test_state_hint () = let env = env () in build_rows env 5; @@ -647,6 +700,7 @@ let () = 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 "popup motion buffers" `Quick test_popup_motion_buffers; test_case "state hint" `Quick test_state_hint; test_case "drop subtree" `Quick test_drop_subtree; test_case "disposed fragments" `Quick test_disposed_fragments; diff --git a/platform/native/lui_raster/test/lui_raster_test.ml b/platform/native/lui_raster/test/lui_raster_test.ml index 387868e0..854b3829 100644 --- a/platform/native/lui_raster/test/lui_raster_test.ml +++ b/platform/native/lui_raster/test/lui_raster_test.ml @@ -655,6 +655,58 @@ let test_native_tile_scroll_budget ?(workers=1) path () = Alcotest.(check bool) "native tiled redraw and pixel copy meets its frame budget" true (times.(57) <= budget) +let test_modal_fade_budget ?(workers = 4) () = + let width = 2880 and height = 1864 in + let output = Lui_raster.Image.create ~w:width ~h:height in + let renderer = Lui_raster.Tile_renderer.create () in + Fun.protect + ~finally:(fun () -> Lui_raster.Tile_renderer.release renderer) + (fun () -> + let emit (area : irect) (tile : Lui_raster.Image.t) = + for y = area.y0 to area.y1 - 1 do + Bytes.blit tile.pix + ((y - area.y0) * tile.stride) + output.pix (y * output.stride) (4 * width) + done + in + let paint alpha = + let s = + scene ~w:width ~h:height ~clear:(color 255 255 255 255) + [ + ifill (rect 0. 0. 480. (float height)) (color 238 238 240 255); + ifill + (rect 0. 0. (float width) (float height)) + (color 0 0 0 (alpha / 2)); + ishadow ~radii:(radii 16.) ~blur:24. + (rect 416. 360. 2048. 1144.) + (color 0 0 0 (alpha / 5)); + ifill ~radii:(radii 16.) + (rect 416. 360. 2048. 1144.) + (color 255 255 255 alpha); + ] + in + Lui_raster.Tile_renderer.render ~workers renderer s ~emit; + s + in + ignore (paint 255); + let times = + Array.init 30 (fun frame -> + let started = Unix.gettimeofday () in + ignore (paint (250 - (frame * 7))); + (Unix.gettimeofday () -. started) *. 1000.) + in + let final = paint 47 in + let expected = Lui_raster.render ~workers:1 ~scene:final () in + Alcotest.(check bool) + "modal fade tiles match complete rendering" true + (Bytes.equal expected.pix output.pix); + Array.sort Float.compare times; + Printf.printf "Retina modal fade p50=%.2fms p95=%.2fms\n%!" times.(15) + times.(28); + Alcotest.(check bool) + "Retina modal compositing fits the 120 FPS frame budget" true + (times.(28) <= 8.33)) + let test_translucent_byte_rounding () = let premul channel alpha = (channel * alpha + 127) / 255 in let over source dest inv = @@ -1160,6 +1212,7 @@ let () = ; ("compositing", [ Alcotest.test_case "translucent rounding" `Slow test_translucent_byte_rounding; Alcotest.test_case "span allocation" `Quick test_translucent_span_allocation; + Alcotest.test_case "modal fade budget" `Slow test_modal_fade_budget; Alcotest.test_case "opaque span edges" `Quick test_opaque_span_edges; Alcotest.test_case "opaque glyph allocation" `Quick test_opaque_glyph_allocation; Alcotest.test_case "translucent span channels" `Quick test_translucent_span_channels; diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 05f790ff..6572fe1a 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -917,9 +917,24 @@ module Ui = struct match update with | None -> () | Some (id,value) -> - Lui_store.apply_op store (SetProp (id,ProgressValue,FloatValue value)); - t.dirty <- true; - emit_all [Dispatch (ValueChanged (id,value))] + let value = + match Lui_store.child_ids store id with + | first :: second :: _ -> + let available = ((Rects.get rects first).w +. (Rects.get rects second).w) /. t.scale in + let minimum child = + float_of_int (Option.value (Lui_store.int_prop store child "min-width") ~default:0) + in + let low = minimum first /. available in + let high = 1. -. minimum second /. available in + if low > high then low /. (low +. 1. -. high) + else Float.min high (Float.max low value) + | _ -> assert false + in + if Lui_store.float_prop store id "value" <> Some value then begin + Lui_store.apply_op store (SetProp (id,ProgressValue,FloatValue value)); + t.dirty <- true; + emit_all [Dispatch (ValueChanged (id,value))] + end in emit_all (sync t store); (match input with diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index 73ee706b..a982e212 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2824,6 +2824,50 @@ let test_split_pointer_resize () = ignore (input (Input.Move (500.,100.))); Alcotest.(check (float 0.00001)) "release ends resize capture" 0.4 (split_fraction store 1) +let test_split_pointer_minimums () = + List.iter + (fun scale -> + let store, rects, ui = split_fixture () in + Ui.set_scale ui scale; + let input event = Ui.handle ui store rects event in + let x = 303. /. scale and y = 200. /. scale in + List.iter (Lui_store.apply_op store) + [ + SetProp (2, MinWidth, IntValue 100); + SetProp (3, MinWidth, IntValue 150); + ]; + ignore (input (Input.Button_down (x, y, Input.Left, 1, Input.mods_none))); + ignore (input (Input.Move (-2000., y))); + let available = 994. /. scale in + Alcotest.(check (float 0.00001)) + "drag preserves the first pane minimum" (100. /. available) + (split_fraction store 1); + Alcotest.(check (option (float 0.00001))) + "dragging beyond the same minimum emits no duplicate resize" None + (resized 1 (input (Input.Move (-2100., y)))); + let acts = + input (Input.Button_up (2000., y, Input.Left, Input.mods_none)) + in + Alcotest.(check (option (float 0.00001))) + "release preserves the second pane minimum" + (Some (1. -. (150. /. available))) + (resized 1 acts); + List.iter (Lui_store.apply_op store) + [ + SetProp (2, MinWidth, IntValue 600); + SetProp (3, MinWidth, IntValue 900); + ]; + ignore (input (Input.Button_down (x, y, Input.Left, 1, Input.mods_none))); + ignore (input (Input.Move (-2000., y))); + Alcotest.(check (float 0.00001)) + "infeasible minimums share the track proportionally" 0.4 + (split_fraction store 1); + ignore (input (Input.Button_up (2000., y, Input.Left, Input.mods_none))); + Alcotest.(check (float 0.00001)) + "infeasible minimums remain stable on release" 0.4 + (split_fraction store 1)) + [ 1.; 2. ] + let test_split_pointer_admission () = let store,rects,ui=split_fixture () in let input event=Ui.handle ui store rects event in @@ -2943,6 +2987,7 @@ let () = Alcotest.test_case "undo/redo" `Quick test_undo_redo ] ); ( "split resize", [ Alcotest.test_case "pointer feedback" `Quick test_split_pointer_resize; + Alcotest.test_case "pane minimums" `Quick test_split_pointer_minimums; Alcotest.test_case "capture admission" `Quick test_split_pointer_admission; Alcotest.test_case "nested scaled track" `Quick test_nested_split_pointer_resize ] ); ( "keys", diff --git a/src/lui_elements.ml b/src/lui_elements.ml index 3a3ded70..f8779614 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -703,10 +703,11 @@ let row ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padd mount_children context node children; node -let column ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?max_width_signal ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?on_press_detail ?on_pointer_down ?on_pointer_up ?opacity ?opacity_signal ?display ?flex_wrap ?transition (children : t list) : t = +let column ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?min_width_signal ?max_width ?max_width_signal ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?on_press_detail ?on_pointer_down ?on_pointer_up ?opacity ?opacity_signal ?display ?flex_wrap ?transition (children : t list) : t = fun context parent -> let node = Lui_ui.column context in apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~margin ~margin_horizontal ~margin_vertical ~margin_top ~margin_right ~margin_bottom ~margin_left ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~data_attrs ~data_attrs_signal ~on_appear ~on_pointer_enter ~on_pointer_leave ~transition; + Option.iter (Lui_ui.int_property_signal context node MinWidth) min_width_signal; Option.iter (Lui_ui.int_property_signal context node MaxWidth) max_width_signal; Option.iter (Lui_ui.float_property context node Opacity) opacity; Option.iter (Lui_ui.float_property_signal context node Opacity) opacity_signal; diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 5ec6215c..ad263aa7 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -410,6 +410,7 @@ val column : ?width:int -> ?height:int -> ?min_width:int -> + ?min_width_signal:int Signal.signal -> ?max_width:int -> ?max_width_signal:int Signal.signal -> ?min_height:int -> From c1f56ae595a28638447376e887baa16a9a5af87e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 16:52:18 +0800 Subject: [PATCH 021/163] fix: keep unfocused windows open on Escape Preserve Escape dismissal and blur while requiring an explicit quit event to close the window. --- platform/native/lui_window/lui_window.ml | 2 +- platform/native/lui_window/test/lui_window_test.ml | 7 +++++-- .../native/lui_window_linux/test/lui_window_linux_test.ml | 7 +++++-- .../lui_window_windows/test/lui_window_windows_test.ml | 7 +++++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 6572fe1a..74e67fad 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -1227,7 +1227,7 @@ module Ui = struct if Lui_focus.focused t.focus <> 0 then begin focus_move t store 0; acts := Focus_changed 0 :: !acts - end else acts := Quit :: !acts) + end) end | Input.Other 4 when prim -> (* A: select all — the editable range, or every diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index a982e212..4bb0e179 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -406,7 +406,7 @@ let test_text_input_and_keys () = ignore (Ui.handle ui s rects (Input.Text_input "x")); Alcotest.(check (option string)) "typing after a UTF-8 model update stays valid" (Some "x字") (Lui_store.string_prop s 4 "text"); - (* Escape blurs first, then quits *) + (* Escape blurs the field and leaves an unfocused window open. *) (match Ui.handle ui s rects (Input.Key_down (Input.Escape, Input.mods_none, false)) @@ -417,8 +417,11 @@ let test_text_input_and_keys () = Ui.handle ui s rects (Input.Key_down (Input.Escape, Input.mods_none, false)) with + | [] -> () + | _ -> Alcotest.fail "unfocused Escape must leave the window open"); + (match Ui.handle ui s rects Input.Quit_input with | [ Quit ] -> () - | _ -> Alcotest.fail "expected Quit") + | _ -> Alcotest.fail "explicit quit must still close the window") let test_caret_ops_wiring () = (* C7: with a caret model wired, keyboard moves and deletes consult diff --git a/platform/native/lui_window_linux/test/lui_window_linux_test.ml b/platform/native/lui_window_linux/test/lui_window_linux_test.ml index bab76ce9..2cd557ea 100644 --- a/platform/native/lui_window_linux/test/lui_window_linux_test.ml +++ b/platform/native/lui_window_linux/test/lui_window_linux_test.ml @@ -236,7 +236,7 @@ let test_focus_and_text () = (match press_actions acts with | [ TextChanged (4, "ab") ] -> () | _ -> Alcotest.fail "expected TextChanged ab"); - (* Escape blurs first, then quits *) + (* Escape blurs the field and leaves an unfocused window open. *) (match Ui.handle ui s rects (Input.Key_down (Input.Escape, Input.mods_none, false)) @@ -247,8 +247,11 @@ let test_focus_and_text () = Ui.handle ui s rects (Input.Key_down (Input.Escape, Input.mods_none, false)) with + | [] -> () + | _ -> Alcotest.fail "unfocused Escape must leave the window open"); + (match Ui.handle ui s rects Input.Quit_input with | [ Quit ] -> () - | _ -> Alcotest.fail "expected Quit") + | _ -> Alcotest.fail "explicit quit must still close the window") let test_ime_flow () = let s, rects = ui_store_fresh () in diff --git a/platform/native/lui_window_windows/test/lui_window_windows_test.ml b/platform/native/lui_window_windows/test/lui_window_windows_test.ml index 0de0520d..89337873 100644 --- a/platform/native/lui_window_windows/test/lui_window_windows_test.ml +++ b/platform/native/lui_window_windows/test/lui_window_windows_test.ml @@ -272,7 +272,7 @@ let test_focus_and_text () = (match press_actions acts with | [ TextChanged (4, "ab") ] -> () | _ -> Alcotest.fail "expected TextChanged ab"); - (* Escape blurs first, then quits *) + (* Escape blurs the field and leaves an unfocused window open. *) (match Ui.handle ui s rects (Input.Key_down (Input.Escape, Input.mods_none, false)) @@ -283,8 +283,11 @@ let test_focus_and_text () = Ui.handle ui s rects (Input.Key_down (Input.Escape, Input.mods_none, false)) with + | [] -> () + | _ -> Alcotest.fail "unfocused Escape must leave the window open"); + (match Ui.handle ui s rects Input.Quit_input with | [ Quit ] -> () - | _ -> Alcotest.fail "expected Quit") + | _ -> Alcotest.fail "explicit quit must still close the window") let test_ime_flow () = let s, rects = ui_store_fresh () in From 8af586fd0aeda1f24f882e91f0a670f0222680ae Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 17:28:30 +0800 Subject: [PATCH 022/163] fix: honor native border geometry and dialog chrome Include border widths in native layout and invalidate geometry when they change. Allow dialog surface styling across protocol backends and retain the existing generated interface declaration. Validate scaled and incremental border layout, dialog property emission, schema consistency, and the complete release suite. --- .../src/main/kotlin/dev/lui/LuiWireSchema.kt | 2 +- .../LUIAppleBackend/LUIWireSchema.swift | 2 +- platform/native/lui_layout/lui_layout.ml | 41 +++++++--------- .../native/lui_layout/test/lui_layout_test.ml | 48 +++++++++++++++++++ platform/native/lui_paint/lui_paint.ml | 2 +- platform/native/lui_store/lui_store.ml | 4 +- .../native/lui_window/test/lui_window_test.ml | 7 ++- schema/components.json | 6 ++- src/lui_protocol.ml | 4 +- src/lui_wire_schema.ml | 2 +- test/test_lui.ml | 32 +++++++++++++ tooling/generate_component_schema.mjs | 1 + 12 files changed, 114 insertions(+), 37 deletions(-) diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt index 560e9b64..71c4cc75 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt @@ -302,6 +302,6 @@ object LuiSchemaMatrix { ) val extra: Map> = mapOf( - LuiNodeKind.dialog to setOf(LuiProperty.description), + LuiNodeKind.dialog to setOf(LuiProperty.description, LuiProperty.background, LuiProperty.borderColor, LuiProperty.borderWidth, LuiProperty.cornerRadius), ) } diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index ce9c617a..afb986d4 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -282,6 +282,6 @@ enum LUISchemaMatrix { ] static let extra: [LUINodeKind: Set] = [ - .dialog: [.description], + .dialog: [.description, .background, .borderColor, .borderWidth, .cornerRadius], ] } diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index 8514ca58..f22ca07b 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -253,37 +253,27 @@ let shorthand_lengths ~scale s = | [ t; r; b; l ] -> Some (t, r, b, l) | _ -> None -(* padding / padding-horizontal / padding-vertical → flex padding edges. - The uniform prop also accepts a string shorthand. *) -let padding_edges ~scale store id : Lui_flex.edges = +(* Uniform edge props accept either a number or the CSS 1–4-value shorthand. *) +let uniform_edges ~scale store id name : Lui_flex.edges = let base = Lui_flex.edges_auto in - let base = - match Lui_store.prop store id "padding" with - | Some (StringValue s) -> ( - match shorthand_lengths ~scale s with - | Some (t, r, b, l) -> { base with top = t; right = r; bottom = b; left = l } - | None -> base) - | Some _ -> { base with all = length_prop ~scale store id "padding" } - | None -> base - in + match Lui_store.prop store id name with + | Some (StringValue s) -> ( + match shorthand_lengths ~scale s with + | Some (t, r, b, l) -> { base with top = t; right = r; bottom = b; left = l } + | None -> base) + | Some _ -> { base with all = length_prop ~scale store id name } + | None -> base + +(* Axis props override the uniform padding shorthand. *) +let padding_edges ~scale store id : Lui_flex.edges = + let base = uniform_edges ~scale store id "padding" in { base with horizontal = length_prop ~scale store id "padding-horizontal"; vertical = length_prop ~scale store id "padding-vertical" } -(* margin family → flex margin edges, same resolution order as - padding: shorthand on the uniform prop, then per-axis and per-side - props win. *) +(* Axis and side props override the uniform margin shorthand. *) let margin_edges ~scale store id : Lui_flex.edges = - let base = Lui_flex.edges_auto in - let base = - match Lui_store.prop store id "margin" with - | Some (StringValue s) -> ( - match shorthand_lengths ~scale s with - | Some (t, r, b, l) -> { base with top = t; right = r; bottom = b; left = l } - | None -> base) - | Some _ -> { base with all = length_prop ~scale store id "margin" } - | None -> base - in + let base = uniform_edges ~scale store id "margin" in let base = { base with horizontal = length_prop ~scale store id "margin-horizontal"; @@ -595,6 +585,7 @@ let style_of ?(scale = 1.) ?(viewport = (0., 0.)) store id : Lui_flex.style = | Unset -> lp "max-height" | l -> l); padding = title_pad ~scale store id kind (padding_edges ~scale store id); + border = uniform_edges ~scale store id "border-width"; margin = margin_edges ~scale store id; position; row_gap = diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index 4bfde5be..7c4e8018 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -59,6 +59,53 @@ let test_column_padding_gap () = check_rect "child1" 10. 10. 180. 20. (place p 3); check_rect "child2" 10. 35. 180. 20. (place p 4) +let test_border_box_layout () = + List.iter + (fun scale -> + let store = Lui_store.create () and layout = Lui_layout.create () in + apply store + [ + CreateNode (1, Root); + CreateNode (2, Column); + CreateNode (3, Box); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); + SetProp (2, WidthValue, IntValue 100); + SetProp (2, HeightValue, IntValue 80); + SetProp (2, PaddingValue, IntValue 5); + SetProp (3, GrowValue, FloatValue 1.); + ]; + let sync () = + Lui_layout.sync ~scale ~width:(200. *. scale) ~height:(120. *. scale) + layout store + in + let expect label (top, right, bottom, left) = + sync (); + (* Exported geometry snaps edges to physical pixels. *) + let x = Float.round ((5. +. left) *. scale) in + let y = Float.round ((5. +. top) *. scale) in + check_some_rect label x y + (Float.round ((95. -. right) *. scale) -. x) + (Float.round ((75. -. bottom) *. scale) -. y) + (Lui_layout.rect layout 3) + in + List.iter + (fun (value, edges) -> + apply store [ SetProp (2, BorderWidth, value) ]; + expect "border insets the child" edges) + [ + (IntValue 3, (3., 3., 3., 3.)); + (FloatValue 1.5, (1.5, 1.5, 1.5, 1.5)); + (StringValue "1 2 3 4", (1., 2., 3., 4.)); + (IntValue 6, (6., 6., 6., 6.)); + (IntValue 0, (0., 0., 0., 0.)); + ]; + let revision = Lui_store.layout_rev store in + apply store [ SetProp (2, BorderColorValue, StringValue "red") ]; + check int "border color remains paint-only" revision + (Lui_store.layout_rev store)) + [ 1.; 2. ] + (* Grid = horizontal flow that wraps: columns=2 → two cells per row. *) let test_grid_wraps () = let store = Lui_store.create () in @@ -1100,6 +1147,7 @@ let () = run "lui_layout" [ ("layout", [ test_case "column padding/gap" `Quick test_column_padding_gap; + test_case "border box layout" `Quick test_border_box_layout; test_case "split divider fraction" `Quick test_split_gap_fraction; test_case "grid wraps" `Quick test_grid_wraps; test_case "grid gap fits" `Quick test_grid_gap_fits_columns; diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index 34a49a00..0935ab53 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -51,7 +51,7 @@ let prop_coverage = ("background", Painted); ("foreground", Painted); ("border-color", Painted); (* CSS shorthand, 1–4 colors *) - ("border-width", Painted); (* CSS shorthand, 1–4 lengths *) + ("border-width", Painted); (* CSS shorthand, 1–4 lengths; also insets layout *) ("corner-radius", Painted); ("width", Layout); ("height", Layout); diff --git a/platform/native/lui_store/lui_store.ml b/platform/native/lui_store/lui_store.ml index 7003de97..133fccf7 100644 --- a/platform/native/lui_store/lui_store.ml +++ b/platform/native/lui_store/lui_store.ml @@ -40,7 +40,7 @@ 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"; + [ "border-width"; "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"; @@ -76,7 +76,7 @@ let child_props = conservative layout|paint default. *) let paint_prop_names = [ "background"; "foreground"; "opacity"; "shadow"; "corner-radius"; - "border-color"; "border-width"; "border-style"; "z-index"; + "border-color"; "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"; diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index 4bb0e179..bc237c5b 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -1348,10 +1348,9 @@ let test_headless_checksum () = with Not_found -> false); let c1 = checksum_of out1 and c2 = checksum_of out2 in Alcotest.(check string) "deterministic" c1 c2; - (* the recorded value pins the demo's initial scene: any render-path - change shows up here *) + (* This scene includes border insets in the demo's control layouts. *) Alcotest.(check string) "golden" - "headless done: frames=6 checksum=7b8b9dbbe6351aa5" c1 + "headless done: frames=6 checksum=d81c0c21f4815325" c1 (* ---------- multi-window ---------- *) @@ -1704,7 +1703,7 @@ let test_headless_multi () = let out1 = run () and out2 = run () in (match win_done_lines out1 with | [ w0; w1 ] -> - Alcotest.(check string) "win0 golden" "7b8b9dbbe6351aa5" + Alcotest.(check string) "win0 golden" "d81c0c21f4815325" (checksum_value w0); Alcotest.(check bool) "independent checksums" true (checksum_value w0 <> checksum_value w1) diff --git a/schema/components.json b/schema/components.json index 5e6e6100..47581035 100644 --- a/schema/components.json +++ b/schema/components.json @@ -2045,7 +2045,11 @@ }, "kindExtraProperties": { "dialog": [ - "description" + "description", + "background", + "border-color", + "border-width", + "corner-radius" ] } } diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index 90ef65ff..2b03c93a 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -1305,7 +1305,9 @@ let property_supported kind property = ; PointerEnabled ] | Dialog -> - property = DescriptionValue || common_property_supported kind property + List.mem property + [DescriptionValue; BackgroundValue; BorderColorValue; BorderWidth; CornerRadius] + || common_property_supported kind property | FilePreview -> property = PathValue || property = PointerEnabled | _ -> common_property_supported kind property diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 50b1e784..1fd3b8a3 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -377,7 +377,7 @@ let kind_property_matrix kind = let kind_extra_properties kind = match kind with - | Dialog -> [ DescriptionValue ] + | Dialog -> [ DescriptionValue; BackgroundValue; BorderColorValue; BorderWidth; CornerRadius ] | _ -> [] let all_node_kinds = [ Root; Row; Column; Grid; Stack; EdgeInset; Overlay; ViewThatFits; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; NumberStepper; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; ListSection; ListSectionHeader; ListSectionFooter; SwipeActions; SwipeAction; FilePicker; Kbd; Link; FileImage; FilePreview; Br; Popover ] diff --git a/test/test_lui.ml b/test/test_lui.ml index d1140ad9..3767194d 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -2751,6 +2751,37 @@ let test_visual_appearance_props () = (DisabledOpacity, FloatValue 0.5); (MaxHeightViewport, FloatValue 0.65) ] +let test_dialog_chrome_props () = + let open Lui_protocol in + let node = ref 0 in + let app = + Lui_app.create (recording_backend ()) () + (fun model _ -> model) + (fun _ _ _ -> + capture_node node + (Lui_elements.dialog ~background:"#ffffff" ~border_color:"#ebeaeb" + ~border_width:1 ~corner_radius:8 [])) + in + ignore (Lui_app.start app); + flush_app app; + List.iter + (fun (property, value) -> + Alcotest.(check bool) + ("dialog emits " ^ Lui_wire_schema.property_name property) + true + (List.exists + (function + | SetProp (id, p, v) -> id = !node && p = property && v = value + | _ -> false) + (all_ops ()))) + [ + (BackgroundValue, StringValue "#ffffff"); + (BorderColorValue, StringValue "#ebeaeb"); + (BorderWidth, IntValue 1); + (CornerRadius, IntValue 8); + ]; + ignore (Lui_app.dispose app) + let test_property_matrix_sync () = (* property_supported's restrictive arms and additive extras must mirror schema/components.json (kindProperties / kindExtraProperties) as emitted @@ -3969,6 +4000,7 @@ let () = test_property_matrix_sync; Alcotest.test_case "visual appearance props" `Quick test_visual_appearance_props; + Alcotest.test_case "dialog chrome props" `Quick test_dialog_chrome_props; Alcotest.test_case "data-attrs protocol" `Quick test_data_attrs_protocol; Alcotest.test_case "finite float encoding" `Quick diff --git a/tooling/generate_component_schema.mjs b/tooling/generate_component_schema.mjs index 2563f910..0b5ada66 100644 --- a/tooling/generate_component_schema.mjs +++ b/tooling/generate_component_schema.mjs @@ -195,6 +195,7 @@ val context_menu_leaf_host_kind : node_kind -> bool val event_supported : node_kind -> event -> bool val true_property : wire_value Property_map.t -> property -> bool val treeitem_properties : wire_value Property_map.t -> bool +val event_supported_for_lookup : node_kind -> (property -> wire_value option) -> event -> bool val event_supported_for_properties : node_kind -> wire_value Property_map.t -> event -> bool val container_relative_frame_supported : string -> bool val orientation_supported : string -> bool From f2938eee0b8a576ed35279f3db7f363faaccbbb4 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 17:40:17 +0800 Subject: [PATCH 023/163] fix: paint native select indicators Reserve a trailing icon slot and clip select and combobox labels before it. Request scale-aware indicator images through the existing decode hook and keep disabled opacity consistent. Cover both backing scales, long labels, narrow controls, and disabled state. The complete release suite passes with the updated deterministic scene checksum. --- platform/native/lui_paint/lui_paint.ml | 60 ++++++++++++++- .../native/lui_paint/test/lui_paint_test.ml | 76 +++++++++++++++++++ .../native/lui_window/test/lui_window_test.ml | 6 +- 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index 0935ab53..981ec9fe 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -1005,8 +1005,8 @@ let text_placement_of store id = | "table-cell" | "table-row" -> (Ta_start, 10., 6., false) | "step" -> (Ta_center, 8., 4., false) | "list-item" -> (Ta_start, 12., 6., false) - | "text-field" | "secure-field" | "input" | "search-field" - | "select" | "combobox" -> (Ta_start, 10., 6., false) + | "text-field" | "secure-field" | "input" | "search-field" -> (Ta_start, 10., 6., false) + | "select" | "combobox" -> (Ta_start, 12., 8., false) | "textarea" -> (Ta_start, 10., 6., true) | _ -> (Ta_start, 0., 0., false) in @@ -1410,6 +1410,58 @@ let text_ops ?fg ctx store id r alpha ~placeholder_ok = | Some (s, fg) -> scale_ops alpha (ctx.hooks.text_ops id r fg s) | None -> [] +(* Select indicators use the host's existing image decoder, including its + scale-aware SVG cache. Clip labels before the trailing icon slot. *) +let select_ops ctx store id rs r alpha fg = + let pad = (text_placement_of store id).tp_pad_x *. ctx.scale in + let _, right, _, left = border_widths ctx store id in + let size = + Float.min (16. *. ctx.scale) + (Float.min r.h (Float.max 0. (r.w -. left -. right -. (2. *. pad)))) + in + let label = { r with w = Float.max 0. (r.w -. right -. pad -. size) } in + let indicator = + if size <= 0. then [] + else + let slot = + rect + (r.x +. r.w -. right -. pad -. size) + (r.y +. ((r.h -. size) /. 2.)) + size size + in + let logical = + { + slot with + x = slot.x /. ctx.scale; + y = slot.y /. ctx.scale; + w = slot.w /. ctx.scale; + h = slot.h /. ctx.scale; + } + in + match ctx.hooks.image_in id logical with + | None -> [] + | Some image -> + [ + Image + { + irect2 = slot; + iradii = (0., 0., 0., 0.); + icontinuous = false; + iimage = image; + isrc = rect 0. 0. (float image.iw) (float image.ih); + igrayscale = false; + iopacity = alpha; + }; + ] + in + box_ops ctx store id rs r alpha + @ [ + Push_clip + { crect = label; cradii = (0., 0., 0., 0.); ccontinuous = false }; + ] + @ text_ops ~fg ctx store id label alpha ~placeholder_ok:true + @ [ Pop_clip ] @ indicator + (* divider: a 1 logical px line centered on the node's minor axis. *) let divider_ops ctx store id rs r alpha = let s = ctx.scale in @@ -2633,8 +2685,8 @@ let paint_own ctx store id st kind r rs alpha = | "text" | "label" | "heading" | "paragraph" | "br" | "link" | "kbd" -> box_ops ctx store id rs r alpha @ labeled_content_ops ctx store id r alpha ~fg ~placeholder_ok:false - | "text-field" | "secure-field" | "input" | "search-field" | "textarea" - | "select" | "combobox" -> + | "select" | "combobox" -> select_ops ctx store id rs r alpha fg + | "text-field" | "secure-field" | "input" | "search-field" | "textarea" -> box_ops ctx store id rs r alpha @ text_ops ~fg ctx store id r alpha ~placeholder_ok:true | "button" | "toggle-button" | "toggle" | "menu-item" | "menu-trigger" diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index 2132b5cc..271b5674 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -41,6 +41,81 @@ let paint_with h t ?(width = 200) ?(height = 200) ?(scale = 1.) ~clear:(color 0 0 0 0); s +let test_select_indicator () = + List.iter + (fun kind -> + List.iter + (fun scale -> + let t = store () in + apply t + [ + Lui_protocol.CreateNode (1, kind); + SetProp (1, BorderWidth, IntValue 1); + SetProp (1, TextValue, StringValue "A very long language name"); + ]; + let image = new_image ~w:1 ~h:1 (Bytes.make 4 '\255') in + let requested = ref None and text_rect = ref None in + let r = + rect (20. *. scale) (10. *. scale) (256. *. scale) (32. *. scale) + in + let h = + { + (hooks ~layout:(fun _ -> Lui_paint.placement_of_rect r) ()) with + Lui_paint.image_in = + (fun _ slot -> + requested := Some slot; + Some image); + text_ops = + (fun _ region _ _ -> + text_rect := Some region; + []); + } + in + let s = paint_with h t ~width:600 ~height:160 ~scale () in + let expected = + rect (247. *. scale) (18. *. scale) (16. *. scale) (16. *. scale) + in + check bool "select requests a trailing sixteen-point indicator" true + (!requested = Some (rect 247. 18. 16. 16.)); + let images = + List.filter_map (function Image i -> Some i | _ -> None) s.ops + in + check int "indicator painted exactly once" 1 (List.length images); + check bool "indicator retains the requested slot" true + ((List.hd images).irect2 = expected); + let label = Option.get !text_rect in + check bool "label ends before the icon" true + (label.x +. label.w <= expected.x); + check bool "label is clipped away from the icon" true + (List.exists + (function Push_clip c -> c.crect = label | _ -> false) + s.ops); + apply t [ SetProp (1, Enabled, BoolValue false) ]; + let disabled = paint_with h t ~width:600 ~height:160 ~scale () in + let opacity = + List.find_map + (function Image i -> Some i.iopacity | _ -> None) + disabled.ops + in + check bool "disabled indicator follows control opacity" true + (Option.get opacity < 1.); + requested := None; + let narrow = + { + h with + Lui_paint.layout = + (fun _ -> + Lui_paint.placement_of_rect + (rect (20. *. scale) (10. *. scale) (20. *. scale) + (32. *. scale))); + } + in + ignore (paint_with narrow t ~width:600 ~height:160 ~scale ()); + check bool "a narrow control does not request an out-of-bounds icon" + true (!requested = None)) + [ 1.; 2. ]) + [ Lui_protocol.Select; Combobox ] + let test_fill_op () = let t = store () in apply t @@ -1013,6 +1088,7 @@ let () = run "lui_paint" [ ("color", [ test_case "hex" `Quick test_hex; + test_case "select indicator" `Quick test_select_indicator; test_case "edges" `Quick test_edges ]); ("ops", [ test_case "fill" `Quick test_fill_op; diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index bc237c5b..0649c620 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -1348,9 +1348,9 @@ let test_headless_checksum () = with Not_found -> false); let c1 = checksum_of out1 and c2 = checksum_of out2 in Alcotest.(check string) "deterministic" c1 c2; - (* This scene includes border insets in the demo's control layouts. *) + (* This scene includes border insets and clipped select labels. *) Alcotest.(check string) "golden" - "headless done: frames=6 checksum=d81c0c21f4815325" c1 + "headless done: frames=6 checksum=1a3748927bb9c905" c1 (* ---------- multi-window ---------- *) @@ -1703,7 +1703,7 @@ let test_headless_multi () = let out1 = run () and out2 = run () in (match win_done_lines out1 with | [ w0; w1 ] -> - Alcotest.(check string) "win0 golden" "d81c0c21f4815325" + Alcotest.(check string) "win0 golden" "1a3748927bb9c905" (checksum_value w0); Alcotest.(check bool) "independent checksums" true (checksum_value w0 <> checksum_value w1) From 1ef9c6b7f2a338768e95ef2aec1537c229c54861 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 17:55:22 +0800 Subject: [PATCH 024/163] fix: honor explicit line heights in text layouts Use styled line boxes and signed half-leading consistently for measurement, baselines, carets, and hit testing. Include line height in the existing bounded layout cache for plain and rich text on both text backends. The complete macOS release suite passes, including pitch, cache identity, spans, and invalid-height regressions. Linux runtime tests remain platform-specific. --- platform/native/lui_text/lui_text.ml | 38 ++++++----- platform/native/lui_text/lui_text.mli | 21 ++++--- .../native/lui_text/test/lui_text_test.ml | 63 ++++++++++++++++++- .../native/lui_text_pango/lui_text_pango.ml | 38 ++++++----- .../native/lui_text_pango/lui_text_pango.mli | 21 ++++--- .../test/lui_text_pango_test.ml | 63 ++++++++++++++++++- 6 files changed, 190 insertions(+), 54 deletions(-) diff --git a/platform/native/lui_text/lui_text.ml b/platform/native/lui_text/lui_text.ml index 5bd04264..8e1009db 100644 --- a/platform/native/lui_text/lui_text.ml +++ b/platform/native/lui_text/lui_text.ml @@ -454,8 +454,11 @@ let units_of_line ~em ~wrap ~rtl ~next_start ~breaks (l : line) = end else base_arr -let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) f s +let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) ?line_height f s (lines : line array) = + Option.iter (fun value -> + if not (Float.is_finite value) || value < 0. then + invalid_arg "line_height must be finite and nonnegative") line_height; let breaks = match breaks with | Some b -> b @@ -468,7 +471,8 @@ let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) f s Array.init n (fun i -> let l = lines.(i) in - let h = l.ascent +. l.descent +. l.leading in + let natural = l.ascent +. l.descent +. l.leading in + let h = Option.value ~default:natural line_height in let next_start = if i + 1 < n then lines.(i + 1).start else -1 in @@ -476,7 +480,7 @@ let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) f s ~breaks l in let lb = { ll_line = l; ll_top = !y; ll_height = h; - ll_baseline = !y +. l.ascent; ll_units = units } + ll_baseline = !y +. (h -. natural) /. 2. +. l.ascent; ll_units = units } in if l.width > !w then w := l.width; y := !y +. h; @@ -486,11 +490,11 @@ let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) f s lay_lines = lbs; lay_width = !w; lay_height = !y; lay_breaks = breaks } -let layout ?(width = 0.) ?(rtl = false) ?breaks f s = - layout_of_lines ?breaks ~width ~rtl f s (shape ~width ~rtl f s) +let layout ?(width = 0.) ?(rtl = false) ?breaks ?line_height f s = + layout_of_lines ?breaks ~width ~rtl ?line_height f s (shape ~width ~rtl f s) -let layout_spans ?(width = 0.) ?(rtl = false) ?breaks f spans = - layout_of_lines ?breaks ~width ~rtl f (span_text spans) +let layout_spans ?(width = 0.) ?(rtl = false) ?breaks ?line_height f spans = + layout_of_lines ?breaks ~width ~rtl ?line_height f (span_text spans) (shape_spans ~width ~rtl f spans) (* ---- caret, hit-testing, selection ---- *) @@ -778,7 +782,7 @@ let truncate ?(mode = `End) ?(rtl = false) ~width f s = (* Shaped layouts are expensive; editing paths measure often and paint sometimes, so the cache is measure-first: [measure] populates the same entry a later [layout] reuses. Entries keyed by - (text, wrap width, direction, font, span signature) live in an LRU; + (text, wrap width, direction, line height, font, span signature) live in an LRU; an entry whose text exceeds [max_bytes] is built but not kept, so a giant paragraph can't evict everything else. *) module Cache = struct @@ -789,7 +793,7 @@ module Cache = struct type key = | K_sentinel - | K of string * float * bool * font * span_sig + | K of string * float * bool * float option * font * span_sig (* Intrusive doubly-linked list behind the table: head.next is the hottest entry, head.prev the eviction candidate. *) @@ -870,12 +874,12 @@ module Cache = struct end; v - let layout t ?(rtl = false) ?(width = 0.) f s = - get t ~key:(K (s, width, rtl, f, [])) ~text_len:(String.length s) - (fun () -> layout ~width ~rtl f s) + let layout t ?(rtl = false) ?(width = 0.) ?line_height f s = + get t ~key:(K (s, width, rtl, line_height, f, [])) ~text_len:(String.length s) + (fun () -> layout ~width ~rtl ?line_height f s) - let measure t ?(rtl = false) ?(width = 0.) f s = - let l = layout t ~rtl ~width f s in + let measure t ?(rtl = false) ?(width = 0.) ?line_height f s = + let l = layout t ~rtl ~width ?line_height f s in (l.lay_width, l.lay_height) let sig_of_spans spans = @@ -886,9 +890,9 @@ module Cache = struct int_of_float (sp.style.skern *. 1024.), sp.style.sunder)) spans - let layout_spans t ?(rtl = false) ?(width = 0.) f spans = + let layout_spans t ?(rtl = false) ?(width = 0.) ?line_height f spans = let s = span_text spans in - get t ~key:(K (s, width, rtl, f, sig_of_spans spans)) + get t ~key:(K (s, width, rtl, line_height, f, sig_of_spans spans)) ~text_len:(String.length s) - (fun () -> layout_spans ~width ~rtl f spans) + (fun () -> layout_spans ~width ~rtl ?line_height f spans) end diff --git a/platform/native/lui_text/lui_text.mli b/platform/native/lui_text/lui_text.mli index 8080c837..50d2047e 100644 --- a/platform/native/lui_text/lui_text.mli +++ b/platform/native/lui_text/lui_text.mli @@ -228,7 +228,7 @@ type cunit = private { type line_layout = private { ll_line : line; (** the shaped line *) ll_top : float; (** its top edge in layout coordinates *) - ll_height : float; (** ascent + descent + leading *) + ll_height : float; (** line box pitch; natural metrics unless overridden *) ll_baseline : float; (** its baseline's y in layout coordinates *) ll_units : cunit array; (** caret units, byte-sorted; a trailing [cnl] unit, when present, covers the @@ -252,17 +252,20 @@ type layout = private { top-left; they stay fractional so subpixel glyph positions drive hit-testing exactly. *) -val layout : ?width:float -> ?rtl:bool -> ?breaks:int array -> font -> +val layout : ?width:float -> ?rtl:bool -> ?breaks:int array -> ?line_height:float -> font -> string -> layout (** [layout f s] shapes [s] ({!width}/{!rtl} as in {!shape}) and stacks its lines. [breaks] overrides the cluster table (tests and - foreign segmenters); default is {!graphemes}. *) + foreign segmenters); default is {!graphemes}. [line_height] overrides + the line box pitch with signed half-leading around the font metrics; + it must be finite and nonnegative. Carets, hit testing, and selection + share those line boxes. *) -val layout_spans : ?width:float -> ?rtl:bool -> ?breaks:int array -> +val layout_spans : ?width:float -> ?rtl:bool -> ?breaks:int array -> ?line_height:float -> font -> span list -> layout (** [layout_spans f spans] is {!layout} over {!shape_spans}. *) -val layout_of_lines : ?breaks:int array -> ?width:float -> ?rtl:bool -> +val layout_of_lines : ?breaks:int array -> ?width:float -> ?rtl:bool -> ?line_height:float -> font -> string -> line array -> layout (** [layout_of_lines f s lines] stacks already-shaped [lines] — what caches and tests use to reuse a [shape] result. *) @@ -333,7 +336,7 @@ val truncate : ?mode:[ `End | `Start | `Middle ] -> ?rtl:bool -> module Cache : sig type t - (** An LRU over (text, wrap, direction, font, span signature) → + (** An LRU over (text, wrap, direction, line height, font, span signature) → {!layout}. Measure-first: {!measure} populates the same entry a later {!layout} reuses, so the common editing path (measure on every frame, layout when painted) pays one shape. *) @@ -343,14 +346,14 @@ module Cache : sig text exceeds [max_bytes] (default 64 KiB) are built but not kept: a giant paragraph can't evict everything else. *) - val layout : t -> ?rtl:bool -> ?width:float -> font -> string -> + val layout : t -> ?rtl:bool -> ?width:float -> ?line_height:float -> font -> string -> layout - val layout_spans : t -> ?rtl:bool -> ?width:float -> font -> + val layout_spans : t -> ?rtl:bool -> ?width:float -> ?line_height:float -> font -> span list -> layout (** The cached builders; on a miss they shape like the top-level functions. *) - val measure : t -> ?rtl:bool -> ?width:float -> font -> string -> + val measure : t -> ?rtl:bool -> ?width:float -> ?line_height:float -> font -> string -> float * float (** [measure] is the entry's extent — a hit costs no shaping. *) diff --git a/platform/native/lui_text/test/lui_text_test.ml b/platform/native/lui_text/test/lui_text_test.ml index 0fc0cae4..67a6fb11 100644 --- a/platform/native/lui_text/test/lui_text_test.ml +++ b/platform/native/lui_text/test/lui_text_test.ml @@ -741,6 +741,66 @@ let test_cache_span_keys () = let _, misses = Cache.stats c in Alcotest.(check int) "both missed" 2 misses +let test_explicit_line_height () = + let text = "a\na" in + let natural = layout sys text in + List.iter + (fun pitch -> + let styled = layout ~line_height:pitch sys text in + Alcotest.(check bool) + "line count preserved" true + (Array.length styled.lay_lines = Array.length natural.lay_lines); + Array.iteri + (fun i line -> + Alcotest.(check bool) + "line boxes use the requested pitch" true + (near line.ll_top (float i *. pitch) && near line.ll_height pitch); + let original = natural.lay_lines.(i) in + let h = original.ll_height in + Alcotest.(check bool) + "baseline includes signed half-leading" true + (near line.ll_baseline + ((float i *. pitch) + +. ((pitch -. h) /. 2.) + +. original.ll_line.ascent))) + styled.lay_lines; + Alcotest.(check bool) + "total height follows the line boxes" true + (near styled.lay_height (2. *. pitch)); + let caret = caret_rect styled 2 in + Alcotest.(check bool) + "caret follows the second line box" true + (near caret.ry pitch && near caret.rh pitch); + if pitch > 0. then + Alcotest.(check int) + "hit follows styled line box" 2 + (hit_test styled (0., pitch *. 1.5))) + [ 0.; 10.; 28.; 40. ]; + let cache = Cache.create 4 in + let first = Cache.layout cache ~line_height:28. sys text in + let second = Cache.layout cache ~line_height:40. sys text in + Alcotest.(check bool) + "line height participates in the cache key" true + (first != second && near second.lay_height 80.); + Alcotest.(check bool) + "unchanged styles reuse their cached layout" true + (first == Cache.layout cache ~line_height:28. sys text); + let _, height = Cache.measure cache ~line_height:40. sys text in + Alcotest.(check bool) + "cached measure shares styled height" true (near height 80.); + let spans = + [ { text; style = default_style; range = (0, String.length text) } ] + in + let sp = Cache.layout_spans cache ~line_height:30. sys spans in + Alcotest.(check bool) + "rich spans retain styled line boxes" true (near sp.lay_height 60.); + List.iter + (fun invalid -> + Alcotest.check_raises "invalid line height fails fast" + (Invalid_argument "line_height must be finite and nonnegative") + (fun () -> ignore (layout ~line_height:invalid sys text))) + [ -1.; Float.nan; Float.infinity ] + let () = let open Alcotest in run "lui_text" @@ -777,7 +837,8 @@ let () = test_case "thick" `Quick test_glyph_thick; test_case "many sizes" `Quick test_fonts_many_sizes ] ); ( "editing", - [ test_case "caret edges" `Quick test_caret_edges; + [ test_case "explicit line height" `Quick test_explicit_line_height; + test_case "caret edges" `Quick test_caret_edges; test_case "hit edges" `Quick test_hit_edges; test_case "ligature" `Quick test_ligature; test_case "rtl model" `Quick test_rtl_model; diff --git a/platform/native/lui_text_pango/lui_text_pango.ml b/platform/native/lui_text_pango/lui_text_pango.ml index 440d3089..41093308 100644 --- a/platform/native/lui_text_pango/lui_text_pango.ml +++ b/platform/native/lui_text_pango/lui_text_pango.ml @@ -452,8 +452,11 @@ let units_of_line ~em ~wrap ~rtl ~next_start ~breaks (l : line) = end else base_arr -let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) f s +let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) ?line_height f s (lines : line array) = + Option.iter (fun value -> + if not (Float.is_finite value) || value < 0. then + invalid_arg "line_height must be finite and nonnegative") line_height; let breaks = match breaks with | Some b -> b @@ -466,7 +469,8 @@ let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) f s Array.init n (fun i -> let l = lines.(i) in - let h = l.ascent +. l.descent +. l.leading in + let natural = l.ascent +. l.descent +. l.leading in + let h = Option.value ~default:natural line_height in let next_start = if i + 1 < n then lines.(i + 1).start else -1 in @@ -474,7 +478,7 @@ let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) f s ~breaks l in let lb = { ll_line = l; ll_top = !y; ll_height = h; - ll_baseline = !y +. l.ascent; ll_units = units } + ll_baseline = !y +. (h -. natural) /. 2. +. l.ascent; ll_units = units } in if l.width > !w then w := l.width; y := !y +. h; @@ -484,11 +488,11 @@ let layout_of_lines ?breaks ?(width = 0.) ?(rtl = false) f s lay_lines = lbs; lay_width = !w; lay_height = !y; lay_breaks = breaks } -let layout ?(width = 0.) ?(rtl = false) ?breaks f s = - layout_of_lines ?breaks ~width ~rtl f s (shape ~width ~rtl f s) +let layout ?(width = 0.) ?(rtl = false) ?breaks ?line_height f s = + layout_of_lines ?breaks ~width ~rtl ?line_height f s (shape ~width ~rtl f s) -let layout_spans ?(width = 0.) ?(rtl = false) ?breaks f spans = - layout_of_lines ?breaks ~width ~rtl f (span_text spans) +let layout_spans ?(width = 0.) ?(rtl = false) ?breaks ?line_height f spans = + layout_of_lines ?breaks ~width ~rtl ?line_height f (span_text spans) (shape_spans ~width ~rtl f spans) (* ---- caret, hit-testing, selection ---- *) @@ -776,7 +780,7 @@ let truncate ?(mode = `End) ?(rtl = false) ~width f s = (* Shaped layouts are expensive; editing paths measure often and paint sometimes, so the cache is measure-first: [measure] populates the same entry a later [layout] reuses. Entries keyed by - (text, wrap width, direction, font, span signature) live in an LRU; + (text, wrap width, direction, line height, font, span signature) live in an LRU; an entry whose text exceeds [max_bytes] is built but not kept, so a giant paragraph can't evict everything else. *) module Cache = struct @@ -787,7 +791,7 @@ module Cache = struct type key = | K_sentinel - | K of string * float * bool * font * span_sig + | K of string * float * bool * float option * font * span_sig (* Intrusive doubly-linked list behind the table: head.next is the hottest entry, head.prev the eviction candidate. *) @@ -868,12 +872,12 @@ module Cache = struct end; v - let layout t ?(rtl = false) ?(width = 0.) f s = - get t ~key:(K (s, width, rtl, f, [])) ~text_len:(String.length s) - (fun () -> layout ~width ~rtl f s) + let layout t ?(rtl = false) ?(width = 0.) ?line_height f s = + get t ~key:(K (s, width, rtl, line_height, f, [])) ~text_len:(String.length s) + (fun () -> layout ~width ~rtl ?line_height f s) - let measure t ?(rtl = false) ?(width = 0.) f s = - let l = layout t ~rtl ~width f s in + let measure t ?(rtl = false) ?(width = 0.) ?line_height f s = + let l = layout t ~rtl ~width ?line_height f s in (l.lay_width, l.lay_height) let sig_of_spans spans = @@ -884,9 +888,9 @@ module Cache = struct int_of_float (sp.style.skern *. 1024.), sp.style.sunder)) spans - let layout_spans t ?(rtl = false) ?(width = 0.) f spans = + let layout_spans t ?(rtl = false) ?(width = 0.) ?line_height f spans = let s = span_text spans in - get t ~key:(K (s, width, rtl, f, sig_of_spans spans)) + get t ~key:(K (s, width, rtl, line_height, f, sig_of_spans spans)) ~text_len:(String.length s) - (fun () -> layout_spans ~width ~rtl f spans) + (fun () -> layout_spans ~width ~rtl ?line_height f spans) end diff --git a/platform/native/lui_text_pango/lui_text_pango.mli b/platform/native/lui_text_pango/lui_text_pango.mli index 654e51ba..138a37c4 100644 --- a/platform/native/lui_text_pango/lui_text_pango.mli +++ b/platform/native/lui_text_pango/lui_text_pango.mli @@ -228,7 +228,7 @@ type cunit = private { type line_layout = private { ll_line : line; (** the shaped line *) ll_top : float; (** its top edge in layout coordinates *) - ll_height : float; (** ascent + descent + leading *) + ll_height : float; (** line box pitch; natural metrics unless overridden *) ll_baseline : float; (** its baseline's y in layout coordinates *) ll_units : cunit array; (** caret units, byte-sorted; a trailing [cnl] unit, when present, covers the @@ -252,17 +252,20 @@ type layout = private { top-left; they stay fractional so subpixel glyph positions drive hit-testing exactly. *) -val layout : ?width:float -> ?rtl:bool -> ?breaks:int array -> font -> +val layout : ?width:float -> ?rtl:bool -> ?breaks:int array -> ?line_height:float -> font -> string -> layout (** [layout f s] shapes [s] ({!width}/{!rtl} as in {!shape}) and stacks its lines. [breaks] overrides the cluster table (tests and - foreign segmenters); default is {!graphemes}. *) + foreign segmenters); default is {!graphemes}. [line_height] overrides + the line box pitch with signed half-leading around the font metrics; + it must be finite and nonnegative. Carets, hit testing, and selection + share those line boxes. *) -val layout_spans : ?width:float -> ?rtl:bool -> ?breaks:int array -> +val layout_spans : ?width:float -> ?rtl:bool -> ?breaks:int array -> ?line_height:float -> font -> span list -> layout (** [layout_spans f spans] is {!layout} over {!shape_spans}. *) -val layout_of_lines : ?breaks:int array -> ?width:float -> ?rtl:bool -> +val layout_of_lines : ?breaks:int array -> ?width:float -> ?rtl:bool -> ?line_height:float -> font -> string -> line array -> layout (** [layout_of_lines f s lines] stacks already-shaped [lines] — what caches and tests use to reuse a [shape] result. *) @@ -333,7 +336,7 @@ val truncate : ?mode:[ `End | `Start | `Middle ] -> ?rtl:bool -> module Cache : sig type t - (** An LRU over (text, wrap, direction, font, span signature) → + (** An LRU over (text, wrap, direction, line height, font, span signature) → {!layout}. Measure-first: {!measure} populates the same entry a later {!layout} reuses, so the common editing path (measure on every frame, layout when painted) pays one shape. *) @@ -343,14 +346,14 @@ module Cache : sig text exceeds [max_bytes] (default 64 KiB) are built but not kept: a giant paragraph can't evict everything else. *) - val layout : t -> ?rtl:bool -> ?width:float -> font -> string -> + val layout : t -> ?rtl:bool -> ?width:float -> ?line_height:float -> font -> string -> layout - val layout_spans : t -> ?rtl:bool -> ?width:float -> font -> + val layout_spans : t -> ?rtl:bool -> ?width:float -> ?line_height:float -> font -> span list -> layout (** The cached builders; on a miss they shape like the top-level functions. *) - val measure : t -> ?rtl:bool -> ?width:float -> font -> string -> + val measure : t -> ?rtl:bool -> ?width:float -> ?line_height:float -> font -> string -> float * float (** [measure] is the entry's extent — a hit costs no shaping. *) diff --git a/platform/native/lui_text_pango/test/lui_text_pango_test.ml b/platform/native/lui_text_pango/test/lui_text_pango_test.ml index 1640203d..bbfb0646 100644 --- a/platform/native/lui_text_pango/test/lui_text_pango_test.ml +++ b/platform/native/lui_text_pango/test/lui_text_pango_test.ml @@ -750,6 +750,66 @@ let test_cache_span_keys () = let _, misses = Cache.stats c in Alcotest.(check int) "both missed" 2 misses +let test_explicit_line_height () = + let text = "a\na" in + let natural = layout sys text in + List.iter + (fun pitch -> + let styled = layout ~line_height:pitch sys text in + Alcotest.(check bool) + "line count preserved" true + (Array.length styled.lay_lines = Array.length natural.lay_lines); + Array.iteri + (fun i line -> + Alcotest.(check bool) + "line boxes use the requested pitch" true + (near line.ll_top (float i *. pitch) && near line.ll_height pitch); + let original = natural.lay_lines.(i) in + let h = original.ll_height in + Alcotest.(check bool) + "baseline includes signed half-leading" true + (near line.ll_baseline + ((float i *. pitch) + +. ((pitch -. h) /. 2.) + +. original.ll_line.ascent))) + styled.lay_lines; + Alcotest.(check bool) + "total height follows the line boxes" true + (near styled.lay_height (2. *. pitch)); + let caret = caret_rect styled 2 in + Alcotest.(check bool) + "caret follows the second line box" true + (near caret.ry pitch && near caret.rh pitch); + if pitch > 0. then + Alcotest.(check int) + "hit follows styled line box" 2 + (hit_test styled (0., pitch *. 1.5))) + [ 0.; 10.; 28.; 40. ]; + let cache = Cache.create 4 in + let first = Cache.layout cache ~line_height:28. sys text in + let second = Cache.layout cache ~line_height:40. sys text in + Alcotest.(check bool) + "line height participates in the cache key" true + (first != second && near second.lay_height 80.); + Alcotest.(check bool) + "unchanged styles reuse their cached layout" true + (first == Cache.layout cache ~line_height:28. sys text); + let _, height = Cache.measure cache ~line_height:40. sys text in + Alcotest.(check bool) + "cached measure shares styled height" true (near height 80.); + let spans = + [ { text; style = default_style; range = (0, String.length text) } ] + in + let sp = Cache.layout_spans cache ~line_height:30. sys spans in + Alcotest.(check bool) + "rich spans retain styled line boxes" true (near sp.lay_height 60.); + List.iter + (fun invalid -> + Alcotest.check_raises "invalid line height fails fast" + (Invalid_argument "line_height must be finite and nonnegative") + (fun () -> ignore (layout ~line_height:invalid sys text))) + [ -1.; Float.nan; Float.infinity ] + let () = let open Alcotest in run "lui_text_pango" @@ -782,7 +842,8 @@ let () = test_case "positions" `Quick test_subpixel_positions; test_case "baseline" `Quick test_baseline ] ); ( "editing", - [ test_case "caret edges" `Quick test_caret_edges; + [ test_case "explicit line height" `Quick test_explicit_line_height; + test_case "caret edges" `Quick test_caret_edges; test_case "hit edges" `Quick test_hit_edges; test_case "ligature" `Quick test_ligature; test_case "rtl model" `Quick test_rtl_model; From d2f3f177fd2d28247be59a2118bbf3fa294f96a6 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 18:29:06 +0800 Subject: [PATCH 025/163] fix: preserve image aspect ratio in native fill mode Crop the source around its center when filling a native image rectangle, matching the existing Apple backend behavior. Cover wide, tall, and matching source ratios. The complete release suite passes. --- platform/native/lui_paint/lui_paint.ml | 8 +++++ .../native/lui_paint/test/lui_paint_test.ml | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index 981ec9fe..a2422b4b 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -1717,6 +1717,14 @@ let image_ops ctx store id r alpha radii = rect sx sy sw' sh' | _ -> rect 0. 0. sw sh in + let isrc = + if pstr store id "image-fit" = Some "fill" && r.w > 0. && r.h > 0. then + let scale = Float.max (r.w /. isrc.w) (r.h /. isrc.h) in + let width = r.w /. scale and height = r.h /. scale in + rect (isrc.x +. (isrc.w -. width) /. 2.) + (isrc.y +. (isrc.h -. height) /. 2.) width height + else isrc + in let dst = match pstr store id "image-fit" with | Some "fit" -> diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index 271b5674..a9ee92e0 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -116,6 +116,36 @@ let test_select_indicator () = [ 1.; 2. ]) [ Lui_protocol.Select; Combobox ] +let test_image_fill_crop () = + List.iter + (fun (width, height, expected) -> + let t = store () in + apply t + [ + Lui_protocol.CreateNode (1, FileImage); + SetProp (1, ImageFitValue, StringValue "fill"); + ]; + let image = + new_image ~w:width ~h:height (Bytes.make (width * height * 4) '\255') + in + let hooks = + { (hooks ()) with Lui_paint.image_of = (fun _ -> Some image) } + in + let scene = paint_with hooks t () in + let op = + List.find_map (function Image i -> Some i | _ -> None) scene.ops + |> Option.get + in + check bool "fill preserves the destination" true + (op.irect2 = rect 0. 0. 100. 40.); + check bool "fill crops the source without distorting its aspect" true + (op.isrc = expected)) + [ + (200, 40, rect 50. 0. 100. 40.); + (100, 100, rect 0. 30. 100. 40.); + (100, 40, rect 0. 0. 100. 40.); + ] + let test_fill_op () = let t = store () in apply t @@ -1089,6 +1119,7 @@ let () = [ ("color", [ test_case "hex" `Quick test_hex; test_case "select indicator" `Quick test_select_indicator; + test_case "image fill crop" `Quick test_image_fill_crop; test_case "edges" `Quick test_edges ]); ("ops", [ test_case "fill" `Quick test_fill_op; From ba07de00011eb4494063118cd2eed0a23c674b1a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 18:52:57 +0800 Subject: [PATCH 026/163] fix: bound and scroll native menu surfaces Apply the shared 18rem menu cap to dropdowns, context menus and menu popovers. Clamp custom limits to viewport and available height, preserve option sizes, and share scroll and clipping classification. Cover resize and scale changes, final-option click delivery, and dynamic menu semantics. The complete serialized release build and test suite passes. --- platform/native/lui_input/lui_scroll.ml | 8 +- platform/native/lui_layout/lui_layout.ml | 28 ++++- .../native/lui_layout/test/lui_layout_test.ml | 100 ++++++++++++++++++ platform/native/lui_paint/lui_paint.ml | 9 +- platform/native/lui_store/lui_store.ml | 4 +- .../native/lui_window/test/lui_window_test.ml | 56 ++++++++++ platform/web/src/lui.css | 4 + 7 files changed, 195 insertions(+), 14 deletions(-) diff --git a/platform/native/lui_input/lui_scroll.ml b/platform/native/lui_input/lui_scroll.ml index 632aefc6..b40d5c6e 100644 --- a/platform/native/lui_input/lui_scroll.ml +++ b/platform/native/lui_input/lui_scroll.ml @@ -68,13 +68,7 @@ let initial = (* Same scrollable rule as the paint pass: a scroll-kind node, or anything opting in through its overflow prop. *) let scrollable store id = - (match Lui_store.kind store id with - | "scroll" | "list" | "virtual-list" -> true - | _ -> false) - || - (match Lui_query.string_prop store id "overflow" with - | "scroll" | "auto" -> true - | _ -> false) + Lui_paint.scrollable (Lui_store.kind store id) store id let offset st axis id = let t = match axis with Y -> st.off | X -> st.off_x in diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index f22ca07b..74f5f05e 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -412,7 +412,9 @@ let overflow_of ~store ~id kind : Lui_flex.overflow = | Some ("auto" | "scroll") -> Scroll | Some ("hidden" | "clip") -> Hidden | Some "visible" -> Visible - | _ -> if scroll_kind kind then Scroll else Visible + | _ -> + if scroll_kind kind || Lui_paint.menu_kind store id kind then Scroll + else Visible (* container-relative-frame(+ -inset): fills the container along the named axes (min-* variants floor it instead); the inset becomes a @@ -668,7 +670,7 @@ let adjust ~pkind ~pid ~index store id (style : Lui_flex.style) : Lui_flex.style else style in let style = - if scroll_kind pkind then + if scroll_kind pkind || Lui_paint.menu_kind store pid pkind then (* A scroller's content keeps its natural main size so it overflows and scrolls — the default flex_shrink=1 would compress children into the clipped viewport instead. *) @@ -1372,6 +1374,25 @@ let refine_popups ctx = match Hashtbl.find_opt ctx.applied sid with | None -> () | Some (pstyle : Lui_flex.style) -> + let max_height = + if Lui_paint.menu_kind ctx.store sid (Lui_store.kind ctx.store sid) then + (* Match the shared Web menu cap; explicit limits and available + height remain bounded by the viewport, including on resize. *) + let viewport = Float.max 0. (ctx.vh -. (16. *. ctx.scale)) in + let declared = + match pstyle.max_height with + | Unset -> 288. *. ctx.scale + | limit -> len_px ~owner:ctx.vh limit + in + let available = + match pnum ctx.store sid "available-height" with + | Some height -> height *. ctx.scale + | None -> viewport + in + Pt (Float.max 0. (Float.min viewport (Float.min declared available))) + else + match pstyle.max_height with Unset -> Pt ctx.vh | limit -> limit + in let pstyle = (* A popup measures against the viewport, not its (possibly tiny) parent — bound its max extents and @@ -1379,8 +1400,7 @@ let refine_popups ctx = { pstyle with max_width = (match pstyle.max_width with Unset -> Pt ctx.vw | m -> m); - max_height = - (match pstyle.max_height with Unset -> Pt ctx.vh | m -> m) } + max_height } in Lui_flex.set_style pnode pstyle; Lui_flex.compute_layout pnode ~width:Lui_flex.undefined diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index 7c4e8018..0f860066 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -1143,6 +1143,104 @@ let test_split_gap_fraction () = [ 0.; 0.25; 0.7; 1. ]) [ 1.; 2. ] +let test_menu_height_cap () = + List.iter + (fun scale -> + List.iter + (fun (kind, props) -> + List.iter + (fun (maximum, available) -> + let store = Lui_store.create () + and layout = Lui_layout.create () in + apply store + [ + CreateNode (1, Root); + CreateNode (2, kind); + InsertChild (1, 2, 0); + SetProp (2, WidthValue, IntValue 200); + SetProp (2, AnchorValue, StringValue "below"); + ]; + apply store props; + Option.iter + (fun value -> + apply store [ SetProp (2, MaxHeight, IntValue value) ]) + maximum; + Option.iter + (fun value -> + apply store [ SetProp (2, AvailableHeight, FloatValue value) ]) + available; + for index = 0 to 29 do + let id = index + 3 in + apply store + [ + CreateNode (id, MenuItem); + InsertChild (2, id, index); + SetProp (id, HeightValue, IntValue 32); + ] + done; + List.iter + (fun height -> + Lui_layout.sync ~scale ~width:(640. *. scale) + ~height:(height *. scale) layout store; + let popup = Option.get (Lui_layout.rect layout 2) in + let limit = + float_of_int (Option.value ~default:288 maximum) + in + let limit = + Float.min limit (Option.value ~default:limit available) + in + check (float eps) "menu height follows its cap and viewport" + (Float.min limit (height -. 16.) *. scale) + popup.h; + check bool "menu stays within the viewport" true + (popup.y >= 0. && popup.y +. popup.h <= height *. scale); + let first = Option.get (Lui_layout.rect layout 3) + and last = Option.get (Lui_layout.rect layout 32) in + check (float eps) "constrained menu rows retain their height" + (32. *. scale) first.h; + check bool "overflow remains available for scrolling" true + (last.y +. last.h > popup.y +. popup.h)) + [ 932.; 180.; 600. ]) + [ + (None, None); (Some 128, None); (Some 480, None); (None, Some 96.); + ]) + [ + (DropdownMenu, []); + (ContextMenu, []); + (Popover, [ SetProp (2, RoleValue, StringValue "menu") ]); + ]) + [ 1.; 2. ] + +let test_menu_role_layout () = + let store = Lui_store.create () and layout = Lui_layout.create () in + apply store + [ + CreateNode (1, Root); + CreateNode (2, Popover); + InsertChild (1, 2, 0); + SetProp (2, WidthValue, IntValue 200); + ]; + for index = 0 to 29 do + let id = index + 3 in + apply store + [ + CreateNode (id, MenuItem); + InsertChild (2, id, index); + SetProp (id, HeightValue, IntValue 32); + ] + done; + let height () = + Lui_layout.sync ~width:640. ~height:932. layout store; + (Option.get (Lui_layout.rect layout 2)).h + in + check (float eps) "ordinary popovers retain their content height" 932. + (height ()); + apply store [ SetProp (2, RoleValue, StringValue "menu") ]; + check (float eps) "adding menu semantics invalidates height" 288. (height ()); + apply store [ SetProp (2, RoleValue, StringValue "") ]; + check (float eps) "removing menu semantics restores content height" 932. + (height ()) + let () = run "lui_layout" [ ("layout", @@ -1167,6 +1265,8 @@ let () = test_case "popup collision flip" `Quick test_popup_collision_flip; test_case "popup in centered dialog" `Quick test_popup_inside_centered_dialog; test_case "menu panel" `Quick test_menu_panel; + test_case "menu height cap" `Quick test_menu_height_cap; + test_case "menu role layout" `Quick test_menu_role_layout; test_case "ac row" `Quick test_ac_row; test_case "vlist window" `Quick test_vlist_window_mounts_slice; test_case "vlist window moves" `Quick test_vlist_window_moves; diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index a2422b4b..797eb4bd 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -103,7 +103,7 @@ let prop_coverage = ("tooltip-delay", Host); ("duration", Host); ("text-alignment", Painted_by_text); - ("role", Host); + ("role", Layout); ("tree-level", Layout); (* row indent *) ("expanded", Unmapped "disclosure chevron glyph"); ("resize-duration", Host); @@ -1431,7 +1431,6 @@ let select_ops ctx store id rs r alpha fg = in let logical = { - slot with x = slot.x /. ctx.scale; y = slot.y /. ctx.scale; w = slot.w /. ctx.scale; @@ -2287,12 +2286,18 @@ let disabled_alpha store id st = (* ---------- node paint ---------- *) +let menu_kind store id = function + | "dropdown-menu" | "context-menu" -> true + | "popover" -> pstr store id "role" = Some "menu" + | _ -> false + let scroll_kind = function | "scroll" | "list" | "virtual-list" -> true | _ -> false let scrollable kind store id = scroll_kind kind + || menu_kind store id kind || match pstr store id "overflow" with | Some ("auto" | "scroll") -> true | _ -> false diff --git a/platform/native/lui_store/lui_store.ml b/platform/native/lui_store/lui_store.ml index 133fccf7..237c1153 100644 --- a/platform/native/lui_store/lui_store.ml +++ b/platform/native/lui_store/lui_store.ml @@ -109,7 +109,7 @@ let has_prefix s p = (* Semantics-only props: the a11y tree cares, paint and layout do not. *) let semantics_prop_names = - [ "accessibility-label"; "accessibility-identifier"; "role"; "expanded"; + [ "accessibility-label"; "accessibility-identifier"; "expanded"; "level"; "sort" ] let semantics_props = @@ -132,6 +132,8 @@ let input_props = 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 + (* Menu semantics change surface bounds, clipping, and child shrink. *) + else if name = "role" then d_layout_kids lor d_layout lor d_paint lor d_semantics 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 diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index 0649c620..26895c82 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2635,6 +2635,61 @@ let test_assembly_scroll_caps () = Alcotest.(check (option int)) "scrolled hit" (Some 4) (hit (Assembly.store asm) (Assembly.rects asm) ~x:10. ~y:90.) +let test_assembly_menu_scroll () = + List.iter + (fun (kind, props) -> + let asm = + assembly_of + [ + CreateNode (1, Root); + CreateNode (2, kind); + CreateNode (3, MenuItem); + CreateNode (4, MenuItem); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); + InsertChild (2, 4, 1); + SetProp (2, HeightValue, IntValue 100); + SetProp (3, HeightValue, IntValue 120); + SetProp (4, HeightValue, IntValue 80); + ] + in + Lui_store.apply_batch (Assembly.store asm) { generation = 2; ops = props }; + ignore (Assembly.store_changed asm); + ignore (Assembly.repaint asm); + let ui = Assembly.ui asm and store = Assembly.store asm in + Alcotest.(check (float 0.001)) + "menus scroll without an overflow override" 100. (Ui.scroll_cap ui 2); + Alcotest.(check bool) + "offscreen menu option cannot be hit" true + (hit store (Assembly.rects asm) ~x:10. ~y:150. <> Some 4); + ignore (Assembly.handle asm (Input.Move (10., 10.))); + ignore (Assembly.handle asm (Input.Wheel (0., -100.))); + ignore (Assembly.repaint asm); + Alcotest.(check (float 0.001)) + "wheel reaches the final option" 100. (Ui.scroll_offset ui 2); + let final = Rects.get (Assembly.rects asm) 4 in + Alcotest.(check (float 0.001)) + "final option is fully visible" 100. (final.y +. final.h); + Alcotest.(check (option int)) + "final option receives hits" (Some 4) + (hit store (Assembly.rects asm) ~x:10. ~y:60.); + ignore + (Ui.handle ui store (Assembly.rects asm) + (Input.Button_down (10., 60., Input.Left, 1, Input.mods_none))); + let events = + press_actions + (Ui.handle ui store (Assembly.rects asm) + (Input.Button_up (10., 60., Input.Left, Input.mods_none))) + in + Alcotest.(check bool) + "final option remains clickable" true + (List.mem (Press 4) events)) + [ + (DropdownMenu, []); + (ContextMenu, []); + (Popover, [ SetProp (2, RoleValue, StringValue "menu") ]); + ] + let test_assembly_scroll_shows_in_paint () = (* scroll_of feeds the scrollbar: a container with range reports metrics; one without reports none. *) @@ -3029,6 +3084,7 @@ let () = [ Alcotest.test_case "scroll caps" `Quick test_assembly_scroll_caps; Alcotest.test_case "inset scroll caps" `Quick test_assembly_inset_scroll_caps; + Alcotest.test_case "intrinsic menu scroll" `Quick test_assembly_menu_scroll; Alcotest.test_case "scrollbar paint" `Quick test_assembly_scroll_shows_in_paint; Alcotest.test_case "autofocus" `Quick test_assembly_autofocus ] ); diff --git a/platform/web/src/lui.css b/platform/web/src/lui.css index 44e18f48..30ffe41c 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -1070,6 +1070,10 @@ body { transition-timing-function: cubic-bezier(0.2, 0, 0, 1); } + .lui-popover[role="menu"] { + max-height: min(18rem, var(--lui-popup-available-height, calc(100vh - 1rem))); + } + .lui-popover:not([data-open]):not([data-ending-style]) { display: none; } From 00d2d87e215d8414a12ca4250de48e9d5ad5026b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 19:16:30 +0800 Subject: [PATCH 027/163] fix: own native menu keyboard navigation Route navigation, activation and typeahead to the active menu. Share pointer and keyboard highlight, reveal options before activation, and report consumed input to document-event hosts. Preserve input and composition in ordinary editors and embedded menu fields. Cover disabled and nested options, scrolling, detail activation and disposal. The complete release build and test suite passes. --- platform/native/lui_input/lui_gesture.ml | 94 +++++++++ platform/native/lui_window/lui_window.ml | 71 ++++++- platform/native/lui_window/lui_window.mli | 7 +- .../native/lui_window/test/lui_window_test.ml | 199 ++++++++++++++++++ 4 files changed, 367 insertions(+), 4 deletions(-) diff --git a/platform/native/lui_input/lui_gesture.ml b/platform/native/lui_input/lui_gesture.ml index 5064aa9b..d1d2e43b 100644 --- a/platform/native/lui_input/lui_gesture.ml +++ b/platform/native/lui_input/lui_gesture.ml @@ -191,3 +191,97 @@ let list_key store st ~scope ~content_rect ~key ~mods = | None -> (st, [])) | None -> (st, [])) | _ -> (st, []) + +(* Menus own their enabled rows, excluding separately presented submenus. *) +let menu_has_items store menu = + let rec contains id = + if id <> menu && Lui_query.presented_overlay store id then false + else + Lui_query.kind_name store id = "menu-item" + || List.exists contains (Lui_store.child_ids store id) + in + contains menu + +let menu_items store menu = + let rec collect acc id = + if id <> menu && Lui_query.presented_overlay store id then acc + else + let acc = + if + Lui_query.kind_name store id = "menu-item" + && Lui_query.node_enabled store id + then id :: acc + else acc + in + List.fold_left collect acc (Lui_store.child_ids store id) + in + List.rev (collect [] menu) + +let menu_state st menu = + if st.list_scope = menu then st + else { list_scope = menu; hl = 0; typeahead = "" } + +let menu_hover store st ~menu path = + match + List.find_opt + (fun id -> + Lui_query.kind_name store id = "menu-item" + && Lui_query.node_enabled store id + && Lui_query.is_desc store id menu) + path + with + | Some id when st.list_scope <> menu || st.hl <> id -> + { list_scope = menu; hl = id; typeahead = "" } + | _ -> st + +let menu_text store st ~menu text = + let st = menu_state st menu in + let prefix = String.lowercase_ascii (st.typeahead ^ text) in + let matches id = + String.starts_with ~prefix + (String.lowercase_ascii (Lui_query.text_of store id)) + in + let st = { st with typeahead = prefix } in + match List.find_opt matches (menu_items store menu) with + | None -> (st, []) + | Some id -> ({ st with hl = id }, [ `Repaint; `Scroll_show id ]) + +let menu_key store st ~menu ~rect_of ~scale ~key ~mods = + let st = menu_state st menu in + let items = menu_items store menu in + let move index = hl_index store { st with typeahead = "" } items index in + let count = List.length items in + let step delta = + let index = + match Lui_query.index_of st.hl items with + | Some index -> (index + delta + count) mod count + | None -> if delta > 0 then 0 else count - 1 + in + move index + in + match key with + | Lui_input.Arrow_down when count > 0 -> step 1 + | Lui_input.Arrow_up when count > 0 -> step (-1) + | Lui_input.Home -> move 0 + | Lui_input.End -> move (count - 1) + | (Lui_input.Return | Lui_input.Space) when List.mem st.hl items -> + let r = rect_of st.hl in + let detail = + Lui_query.detail_of store st.hl + ((r.Lui_scene.x +. (r.w /. 2.)) /. scale) + ((r.y +. (r.h /. 2.)) /. scale) + mods 0 + in + let event = PressDetail (st.hl, detail) in + let details = + if Lui_query.event_supported store st.hl event then [ `Dispatch event ] + else [] + in + (st, details @ Lui_query.primary_press store st.hl) + | Lui_input.Page_down | Lui_input.Page_up -> + let r = rect_of menu in + let direction = if key = Lui_input.Page_down then 1. else -1. in + ( st, + [ `Scroll_by (Lui_scroll.Y, menu, direction *. 0.9 *. r.Lui_scene.h) ] + ) + | _ -> (st, []) diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 74e67fad..d51d58d5 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -519,6 +519,7 @@ module Ui = struct scrollbar_deadlines : (int, float) Hashtbl.t; mutable drag : Lui_drag.state; mutable overlay : Lui_overlay.state; + mutable menu_scope : int; (* 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 @@ -563,6 +564,7 @@ module Ui = struct scrollbar_deadlines = Hashtbl.create 8; drag = Lui_drag.initial; overlay = Lui_overlay.initial; + menu_scope = 0; scrolled = Hashtbl.create 8; scroll_metrics = Hashtbl.create 8; paint_snap = @@ -860,6 +862,10 @@ module Ui = struct in t.overlay <- ov'; run m); + (let id = Lui_overlay.top t.overlay in + t.menu_scope <- + if id <> 0 && Lui_paint.menu_kind store id (Lui_store.kind store id) + && Lui_gesture.menu_has_items store id then id else 0); (let f', cand = Lui_focus.scan_autofocus store t.focus ~scope:(Lui_overlay.top t.overlay) @@ -907,7 +913,31 @@ module Ui = struct if List.exists (Lui_query.presented_overlay store) path then None else List.find_opt (fun id -> split_handle t store rects id ~x ~y) path - let handle ?spatial t store rects input = + let active_menu t store = + let id = t.menu_scope and focused = Lui_focus.focused t.focus in + if + id <> 0 + && not + (focused <> 0 + && Lui_text_input.editable store focused + && Lui_query.is_desc store focused id) + then Some id + else None + + let visible_rect t store id = + let rec offsets id x y = + match Lui_store.parent store id with + | None -> (x, y) + | Some parent -> + offsets parent + (x +. scroll_offset_x t parent) + (y +. scroll_offset t parent) + in + let x, y = offsets id 0. 0. in + let r = t.content_rect id in + { r with Lui_scene.x = r.x -. x; y = r.y -. y } + + let handle ?spatial ?(on_component_input = fun () -> ()) t store rects input = let acts = ref [] in let emit_all l = acts := List.rev_append l !acts in let run m_acts = emit_all (perform_all t store m_acts) in @@ -982,6 +1012,14 @@ module Ui = struct let path = hit_path ?spatial store rects ~x:dx ~y:dy in let p, m = Lui_pointer.move store t.pointer ~path ~x ~y in t.pointer <- p; + (match active_menu t store with + | Some menu -> + let gesture = Lui_gesture.menu_hover store t.gesture ~menu path in + if gesture != t.gesture then begin + t.gesture <- gesture; + t.dirty <- true + end + | None -> ()); run m end end @@ -1166,12 +1204,20 @@ module Ui = struct t.pointer <- Lui_pointer.clear_press t.pointer; t.dirty <- true | _ -> ()) + | Input.Text_input s when Option.is_some (active_menu t store) -> + on_component_input (); + let g,m = Lui_gesture.menu_text store t.gesture + ~menu:(Option.get (active_menu t store)) s in + t.gesture <- g; + run m | Input.Text_input s -> let s', m = Lui_text_input.feed_ime store t.session (`Commit s) in t.session <- s'; run m + | Input.Text_editing _ when Option.is_some (active_menu t store) -> + on_component_input () | Input.Text_editing (text, start, len) -> let s', m = Lui_text_input.feed_ime store t.session @@ -1195,6 +1241,21 @@ module Ui = struct t.session <- s'; run m end + | Input.Key_down (key, mods, _repeat) + when not (mods.Input.ctrl || mods.Input.meta || mods.Input.alt) + && Option.is_some (active_menu t store) + && (match key with + | Input.Arrow_down | Input.Arrow_up | Input.Home | Input.End + | Input.Page_down | Input.Page_up | Input.Return | Input.Space -> true + | Input.Other scan -> Option.is_some (Input.char_of_scancode scan) + | _ -> false) -> + on_component_input (); + let g,m = Lui_gesture.menu_key store t.gesture + ~menu:(Option.get (active_menu t store)) + ~rect_of:(visible_rect t store) ~scale:t.scale + ~key:(conv_key key) ~mods:(conv_mods mods) in + t.gesture <- g; + run m | Input.Key_down (key, mods, _repeat) -> (* While marked text is on screen the keys belong to the IME — SDL filters most of them already; this gate is the @@ -1371,8 +1432,14 @@ module Ui = struct List.rev !acts let state_of t store id = + let menu_highlight = + kind_name store id = "menu-item" + && t.gesture.Lui_gesture.hl <> 0 + && active_menu t store = Some t.gesture.Lui_gesture.list_scope + in { Lui_paint.hovered = - (Lui_pointer.hovered t.pointer = id + ((if menu_highlight then Lui_gesture.hl t.gesture = id + else Lui_pointer.hovered t.pointer = id) || Lui_drag.target t.drag = id); pressed = (Lui_pointer.pressed t.pointer = id diff --git a/platform/native/lui_window/lui_window.mli b/platform/native/lui_window/lui_window.mli index a839ca1f..e84106c6 100644 --- a/platform/native/lui_window/lui_window.mli +++ b/platform/native/lui_window/lui_window.mli @@ -396,9 +396,12 @@ module Ui : sig val store_changed : t -> Lui_store.t -> action list (** Map one input event to ordered actions. [?spatial] accelerates - the rect hit-tests — see {!Lui_window.hit_path}. *) + the rect hit-tests — see {!Lui_window.hit_path}. + [on_component_input] runs when a menu consumes the input; hosts + forwarding document events must suppress that duplicate delivery. *) val handle : - ?spatial:Lui_spatial.t -> t -> Lui_store.t -> Rects.t -> + ?spatial:Lui_spatial.t -> ?on_component_input:(unit -> unit) -> + t -> Lui_store.t -> Rects.t -> Input.t -> action list (** [state_of] hook for the paint pipeline: folds hover/press/focus diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index 26895c82..aabf525c 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2690,6 +2690,203 @@ let test_assembly_menu_scroll () = (Popover, [ SetProp (2, RoleValue, StringValue "menu") ]); ] +let test_assembly_menu_keyboard () = + List.iter + (fun (kind, props) -> + let asm = + assembly_of + [ + CreateNode (1, Root); + CreateNode (2, kind); + CreateNode (3, TextField); + InsertChild (1, 3, 0); + InsertChild (1, 2, 1); + SetProp (3, HeightValue, IntValue 30); + SetProp (3, TextValue, StringValue "background"); + SetProp (2, HeightValue, IntValue 100); + ] + in + let ui = Assembly.ui asm and store = Assembly.store asm in + Lui_store.apply_batch store { generation = 2; ops = props }; + for index = 0 to 19 do + let id = index + 4 in + Lui_store.apply_batch store + { + generation = index + 3; + ops = + [ + CreateNode (id, MenuItem); + InsertChild (2, id, index); + SetProp (id, HeightValue, IntValue 32); + SetProp + ( id, + TextValue, + StringValue (if id = 23 then "Last item" else "Option") ); + SetProp (id, Enabled, BoolValue (id <> 5)); + ]; + } + done; + ignore (Assembly.store_changed asm); + ignore (Assembly.repaint asm); + Ui.set_focused ui store 3; + let input key = + Assembly.handle asm (Input.Key_down (key, Input.mods_none, false)) + in + List.iter + (fun (key, expected) -> + ignore (input key); + Alcotest.(check int) + "menu keys navigate enabled options before the background field" + expected (Ui.hl_item ui)) + [ + (Input.Home, 4); + (Input.Arrow_down, 6); + (Input.Arrow_up, 4); + (Input.Arrow_up, 23); + (Input.Arrow_down, 4); + (Input.End, 23); + ]; + Alcotest.(check string) + "menu navigation preserves the background field" "background" + (Ui.shadow ui); + Alcotest.(check bool) + "keyboard highlight paints as hovered" true + (Ui.state_of ui store 23).Lui_paint.hovered; + ignore (Assembly.repaint asm); + let panel = Rects.get (Assembly.rects asm) 2 + and last = Rects.get (Assembly.rects asm) 23 in + Alcotest.(check bool) + "End reveals the entire final option" true + (Ui.scroll_offset ui 2 > 0. + && last.y >= panel.y + && last.y +. last.h <= panel.y +. panel.h); + List.iter + (fun key -> + let events = + press_actions + (Ui.handle ui store (Assembly.rects asm) + (Input.Key_down (key, Input.mods_none, false))) + in + Alcotest.(check int) + "keyboard activation dispatches Press exactly once" 1 + (List.length (List.filter (( = ) (Press 23)) events)); + Alcotest.(check bool) + "detail-only menu actions receive keyboard activation" true + (List.exists + (function + | PressDetail (23, d) -> + d.x >= last.x + && d.x <= last.x +. last.w + && d.y >= last.y + && d.y <= last.y +. last.h + | _ -> false) + events)) + [ Input.Return; Input.Space ]; + let previous = Rects.get (Assembly.rects asm) 22 in + ignore + (Assembly.handle asm + (Input.Move + ( previous.x +. (previous.w /. 2.), + previous.y +. (previous.h /. 2.) ))); + Alcotest.(check int) + "pointer and keyboard share one menu highlight" 22 (Ui.hl_item ui); + Alcotest.(check bool) + "moving the pointer clears the previous keyboard highlight" false + (Ui.state_of ui store 23).Lui_paint.hovered; + ignore (input Input.Home); + ignore (input Input.End); + Alcotest.(check bool) + "keyboard navigation clears the previous pointer highlight" false + (Ui.state_of ui store 22).Lui_paint.hovered; + ignore (input Input.Home); + ignore (Assembly.handle asm (Input.Text_input "La")); + Alcotest.(check int) + "typeahead finds a matching menu option" 23 (Ui.hl_item ui); + Alcotest.(check string) + "menu typeahead cannot edit the background field" "background" + (Ui.shadow ui); + Lui_store.apply_batch store { generation = 30; ops = [ DropNode 2 ] }; + ignore (Assembly.store_changed asm); + ignore (Assembly.handle asm (Input.Text_input "x")); + Alcotest.(check string) + "typing resumes after the menu is disposed" "backgroundx" (Ui.shadow ui)) + [ + (DropdownMenu, []); + (ContextMenu, []); + (Popover, [ SetProp (2, RoleValue, StringValue "menu") ]); + ] + +let test_menu_keyboard_scope () = + let store = + store_of + [ + CreateNode (1, Root); + CreateNode (2, TextField); + InsertChild (1, 2, 0); + SetProp (2, TextValue, StringValue "background"); + CreateNode (3, DropdownMenu); + InsertChild (1, 3, 1); + CreateNode (4, MenuItem); + InsertChild (3, 4, 0); + CreateNode (5, Popover); + InsertChild (4, 5, 0); + SetProp (5, RoleValue, StringValue "menu"); + CreateNode (6, MenuItem); + InsertChild (5, 6, 0); + CreateNode (7, MenuItem); + InsertChild (5, 7, 1); + SetProp (6, Enabled, BoolValue false); + ] + in + let ui = Ui.create () and rects = Rects.create () in + ignore (Ui.store_changed ui store); + Ui.set_focused ui store 2; + ignore (Ui.handle ui store rects (key Input.End)); + Alcotest.(check int) "only the top nested menu navigates" 7 (Ui.hl_item ui); + let events = press_actions (Ui.handle ui store rects (key Input.Return)) in + Alcotest.(check bool) + "nested activation cannot reach the parent action" true + (List.mem (Press 7) events && not (List.mem (Press 4) events)); + Lui_store.apply_batch store + { generation = 2; ops = [ SetProp (7, Enabled, BoolValue false) ] }; + let events = press_actions (Ui.handle ui store rects (key Input.Return)) in + Alcotest.(check bool) + "disabling the highlighted option prevents stale activation" true + (not + (List.exists + (function Press _ | PressDetail _ -> true | _ -> false) + events)); + let consumed = ref 0 in + ignore + (Ui.handle + ~on_component_input:(fun () -> incr consumed) + ui store rects + (Input.Text_editing ("ni", 2, 0))); + Alcotest.(check string) + "menu composition cannot attach to the background editor" "" (Ui.marked ui); + Alcotest.(check int) + "menu composition suppresses duplicate document delivery" 1 !consumed; + ignore (Ui.handle ui store rects (Input.Text_input "x")); + Alcotest.(check string) + "a disabled menu still owns text input" "background" (Ui.shadow ui); + Lui_store.apply_batch store { generation = 3; ops = [ DropNode 5 ] }; + ignore (Ui.handle ui store rects (key Input.Home)); + Alcotest.(check int) + "dismissing the submenu restores parent navigation" 4 (Ui.hl_item ui); + Lui_store.apply_batch store + { + generation = 4; + ops = [ CreateNode (8, TextField); InsertChild (3, 8, 1) ]; + }; + ignore (Ui.store_changed ui store); + Ui.set_focused ui store 8; + ignore (Ui.handle ui store rects (Input.Text_editing ("ni", 2, 0))); + Alcotest.(check string) + "an editor inside a menu retains composition" "ni" (Ui.marked ui); + ignore (Ui.handle ui store rects (Input.Text_input "ni")); + Alcotest.(check string) + "an editor inside a menu retains text input" "ni" (Ui.shadow ui) + let test_assembly_scroll_shows_in_paint () = (* scroll_of feeds the scrollbar: a container with range reports metrics; one without reports none. *) @@ -3085,6 +3282,8 @@ let () = test_assembly_scroll_caps; Alcotest.test_case "inset scroll caps" `Quick test_assembly_inset_scroll_caps; Alcotest.test_case "intrinsic menu scroll" `Quick test_assembly_menu_scroll; + Alcotest.test_case "intrinsic menu keyboard" `Quick test_assembly_menu_keyboard; + Alcotest.test_case "nested menu keyboard scope" `Quick test_menu_keyboard_scope; Alcotest.test_case "scrollbar paint" `Quick test_assembly_scroll_shows_in_paint; Alcotest.test_case "autofocus" `Quick test_assembly_autofocus ] ); From 7e3f3fb3d29975128bf757c8f29a111c37085465 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 19:40:14 +0800 Subject: [PATCH 028/163] fix: preserve native menu text ownership Keep platform text input enabled for menu prefix search. Consume the queued Space commit after activation without editing a background field. --- platform/native/lui_window/lui_window.ml | 34 ++++++++++---- platform/native/lui_window/lui_window.mli | 4 ++ .../native/lui_window/test/lui_window_test.ml | 46 +++++++++++++++++++ 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index d51d58d5..ebf59721 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -520,6 +520,8 @@ module Ui = struct mutable drag : Lui_drag.state; mutable overlay : Lui_overlay.state; mutable menu_scope : int; + (* Space activation may dispose the menu before its text commit arrives. *) + mutable menu_space_commit : bool; (* 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 @@ -565,6 +567,7 @@ module Ui = struct drag = Lui_drag.initial; overlay = Lui_overlay.initial; menu_scope = 0; + menu_space_commit = false; scrolled = Hashtbl.create 8; scroll_metrics = Hashtbl.create 8; paint_snap = @@ -913,16 +916,16 @@ module Ui = struct if List.exists (Lui_query.presented_overlay store) path then None else List.find_opt (fun id -> split_handle t store rects id ~x ~y) path - let active_menu t store = + let menu_input_active t store = let id = t.menu_scope and focused = Lui_focus.focused t.focus in - if - id <> 0 - && not - (focused <> 0 - && Lui_text_input.editable store focused - && Lui_query.is_desc store focused id) - then Some id - else None + id <> 0 + && not + (focused <> 0 + && Lui_text_input.editable store focused + && Lui_query.is_desc store focused id) + + let active_menu t store = + if menu_input_active t store then Some t.menu_scope else None let visible_rect t store id = let rec offsets id x y = @@ -938,6 +941,17 @@ module Ui = struct { r with Lui_scene.x = r.x -. x; y = r.y -. y } let handle ?spatial ?(on_component_input = fun () -> ()) t store rects input = + let suppress_space = + match input with + | Input.Text_input s -> + let pending = t.menu_space_commit in + t.menu_space_commit <- false; + pending && s = " " + | Input.Key_down _ | Input.Button_down _ -> + t.menu_space_commit <- false; + false + | _ -> false + in let acts = ref [] in let emit_all l = acts := List.rev_append l !acts in let run m_acts = emit_all (perform_all t store m_acts) in @@ -1204,6 +1218,7 @@ module Ui = struct t.pointer <- Lui_pointer.clear_press t.pointer; t.dirty <- true | _ -> ()) + | Input.Text_input _ when suppress_space -> on_component_input () | Input.Text_input s when Option.is_some (active_menu t store) -> on_component_input (); let g,m = Lui_gesture.menu_text store t.gesture @@ -1250,6 +1265,7 @@ module Ui = struct | Input.Other scan -> Option.is_some (Input.char_of_scancode scan) | _ -> false) -> on_component_input (); + t.menu_space_commit <- key = Input.Space; let g,m = Lui_gesture.menu_key store t.gesture ~menu:(Option.get (active_menu t store)) ~rect_of:(visible_rect t store) ~scale:t.scale diff --git a/platform/native/lui_window/lui_window.mli b/platform/native/lui_window/lui_window.mli index e84106c6..cba622d5 100644 --- a/platform/native/lui_window/lui_window.mli +++ b/platform/native/lui_window/lui_window.mli @@ -395,6 +395,10 @@ module Ui : sig before each input too. *) val store_changed : t -> Lui_store.t -> action list + (** Whether a live menu owns text commits for typeahead. Hosts must + keep platform text input enabled while this is true. *) + val menu_input_active : t -> Lui_store.t -> bool + (** Map one input event to ordered actions. [?spatial] accelerates the rect hit-tests — see {!Lui_window.hit_path}. [on_component_input] runs when a menu consumes the input; hosts diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index aabf525c..5196a1cc 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2887,6 +2887,51 @@ let test_menu_keyboard_scope () = Alcotest.(check string) "an editor inside a menu retains text input" "ni" (Ui.shadow ui) +let test_menu_activation_text_commit () = + List.iter + (fun commit -> + let store = + store_of + [ + CreateNode (1, Root); + CreateNode (2, TextField); + InsertChild (1, 2, 0); + SetProp (2, TextValue, StringValue "background"); + CreateNode (3, DropdownMenu); + InsertChild (1, 3, 1); + CreateNode (4, MenuItem); + InsertChild (3, 4, 0); + ] + in + let ui = Ui.create () and rects = Rects.create () in + ignore (Ui.store_changed ui store); + Ui.set_focused ui store 2; + ignore (Ui.handle ui store rects (key Input.Home)); + let activation = Ui.handle ui store rects (key Input.Space) in + Alcotest.(check bool) + "Space activates the menu item" true + (List.mem (Press 4) (press_actions activation)); + Lui_store.apply_batch store { generation = 2; ops = [ DropNode 3 ] }; + ignore (Ui.store_changed ui store); + if commit then begin + let consumed = ref false in + ignore + (Ui.handle + ~on_component_input:(fun () -> consumed := true) + ui store rects (Input.Text_input " ")); + Alcotest.(check string) + "activation cannot insert behind the closed menu" "background" + (Ui.shadow ui); + Alcotest.(check bool) + "activation consumes its trailing text commit" true !consumed + end; + ignore (Ui.handle ui store rects (key Input.Space)); + ignore (Ui.handle ui store rects (Input.Text_input " ")); + Alcotest.(check string) + "the next ordinary Space still edits the field" "background " + (Ui.shadow ui)) + [ true; false ] + let test_assembly_scroll_shows_in_paint () = (* scroll_of feeds the scrollbar: a container with range reports metrics; one without reports none. *) @@ -3284,6 +3329,7 @@ let () = Alcotest.test_case "intrinsic menu scroll" `Quick test_assembly_menu_scroll; Alcotest.test_case "intrinsic menu keyboard" `Quick test_assembly_menu_keyboard; Alcotest.test_case "nested menu keyboard scope" `Quick test_menu_keyboard_scope; + Alcotest.test_case "menu activation text commit" `Quick test_menu_activation_text_commit; Alcotest.test_case "scrollbar paint" `Quick test_assembly_scroll_shows_in_paint; Alcotest.test_case "autofocus" `Quick test_assembly_autofocus ] ); From ffb37fa2248c79f0a55e85da22e7dcb30f9f55de Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 20:11:39 +0800 Subject: [PATCH 029/163] fix: paint native control icons once Keep button icons in the content path and preserve stepper chrome. Cover labeled, icon-only, placement, and backing-scale variants. --- platform/native/lui_paint/lui_paint.ml | 4 +- .../native/lui_paint/test/lui_paint_test.ml | 75 ++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index 797eb4bd..db62390b 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -2082,7 +2082,9 @@ let default_chrome ctx store id st kind r rs alpha = || List.mem variant [ "primary"; "destructive"; "secondary" ] then [] else [ face zc rad bw1 (col_control_border ctx) ~dashed:false ]) - @ control_icon_ops ctx store id r alpha + (* Button-like kinds paint content through labeled_content_ops. *) + @ (if kind = "number-stepper" then control_icon_ops ctx store id r alpha + else []) | "text-field" | "secure-field" | "input" | "search-field" | "textarea" | "select" | "combobox" -> let rad = rad (6. *. s) in diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index a9ee92e0..ad717251 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -1114,6 +1114,78 @@ let test_menu_item_single_icon () = | [ i ] -> check (float 0.01) "leading slot x" 8. i.irect2.x | _ -> () +let test_control_single_icon () = + List.iter + (fun kind -> + List.iter + (fun scale -> + List.iter + (fun label -> + List.iter + (fun placement -> + let t = store () in + apply t + ([ + Lui_protocol.CreateNode (1, kind); + SetProp (1, InlineIconName, StringValue "keyboard"); + SetProp (1, IconPlacementValue, StringValue placement); + ] + @ + match label with + | None -> [] + | Some value -> + [ SetProp (1, TextValue, StringValue value) ]); + let image = new_image ~w:4 ~h:4 (Bytes.make 64 '\255') in + let label_rect = ref None in + let r = + rect (20. *. scale) (10. *. scale) (240. *. scale) + (40. *. scale) + in + let h = + { + (hooks + ~layout:(fun _ -> Lui_paint.placement_of_rect r) + ()) + with + Lui_paint.image_of = (fun _ -> Some image); + image_in = (fun _ _ -> Some image); + text_ops = + (fun _ region _ _ -> + label_rect := Some region; + []); + } + in + let painted = + paint_with h t ~width:600 ~height:160 ~scale () + in + let images = + List.filter_map + (function Image value -> Some value | _ -> None) + painted.ops + in + check int "a control paints its icon once" 1 + (List.length images); + let slot = (List.hd images).irect2 in + match label with + | Some value when value <> "" -> + let ink = Option.get !label_rect in + let pad = + (Lui_paint.text_placement_of t 1).tp_pad_x *. scale + in + check bool "the label leaves the icon slot clear" true + (if placement = "trailing" then + ink.x +. ink.w -. pad <= slot.x + else ink.x +. pad >= slot.x +. slot.w) + | _ -> + check bool "an icon-only control stays centered" true + (abs_float + (slot.x +. (slot.w /. 2.) -. (r.x +. (r.w /. 2.))) + < 0.01)) + [ "leading"; "trailing" ]) + [ None; Some ""; Some "Search by keys" ]) + [ 1.; 2. ]) + [ Lui_protocol.Button; ToggleButton; Toggle; MenuTrigger ] + let () = run "lui_paint" [ ("color", @@ -1170,7 +1242,8 @@ let () = test_case "slider" `Quick test_slider; test_case "scrollbar" `Quick test_scrollbar; test_case "extension" `Quick test_extension; - test_case "menu item single icon" `Quick test_menu_item_single_icon ]); + test_case "menu item single icon" `Quick test_menu_item_single_icon; + test_case "control single icon" `Quick test_control_single_icon ]); ("secure", [ test_case "masked paint" `Quick test_secure_masked; test_case "mask per grapheme" `Quick test_secure_masked_graphemes; From 589a3b290515ac50dcc389d71d8b2d1669e58689 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 21:16:21 +0800 Subject: [PATCH 030/163] fix: align native content geometry Support typed padding edges across layout, text placement and hit testing. Resolve absolute cross-axis alignment after intrinsic measurement and keep shared platform property handlers in sync. --- .../src/main/kotlin/dev/lui/LuiKindRules.kt | 6 +- .../lui/src/main/kotlin/dev/lui/LuiRender.kt | 12 +- .../src/main/kotlin/dev/lui/LuiWireSchema.kt | 8 +- .../LUIAppleBackend/LUISwiftUIRoot.swift | 9 +- .../LUIAppleBackend/LUIWireProtocol.swift | 5 +- .../LUIAppleBackend/LUIWireSchema.swift | 8 +- platform/native/lui_flex/lui_flex.ml | 21 ++- platform/native/lui_gallery/lui_gallery.ml | 16 +- .../native/lui_gallery/lui_gallery_text.ml | 12 +- platform/native/lui_input/lui_query.ml | 8 +- .../native/lui_input/test/lui_input_test.ml | 50 ++++++ platform/native/lui_layout/lui_layout.ml | 11 +- .../native/lui_layout/test/lui_layout_test.ml | 164 ++++++++++++++++++ platform/native/lui_paint/lui_paint.ml | 27 ++- .../native/lui_paint/test/lui_paint_test.ml | 23 ++- platform/native/lui_store/lui_store.ml | 2 +- platform/native/lui_window/lui_window.ml | 23 ++- platform/native/lui_window/lui_window_text.ml | 28 +-- .../native/lui_window/test/lui_window_test.ml | 43 ++++- platform/web/melange/render/lui_web_props.ml | 12 ++ schema/components.json | 28 +++ src/lui_protocol.ml | 15 +- src/lui_protocol.mli | 4 + src/lui_ui.ml | 8 + src/lui_ui.mli | 4 + src/lui_wire_schema.ml | 10 +- test/test_lui.ml | 40 +++++ 27 files changed, 528 insertions(+), 69 deletions(-) diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt index 3c79b8ff..30b29a0d 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt @@ -601,7 +601,8 @@ internal object LuiKindRules { value.intValue != null && kind != LuiNodeKind.avatar && kind != LuiNodeKind.tooltip - "padding-horizontal", "padding-vertical" -> + "padding-horizontal", "padding-vertical", + "padding-top", "padding-right", "padding-bottom", "padding-left" -> value.intValue?.let { it >= 0 } == true && kind != LuiNodeKind.avatar && kind != LuiNodeKind.tooltip @@ -950,7 +951,8 @@ internal object LuiKindRules { "pointer-enabled" -> value is LuiWireValue.Bool "height", "width", "min-width", "max-width", "min-height", "max-height", "active", "gap", "border-width", "corner-radius", - "padding-horizontal", "padding-vertical", "duration" -> + "padding-horizontal", "padding-vertical", + "padding-top", "padding-right", "padding-bottom", "padding-left", "duration" -> value.intValue?.let { it >= 0 } == true "padding" -> value is LuiWireValue.IntValue "margin", "margin-horizontal", "margin-vertical", "margin-top", diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt index f7f27289..4b1b2e5c 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiRender.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.absolutePadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width @@ -155,6 +156,10 @@ internal fun Modifier.luiChrome(node: LuiNodeLike): Modifier { val padding = node.prop("padding")?.intValue ?: defaultPadding(node.kind) val paddingH = node.prop("padding-horizontal")?.intValue ?: padding val paddingV = node.prop("padding-vertical")?.intValue ?: padding + val paddingLeft = node.prop("padding-left")?.intValue ?: paddingH + val paddingRight = node.prop("padding-right")?.intValue ?: paddingH + val paddingTop = node.prop("padding-top")?.intValue ?: paddingV + val paddingBottom = node.prop("padding-bottom")?.intValue ?: paddingV val radius = node.prop("corner-radius")?.intValue ?: if (isSurface) 12 else if (node.kind == LuiNodeKind.tabs) 8 else 0 val borderWidth = node.prop("border-width")?.intValue ?: if (isSurface) 1 else 0 @@ -184,8 +189,11 @@ internal fun Modifier.luiChrome(node: LuiNodeLike): Modifier { ) } if (radius > 0) modifier = modifier.clip(shape) - if (paddingH > 0 || paddingV > 0) { - modifier = modifier.padding(horizontal = paddingH.dp, vertical = paddingV.dp) + if (paddingLeft > 0 || paddingRight > 0 || paddingTop > 0 || paddingBottom > 0) { + modifier = modifier.absolutePadding( + left = paddingLeft.dp, top = paddingTop.dp, + right = paddingRight.dp, bottom = paddingBottom.dp, + ) } node.prop("opacity")?.numberValue?.let { opacity -> if (opacity < 1) modifier = modifier.alpha(opacity.toFloat()) diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt index 71c4cc75..48075b24 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt @@ -110,6 +110,10 @@ enum class LuiProperty(val wireName: String) { padding("padding"), paddingHorizontal("padding-horizontal"), paddingVertical("padding-vertical"), + paddingTop("padding-top"), + paddingRight("padding-right"), + paddingBottom("padding-bottom"), + paddingLeft("padding-left"), margin("margin"), marginHorizontal("margin-horizontal"), marginVertical("margin-vertical"), @@ -288,7 +292,7 @@ object LuiSchemaMatrix { LuiNodeKind.timelineItem to setOf(LuiProperty.title, LuiProperty.description, LuiProperty.meta, LuiProperty.indicator, LuiProperty.icon, LuiProperty.variant, LuiProperty.connector, LuiProperty.selected, LuiProperty.pressEnabled, LuiProperty.pointerEnabled), LuiNodeKind.inputGroup to setOf(LuiProperty.accessibilityLabel, LuiProperty.width, LuiProperty.height, LuiProperty.minWidth, LuiProperty.grow, LuiProperty.pointerEnabled), LuiNodeKind.inputGroupActions to setOf(LuiProperty.gap, LuiProperty.pointerEnabled), - LuiNodeKind.toast to setOf(LuiProperty.duration, LuiProperty.accessibilityLabel, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.pointerEnabled, LuiProperty.padding, LuiProperty.paddingHorizontal, LuiProperty.paddingVertical, LuiProperty.margin, LuiProperty.marginHorizontal, LuiProperty.marginVertical, LuiProperty.marginTop, LuiProperty.marginRight, LuiProperty.marginBottom, LuiProperty.marginLeft), + LuiNodeKind.toast to setOf(LuiProperty.duration, LuiProperty.accessibilityLabel, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.pointerEnabled, LuiProperty.padding, LuiProperty.paddingHorizontal, LuiProperty.paddingVertical, LuiProperty.paddingTop, LuiProperty.paddingRight, LuiProperty.paddingBottom, LuiProperty.paddingLeft, LuiProperty.margin, LuiProperty.marginHorizontal, LuiProperty.marginVertical, LuiProperty.marginTop, LuiProperty.marginRight, LuiProperty.marginBottom, LuiProperty.marginLeft), LuiNodeKind.toolbar to setOf(LuiProperty.orientation, LuiProperty.accessibilityLabel, LuiProperty.gap, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.placement, LuiProperty.pointerEnabled), LuiNodeKind.bottomTabs to setOf(LuiProperty.accessibilityLabel, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.grow, LuiProperty.width, LuiProperty.height, LuiProperty.minWidth, LuiProperty.maxWidth, LuiProperty.minHeight, LuiProperty.maxHeight, LuiProperty.pointerEnabled), LuiNodeKind.bottomTab to setOf(LuiProperty.title, LuiProperty.icon, LuiProperty.selected, LuiProperty.enabled, LuiProperty.pressEnabled, LuiProperty.pointerEnabled), @@ -298,7 +302,7 @@ object LuiSchemaMatrix { LuiNodeKind.swipeAction to setOf(LuiProperty.text, LuiProperty.icon, LuiProperty.variant, LuiProperty.edge, LuiProperty.enabled, LuiProperty.background, LuiProperty.pressEnabled, LuiProperty.pointerEnabled), LuiNodeKind.filePicker to setOf(LuiProperty.request, LuiProperty.types, LuiProperty.multiple, LuiProperty.source, LuiProperty.completion, LuiProperty.pickerAccept, LuiProperty.pickerDirectory, LuiProperty.enabled, LuiProperty.appearEnabled, LuiProperty.pointerEnabled), LuiNodeKind.filePreview to setOf(LuiProperty.path, LuiProperty.pointerEnabled), - LuiNodeKind.kbd to setOf(LuiProperty.text, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.pointerEnabled, LuiProperty.background, LuiProperty.borderColor, LuiProperty.borderWidth, LuiProperty.cornerRadius, LuiProperty.foreground, LuiProperty.grow, LuiProperty.padding, LuiProperty.paddingHorizontal, LuiProperty.paddingVertical, LuiProperty.width, LuiProperty.height, LuiProperty.minWidth, LuiProperty.maxWidth, LuiProperty.minHeight, LuiProperty.maxHeight, LuiProperty.opacity, LuiProperty.fontSize, LuiProperty.fontWeight, LuiProperty.fontStyle, LuiProperty.lineHeight, LuiProperty.letterSpacing, LuiProperty.fontFamily, LuiProperty.textDecoration, LuiProperty.whiteSpace, LuiProperty.textOverflow, LuiProperty.userSelect, LuiProperty.cursor, LuiProperty.shadow, LuiProperty.hoverBackground, LuiProperty.hoverOpacity, LuiProperty.hoverShadow, LuiProperty.pressedBackground, LuiProperty.pressedOpacity, LuiProperty.pressedShadow, LuiProperty.focusShadow, LuiProperty.selectedBackground, LuiProperty.selectedShadow, LuiProperty.selectedHoverShadow, LuiProperty.disabledOpacity, LuiProperty.transition), + LuiNodeKind.kbd to setOf(LuiProperty.text, LuiProperty.styleClass, LuiProperty.dataAttrs, LuiProperty.pointerEnabled, LuiProperty.background, LuiProperty.borderColor, LuiProperty.borderWidth, LuiProperty.cornerRadius, LuiProperty.foreground, LuiProperty.grow, LuiProperty.padding, LuiProperty.paddingHorizontal, LuiProperty.paddingVertical, LuiProperty.paddingTop, LuiProperty.paddingRight, LuiProperty.paddingBottom, LuiProperty.paddingLeft, LuiProperty.width, LuiProperty.height, LuiProperty.minWidth, LuiProperty.maxWidth, LuiProperty.minHeight, LuiProperty.maxHeight, LuiProperty.opacity, LuiProperty.fontSize, LuiProperty.fontWeight, LuiProperty.fontStyle, LuiProperty.lineHeight, LuiProperty.letterSpacing, LuiProperty.fontFamily, LuiProperty.textDecoration, LuiProperty.whiteSpace, LuiProperty.textOverflow, LuiProperty.userSelect, LuiProperty.cursor, LuiProperty.shadow, LuiProperty.hoverBackground, LuiProperty.hoverOpacity, LuiProperty.hoverShadow, LuiProperty.pressedBackground, LuiProperty.pressedOpacity, LuiProperty.pressedShadow, LuiProperty.focusShadow, LuiProperty.selectedBackground, LuiProperty.selectedShadow, LuiProperty.selectedHoverShadow, LuiProperty.disabledOpacity, LuiProperty.transition), ) val extra: Map> = mapOf( diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index caab7262..f3c1bca6 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -7553,6 +7553,12 @@ private struct LUISurfaceModifier: ViewModifier { ? 0 : model.property(.paddingHorizontal)?.intValue ?? padding let vertical = model.property(.paddingVertical)?.intValue ?? padding + let insets = EdgeInsets( + top: CGFloat(model.property(.paddingTop)?.intValue ?? vertical), + leading: CGFloat(model.property(.paddingLeft)?.intValue ?? horizontal), + bottom: CGFloat(model.property(.paddingBottom)?.intValue ?? vertical), + trailing: CGFloat(model.property(.paddingRight)?.intValue ?? horizontal) + ) let radius = CGFloat( model.property(.cornerRadius)?.intValue ?? (isSurface ? 12 : (isTabs ? 8 : 0)) ) @@ -7579,8 +7585,7 @@ private struct LUISurfaceModifier: ViewModifier { ) content - .padding(.horizontal, CGFloat(horizontal)) - .padding(.vertical, CGFloat(vertical)) + .padding(insets) .opacity(model.property(.opacity)?.doubleValue ?? 1) .modifier(LUITooltipModifier( text: model.property(.tooltip)?.stringValue)) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index 8a3c07d4..53bf5deb 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -156,7 +156,9 @@ enum LUIWireValue: Decodable, Equatable { case .progressValue: return doubleValue != nil case .resizeDuration, .image, .surface, .active, .columns, - .paddingHorizontal, .paddingVertical, .borderWidth, .cornerRadius, + .paddingHorizontal, .paddingVertical, + .paddingTop, .paddingRight, .paddingBottom, .paddingLeft, + .borderWidth, .cornerRadius, .width, .height, .minWidth, .maxWidth, .minHeight, .maxHeight, .containerRelativeFrameInset, .scrollToken: guard let value = intValue else { return false } @@ -1085,6 +1087,7 @@ struct LUIRetainedTree { case .grow: kind != .avatar && kind != .tooltip && !isModalSurface(kind) case .columns: kind == .grid case .padding, .paddingHorizontal, .paddingVertical, + .paddingTop, .paddingRight, .paddingBottom, .paddingLeft, .margin, .marginHorizontal, .marginVertical, .marginTop, .marginRight, .marginBottom, .marginLeft: kind != .avatar && kind != .tooltip diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index afb986d4..223df8aa 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -103,6 +103,10 @@ enum LUIProperty: String, Decodable, Hashable { case padding = "padding" case paddingHorizontal = "padding-horizontal" case paddingVertical = "padding-vertical" + case paddingTop = "padding-top" + case paddingRight = "padding-right" + case paddingBottom = "padding-bottom" + case paddingLeft = "padding-left" case margin = "margin" case marginHorizontal = "margin-horizontal" case marginVertical = "margin-vertical" @@ -268,7 +272,7 @@ enum LUISchemaMatrix { .timelineItem: [.title, .description, .meta, .indicator, .icon, .variant, .connector, .selected, .pressEnabled, .pointerEnabled], .inputGroup: [.accessibilityLabel, .width, .height, .minWidth, .grow, .pointerEnabled], .inputGroupActions: [.gap, .pointerEnabled], - .toast: [.duration, .accessibilityLabel, .styleClass, .dataAttrs, .pointerEnabled, .padding, .paddingHorizontal, .paddingVertical, .margin, .marginHorizontal, .marginVertical, .marginTop, .marginRight, .marginBottom, .marginLeft], + .toast: [.duration, .accessibilityLabel, .styleClass, .dataAttrs, .pointerEnabled, .padding, .paddingHorizontal, .paddingVertical, .paddingTop, .paddingRight, .paddingBottom, .paddingLeft, .margin, .marginHorizontal, .marginVertical, .marginTop, .marginRight, .marginBottom, .marginLeft], .toolbar: [.orientation, .accessibilityLabel, .gap, .styleClass, .dataAttrs, .placement, .pointerEnabled], .bottomTabs: [.accessibilityLabel, .styleClass, .dataAttrs, .grow, .width, .height, .minWidth, .maxWidth, .minHeight, .maxHeight, .pointerEnabled], .bottomTab: [.title, .icon, .selected, .enabled, .pressEnabled, .pointerEnabled], @@ -278,7 +282,7 @@ enum LUISchemaMatrix { .swipeAction: [.text, .icon, .variant, .edge, .enabled, .background, .pressEnabled, .pointerEnabled], .filePicker: [.request, .types, .multiple, .source, .completion, .pickerAccept, .pickerDirectory, .enabled, .appearEnabled, .pointerEnabled], .filePreview: [.path, .pointerEnabled], - .kbd: [.text, .styleClass, .dataAttrs, .pointerEnabled, .background, .borderColor, .borderWidth, .cornerRadius, .foreground, .grow, .padding, .paddingHorizontal, .paddingVertical, .width, .height, .minWidth, .maxWidth, .minHeight, .maxHeight, .opacity, .fontSize, .fontWeight, .fontStyle, .lineHeight, .letterSpacing, .fontFamily, .textDecoration, .whiteSpace, .textOverflow, .userSelect, .cursor, .shadow, .hoverBackground, .hoverOpacity, .hoverShadow, .pressedBackground, .pressedOpacity, .pressedShadow, .focusShadow, .selectedBackground, .selectedShadow, .selectedHoverShadow, .disabledOpacity, .transition], + .kbd: [.text, .styleClass, .dataAttrs, .pointerEnabled, .background, .borderColor, .borderWidth, .cornerRadius, .foreground, .grow, .padding, .paddingHorizontal, .paddingVertical, .paddingTop, .paddingRight, .paddingBottom, .paddingLeft, .width, .height, .minWidth, .maxWidth, .minHeight, .maxHeight, .opacity, .fontSize, .fontWeight, .fontStyle, .lineHeight, .letterSpacing, .fontFamily, .textDecoration, .whiteSpace, .textOverflow, .userSelect, .cursor, .shadow, .hoverBackground, .hoverOpacity, .hoverShadow, .pressedBackground, .pressedOpacity, .pressedShadow, .focusShadow, .selectedBackground, .selectedShadow, .selectedHoverShadow, .disabledOpacity, .transition], ] static let extra: [LUINodeKind: Set] = [ diff --git a/platform/native/lui_flex/lui_flex.ml b/platform/native/lui_flex/lui_flex.ml index 63cd0e86..4f08dfd3 100644 --- a/platform/native/lui_flex/lui_flex.ml +++ b/platform/native/lui_flex/lui_flex.ml @@ -1882,7 +1882,26 @@ and absolute_layout_child (layout_measured_dimension_for_axis node cross_axis -. layout_measured_dimension_for_axis child cross_axis -. get_trailing_border node cross_axis - -. get_trailing_position child cross_axis)) + -. get_trailing_position child cross_axis) + else if not (is_leading_pos_defined child cross_axis) then begin + (* Static cross-axis alignment needs the absolute child's final + content size, which is not available during the flex-line pass. *) + let leading = get_leading_padding_and_border node cross_axis in + let trailing = get_trailing_padding_and_border node cross_axis in + let remaining = + layout_measured_dimension_for_axis node cross_axis + -. leading -. trailing + -. layout_measured_dimension_for_axis child cross_axis + -. get_margin_axis child cross_axis + in + let offset = match get_align_item node child with + | Align_center -> remaining /. 2. + | Align_flex_end -> remaining + | Align_auto | Align_flex_start | Align_stretch -> 0. + in + set_layout_leading_position_for_axis child cross_axis + (leading +. get_leading_margin child cross_axis +. offset) + end) and layout_node_impl (node : node) diff --git a/platform/native/lui_gallery/lui_gallery.ml b/platform/native/lui_gallery/lui_gallery.ml index 35e039fc..c89d961e 100644 --- a/platform/native/lui_gallery/lui_gallery.ml +++ b/platform/native/lui_gallery/lui_gallery.ml @@ -167,11 +167,11 @@ let stub_text_ops ?store id r c s = match store with | Some st -> Lui_paint.text_placement_of st id | None -> - { Lui_paint.tp_align = Ta_start; tp_pad_x = 0.; tp_pad_y = 0.; + { Lui_paint.tp_align = Ta_start; tp_pad_left = 0.; tp_pad_right = 0.; tp_pad_top = 0.; tp_pad_bottom = 0.; tp_top = false } in - let pad = tp.tp_pad_x in - let w' = Float.max 0. (r.w -. (2. *. pad)) in + let pad = tp.tp_pad_left in + let w' = Float.max 0. (r.w -. (tp.tp_pad_left +. tp.tp_pad_right)) in let text_w = 7. *. float_of_int (String.length s) in let x = match tp.tp_align with @@ -181,8 +181,8 @@ let stub_text_ops ?store id r c s = | Ta_end -> ref (r.x +. pad +. Float.max 0. (w' -. text_w)) in let base = - if tp.tp_top then r.y +. tp.tp_pad_y - else r.y +. Float.max 1. ((r.h -. 9.) /. 2.) + if tp.tp_top then r.y +. tp.tp_pad_top + else r.y +. Float.max 1. ((r.h -. 9. +. tp.tp_pad_top -. tp.tp_pad_bottom) /. 2.) in let ops = ref [] in let put y w h = ops := fill1 !x y w h c :: !ops in @@ -205,12 +205,12 @@ let stub_measure ?store id text _w _h = match store with | Some st -> Lui_paint.text_placement_of st id | None -> - { Lui_paint.tp_align = Ta_start; tp_pad_x = 0.; tp_pad_y = 0.; + { Lui_paint.tp_align = Ta_start; tp_pad_left = 0.; tp_pad_right = 0.; tp_pad_top = 0.; tp_pad_bottom = 0.; tp_top = false } in Some - ( (7. *. float_of_int (String.length text)) +. (2. *. tp.tp_pad_x), - 10. +. (2. *. tp.tp_pad_y) ) + ( (7. *. float_of_int (String.length text)) +. (tp.tp_pad_left +. tp.tp_pad_right), + 10. +. (tp.tp_pad_top +. tp.tp_pad_bottom) ) (* Fixed 24x24 two-tone checkerboard, premultiplied RGBA — the bitmap every image-driven kind samples. *) diff --git a/platform/native/lui_gallery/lui_gallery_text.ml b/platform/native/lui_gallery/lui_gallery_text.ml index 31abcfb6..bb5a4256 100644 --- a/platform/native/lui_gallery/lui_gallery_text.ml +++ b/platform/native/lui_gallery/lui_gallery_text.ml @@ -193,9 +193,9 @@ let text_ops t id r fg s = let font = font_of store id in (* Horizontal placement from the shared placement table. *) let tp = Lui_paint.text_placement_of store id in - let pad = tp.tp_pad_x *. scale in + let pad = tp.tp_pad_left *. scale in let x0 = r.x +. pad in - let w' = Float.max 0. (r.w -. (2. *. pad)) in + let w' = Float.max 0. (r.w -. ((tp.tp_pad_left +. tp.tp_pad_right) *. scale)) in (* Flowing text wraps at its padded width — mirror of Lui_window_text.text_ops. *) let wrap_w = @@ -215,8 +215,8 @@ let text_ops t id r fg s = *. scale in let y0 = - if tp.tp_top then r.y +. (tp.tp_pad_y *. scale) - else r.y +. Float.max 0. ((r.h -. text_h) /. 2.) + if tp.tp_top then r.y +. (tp.tp_pad_top *. scale) + else r.y +. Float.max 0. ((r.h -. text_h +. (tp.tp_pad_top -. tp.tp_pad_bottom) *. scale) /. 2.) in let gstart = List.length scene.glyphs in let line_y = ref 0. in @@ -270,5 +270,5 @@ let measure t id _text _w _h = let w, h = Lui_text.measure (font_of store id) text in let tp = Lui_paint.text_placement_of store id in Some - ((w +. (2. *. tp.tp_pad_x)) *. scale, - (h +. (2. *. tp.tp_pad_y)) *. scale) + ((w +. (tp.tp_pad_left +. tp.tp_pad_right)) *. scale, + (h +. (tp.tp_pad_top +. tp.tp_pad_bottom)) *. scale) diff --git a/platform/native/lui_input/lui_query.ml b/platform/native/lui_input/lui_query.ml index 26d2bb70..82275252 100644 --- a/platform/native/lui_input/lui_query.ml +++ b/platform/native/lui_input/lui_query.ml @@ -518,9 +518,13 @@ let boundary_at_x hooks store id line lx = let offset_at_point hooks store rects id ~x ~y text = let r = Rects.get rects id in let ph = - pad_axis store id ~scale:hooks.scale "padding-horizontal" "padding" in + Option.value + ~default:(pad_axis store id ~scale:hooks.scale "padding-horizontal" "padding") + (len_prop store id "padding-left" ~scale:hooks.scale) in let pv = - pad_axis store id ~scale:hooks.scale "padding-vertical" "padding" in + Option.value + ~default:(pad_axis store id ~scale:hooks.scale "padding-vertical" "padding") + (len_prop store id "padding-top" ~scale:hooks.scale) in let lx = x -. r.Lui_scene.x -. ph and ly = y -. r.Lui_scene.y -. pv in match hooks.hit_text with diff --git a/platform/native/lui_input/test/lui_input_test.ml b/platform/native/lui_input/test/lui_input_test.ml index f8905a34..22079d22 100644 --- a/platform/native/lui_input/test/lui_input_test.ml +++ b/platform/native/lui_input/test/lui_input_test.ml @@ -213,6 +213,55 @@ let test_text_undo () = Alcotest.(check string) "redo replays" "abc" (Lui_text_input.text st) +let test_text_padding_hit () = + List.iter + (fun scale -> + let store = + store_of + [ + CreateNode (1, SearchField); + SetProp (1, PaddingHorizontal, IntValue 10); + SetProp (1, PaddingVertical, IntValue 6); + SetProp (1, PaddingLeft, IntValue 28); + SetProp (1, PaddingRight, IntValue 8); + SetProp (1, PaddingTop, IntValue 3); + SetProp (1, PaddingBottom, IntValue 9); + ] + in + let rects = + rects_of + [ + ( 1, + rect (10. *. scale) (20. *. scale) (200. *. scale) (40. *. scale) + ); + ] + in + let hooks = + { + hooks with + Lui_query.scale; + measure = + (fun _ _ text -> + (float (String.length text * 8) *. scale, 16. *. scale)); + } + in + List.iter + (fun (x, expected) -> + let got = + Lui_query.offset_at_point hooks store rects 1 + ~x:((10. +. x) *. scale) + ~y:(28. *. scale) "hello" + in + Alcotest.(check int) + "click maps through the leading inset" expected got) + [ (12., 0); (28., 0); (36., 1); (44., 2); (68., 5) ]; + let got = + Lui_query.offset_at_point hooks store rects 1 ~x:(38. *. scale) + ~y:(39.5 *. scale) "a\nb" + in + Alcotest.(check int) "multiline hit uses the top inset" 2 got) + [ 1.; 2. ] + (* The caret-ops hook (C7): horizontal moves and Backspace/Delete consult the engine's grapheme model; without it the codepoint fallback stands. *) @@ -781,6 +830,7 @@ let () = [ Alcotest.test_case "splice" `Quick test_text_session; Alcotest.test_case "undo" `Quick test_text_undo; Alcotest.test_case "caret ops" `Quick test_text_caret_ops; + Alcotest.test_case "padding hit" `Quick test_text_padding_hit; Alcotest.test_case "ime commit" `Quick test_text_ime_commit; Alcotest.test_case "rapid typing" `Quick test_revision_rapid_typing; diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index 74f5f05e..d7d7f058 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -267,9 +267,18 @@ let uniform_edges ~scale store id name : Lui_flex.edges = (* Axis props override the uniform padding shorthand. *) let padding_edges ~scale store id : Lui_flex.edges = let base = uniform_edges ~scale store id "padding" in + let side name uniform = + match Lui_store.prop store id name with + | None -> uniform + | Some _ -> length_prop ~scale store id name + in { base with horizontal = length_prop ~scale store id "padding-horizontal"; - vertical = length_prop ~scale store id "padding-vertical" } + vertical = length_prop ~scale store id "padding-vertical"; + top = side "padding-top" base.top; + right = side "padding-right" base.right; + bottom = side "padding-bottom" base.bottom; + left = side "padding-left" base.left } (* Axis and side props override the uniform margin shorthand. *) let margin_edges ~scale store id : Lui_flex.edges = diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index 0f860066..a60d5e99 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -59,6 +59,168 @@ let test_column_padding_gap () = check_rect "child1" 10. 10. 180. 20. (place p 3); check_rect "child2" 10. 35. 180. 20. (place p 4) +let test_padding_edges () = + let property name = + match + List.find_opt + (fun p -> Lui_wire_schema.property_name p = name) + Lui_wire_schema.all_properties + with + | Some p -> p + | None -> fail ("Missing typed property: " ^ name) + in + let top = property "padding-top" + and right = property "padding-right" + and bottom = property "padding-bottom" + and left = property "padding-left" in + List.iter + (fun scale -> + let store = Lui_store.create () and layout = Lui_layout.create () in + apply store + [ + CreateNode (1, Root); + CreateNode (2, Column); + CreateNode (3, Box); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); + SetProp (2, WidthValue, IntValue 100); + SetProp (2, HeightValue, IntValue 80); + SetProp (2, PaddingValue, IntValue 5); + SetProp (2, PaddingHorizontal, IntValue 10); + SetProp (2, PaddingVertical, IntValue 6); + SetProp (3, GrowValue, FloatValue 1.); + ]; + let expect label l t r b = + Lui_layout.sync ~scale ~width:(200. *. scale) ~height:(120. *. scale) + layout store; + check_some_rect label (l *. scale) (t *. scale) + ((100. -. l -. r) *. scale) + ((80. -. t -. b) *. scale) + (Lui_layout.rect layout 3) + in + expect "axis padding" 10. 6. 10. 6.; + apply store + [ + SetProp (2, left, IntValue 28); + SetProp (2, right, IntValue 8); + SetProp (2, top, IntValue 3); + SetProp (2, bottom, IntValue 9); + ]; + expect "sides override axes" 28. 3. 8. 9.; + apply store + [ SetProp (2, left, IntValue 0); SetProp (2, bottom, IntValue 0) ]; + expect "zero side padding invalidates retained layout" 0. 3. 8. 0.; + apply store + [ + RemoveProp (2, left); + RemoveProp (2, right); + RemoveProp (2, top); + RemoveProp (2, bottom); + ]; + expect "removing sides restores axes" 10. 6. 10. 6.; + apply store + [ RemoveProp (2, PaddingHorizontal); RemoveProp (2, PaddingVertical) ]; + expect "removing axes restores uniform padding" 5. 5. 5. 5.) + [ 1.; 2. ] + +let test_absolute_cross_alignment () = + List.iter + (fun scale -> + List.iter + (fun kind -> + List.iter + (fun intrinsic -> + let store = Lui_store.create () + and layout = Lui_layout.create () in + let cross_size = if kind = Row then HeightValue else WidthValue in + let leading = if kind = Row then InsetTop else InsetLeft in + let trailing = if kind = Row then InsetBottom else InsetRight in + apply store + ([ + CreateNode (1, Root); + CreateNode (2, kind); + CreateNode (3, Box); + CreateNode (4, Box); + CreateNode (5, Box); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); + InsertChild (2, 4, 1); + InsertChild (4, 5, 0); + SetProp (2, WidthValue, IntValue 100); + SetProp (2, HeightValue, IntValue 80); + SetProp (2, PaddingHorizontal, IntValue 10); + SetProp (2, PaddingVertical, IntValue 6); + SetProp (2, BorderWidth, IntValue 2); + SetProp (3, WidthValue, IntValue 30); + SetProp (3, HeightValue, IntValue 20); + SetProp (4, Position, StringValue "absolute"); + SetProp (5, WidthValue, IntValue 20); + SetProp (5, HeightValue, IntValue 10); + ] + @ + if intrinsic then [] + else + [ + SetProp (4, WidthValue, IntValue 20); + SetProp (4, HeightValue, IntValue 10); + ]); + let expect label value = + Lui_layout.sync ~scale ~width:(200. *. scale) + ~height:(160. *. scale) layout store; + let child = Option.get (Lui_layout.rect layout 4) in + let actual = if kind = Row then child.y else child.x in + check (float eps) label (value *. scale) actual + in + let origin, available, extent = + if kind = Row then (8., 64., 10.) else (12., 76., 20.) + in + List.iter + (fun (align, offset) -> + apply store [ SetProp (2, CrossAlignment, StringValue align) ]; + expect "absolute static cross alignment" (origin +. offset)) + [ + ("start", 0.); + ("stretch", 0.); + ("center", (available -. extent) /. 2.); + ("end", available -. extent); + ]; + apply store [ SetProp (2, CrossAlignment, StringValue "center") ]; + apply store [ SetProp (4, leading, FloatValue 4.) ]; + expect "leading inset overrides alignment" 6.; + apply store + [ + RemoveProp (4, leading); SetProp (4, trailing, FloatValue 4.); + ]; + expect "trailing inset overrides alignment" + ((if kind = Row then 80. else 100.) -. 6. -. extent); + apply store + [ + RemoveProp (4, trailing); SetProp (2, cross_size, IntValue 120); + ]; + expect "resize recomputes static alignment" + ((120. -. extent) /. 2.); + let cross_before, cross_after, delta = + if kind = Row then (PaddingTop, PaddingBottom, 8.) + else (PaddingLeft, PaddingRight, 20.) + in + apply store + [ + SetProp + (2, cross_before, IntValue (if kind = Row then 10 else 28)); + SetProp + (2, cross_after, IntValue (if kind = Row then 2 else 8)); + ]; + expect "static alignment uses asymmetric content edges" + ((120. -. extent +. delta) /. 2.); + if not intrinsic then begin + apply store [ SetProp (4, cross_size, IntValue 200) ]; + expect "oversized absolute content retains center alignment" + ((120. -. 200. +. delta) /. 2.) + end) + [ false; true ]) + [ Row; Column ]) + [ 1.; 2. ] + let test_border_box_layout () = List.iter (fun scale -> @@ -1245,6 +1407,8 @@ let () = run "lui_layout" [ ("layout", [ test_case "column padding/gap" `Quick test_column_padding_gap; + test_case "padding edges" `Quick test_padding_edges; + test_case "absolute cross alignment" `Quick test_absolute_cross_alignment; test_case "border box layout" `Quick test_border_box_layout; test_case "split divider fraction" `Quick test_split_gap_fraction; test_case "grid wraps" `Quick test_grid_wraps; diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index db62390b..3945706d 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -48,6 +48,10 @@ let prop_coverage = ("padding", Layout); ("padding-horizontal", Layout); ("padding-vertical", Layout); + ("padding-top", Layout); + ("padding-right", Layout); + ("padding-bottom", Layout); + ("padding-left", Layout); ("background", Painted); ("foreground", Painted); ("border-color", Painted); (* CSS shorthand, 1–4 colors *) @@ -977,8 +981,10 @@ type text_align = Ta_start | Ta_center | Ta_end type text_placement = { tp_align : text_align; - tp_pad_x : float; (* logical px inset on each side *) - tp_pad_y : float; (* logical px measure adds top and bottom *) + tp_pad_left : float; (* logical px insets *) + tp_pad_right : float; + tp_pad_top : float; + tp_pad_bottom : float; tp_top : bool; (* anchor first line at pad-y instead of centering *) } @@ -1026,7 +1032,12 @@ let text_placement_of store id = | None, Some p, v -> (p, Option.value ~default:p v) | None, None, v -> (pad_x, Option.value ~default:pad_y v) in - { tp_align = align; tp_pad_x = pad_x; tp_pad_y = pad_y; tp_top = top } + { tp_align = align; + tp_pad_left = Option.value ~default:pad_x (pnum store id "padding-left"); + tp_pad_right = Option.value ~default:pad_x (pnum store id "padding-right"); + tp_pad_top = Option.value ~default:pad_y (pnum store id "padding-top"); + tp_pad_bottom = Option.value ~default:pad_y (pnum store id "padding-bottom"); + tp_top = top } (* ---------- secure-field masking ---------- *) @@ -1413,11 +1424,13 @@ let text_ops ?fg ctx store id r alpha ~placeholder_ok = (* Select indicators use the host's existing image decoder, including its scale-aware SVG cache. Clip labels before the trailing icon slot. *) let select_ops ctx store id rs r alpha fg = - let pad = (text_placement_of store id).tp_pad_x *. ctx.scale in + let tp = text_placement_of store id in + let pad_left = tp.tp_pad_left *. ctx.scale in + let pad = tp.tp_pad_right *. ctx.scale in let _, right, _, left = border_widths ctx store id in let size = Float.min (16. *. ctx.scale) - (Float.min r.h (Float.max 0. (r.w -. left -. right -. (2. *. pad)))) + (Float.min r.h (Float.max 0. (r.w -. left -. right -. pad_left -. pad))) in let label = { r with w = Float.max 0. (r.w -. right -. pad -. size) } in let indicator = @@ -1814,9 +1827,9 @@ let labeled_content_ops ctx store id r alpha ~fg ~placeholder_ok = if size <= 0. then [] else let tp = text_placement_of store id in - let pad = tp.tp_pad_x *. s in - let gap = 6. *. s in let trailing = pstr store id "icon-placement" = Some "trailing" in + let pad = (if trailing then tp.tp_pad_right else tp.tp_pad_left) *. s in + let gap = 6. *. s in let ix = if trailing then r.x +. r.w -. pad -. size else r.x +. pad in diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index ad717251..e78a430b 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -1170,7 +1170,7 @@ let test_control_single_icon () = | Some value when value <> "" -> let ink = Option.get !label_rect in let pad = - (Lui_paint.text_placement_of t 1).tp_pad_x *. scale + (Lui_paint.text_placement_of t 1).tp_pad_left *. scale in check bool "the label leaves the icon slot clear" true (if placement = "trailing" then @@ -1186,6 +1186,24 @@ let test_control_single_icon () = [ 1.; 2. ]) [ Lui_protocol.Button; ToggleButton; Toggle; MenuTrigger ] +let test_text_edge_placement () = + let t = store () in + apply t + [ + Lui_protocol.CreateNode (1, SearchField); + SetProp (1, PaddingHorizontal, IntValue 10); + SetProp (1, PaddingVertical, IntValue 6); + SetProp (1, PaddingLeft, IntValue 28); + SetProp (1, PaddingRight, IntValue 8); + SetProp (1, PaddingTop, IntValue 3); + SetProp (1, PaddingBottom, IntValue 9); + ]; + let placement = Lui_paint.text_placement_of t 1 in + check (float 0.001) "text starts at its left edge inset" 28. + placement.tp_pad_left; + check (float 0.001) "text starts at its top edge inset" 3. + placement.tp_pad_top + let () = run "lui_paint" [ ("color", @@ -1243,7 +1261,8 @@ let () = test_case "scrollbar" `Quick test_scrollbar; test_case "extension" `Quick test_extension; test_case "menu item single icon" `Quick test_menu_item_single_icon; - test_case "control single icon" `Quick test_control_single_icon ]); + test_case "control single icon" `Quick test_control_single_icon; + test_case "text edge placement" `Quick test_text_edge_placement ]); ("secure", [ test_case "masked paint" `Quick test_secure_masked; test_case "mask per grapheme" `Quick test_secure_masked_graphemes; diff --git a/platform/native/lui_store/lui_store.ml b/platform/native/lui_store/lui_store.ml index 237c1153..da1acb2e 100644 --- a/platform/native/lui_store/lui_store.ml +++ b/platform/native/lui_store/lui_store.ml @@ -52,7 +52,7 @@ let layout_prop_names = "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"; + "padding-vertical"; "padding-top"; "padding-right"; "padding-bottom"; "padding-left"; "placeholder"; "position"; "selected"; "size"; "text"; "title"; "value" ] let layout_props = diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index ebf59721..7f3a372a 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -319,7 +319,13 @@ module Layout = struct | Vertical -> "padding-vertical" | Horizontal -> "padding-horizontal" in - Option.value ~default:uniform (len_prop store id name ~scale) + let axis_padding = Option.value ~default:uniform (len_prop store id name ~scale) in + let before, after = match axis with + | Vertical -> "padding-top", "padding-bottom" + | Horizontal -> "padding-left", "padding-right" + in + (Option.value ~default:axis_padding (len_prop store id before ~scale), + Option.value ~default:axis_padding (len_prop store id after ~scale)) let gap_px store id ~scale = Option.value ~default:0. (len_prop store id "gap" ~scale) @@ -364,7 +370,8 @@ module Layout = struct | Vertical -> if mh > 0. then mh else dh | Horizontal -> if mw > 0. then mw else max dw 24.) | children -> - let pm = 2. *. pad_axis store id ~scale dir in + let before, after = pad_axis store id ~scale dir in + let pm = before +. after in let gap = gap_px store id ~scale in let n = List.length children in let sum = @@ -394,8 +401,8 @@ module Layout = struct | children -> let dir = direction store id in let gap = gap_px store id ~scale in - let pm = pad_axis store id ~scale dir in - let pc = + let pm, pm_end = pad_axis store id ~scale dir in + let pc, pc_end = pad_axis store id ~scale (match dir with Vertical -> Horizontal | Horizontal -> Vertical) in @@ -403,10 +410,10 @@ module Layout = struct match dir with | Vertical -> ( r.x +. pc, r.y +. pm, - max 0. (r.w -. (2. *. pc)), max 0. (r.h -. (2. *. pm)) ) + max 0. (r.w -. pc -. pc_end), max 0. (r.h -. pm -. pm_end) ) | Horizontal -> ( r.x +. pm, r.y +. pc, - max 0. (r.w -. (2. *. pm)), max 0. (r.h -. (2. *. pc)) ) + max 0. (r.w -. pm -. pm_end), max 0. (r.h -. pc -. pc_end) ) in let n = List.length children in let entries = @@ -2511,8 +2518,8 @@ module Assembly = struct let tp = Lui_paint.text_placement_of (store_of ()) id in let sc = !scale_cell in Some - (w +. (2. *. tp.tp_pad_x *. sc), - h +. (2. *. tp.tp_pad_y *. sc))) + (w +. ((tp.tp_pad_left +. tp.tp_pad_right) *. sc), + h +. ((tp.tp_pad_top +. tp.tp_pad_bottom) *. sc))) ~renderer ~width ~height ~scale:!scale_cell () in host_cell := Some host; diff --git a/platform/native/lui_window/lui_window_text.ml b/platform/native/lui_window/lui_window_text.ml index f88e1102..7da90c79 100644 --- a/platform/native/lui_window/lui_window_text.ml +++ b/platform/native/lui_window/lui_window_text.ml @@ -428,9 +428,9 @@ let text_ops t id r fg s = (* Horizontal placement: the shared placement table decides the kind's alignment and content inset; props win over it. *) let tp = Lui_paint.text_placement_of store id in - let pad = tp.tp_pad_x *. scale in + let pad = tp.tp_pad_left *. scale in let x0 = r.x +. pad in - let w' = Float.max 0. (r.w -. (2. *. pad)) in + let w' = Float.max 0. (r.w -. ((tp.tp_pad_left +. tp.tp_pad_right) *. scale)) in (* Flowing text wraps at its padded width so a long line never spills into the node's inset; labeled controls keep a single line. white-space:nowrap forces the single-line path. *) @@ -463,8 +463,8 @@ let text_ops t id r fg s = Array.fold_left (fun a l -> a +. line_h st l) 0. lines *. scale in let y0 = - if tp.tp_top then r.y +. (tp.tp_pad_y *. scale) - else r.y +. Float.max 0. ((r.h -. text_h) /. 2.) + if tp.tp_top then r.y +. (tp.tp_pad_top *. scale) + else r.y +. Float.max 0. ((r.h -. text_h +. (tp.tp_pad_top -. tp.tp_pad_bottom) *. scale) /. 2.) in let gstart = gcount t scene in (* Decoration fills draw under the ink, one rect per line per @@ -562,8 +562,8 @@ let measure t store id ~scale = let st = style_of t store id in let w, h = extent_of t st text in let tp = Lui_paint.text_placement_of store id in - ((w +. (2. *. tp.tp_pad_x)) *. scale, - (h +. (2. *. tp.tp_pad_y)) *. scale) + ((w +. (tp.tp_pad_left +. tp.tp_pad_right)) *. scale, + (h +. (tp.tp_pad_top +. tp.tp_pad_bottom)) *. scale) (* Device-pixel (w, h) of an arbitrary string in node [id]'s font — the caret offset and the composition overlay measure through this @@ -598,9 +598,9 @@ let hit_text t id text ~lx ~ly ~lw ~lh = let shown = if masked then Lui_paint.secure_mask text else text in let st = style_of t store id in let tp = Lui_paint.text_placement_of store id in - let pad = tp.tp_pad_x *. scale in + let pad = tp.tp_pad_left *. scale in let lx = lx -. pad in - let lw = Float.max 0. (lw -. (2. *. pad)) in + let lw = Float.max 0. (lw -. ((tp.tp_pad_left +. tp.tp_pad_right) *. scale)) in let wrap_w = if st.ts_nowrap || lw <= 0. then 0. else if wrapping_kind (Lui_store.kind store id) then lw /. scale @@ -609,8 +609,8 @@ let hit_text t id text ~lx ~ly ~lw ~lh = let lay = layout_of t st ~width:wrap_w shown in let text_h = styled_height st lay *. scale in let y0 = - if tp.tp_top then tp.tp_pad_y *. scale - else Float.max 0. ((lh -. text_h) /. 2.) + if tp.tp_top then tp.tp_pad_top *. scale + else Float.max 0. ((lh -. text_h +. (tp.tp_pad_top -. tp.tp_pad_bottom) *. scale) /. 2.) in let py = (ly -. y0) /. scale in (* With a line-height override the painted pitch is [ts_lh], not the @@ -668,8 +668,8 @@ let caret_ops t id text ~lw ~lh : Lui_window.Ui.caret_ops = let scale = t.scale () in let st = style_of t store id in let tp = Lui_paint.text_placement_of store id in - let pad = tp.tp_pad_x *. scale in - let lw = Float.max 0. (lw -. (2. *. pad)) in + let pad = tp.tp_pad_left *. scale in + let lw = Float.max 0. (lw -. ((tp.tp_pad_left +. tp.tp_pad_right) *. scale)) in let wrap_w = if st.ts_nowrap || lw <= 0. then 0. else if wrapping_kind (Lui_store.kind store id) then lw /. scale @@ -682,8 +682,8 @@ let caret_ops t id text ~lw ~lh : Lui_window.Ui.caret_ops = let lay = layout_of t st ~width:wrap_w shown in let text_h = styled_height st lay *. scale in let y0 = - if tp.tp_top then tp.tp_pad_y *. scale - else Float.max 0. ((lh -. text_h) /. 2.) + if tp.tp_top then tp.tp_pad_top *. scale + else Float.max 0. ((lh -. text_h +. (tp.tp_pad_top -. tp.tp_pad_bottom) *. scale) /. 2.) in { Lui_window.Ui.c_before = (fun i -> of_m (Lui_text.caret_before lay (to_m i))); c_after = (fun i -> of_m (Lui_text.caret_after lay (to_m i))); diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index 5196a1cc..c40aad5a 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -175,6 +175,46 @@ let test_layout () = Alcotest.(check (float 0.001)) "scaled btn h" 64. r3'.h; Alcotest.(check (float 0.001)) "scaled field y" 72. (Rects.get rects 4).y +let test_layout_padding_edges () = + List.iter + (fun scale -> + List.iter + (fun kind -> + let store = + store_of + [ + CreateNode (1, Root); + CreateNode (2, kind); + CreateNode (3, Box); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); + SetProp (2, WidthValue, IntValue 100); + SetProp (2, HeightValue, IntValue 80); + SetProp (2, PaddingHorizontal, IntValue 10); + SetProp (2, PaddingVertical, IntValue 6); + SetProp (2, PaddingTop, IntValue 3); + SetProp (2, PaddingRight, IntValue 8); + SetProp (2, PaddingBottom, IntValue 9); + SetProp (2, PaddingLeft, IntValue 28); + SetProp (3, GrowValue, FloatValue 1.); + ] + in + let rects = Rects.create () in + Layout.refresh store rects ~width:200 ~height:120 ~scale + ~measure:Layout.default_measure; + let child = Rects.get rects 3 in + List.iter + (fun (label, expected, actual) -> + Alcotest.(check (float 0.001)) label (expected *. scale) actual) + [ + ("child left", 28., child.x); + ("child top", 3., child.y); + ("child width", 64., child.w); + ("child height", 68., child.h); + ]) + [ Column; Row ]) + [ 1.; 2. ] + let test_layout_props () = let s = store_of @@ -3214,7 +3254,8 @@ let () = [ Alcotest.test_case "event_supported" `Quick test_event_supported ] ); ( "layout", [ Alcotest.test_case "stack" `Quick test_layout; - Alcotest.test_case "props" `Quick test_layout_props ] ); + Alcotest.test_case "props" `Quick test_layout_props; + Alcotest.test_case "padding edges" `Quick test_layout_padding_edges ] ); ( "hit", [ Alcotest.test_case "path" `Quick test_hit; Alcotest.test_case "z-order" `Quick test_hit_z_order; diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index bebb42ab..1eee9870 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -662,6 +662,14 @@ let rec apply_property renderer queue_split node kind dom_node property set_style dom_node "padding-inline" (string_of_int padding ^ "px") | PaddingVertical, IntValue padding -> set_style dom_node "padding-block" (string_of_int padding ^ "px") + | PaddingTop, IntValue padding -> + set_style dom_node "padding-top" (string_of_int padding ^ "px") + | PaddingRight, IntValue padding -> + set_style dom_node "padding-right" (string_of_int padding ^ "px") + | PaddingBottom, IntValue padding -> + set_style dom_node "padding-bottom" (string_of_int padding ^ "px") + | PaddingLeft, IntValue padding -> + set_style dom_node "padding-left" (string_of_int padding ^ "px") | MarginValue, IntValue margin -> set_style dom_node "margin" (string_of_int margin ^ "px") | MarginHorizontal, IntValue margin -> @@ -1047,6 +1055,10 @@ let remove_property renderer queue_split node kind dom_node property = set_style dom_node "--lui-content-padding" "" | PaddingHorizontal -> set_style dom_node "padding-inline" "" | PaddingVertical -> set_style dom_node "padding-block" "" + | PaddingTop -> set_style dom_node "padding-top" "" + | PaddingRight -> set_style dom_node "padding-right" "" + | PaddingBottom -> set_style dom_node "padding-bottom" "" + | PaddingLeft -> set_style dom_node "padding-left" "" | MarginValue -> set_style dom_node "margin" "" | MarginHorizontal -> set_style dom_node "margin-inline" "" | MarginVertical -> set_style dom_node "margin-block" "" diff --git a/schema/components.json b/schema/components.json index 47581035..1e9c197d 100644 --- a/schema/components.json +++ b/schema/components.json @@ -931,6 +931,26 @@ "wire": "padding-vertical", "swift": "paddingVertical" }, + { + "lg": "PaddingTop", + "wire": "padding-top", + "swift": "paddingTop" + }, + { + "lg": "PaddingRight", + "wire": "padding-right", + "swift": "paddingRight" + }, + { + "lg": "PaddingBottom", + "wire": "padding-bottom", + "swift": "paddingBottom" + }, + { + "lg": "PaddingLeft", + "wire": "padding-left", + "swift": "paddingLeft" + }, { "lg": "MarginValue", "wire": "margin", @@ -1907,6 +1927,10 @@ "padding", "padding-horizontal", "padding-vertical", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", "margin", "margin-horizontal", "margin-vertical", @@ -2010,6 +2034,10 @@ "padding", "padding-horizontal", "padding-vertical", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", "width", "height", "min-width", diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index 2b03c93a..17e4945a 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -124,6 +124,10 @@ type property = | PaddingValue | PaddingHorizontal | PaddingVertical + | PaddingTop + | PaddingRight + | PaddingBottom + | PaddingLeft | MarginValue | MarginHorizontal | MarginVertical @@ -889,7 +893,8 @@ let common_property_supported kind property = | GrowValue -> kind <> Avatar && (not (modal_surface kind)) && kind <> Tooltip | GridColumns -> kind = Grid - | PaddingValue | PaddingHorizontal | PaddingVertical -> + | PaddingValue | PaddingHorizontal | PaddingVertical + | PaddingTop | PaddingRight | PaddingBottom | PaddingLeft -> kind <> Avatar && kind <> Tooltip | MarginValue | MarginHorizontal | MarginVertical | MarginTop | MarginRight | MarginBottom | MarginLeft -> @@ -1213,7 +1218,8 @@ let property_supported kind property = || property = StyleClass || property = PointerEnabled || property = DataAttrs || property = PaddingValue || property = PaddingHorizontal - || property = PaddingVertical + || property = PaddingVertical || property = PaddingTop + || property = PaddingRight || property = PaddingBottom || property = PaddingLeft || property = MarginValue || property = MarginHorizontal || property = MarginVertical || property = MarginTop || property = MarginRight || property = MarginBottom @@ -1276,7 +1282,8 @@ let property_supported kind property = || property = BorderWidth || property = CornerRadius || property = ForegroundValue || property = GrowValue || property = PaddingValue || property = PaddingHorizontal - || property = PaddingVertical + || property = PaddingVertical || property = PaddingTop + || property = PaddingRight || property = PaddingBottom || property = PaddingLeft || property = WidthValue || property = HeightValue || property = MinWidth || property = MaxWidth || property = MinHeight || property = MaxHeight || property = Opacity @@ -1460,7 +1467,7 @@ let property_value_supported property value = | GridColumns, IntValue value -> value >= 0 | PaddingValue, IntValue _ -> true | PaddingHorizontal, IntValue value -> value >= 0 - | PaddingVertical, IntValue value -> value >= 0 + | (PaddingVertical | PaddingTop | PaddingRight | PaddingBottom | PaddingLeft), IntValue value -> value >= 0 (* Margins may be negative — collapsing adjacent space is a legitimate use, unlike padding which is clamped inside the box. *) | ( MarginValue | MarginHorizontal | MarginVertical | MarginTop diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index 723bace1..ca0d676f 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -122,6 +122,10 @@ type property = | PaddingValue | PaddingHorizontal | PaddingVertical + | PaddingTop + | PaddingRight + | PaddingBottom + | PaddingLeft | MarginValue | MarginHorizontal | MarginVertical diff --git a/src/lui_ui.ml b/src/lui_ui.ml index 725b204e..f963db5a 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -387,6 +387,14 @@ let padding_horizontal context node padding = int_property context node PaddingHorizontal padding let padding_vertical context node padding = int_property context node PaddingVertical padding +let padding_top context node padding = + int_property context node PaddingTop padding +let padding_right context node padding = + int_property context node PaddingRight padding +let padding_bottom context node padding = + int_property context node PaddingBottom padding +let padding_left context node padding = + int_property context node PaddingLeft padding let margin context node margin = int_property context node MarginValue margin let margin_horizontal context node margin = int_property context node MarginHorizontal margin diff --git a/src/lui_ui.mli b/src/lui_ui.mli index 8daccd0d..4d328b02 100644 --- a/src/lui_ui.mli +++ b/src/lui_ui.mli @@ -193,6 +193,10 @@ val columns : ui_context -> int -> int -> unit val padding : ui_context -> int -> int -> unit val padding_horizontal : ui_context -> int -> int -> unit val padding_vertical : ui_context -> int -> int -> unit +val padding_top : ui_context -> int -> int -> unit +val padding_right : ui_context -> int -> int -> unit +val padding_bottom : ui_context -> int -> int -> unit +val padding_left : ui_context -> int -> int -> unit val margin : ui_context -> int -> int -> unit val margin_horizontal : ui_context -> int -> int -> unit val margin_vertical : ui_context -> int -> int -> unit diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 1fd3b8a3..392bb6d8 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -198,6 +198,10 @@ let property_name property = | PaddingValue -> "padding" | PaddingHorizontal -> "padding-horizontal" | PaddingVertical -> "padding-vertical" + | PaddingTop -> "padding-top" + | PaddingRight -> "padding-right" + | PaddingBottom -> "padding-bottom" + | PaddingLeft -> "padding-left" | MarginValue -> "margin" | MarginHorizontal -> "margin-horizontal" | MarginVertical -> "margin-vertical" @@ -362,7 +366,7 @@ let kind_property_matrix kind = | TimelineItem -> Some [ TitleValue; DescriptionValue; MetaValue; IndicatorValue; InlineIconName; VariantValue; Connector; Selected; PressEnabled; PointerEnabled ] | InputGroup -> Some [ AccessibilityLabel; WidthValue; HeightValue; MinWidth; GrowValue; PointerEnabled ] | InputGroupActions -> Some [ Gap; PointerEnabled ] - | Toast -> Some [ DurationValue; AccessibilityLabel; StyleClass; DataAttrs; PointerEnabled; PaddingValue; PaddingHorizontal; PaddingVertical; MarginValue; MarginHorizontal; MarginVertical; MarginTop; MarginRight; MarginBottom; MarginLeft ] + | Toast -> Some [ DurationValue; AccessibilityLabel; StyleClass; DataAttrs; PointerEnabled; PaddingValue; PaddingHorizontal; PaddingVertical; PaddingTop; PaddingRight; PaddingBottom; PaddingLeft; MarginValue; MarginHorizontal; MarginVertical; MarginTop; MarginRight; MarginBottom; MarginLeft ] | Toolbar -> Some [ OrientationValue; AccessibilityLabel; Gap; StyleClass; DataAttrs; PlacementValue; PointerEnabled ] | BottomTabs -> Some [ AccessibilityLabel; StyleClass; DataAttrs; GrowValue; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; PointerEnabled ] | BottomTab -> Some [ TitleValue; InlineIconName; Selected; Enabled; PressEnabled; PointerEnabled ] @@ -372,7 +376,7 @@ let kind_property_matrix kind = | SwipeAction -> Some [ TextValue; InlineIconName; VariantValue; EdgeValue; Enabled; BackgroundValue; PressEnabled; PointerEnabled ] | FilePicker -> Some [ PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; PickerAccept; PickerDirectory; Enabled; AppearEnabled; PointerEnabled ] | FilePreview -> Some [ PathValue; PointerEnabled ] - | Kbd -> Some [ TextValue; StyleClass; DataAttrs; PointerEnabled; BackgroundValue; BorderColorValue; BorderWidth; CornerRadius; ForegroundValue; GrowValue; PaddingValue; PaddingHorizontal; PaddingVertical; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; Opacity; FontSize; FontWeight; FontStyle; LineHeight; LetterSpacing; FontFamily; TextDecoration; WhiteSpace; TextOverflow; UserSelect; Cursor; Shadow; HoverBackground; HoverOpacity; HoverShadow; PressedBackground; PressedOpacity; PressedShadow; FocusShadow; SelectedBackground; SelectedShadow; SelectedHoverShadow; DisabledOpacity; Transition ] + | Kbd -> Some [ TextValue; StyleClass; DataAttrs; PointerEnabled; BackgroundValue; BorderColorValue; BorderWidth; CornerRadius; ForegroundValue; GrowValue; PaddingValue; PaddingHorizontal; PaddingVertical; PaddingTop; PaddingRight; PaddingBottom; PaddingLeft; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; Opacity; FontSize; FontWeight; FontStyle; LineHeight; LetterSpacing; FontFamily; TextDecoration; WhiteSpace; TextOverflow; UserSelect; Cursor; Shadow; HoverBackground; HoverOpacity; HoverShadow; PressedBackground; PressedOpacity; PressedShadow; FocusShadow; SelectedBackground; SelectedShadow; SelectedHoverShadow; DisabledOpacity; Transition ] | _ -> None let kind_extra_properties kind = @@ -382,4 +386,4 @@ let kind_extra_properties kind = let all_node_kinds = [ Root; Row; Column; Grid; Stack; EdgeInset; Overlay; ViewThatFits; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; NumberStepper; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; ListSection; ListSectionHeader; ListSectionFooter; SwipeActions; SwipeAction; FilePicker; Kbd; Link; FileImage; FilePreview; Br; Popover ] -let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; MarginValue; MarginHorizontal; MarginVertical; MarginTop; MarginRight; MarginBottom; MarginLeft; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; KeyValue; SeparatorValue; StyleValue; ScrollTarget; ScrollAnchor; ScrollToken; ScrollAnimated; TrackVisibleRange; EdgeValue; PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; MinValue; MaxValue; StepValue; Detents; Sizing; PathValue; UrlValue; MaxPixelSize; ImageFitValue; Visible; AlignmentValue; PointerEnabled; DataAttrs; As; AltValue; LoadingValue; ReferrerPolicy; TargetValue; Opacity; DisplayValue; TooltipText; TooltipKeys; InputType; PickerAccept; PickerDirectory; PopupX; PopupY; AvailableHeight; FontSize; FontWeight; FontStyle; LineHeight; LetterSpacing; Position; Inset; InsetTop; InsetRight; InsetBottom; InsetLeft; ZIndex; WhiteSpace; TextOverflow; Overflow; UserSelect; Cursor; Shadow; HoverBackground; HoverOpacity; HoverShadow; PressedBackground; PressedOpacity; PressedShadow; FocusShadow; SelectedBackground; SelectedShadow; SelectedHoverShadow; DisabledOpacity; WidthViewport; HeightViewport; MinWidthViewport; MaxWidthViewport; MinHeightViewport; MaxHeightViewport; FontFamily; TextDecoration; ListStyle; FlexWrap; HoverForeground; HoverBorder; Transition ] +let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; PaddingTop; PaddingRight; PaddingBottom; PaddingLeft; MarginValue; MarginHorizontal; MarginVertical; MarginTop; MarginRight; MarginBottom; MarginLeft; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; KeyValue; SeparatorValue; StyleValue; ScrollTarget; ScrollAnchor; ScrollToken; ScrollAnimated; TrackVisibleRange; EdgeValue; PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; MinValue; MaxValue; StepValue; Detents; Sizing; PathValue; UrlValue; MaxPixelSize; ImageFitValue; Visible; AlignmentValue; PointerEnabled; DataAttrs; As; AltValue; LoadingValue; ReferrerPolicy; TargetValue; Opacity; DisplayValue; TooltipText; TooltipKeys; InputType; PickerAccept; PickerDirectory; PopupX; PopupY; AvailableHeight; FontSize; FontWeight; FontStyle; LineHeight; LetterSpacing; Position; Inset; InsetTop; InsetRight; InsetBottom; InsetLeft; ZIndex; WhiteSpace; TextOverflow; Overflow; UserSelect; Cursor; Shadow; HoverBackground; HoverOpacity; HoverShadow; PressedBackground; PressedOpacity; PressedShadow; FocusShadow; SelectedBackground; SelectedShadow; SelectedHoverShadow; DisabledOpacity; WidthViewport; HeightViewport; MinWidthViewport; MaxWidthViewport; MinHeightViewport; MaxHeightViewport; FontFamily; TextDecoration; ListStyle; FlexWrap; HoverForeground; HoverBorder; Transition ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 3767194d..d4550e8e 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -3886,6 +3886,45 @@ let test_drive_scenarios () = run_drive_scenario "counter.drive" (drive_mount ~initial:0 ~reducer:counter_reducer ~view:counter_view ()) +let test_padding_edge_props () = + let open Lui_protocol in + List.iter + (fun name -> + let property = + match + List.find_opt + (fun p -> Lui_wire_schema.property_name p = name) + Lui_wire_schema.all_properties + with + | Some property -> property + | None -> Alcotest.fail ("Missing typed property: " ^ name) + in + List.iter + (fun kind -> + Alcotest.(check bool) + "padding side supported" true + (property_supported kind property)) + [ Box; SearchField; Kbd; Toast ]; + List.iter + (fun kind -> + Alcotest.(check bool) + "padding side rejected" false + (property_supported kind property)) + [ Avatar; Tooltip ]; + List.iter + (fun n -> + Alcotest.(check bool) + "nonnegative padding" true + (property_value_supported property (IntValue n))) + [ 0; 28 ]; + Alcotest.(check bool) + "negative padding rejected" false + (property_value_supported property (IntValue (-1))); + Alcotest.(check bool) + "string padding rejected" false + (property_value_supported property (StringValue "28"))) + [ "padding-top"; "padding-right"; "padding-bottom"; "padding-left" ] + let test_margin_props () = let open Lui_protocol in let margins = @@ -3982,6 +4021,7 @@ let () = ] ); ( "protocol", [ + Alcotest.test_case "padding edge prop family" `Quick test_padding_edge_props; Alcotest.test_case "margin prop family" `Quick test_margin_props; Alcotest.test_case "margin wire props" `Quick test_margin_wire_props; Alcotest.test_case "toast padding" `Quick test_toast_padding; From c470632d15009396fa696c0924dec4b3e3194d44 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 22:04:28 +0800 Subject: [PATCH 031/163] fix: match list item content layout Use horizontal flow, cross-axis centering and an eight-point default gap for native list items, matching the Web component contract. Cover both backing scales, alignment changes, resize and property removal. --- platform/native/lui_layout/lui_layout.ml | 6 +-- .../native/lui_layout/test/lui_layout_test.ml | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index d7d7f058..edb649eb 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -405,7 +405,7 @@ let direction_of ~store ~id kind : Lui_flex.flex_direction = | Some "vertical" -> Column | _ -> ( match kind with - | "row" | "grid" | "button" | "toggle-button" | "menu-item" + | "row" | "grid" | "button" | "toggle-button" | "menu-item" | "list-item" | "button-group" | "toggle-group" | "input-group-actions" | "table-row" | "tabs" | "bottom-tabs" | "toolbar" | "swipe-actions" | "stepper" | "breadcrumb" @@ -483,7 +483,7 @@ let style_of ?(scale = 1.) ?(viewport = (0., 0.)) store id : Lui_flex.style = | "input-group-actions" -> Pt (6. *. scale) | "pagination" -> Pt (2. *. scale) | "dialog" | "sheet" -> Pt (16. *. scale) - | "popover" | "alert" -> Pt (8. *. scale) + | "popover" | "alert" | "list-item" -> Pt (8. *. scale) | _ -> Unset) | l -> l in @@ -547,7 +547,7 @@ let style_of ?(scale = 1.) ?(viewport = (0., 0.)) store id : Lui_flex.style = on the cross axis — .lui-button / .lui-menu-item are items-center flex rows on the web. *) match kind with - | "button" | "toggle-button" | "menu-item" + | "button" | "toggle-button" | "menu-item" | "list-item" | "button-group" | "toggle-group" | "input-group-actions" | "breadcrumb" | "pagination" | "toolbar" -> Align_center | _ -> d.align_items)); diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index a60d5e99..d32bc64a 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -59,6 +59,55 @@ let test_column_padding_gap () = check_rect "child1" 10. 10. 180. 20. (place p 3); check_rect "child2" 10. 35. 180. 20. (place p 4) +let test_list_item_content_flow () = + List.iter + (fun scale -> + let store = Lui_store.create () and layout = Lui_layout.create () in + apply store + [ + CreateNode (1, Root); + CreateNode (2, ListItem); + CreateNode (3, Box); + CreateNode (4, Box); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); + InsertChild (2, 4, 1); + SetProp (2, WidthValue, IntValue 100); + SetProp (2, HeightValue, IntValue 40); + SetProp (2, PaddingValue, IntValue 0); + SetProp (3, WidthValue, IntValue 10); + SetProp (3, HeightValue, IntValue 10); + SetProp (4, WidthValue, IntValue 20); + SetProp (4, HeightValue, IntValue 20); + ]; + let expect x1 y1 x2 y2 = + Lui_layout.sync ~scale ~width:(200. *. scale) ~height:(80. *. scale) + layout store; + let item = Option.get (Lui_layout.rect layout 2) in + let child id x y w h = + check_some_rect "list item child" + (item.x +. (x *. scale)) + (item.y +. (y *. scale)) + (w *. scale) (h *. scale) + (Lui_layout.rect layout id) + in + child 3 x1 y1 10. 10.; + child 4 x2 y2 20. 20. + in + expect 0. 15. 18. 10.; + apply store + [ + SetProp (2, MainAlignment, StringValue "end"); + SetProp (2, CrossAlignment, StringValue "start"); + ]; + expect 62. 0. 80. 0.; + apply store [ SetProp (2, WidthValue, IntValue 160) ]; + expect 122. 0. 140. 0.; + apply store + [ RemoveProp (2, CrossAlignment); RemoveProp (2, MainAlignment) ]; + expect 0. 15. 18. 10.) + [ 1.; 2. ] + let test_padding_edges () = let property name = match @@ -1407,6 +1456,7 @@ let () = run "lui_layout" [ ("layout", [ test_case "column padding/gap" `Quick test_column_padding_gap; + test_case "list item content flow" `Quick test_list_item_content_flow; test_case "padding edges" `Quick test_padding_edges; test_case "absolute cross alignment" `Quick test_absolute_cross_alignment; test_case "border box layout" `Quick test_border_box_layout; From 33b3c88ef78ebf1a1734bc0eba374604e1d6f79a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 16 Oct 2026 22:31:03 +0800 Subject: [PATCH 032/163] fix: round percentage colors consistently Keep percentage alpha and channel rounding consistent with equivalent numeric colors, with regression coverage. --- platform/native/lui_window/lui_window.ml | 4 ++-- .../native/lui_window/test/lui_window_test.ml | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 7f3a372a..5b4a82de 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -1659,7 +1659,7 @@ module Theme = struct float_of_string_opt (if pct then String.sub t 0 (String.length t - 1) else t) with - | Some v -> Some (int_of_float (Float.round (if pct then v *. 2.55 else v))) + | Some v -> Some (int_of_float (Float.round (if pct then v /. 100. *. 255. else v))) | None -> None in let alpha t = @@ -1672,7 +1672,7 @@ module Theme = struct Some (int_of_float (Float.round - (if pct then v *. 2.55 + (if pct then v /. 100. *. 255. else if v <= 1. then v *. 255. else v))) | None -> None diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index c40aad5a..f7bebb9e 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -8,6 +8,24 @@ open Lui_protocol open Lui_window open Lui_window_demo +let test_css_percentage_colors () = + List.iter + (fun (css, (r, g, b, a)) -> + let actual = Option.get (Theme.color_of css) in + Alcotest.(check int) (css ^ " alpha") a actual.a; + Alcotest.(check int) (css ^ " red") r actual.r; + Alcotest.(check int) (css ^ " green") g actual.g; + Alcotest.(check int) (css ^ " blue") b actual.b) + [ + ("rgb(18 52 86 / 50%)", (18, 52, 86, 128)); + ("rgba(18, 52, 86, 0.5)", (18, 52, 86, 128)); + ("hsl(0 0% 100% / 50%)", (255, 255, 255, 128)); + ("hsla(0, 0%, 100%, 0.5)", (255, 255, 255, 128)); + ("rgb(50% 50% 50%)", (128, 128, 128, 255)); + ("rgb(0% 50% 100% / 0%)", (0, 128, 255, 0)); + ("rgb(0% 50% 100% / 100%)", (0, 128, 255, 255)); + ] + (* ---------- store helpers ---------- *) let store_of ops = @@ -3375,7 +3393,8 @@ let () = test_assembly_scroll_shows_in_paint; Alcotest.test_case "autofocus" `Quick test_assembly_autofocus ] ); ( "parity", - [ Alcotest.test_case "win32 key table" `Quick + [ Alcotest.test_case "CSS percentage colors" `Quick test_css_percentage_colors; + Alcotest.test_case "win32 key table" `Quick test_win32_key_parity; Alcotest.test_case "win32 accelerators" `Quick test_win32_accelerators ] ) ] From c0f23b28ecc8f7ac4ce7e4810636589e3c8db0da Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 00:47:29 +0800 Subject: [PATCH 033/163] fix: deliver web appearance lifecycle events Accept appearance properties and announce connected nodes after committed batches. Preserve mount order, release disposed registrations, and coalesce delivery without idle polling. --- platform/web/melange/core/lui_web_types.ml | 3 + platform/web/melange/events/lui_web_events.ml | 66 ++++++++++ platform/web/melange/render/lui_web_props.ml | 5 +- platform/web/melange/shell/lui_web.ml | 7 +- platform/web/melange/shell/lui_web_apply.ml | 1 + .../web/test/appearance-lifecycle.test.mjs | 121 ++++++++++++++++++ 6 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 platform/web/test/appearance-lifecycle.test.mjs diff --git a/platform/web/melange/core/lui_web_types.ml b/platform/web/melange/core/lui_web_types.ml index 59e29451..76553067 100644 --- a/platform/web/melange/core/lui_web_types.ml +++ b/platform/web/melange/core/lui_web_types.ml @@ -127,6 +127,9 @@ type web_renderer = { (* container ids with a pending initial-highlight timer — each timer runs a full querySelectorAll scan, so multi-item mounts share one *) web_menu_highlights : (int, unit) Hashtbl.t; + web_appear_pending : (int, unit) Hashtbl.t; + web_appear_seen : (int, unit) Hashtbl.t; + web_appear_timer : Js.Global.timeoutId option ref; web_extension_registry : Lui_extension.extension_registry; web_extension_adapters : (string, web_extension_adapter) Hashtbl.t; } diff --git a/platform/web/melange/events/lui_web_events.ml b/platform/web/melange/events/lui_web_events.ml index 159b4843..c7c970e2 100644 --- a/platform/web/melange/events/lui_web_events.ml +++ b/platform/web/melange/events/lui_web_events.ml @@ -281,6 +281,72 @@ let button_connected renderer dom_node = (W.Element.asNode dom_node) (W.Document.documentElement renderer.web_document) +let release_appear renderer node = + Hashtbl.remove renderer.web_appear_pending node; + Hashtbl.remove renderer.web_appear_seen node; + if Hashtbl.length renderer.web_appear_pending = 0 then begin + Option.iter Js.Global.clearTimeout !(renderer.web_appear_timer); + renderer.web_appear_timer := None + end + +let update_appear renderer node = + if + enabled_node renderer node + && Store.event_admitted renderer node (Appear node) + then + begin if not (Hashtbl.mem renderer.web_appear_seen node) then + Hashtbl.replace renderer.web_appear_pending node () + end + else release_appear renderer node + +let appear_ready renderer node = + Hashtbl.mem renderer.web_appear_pending node + && enabled_node renderer node + && Store.event_admitted renderer node (Appear node) + && + match Store.node renderer.web_store node with + | Some current when Lazy.is_val current.platform_node -> + button_connected renderer (Lazy.force current.platform_node) + | _ -> false + +(* Lifecycle callbacks run after the batch and root attachment. One shared + timeout coalesces mounts; unchanged patches and idle hosts queue none. *) +let schedule_appear renderer = + if !(renderer.web_appear_timer) = None then begin + let ready = ref false in + Hashtbl.iter + (fun node () -> if appear_ready renderer node then ready := true) + renderer.web_appear_pending; + if !ready then + renderer.web_appear_timer := + Some + (Js.Global.setTimeout + ~f:(fun () -> + renderer.web_appear_timer := None; + let candidates = ref [] in + let rec visit node = + if appear_ready renderer node then + candidates := node :: !candidates; + match Store.node renderer.web_store node with + | Some current -> + Lui_sequence.iter visit current.retained_children + | None -> () + in + Hashtbl.fold + (fun root _ roots -> root :: roots) + renderer.web_mounted_roots [] + |> List.sort Int.compare |> List.iter visit; + List.iter + (fun node -> + if appear_ready renderer node then begin + Hashtbl.remove renderer.web_appear_pending node; + Hashtbl.replace renderer.web_appear_seen node (); + emit renderer (Appear node) + end) + (List.rev !candidates)) + 0) + end + let button_cancel_timer state = (match !(state.button_timer) with | Some timer_id -> Js.Global.clearTimeout timer_id diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 1eee9870..0d53a7b1 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -644,7 +644,8 @@ let rec apply_property renderer queue_split node kind dom_node property | TextValue, StringValue text -> apply_text_value renderer node kind dom_node text | Enabled, BoolValue enabled -> - apply_enabled renderer node kind dom_node enabled + apply_enabled renderer node kind dom_node enabled; + Lui_web_events.update_appear renderer node | Gap, IntValue gap -> apply_gap renderer queue_split node kind dom_node gap | MainAlignment, StringValue alignment -> @@ -800,6 +801,7 @@ and apply_secondary_property renderer queue_split node kind dom_node | Selected, BoolValue selected -> apply_selected renderer node kind dom_node selected | Autofocus, BoolValue autofocus -> apply_autofocus dom_node autofocus + | AppearEnabled, BoolValue _ -> Lui_web_events.update_appear renderer node | SubmitOnEnter, BoolValue enabled -> Util.set_state_attribute dom_node "data-submit-on-enter" enabled | LongPressEnabled, BoolValue enabled -> @@ -1034,6 +1036,7 @@ let remove_property renderer queue_split node kind dom_node property = match Store.node renderer.web_store node with | Some _current -> (match property with + | AppearEnabled -> Lui_web_events.release_appear renderer node | StyleValue -> W.Element.removeAttribute "data-list-style" dom_node | ScrollTarget | ScrollAnchor | ScrollToken | ScrollAnimated | TrackVisibleRange -> () | TextValue -> apply_text_value renderer node kind dom_node "" diff --git a/platform/web/melange/shell/lui_web.ml b/platform/web/melange/shell/lui_web.ml index 1542981f..3ef43737 100644 --- a/platform/web/melange/shell/lui_web.ml +++ b/platform/web/melange/shell/lui_web.ml @@ -56,6 +56,9 @@ let create_with_extensions host app_icons registry adapters = web_splits = Hashtbl.create 4; web_context_hosts = Hashtbl.create 4; web_menu_highlights = Hashtbl.create 4; + web_appear_pending = Hashtbl.create 4; + web_appear_seen = Hashtbl.create 4; + web_appear_timer = ref None; web_extension_registry = registry; web_extension_adapters } in @@ -130,6 +133,7 @@ let backend renderer = invalid_arg ("DOM recovery failed after " ^ Printexc.to_string original_error ^ ": " ^ Printexc.to_string recovery_error))); + if applied then Lui_web_events.schedule_appear renderer; true with Js.Exn.Error error -> (* DOM calls and platform node creation throw JS exceptions, not @@ -151,7 +155,8 @@ let mount renderer root host = host; Lui_web_split.update_splits_under renderer root; Hashtbl.iter (fun _ state -> state.refresh_virtual ()) renderer.web_virtual_lists; - Hashtbl.iter (fun _ refresh -> refresh false) renderer.web_lists + Hashtbl.iter (fun _ refresh -> refresh false) renderer.web_lists; + Lui_web_events.schedule_appear renderer let rec first_section_title renderer node = match Store.node renderer.web_store node with diff --git a/platform/web/melange/shell/lui_web_apply.ml b/platform/web/melange/shell/lui_web_apply.ml index 1f11d814..ad15d146 100644 --- a/platform/web/melange/shell/lui_web_apply.ml +++ b/platform/web/melange/shell/lui_web_apply.ml @@ -64,6 +64,7 @@ let portal_parent renderer previous_nodes parent child = (Nodes.dom_node_before renderer previous_nodes parent) let cleanup_node renderer node = + Lui_web_events.release_appear renderer node; (match Hashtbl.find_opt renderer.web_virtual_lists node with | Some state -> state.dispose_virtual () | None -> ()); diff --git a/platform/web/test/appearance-lifecycle.test.mjs b/platform/web/test/appearance-lifecycle.test.mjs new file mode 100644 index 00000000..4b93d656 --- /dev/null +++ b/platform/web/test/appearance-lifecycle.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +const build = process.env.LUI_TEST_BUILD_DIR ?? '_build' +const modules = new URL(`../../../${build}/default/examples/components/web/lui-components-web/node_modules/`, import.meta.url) +const [Store, Protocol, Schema, Props, Events, Tables, Lazy] = await Promise.all([ + import(new URL('lui.web.dom/core/lui_web_store.js', modules)), + import(new URL('lui/lui_protocol.js', modules)), + import(new URL('lui/lui_wire_schema.js', modules)), + import(new URL('lui.web.dom/render/lui_web_props.js', modules)), + import(new URL('lui.web.dom/events/lui_web_events.js', modules)), + import(new URL('melange/hashtbl.js', modules)), + import(new URL('melange/camlinternalLazy.js', modules)), +]) +const list = items => items.reduceRight((tl, hd) => ({hd, tl}), 0) +const value = (values, nameOf, name) => { + for (let rest = values; rest; rest = rest.tl) if (nameOf(rest.hd) === name) return rest.hd + throw new Error(`Unknown schema value: ${name}`) +} +const column = value(Schema.all_node_kinds, Schema.node_kind_name, 'column') +const row = value(Schema.all_node_kinds, Schema.node_kind_name, 'list-item') +const appear = value(Schema.all_properties, Schema.property_name, 'appear-enabled') +const enabled = value(Schema.all_properties, Schema.property_name, 'enabled') +const text = value(Schema.all_properties, Schema.property_name, 'text') +const bool = flag => ({TAG: 1, _0: flag}) +const settle = () => new Promise(resolve => setTimeout(resolve, 10)) + +function fixture() { + const store = Store.create_store() + const elements = [] + const events = [] + let generation = 0 + let mounted = false + const renderer = { + web_store: store, + web_document: {documentElement: {contains: element => mounted && elements.includes(element)}}, + web_appear_pending: Tables.create(undefined, 8), + web_appear_seen: Tables.create(undefined, 8), + web_appear_timer: {contents: undefined}, + web_mounted_roots: Tables.create(undefined, 4), + web_event_handler: {contents: event => {events.push(event._0); return true}}, + } + const dom = () => { + const element = {setAttribute() {}, removeAttribute() {}} + elements.push(element) + return element + } + const apply = ops => Store.apply_batch(store, dom, {generation: ++generation, ops: list(ops)}) + const set = (id, property, flag) => { + apply([Protocol.set_prop_op(id, property, bool(flag))]) + Props.apply_property(renderer, () => {}, id, id === 1 ? row : column, + Lazy.force(Store.node(store, id).platform_node), property, bool(flag)) + } + apply([Protocol.create_node_op(1, row), Protocol.set_prop_op(1, text, {TAG: 0, _0: 'Parent'}), + Protocol.create_node_op(2, column), Protocol.insert_child_op(1, 2, 0)]) + Tables.replace(renderer.web_mounted_roots, 1, {}) + return {renderer, events, apply, set, mount() {mounted = true}, appear} +} + +test('appearance runs once in tree order after DOM attachment and outside batch application', async () => { + const f = fixture() + f.set(2, appear, true) + f.set(1, appear, true) + Events.schedule_appear(f.renderer) + await settle() + assert.deepEqual(f.events, [], 'Unattached elements must not appear') + f.mount() + Events.schedule_appear(f.renderer) + assert.deepEqual(f.events, [], 'Appearance must not reenter batch application') + await settle() + assert.deepEqual(f.events, [1, 2]) + Events.schedule_appear(f.renderer) + await settle() + assert.deepEqual(f.events, [1, 2], 'Unrelated updates must not repeat appearance') + assert.equal(f.renderer.web_appear_timer.contents, undefined, 'No timer may remain while idle') +}) + +test('appearance respects enablement, property removal and disposal before delivery', async () => { + const f = fixture() + f.mount() + f.set(1, appear, true) + f.set(1, enabled, false) + Events.schedule_appear(f.renderer) + await settle() + assert.deepEqual(f.events, []) + f.set(1, enabled, true) + Events.schedule_appear(f.renderer) + await settle() + assert.deepEqual(f.events, [1]) + f.apply([Protocol.remove_prop_op(1, appear)]) + Props.remove_property(f.renderer, () => {}, 1, row, {}, appear) + f.set(1, appear, true) + Events.schedule_appear(f.renderer) + await settle() + assert.deepEqual(f.events, [1, 1], 'Re-enabling the lifecycle must announce it again') + f.set(2, appear, true) + Events.schedule_appear(f.renderer) + f.apply([Protocol.remove_child_op(1, 2), Protocol.drop_node_op(2)]) + await settle() + assert.deepEqual(f.events, [1, 1], 'Disposed nodes must not deliver stale callbacks') +}) + +test('appearance callbacks may change lifecycle membership without replay or iteration corruption', async () => { + const f = fixture() + f.mount() + f.set(1, appear, true) + f.renderer.web_event_handler.contents = event => { + f.events.push(event._0) + if (event._0 === 1) { + f.set(2, appear, true) + Events.schedule_appear(f.renderer) + } + return true + } + Events.schedule_appear(f.renderer) + await settle() + assert.deepEqual(f.events, [1, 2]) + Events.schedule_appear(f.renderer) + await settle() + assert.deepEqual(f.events, [1, 2]) +}) From 57fb35b364ecfd1c52ab03ecabed3e3c96959416 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 01:12:18 +0800 Subject: [PATCH 034/163] fix: size buttons from custom content Keep icon buttons square only when they have no custom children, and refresh button sizing when children are attached or removed. Cover content bounds and incremental sizing at both display scales. --- platform/native/lui_layout/lui_layout.ml | 11 ++++++ .../native/lui_layout/test/lui_layout_test.ml | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index edb649eb..c9f9c1ed 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -502,6 +502,7 @@ let style_of ?(scale = 1.) ?(viewport = (0., 0.)) store id : Lui_flex.style = not (blank (pstr store id "icon")) && blank (pstr store id "text") && blank (pstr store id "label") + && Lui_store.children store id = [] in if icon_only then let dfl = @@ -2286,6 +2287,12 @@ let index_sensitive = function let restyle_children_of ctx o = iter_produced_indices ctx o (pkids_of ctx o) (restyle_gen_at ctx o) +let restyle_content_owner ctx o = + match kind_of ctx o with + | "button" | "toggle-button" -> + restyle_gen ctx (Hashtbl.find ctx.owner o) 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. *) @@ -2294,6 +2301,7 @@ let attach_child ctx ~parent:p ~child:c = | Some op when op <> p -> sync_entries ctx op; commit ctx op; + restyle_content_owner ctx (children_owner ctx op); mark_affected ctx (children_owner ctx op) | _ -> ()); let o = children_owner ctx p in @@ -2303,6 +2311,7 @@ let attach_child ctx ~parent:p ~child:c = (* Commit before restyling: gens indices inside the owner's produced list only read correctly once the parent chain has propagated. *) commit ctx p; + restyle_content_owner ctx o; List.iter (fun g -> Hashtbl.replace ctx.owner g o; @@ -2318,6 +2327,7 @@ let detach_child ctx ~parent:p ~child:c = Hashtbl.replace ctx.pkids p (List.filter (fun e -> e.raw <> c) (pkids_of ctx p)); commit ctx p; let o = children_owner ctx p in + restyle_content_owner ctx o; (if index_sensitive (kind_of ctx o) then restyle_children_of ctx o); mark_affected ctx o @@ -2329,6 +2339,7 @@ let detach_from_parent ctx id = Hashtbl.remove ctx.sparent id; Hashtbl.replace ctx.pkids par (List.filter (fun e -> e.raw <> id) (pkids_of ctx par)); commit ctx par; + restyle_content_owner ctx (children_owner ctx par); mark_affected ctx (children_owner ctx par) | None -> () diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index d32bc64a..eefe6967 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -1452,10 +1452,45 @@ let test_menu_role_layout () = check (float eps) "removing menu semantics restores content height" 932. (height ()) +let test_button_custom_content () = + List.iter (fun scale -> + let store = Lui_store.create () and layout = Lui_layout.create () in + apply store + [ CreateNode (1, Root); CreateNode (2, Row); + CreateNode (3, Button); CreateNode (4, Row); CreateNode (5, Box); + CreateNode (6, Button); + InsertChild (1, 2, 0); InsertChild (2, 3, 0); InsertChild (2, 6, 1); + InsertChild (3, 4, 0); InsertChild (4, 5, 0); + SetProp (3, InlineIconName, StringValue "keyboard"); + SetProp (3, HeightValue, IntValue 30); + SetProp (3, PaddingHorizontal, IntValue 10); + SetProp (3, PaddingVertical, IntValue 0); + SetProp (5, WidthValue, IntValue 120); + SetProp (5, HeightValue, IntValue 20); + SetProp (6, InlineIconName, StringValue "x"); + SetProp (6, HeightValue, IntValue 30) ]; + Lui_layout.sync ~scale ~width:(600. *. scale) ~height:(80. *. scale) + layout store; + let rect id = Option.get (Lui_layout.rect layout id) in + let button = rect 3 and child = rect 5 in + check (float eps) "custom content determines button width" + (140. *. scale) button.w; + check bool "custom content stays inside its button" true + (child.x >= button.x && child.x +. child.w <= button.x +. button.w); + check (float eps) "childless icon buttons remain square" + (30. *. scale) (rect 6).w; + apply store [RemoveChild (4, 5); RemoveChild (3, 4)]; + Lui_layout.sync ~scale ~width:(600. *. scale) ~height:(80. *. scale) + layout store; + check (float eps) "removing custom content restores icon sizing" + (30. *. scale) (rect 3).w) + [1.; 2.] + let () = run "lui_layout" [ ("layout", [ test_case "column padding/gap" `Quick test_column_padding_gap; + test_case "button custom content" `Quick test_button_custom_content; test_case "list item content flow" `Quick test_list_item_content_flow; test_case "padding edges" `Quick test_padding_edges; test_case "absolute cross alignment" `Quick test_absolute_cross_alignment; From 89180d362be77b9f1afa4d9215f8bbf56ff47204 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 01:37:37 +0800 Subject: [PATCH 035/163] perf: reuse repeated translucent pixel blends Reuse the previous exact input and output pair within each scanline span. Preserve scalar rounding and odd edges, and cover mixed repeated runs plus allocation on varying backgrounds. --- platform/native/lui_raster/lui_raster.ml | 14 ++++- .../native/lui_raster/test/lui_raster_test.ml | 51 ++++++++++++++++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/platform/native/lui_raster/lui_raster.ml b/platform/native/lui_raster/lui_raster.ml index 9b30b008..20571bb2 100644 --- a/platform/native/lui_raster/lui_raster.ml +++ b/platform/native/lui_raster/lui_raster.ml @@ -135,10 +135,17 @@ let blend_span p row x0 x1 (r_, g_, b_, a_) cov = and ga = Int64.of_int (pg lor (pa lsl 16)) in let rb = Int64.logor rb (Int64.shift_left rb 32) and ga = Int64.logor ga (Int64.shift_left ga 32) in + (* Flat backdrops repeat destination pairs. Reuse their exact blended + result within this span; changing pixels still take the full path. *) + let previous_input = ref 0L and previous_output = ref 0L in + let previous_valid = ref false in let x = ref x0 in while !x + 1 < x1 do let i = row + 4 * !x in let pixels = Bytes.get_int64_le p i in + if !previous_valid && Int64.equal pixels !previous_input then + Bytes.set_int64_le p i !previous_output + else begin (* Four independent 16-bit lanes hold a byte product and its rounding bias. A premultiplied source channel is at most pa, and the destination contribution is at most 255-pa, so the @@ -151,7 +158,12 @@ let blend_span p row x0 x1 (r_, g_, b_, a_) cov = let dga = Int64.logand (Int64.shift_right_logical (Int64.add tga (Int64.logand (Int64.shift_right_logical tga 8) lanes)) 8) lanes in let srb = Int64.add rb drb and sga = Int64.add ga dga in - Bytes.set_int64_le p i (Int64.logor srb (Int64.shift_left sga 8)); + let result = Int64.logor srb (Int64.shift_left sga 8) in + previous_input := pixels; + previous_output := result; + previous_valid := true; + Bytes.set_int64_le p i result + end; x := !x + 2 done; if !x < x1 then begin diff --git a/platform/native/lui_raster/test/lui_raster_test.ml b/platform/native/lui_raster/test/lui_raster_test.ml index 854b3829..1b354064 100644 --- a/platform/native/lui_raster/test/lui_raster_test.ml +++ b/platform/native/lui_raster/test/lui_raster_test.ml @@ -757,14 +757,20 @@ let test_parallel_redraw_gc () = Alcotest.(check bool) "Release and worker changes preserve rendering" true !pixels_ok) let test_translucent_span_allocation () = - let s = scene ~w:512 ~h:128 [ifill (rect 0. 0. 512. 128.) (color 37 151 243 128)] in - let image = Lui_raster.Image.create ~w:512 ~h:128 in - let before = Gc.allocated_bytes () in - Lui_raster.render_into ~workers:1 ~image ~scene:s (); - let allocated = Gc.allocated_bytes () -. before in - Printf.printf "translucent span allocated %.0f bytes\n%!" allocated; - Alcotest.(check bool) "translucent pixel operations do not allocate per pixel" true - (allocated < 131_072.) + let source = new_image ~w:512 ~h:128 + (Bytes.init (512 * 128 * 4) (fun index -> + Char.chr (if index mod 4 = 3 then 255 else (index / 4 + index mod 4 * 71) mod 256))) in + List.iter (fun (name, background) -> + let s = scene ~w:512 ~h:128 + (background @ [ifill (rect 0. 0. 512. 128.) (color 37 151 243 128)]) in + let image = Lui_raster.Image.create ~w:512 ~h:128 in + let before = Gc.allocated_bytes () in + Lui_raster.render_into ~workers:1 ~image ~scene:s (); + let allocated = Gc.allocated_bytes () -. before in + Printf.printf "%s translucent span allocated %.0f bytes\n%!" name allocated; + Alcotest.(check bool) "translucent pixel operations do not allocate per pixel" true + (allocated < 131_072.)) + ["flat", []; "varying", [iimage (rect 0. 0. 512. 128.) source (rect 0. 0. 512. 128.)]] let test_opaque_span_edges () = List.iter (fun height -> List.iter (fun width -> List.iter (fun offset -> @@ -801,6 +807,34 @@ let test_translucent_span_channels () = true (Bytes.equal expected image.pix) done +let test_translucent_repeated_runs () = + let width = 259 and height = 3 in + let channel x y c = + if c = 3 then 255 + else if x mod 64 < 48 then 255 + else ((x / (y + 1)) + (2 - c) * 71 + y * 17) mod 256 in + let source = new_image ~w:width ~h:height + (Bytes.init (width * height * 4) (fun index -> + let pixel = index / 4 in + Char.chr (channel (pixel mod width) (pixel / width) (index mod 4)))) in + for alpha = 1 to 254 do + let s = scene ~w:width ~h:height + [ iimage (rect 0. 0. (float width) (float height)) source + (rect 0. 0. (float width) (float height)); + ifill (rect 1. 0. 257. (float height)) (color 255 151 37 alpha) ] in + let image = Lui_raster.render ~workers:1 ~scene:s () in + let expected = Bytes.init (Bytes.length image.pix) (fun index -> + let pixel = index / 4 and c = index mod 4 in + let x = pixel mod width and y = pixel / width in + let dest = channel x y (if c = 3 then 3 else 2 - c) in + let value = if x = 0 || x = 258 then dest else + let src = ([|37;151;255;255|].(c) * alpha + 127) / 255 in + Int.min 255 (src + (dest * (255 - alpha) + 127) / 255) in + Char.chr value) in + Alcotest.(check bool) "Repeated and changing destination runs match scalar blending" + true (Bytes.equal expected image.pix) + done + let assert_pixels_close message expected actual = Alcotest.(check int) (message ^ " size") (Bytes.length expected) (Bytes.length actual); Bytes.iteri (fun i byte -> @@ -1213,6 +1247,7 @@ let () = Alcotest.test_case "translucent rounding" `Slow test_translucent_byte_rounding; Alcotest.test_case "span allocation" `Quick test_translucent_span_allocation; Alcotest.test_case "modal fade budget" `Slow test_modal_fade_budget; + Alcotest.test_case "translucent repeated runs" `Quick test_translucent_repeated_runs; Alcotest.test_case "opaque span edges" `Quick test_opaque_span_edges; Alcotest.test_case "opaque glyph allocation" `Quick test_opaque_glyph_allocation; Alcotest.test_case "translucent span channels" `Quick test_translucent_span_channels; From 2ca427de2cefb3ae46c9b5729b349dafe495094c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 02:09:32 +0800 Subject: [PATCH 036/163] fix: render menu selection indicators across hosts Add typed selection placement and reserve aligned label slots. Paint reusable native check marks and preserve icons beside shortcut children. --- .../src/main/kotlin/dev/lui/LuiWireSchema.kt | 1 + .../LUIAppleBackend/LUIWireSchema.swift | 1 + platform/native/lui_paint/lui_paint.ml | 118 ++++++++++++++++-- .../native/lui_paint/test/lui_paint_test.ml | 101 ++++++++++++++- platform/web/melange/render/lui_web_props.ml | 4 + platform/web/src/lui.css | 5 + schema/components.json | 5 + src/lui_elements.ml | 4 +- src/lui_elements.mli | 1 + src/lui_json_view.ml | 2 +- src/lui_protocol.ml | 5 +- src/lui_protocol.mli | 1 + src/lui_wire_schema.ml | 3 +- 13 files changed, 233 insertions(+), 18 deletions(-) diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt index 48075b24..71c504e2 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt @@ -148,6 +148,7 @@ enum class LuiProperty(val wireName: String) { variant("variant"), icon("icon"), iconPlacement("icon-placement"), + selectionPlacement("selection-placement"), selected("selected"), autofocus("autofocus"), submitOnEnter("submit-on-enter"), diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 223df8aa..bade315c 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -141,6 +141,7 @@ enum LUIProperty: String, Decodable, Hashable { case variant = "variant" case icon = "icon" case iconPlacement = "icon-placement" + case selectionPlacement = "selection-placement" case selected = "selected" case autofocus = "autofocus" case submitOnEnter = "submit-on-enter" diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index 3945706d..fda2c407 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -79,6 +79,7 @@ let prop_coverage = ("variant", Host); (* styling hint; resolved upstream to props *) ("icon", Painted_by_image); ("icon-placement", Layout); + ("selection-placement", Layout); ("selected", Painted); (* folds into state.selected *) ("autofocus", Host); ("submit-on-enter", Host); @@ -1004,10 +1005,7 @@ let text_placement_of store id = | "bottom-tab" | "tab" -> (Ta_center, 12., 5., false) | "tooltip" | "toast" -> (Ta_center, 8., 4., false) | "alert" | "bubble" -> (Ta_start, 10., 6., false) - | "menu-item" -> - (* icon slot [x+8, x+26] + 4px gap shifts the label — the web - stylesheet's icon+gap-1 inside px-2 *) - (Ta_start, (if pstr store id "icon" = None then 8. else 30.), 6., false) + | "menu-item" -> (Ta_start, 8., 6., false) | "table-cell" | "table-row" -> (Ta_start, 10., 6., false) | "step" -> (Ta_center, 8., 4., false) | "list-item" -> (Ta_start, 12., 6., false) @@ -1032,9 +1030,19 @@ let text_placement_of store id = | None, Some p, v -> (p, Option.value ~default:p v) | None, None, v -> (pad_x, Option.value ~default:pad_y v) in + let leading, trailing = + if Lui_store.kind store id <> "menu-item" then (0., 0.) + else + let zone = 16. +. Option.value ~default:8. (pnum store id "gap") in + let icon = if pstr store id "icon" = None then 0. else zone in + if Lui_store.prop store id "selected" = None then (icon, 0.) + else if pstr store id "selection-placement" = Some "leading" then + (icon +. zone, 0.) + else (icon, zone) + in { tp_align = align; - tp_pad_left = Option.value ~default:pad_x (pnum store id "padding-left"); - tp_pad_right = Option.value ~default:pad_x (pnum store id "padding-right"); + tp_pad_left = Option.value ~default:pad_x (pnum store id "padding-left") +. leading; + tp_pad_right = Option.value ~default:pad_x (pnum store id "padding-right") +. trailing; tp_pad_top = Option.value ~default:pad_y (pnum store id "padding-top"); tp_pad_bottom = Option.value ~default:pad_y (pnum store id "padding-bottom"); tp_top = top } @@ -1764,18 +1772,20 @@ let control_icon_ops ctx store id r alpha = | None -> [] | Some img -> ( match Lui_store.child_ids store id with - | _ :: _ -> [] - | [] -> + | _ :: _ when Lui_store.kind store id <> "menu-item" -> [] + | _ -> let s = ctx.scale in - let size = Float.min (18. *. s) (Float.min r.w r.h) in + let icon_size = if Lui_store.kind store id = "menu-item" then 16. else 18. in + let size = Float.min (icon_size *. s) (Float.min r.w r.h) in if size <= 0. then [] else let dst = match Lui_store.kind store id with | "menu-item" -> - (* leading icon slot: 8px in, vertically centered — - .lui-menu-item's icon span *) - rect (r.x +. (8. *. s)) + (* The icon follows the optional leading check and outer padding. *) + let tp = text_placement_of store id in + let gap = Option.value ~default:8. (pnum store id "gap") in + rect (r.x +. ((tp.tp_pad_left -. 16. -. gap) *. s)) (r.y +. ((r.h -. size) /. 2.)) size size | _ -> rect (r.x +. ((r.w -. size) /. 2.)) @@ -1788,6 +1798,84 @@ let control_icon_ops ctx store id r alpha = { x = 0.; y = 0.; w = float img.iw; h = float img.ih }; igrayscale = false; iopacity = alpha } ])) +(* A single immutable bitmap is reused across rows and frames. The bounded + cache also allows theme or display-scale changes without retaining old sizes. *) +let menu_check_cache = ref None + +let menu_check_image size c = + match !menu_check_cache with + | Some (cached_size, cached_color, image) + when cached_size = size && cached_color = c -> + image + | _ -> + let distance x y ax ay bx by = + let dx = bx -. ax and dy = by -. ay in + let t = + Float.max 0. + (Float.min 1. + ((((x -. ax) *. dx) +. ((y -. ay) *. dy)) + /. ((dx *. dx) +. (dy *. dy)))) + in + Float.hypot (x -. ax -. (t *. dx)) (y -. ay -. (t *. dy)) + in + let pixels = Bytes.make (size * size * 4) '\000' in + let step = 24. /. float size in + for y = 0 to size - 1 do + for x = 0 to size - 1 do + let coverage = ref 0 in + for sy = 0 to 3 do + for sx = 0 to 3 do + let px = (float x +. ((float sx +. 0.5) /. 4.)) *. step in + let py = (float y +. ((float sy +. 0.5) /. 4.)) *. step in + if + Float.min + (distance px py 5. 12. 10. 17.) + (distance px py 10. 17. 20. 7.) + <= 1. + then incr coverage + done + done; + let alpha = ((c.a * !coverage) + 8) / 16 in + let offset = ((y * size) + x) * 4 in + Bytes.set pixels offset (Char.chr (((c.r * alpha) + 127) / 255)); + Bytes.set pixels (offset + 1) (Char.chr (((c.g * alpha) + 127) / 255)); + Bytes.set pixels (offset + 2) (Char.chr (((c.b * alpha) + 127) / 255)); + Bytes.set pixels (offset + 3) (Char.chr alpha) + done + done; + let image = new_image ~w:size ~h:size pixels in + menu_check_cache := Some (size, c, image); + image + +let menu_selection_ops ctx store id r alpha fg = + if not (pbool store id "selected") then [] + else + let s = ctx.scale in + let size = 16. *. s in + let tp = text_placement_of store id in + let gap = Option.value ~default:8. (pnum store id "gap") in + let x = + if pstr store id "selection-placement" = Some "leading" then + let icon_zone = + if pstr store id "icon" = None then 0. else 16. +. gap + in + r.x +. ((tp.tp_pad_left -. 16. -. gap -. icon_zone) *. s) + else r.x +. r.w -. ((tp.tp_pad_right -. gap) *. s) + in + let image = menu_check_image (max 1 (int_of_float (Float.ceil size))) fg in + [ + Image + { + irect2 = rect x (r.y +. ((r.h -. size) /. 2.)) size size; + iradii = (0., 0., 0., 0.); + icontinuous = false; + iimage = image; + isrc = rect 0. 0. (float image.iw) (float image.ih); + igrayscale = false; + iopacity = alpha; + }; + ] + (* Labeled chrome carrying an [icon] prop and no children paints the glyph in a side slot — on the web the icon is a flex child sitting before ("leading", default) or after ("trailing") the label, so the @@ -2717,7 +2805,11 @@ let paint_own ctx store id st kind r rs alpha = | "text-field" | "secure-field" | "input" | "search-field" | "textarea" -> box_ops ctx store id rs r alpha @ text_ops ~fg ctx store id r alpha ~placeholder_ok:true - | "button" | "toggle-button" | "toggle" | "menu-item" | "menu-trigger" + | "menu-item" -> + box_ops ctx store id rs r alpha + @ labeled_content_ops ctx store id r alpha ~fg ~placeholder_ok:false + @ menu_selection_ops ctx store id r alpha fg + | "button" | "toggle-button" | "toggle" | "menu-trigger" | "bottom-tab" | "list-item" | "swipe-action" | "file-picker" | "step" | "list-section-header" | "list-section-footer" | "tab" | "table-cell" -> (* labeled chrome: box decoration plus the label through the text diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index e78a430b..5a5767e1 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -1111,9 +1111,106 @@ let test_menu_item_single_icon () = in check int "one icon image" 1 (List.length images); match images with - | [ i ] -> check (float 0.01) "leading slot x" 8. i.irect2.x + | [ i ] -> + check (float 0.01) "leading slot x" 8. i.irect2.x; + apply t [CreateNode (2, Kbd); SetProp (2, TextValue, StringValue "K"); InsertChild (1, 2, 0)]; + let images = List.filter_map (function Image i -> Some i | _ -> None) (paint_with h t ()).ops in + check int "shortcut children preserve the menu icon" 1 (List.length images) | _ -> () +let test_menu_selection_indicator () = + List.iter + (fun scale -> + let t = store () in + apply t + [ + Lui_protocol.CreateNode (1, MenuItem); + SetProp (1, TextValue, StringValue "English"); + SetProp (1, Selected, BoolValue true); + ]; + let r = + rect (20. *. scale) (10. *. scale) (200. *. scale) (32. *. scale) + in + let h = hooks ~layout:(fun _ -> Lui_paint.placement_of_rect r) () in + let images scene = + List.filter_map (function Image i -> Some i | _ -> None) scene.ops + in + let selected = images (paint_with h t ~width:500 ~height:100 ~scale ()) in + check int "selected menu row paints one check" 1 (List.length selected); + let mark = List.hd selected in + check bool "check contains ink" true + (Bytes.exists (fun byte -> byte <> '\000') mark.iimage.ipix); + let repeated = + List.hd (images (paint_with h t ~width:500 ~height:100 ~scale ())) + in + check bool "unchanged rows reuse the check bitmap" true + (mark.iimage == repeated.iimage); + check bool "selection check occupies the trailing sixteen-point slot" true + (mark.irect2 + = rect (196. *. scale) (18. *. scale) (16. *. scale) (16. *. scale)); + let tp = Lui_paint.text_placement_of t 1 in + check (float 0.01) "selection reserves label space" 32. tp.tp_pad_right; + apply t [ SetProp (1, Selected, BoolValue false) ]; + check int "unchecked menu row hides its check" 0 + (List.length (images (paint_with h t ~width:500 ~height:100 ~scale ()))); + let tp = Lui_paint.text_placement_of t 1 in + check (float 0.01) "unchecked options preserve alignment" 32. + tp.tp_pad_right; + apply t [ RemoveProp (1, Selected) ]; + check (float 0.01) "ordinary actions need no selection slot" 8. + (Lui_paint.text_placement_of t 1).tp_pad_right) + [ 1.; 2. ] + +let test_menu_selection_placement () = + let property = + List.find_opt + (fun p -> Lui_wire_schema.property_name p = "selection-placement") + Lui_wire_schema.all_properties + in + check bool "selection placement has a typed wire property" true + (Option.is_some property); + let property = Option.get property in + List.iter + (fun scale -> + let t = store () in + apply t + [ + Lui_protocol.CreateNode (1, MenuItem); + SetProp (1, TextValue, StringValue "English"); + SetProp (1, Selected, BoolValue true); + SetProp (1, property, StringValue "leading"); + SetProp (1, PaddingHorizontal, IntValue 12); + SetProp (1, Gap, IntValue 4); + ]; + let r = + rect (20. *. scale) (10. *. scale) (200. *. scale) (32. *. scale) + in + let h = hooks ~layout:(fun _ -> Lui_paint.placement_of_rect r) () in + let images scene = + List.filter_map (function Image i -> Some i | _ -> None) scene.ops + in + let first = + List.hd (images (paint_with h t ~width:500 ~height:100 ~scale ())) + in + check bool "leading selection honors typed padding" true + (first.irect2 + = rect (32. *. scale) (18. *. scale) (16. *. scale) (16. *. scale)); + let tp = Lui_paint.text_placement_of t 1 in + check (float 0.01) "leading check and gap reserve label space" 32. + tp.tp_pad_left; + check (float 0.01) "leading check leaves trailing padding intact" 12. + tp.tp_pad_right; + apply t [ SetProp (1, Enabled, BoolValue false) ]; + let disabled = + List.hd (images (paint_with h t ~width:500 ~height:100 ~scale ())) + in + check bool "disabled check follows row opacity" true + (disabled.iopacity > 0. && disabled.iopacity < first.iopacity); + apply t [ SetProp (1, property, StringValue "trailing") ]; + check (float 0.01) "placement changes update trailing label space" 32. + (Lui_paint.text_placement_of t 1).tp_pad_right) + [ 1.; 2. ] + let test_control_single_icon () = List.iter (fun kind -> @@ -1261,6 +1358,8 @@ let () = test_case "scrollbar" `Quick test_scrollbar; test_case "extension" `Quick test_extension; test_case "menu item single icon" `Quick test_menu_item_single_icon; + test_case "menu selection indicator" `Quick test_menu_selection_indicator; + test_case "menu selection placement" `Quick test_menu_selection_placement; test_case "control single icon" `Quick test_control_single_icon; test_case "text edge placement" `Quick test_text_edge_placement ]); ("secure", diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 0d53a7b1..1153d956 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -798,6 +798,8 @@ and apply_secondary_property renderer queue_split node kind dom_node apply_inline_icon_name renderer node kind dom_node name | IconPlacementValue, StringValue placement -> W.Element.setAttribute "data-icon-placement" placement dom_node + | SelectionPlacementValue, StringValue placement -> + W.Element.setAttribute "data-selection-placement" placement dom_node | Selected, BoolValue selected -> apply_selected renderer node kind dom_node selected | Autofocus, BoolValue autofocus -> apply_autofocus dom_node autofocus @@ -1036,6 +1038,8 @@ let remove_property renderer queue_split node kind dom_node property = match Store.node renderer.web_store node with | Some _current -> (match property with + | SelectionPlacementValue -> + W.Element.removeAttribute "data-selection-placement" dom_node | AppearEnabled -> Lui_web_events.release_appear renderer node | StyleValue -> W.Element.removeAttribute "data-list-style" dom_node | ScrollTarget | ScrollAnchor | ScrollToken | ScrollAnimated | TrackVisibleRange -> () diff --git a/platform/web/src/lui.css b/platform/web/src/lui.css index 30ffe41c..f5ac6bf7 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -1148,6 +1148,11 @@ body { @apply invisible; } + .lui-menu-item[data-selection-placement="leading"] > .lui-menu-item-check { + display: flex; + order: -4; + } + .lui-menu-item-label { @apply flex-1; } diff --git a/schema/components.json b/schema/components.json index 1e9c197d..123df574 100644 --- a/schema/components.json +++ b/schema/components.json @@ -1121,6 +1121,11 @@ "wire": "icon-placement", "swift": "iconPlacement" }, + { + "lg": "SelectionPlacementValue", + "wire": "selection-placement", + "swift": "selectionPlacement" + }, { "lg": "Selected", "wire": "selected", diff --git a/src/lui_elements.ml b/src/lui_elements.ml index f8779614..269a4b15 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -1949,7 +1949,7 @@ let accordion ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal mount_children context node children; node -let menu_item ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?text ?text_signal ?icon ?icon_signal ?role ?variant ?size ?tree_level ?expanded ?selected ?checked ?selected_signal ?checked_signal ?disabled ?disabled_signal ?on_press ?on_input ?on_submit ?on_dismiss ?on_press_detail ?on_pointer_down ?on_pointer_up ?on_context_menu ?tooltip ?tooltip_signal ?shortcut_hint ?shortcut_hint_signal ?transition (children : t list) : t = +let menu_item ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?text ?text_signal ?icon ?icon_signal ?selection_placement ?role ?variant ?size ?tree_level ?expanded ?selected ?checked ?selected_signal ?checked_signal ?disabled ?disabled_signal ?on_press ?on_input ?on_submit ?on_dismiss ?on_press_detail ?on_pointer_down ?on_pointer_up ?on_context_menu ?tooltip ?tooltip_signal ?shortcut_hint ?shortcut_hint_signal ?transition (children : t list) : t = fun context parent -> let node = Lui_ui.menu_item context in apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~margin ~margin_horizontal ~margin_vertical ~margin_top ~margin_right ~margin_bottom ~margin_left ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~data_attrs ~data_attrs_signal ~on_appear ~on_pointer_enter ~on_pointer_leave ~transition; @@ -1957,6 +1957,8 @@ let menu_item ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal Option.iter (Lui_ui.string_property_signal context node TextValue) text_signal; Option.iter (Lui_ui.string_property context node InlineIconName) (Option.map icon_value icon); Option.iter (fun signal_ -> Lui_ui.string_property_signal context node InlineIconName (owned_map context icon_value signal_)) icon_signal; + Option.iter (Lui_ui.string_property context node SelectionPlacementValue) + (Option.map icon_placement_value selection_placement); Option.iter (Lui_ui.string_property context node RoleValue) (Option.map role_value role); Option.iter (Lui_ui.string_property context node VariantValue) (Option.map variant_value variant); Option.iter (Lui_ui.string_property context node SizeValue) (Option.map control_size_value size); diff --git a/src/lui_elements.mli b/src/lui_elements.mli index ad263aa7..d3453d8c 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -3795,6 +3795,7 @@ val menu_item : ?text_signal:string Signal.signal -> ?icon:icon -> ?icon_signal:icon Signal.signal -> + ?selection_placement:icon_placement -> ?role:role -> ?variant:variant -> ?size:control_size -> diff --git a/src/lui_json_view.ml b/src/lui_json_view.ml index fa7bf591..f4e013c6 100644 --- a/src/lui_json_view.ml +++ b/src/lui_json_view.ml @@ -522,7 +522,7 @@ let rec element ~emit (node : value) : E.t = ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?margin:s.s_margin ?margin_horizontal:s.s_margin_h ?margin_vertical:s.s_margin_v ?margin_top:s.s_margin_top ?margin_right:s.s_margin_right ?margin_bottom:s.s_margin_bottom ?margin_left:s.s_margin_left ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?selected:(v_bool props "selected") ?accordion_height:(v_int props "accordion-height") ?on_toggle:(ev "toggle" emit props) (List.map (element ~emit) child_nodes) | "menu-item" -> E.menu_item - ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?margin:s.s_margin ?margin_horizontal:s.s_margin_h ?margin_vertical:s.s_margin_v ?margin_top:s.s_margin_top ?margin_right:s.s_margin_right ?margin_bottom:s.s_margin_bottom ?margin_left:s.s_margin_left ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?icon:(icon_prop props "icon") ?role:(role_opt (v_str props "role")) ?variant:(variant_opt (v_str props "variant")) ?size:(control_size_opt (v_str props "size")) ?tree_level:(v_int props "tree-level") ?expanded:(v_bool props "expanded") ?selected:(v_bool props "selected") ?checked:(v_bool props "checked") ?disabled:(v_bool props "disabled") ?on_press:(ev "press" emit props) ?tooltip:(v_str props "tooltip") ?shortcut_hint:(v_str props "shortcut-hint") ?transition:(v_str props "transition") (List.map (element ~emit) child_nodes) + ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?margin:s.s_margin ?margin_horizontal:s.s_margin_h ?margin_vertical:s.s_margin_v ?margin_top:s.s_margin_top ?margin_right:s.s_margin_right ?margin_bottom:s.s_margin_bottom ?margin_left:s.s_margin_left ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?icon:(icon_prop props "icon") ?selection_placement:(icon_placement_opt (v_str props "selection-placement")) ?role:(role_opt (v_str props "role")) ?variant:(variant_opt (v_str props "variant")) ?size:(control_size_opt (v_str props "size")) ?tree_level:(v_int props "tree-level") ?expanded:(v_bool props "expanded") ?selected:(v_bool props "selected") ?checked:(v_bool props "checked") ?disabled:(v_bool props "disabled") ?on_press:(ev "press" emit props) ?tooltip:(v_str props "tooltip") ?shortcut_hint:(v_str props "shortcut-hint") ?transition:(v_str props "transition") (List.map (element ~emit) child_nodes) | "list-item" -> E.list_item ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?margin:s.s_margin ?margin_horizontal:s.s_margin_h ?margin_vertical:s.s_margin_v ?margin_top:s.s_margin_top ?margin_right:s.s_margin_right ?margin_bottom:s.s_margin_bottom ?margin_left:s.s_margin_left ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?icon:(icon_prop props "icon") ?icon_placement:(icon_placement_opt (v_str props "icon-placement")) ?role:(role_opt (v_str props "role")) ?tree_level:(v_int props "tree-level") ?expanded:(v_bool props "expanded") ?selected:(v_bool props "selected") ?disabled:(v_bool props "disabled") ?on_press:(ev "press" emit props) ?on_long_press:(ev "long-press" emit props) ?on_double_press:(ev "double-press" emit props) ?on_submit:(ev "submit" emit props) ?on_toggle:(ev "toggle" emit props) ?transition:(v_str props "transition") (List.map (element ~emit) child_nodes) diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index 17e4945a..fcf013bf 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -162,6 +162,7 @@ type property = | VariantValue | InlineIconName | IconPlacementValue + | SelectionPlacementValue | Selected | Autofocus | SubmitOnEnter @@ -1005,6 +1006,7 @@ let common_property_supported kind property = || kind = Link | IconPlacementValue -> kind = Button || kind = ToggleButton || kind = ListItem || kind = Link + | SelectionPlacementValue -> kind = MenuItem | Selected -> kind = Button || kind = ToggleButton @@ -1500,7 +1502,8 @@ let property_value_supported property value = | IconName, StringValue value -> icon_name_supported value | VariantValue, StringValue value -> button_variant_supported value | InlineIconName, StringValue value -> icon_name_supported value - | IconPlacementValue, StringValue value -> icon_placement_supported value + | IconPlacementValue, StringValue value + | SelectionPlacementValue, StringValue value -> icon_placement_supported value | Selected, BoolValue _ -> true | Autofocus, BoolValue _ -> true | SubmitOnEnter, BoolValue _ -> true diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index ca0d676f..9eb1caf0 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -160,6 +160,7 @@ type property = | VariantValue | InlineIconName | IconPlacementValue + | SelectionPlacementValue | Selected | Autofocus | SubmitOnEnter diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 392bb6d8..044fccf8 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -236,6 +236,7 @@ let property_name property = | VariantValue -> "variant" | InlineIconName -> "icon" | IconPlacementValue -> "icon-placement" + | SelectionPlacementValue -> "selection-placement" | Selected -> "selected" | Autofocus -> "autofocus" | SubmitOnEnter -> "submit-on-enter" @@ -386,4 +387,4 @@ let kind_extra_properties kind = let all_node_kinds = [ Root; Row; Column; Grid; Stack; EdgeInset; Overlay; ViewThatFits; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; NumberStepper; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; ListSection; ListSectionHeader; ListSectionFooter; SwipeActions; SwipeAction; FilePicker; Kbd; Link; FileImage; FilePreview; Br; Popover ] -let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; PaddingTop; PaddingRight; PaddingBottom; PaddingLeft; MarginValue; MarginHorizontal; MarginVertical; MarginTop; MarginRight; MarginBottom; MarginLeft; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; KeyValue; SeparatorValue; StyleValue; ScrollTarget; ScrollAnchor; ScrollToken; ScrollAnimated; TrackVisibleRange; EdgeValue; PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; MinValue; MaxValue; StepValue; Detents; Sizing; PathValue; UrlValue; MaxPixelSize; ImageFitValue; Visible; AlignmentValue; PointerEnabled; DataAttrs; As; AltValue; LoadingValue; ReferrerPolicy; TargetValue; Opacity; DisplayValue; TooltipText; TooltipKeys; InputType; PickerAccept; PickerDirectory; PopupX; PopupY; AvailableHeight; FontSize; FontWeight; FontStyle; LineHeight; LetterSpacing; Position; Inset; InsetTop; InsetRight; InsetBottom; InsetLeft; ZIndex; WhiteSpace; TextOverflow; Overflow; UserSelect; Cursor; Shadow; HoverBackground; HoverOpacity; HoverShadow; PressedBackground; PressedOpacity; PressedShadow; FocusShadow; SelectedBackground; SelectedShadow; SelectedHoverShadow; DisabledOpacity; WidthViewport; HeightViewport; MinWidthViewport; MaxWidthViewport; MinHeightViewport; MaxHeightViewport; FontFamily; TextDecoration; ListStyle; FlexWrap; HoverForeground; HoverBorder; Transition ] +let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; PaddingTop; PaddingRight; PaddingBottom; PaddingLeft; MarginValue; MarginHorizontal; MarginVertical; MarginTop; MarginRight; MarginBottom; MarginLeft; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; SelectionPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; KeyValue; SeparatorValue; StyleValue; ScrollTarget; ScrollAnchor; ScrollToken; ScrollAnimated; TrackVisibleRange; EdgeValue; PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; MinValue; MaxValue; StepValue; Detents; Sizing; PathValue; UrlValue; MaxPixelSize; ImageFitValue; Visible; AlignmentValue; PointerEnabled; DataAttrs; As; AltValue; LoadingValue; ReferrerPolicy; TargetValue; Opacity; DisplayValue; TooltipText; TooltipKeys; InputType; PickerAccept; PickerDirectory; PopupX; PopupY; AvailableHeight; FontSize; FontWeight; FontStyle; LineHeight; LetterSpacing; Position; Inset; InsetTop; InsetRight; InsetBottom; InsetLeft; ZIndex; WhiteSpace; TextOverflow; Overflow; UserSelect; Cursor; Shadow; HoverBackground; HoverOpacity; HoverShadow; PressedBackground; PressedOpacity; PressedShadow; FocusShadow; SelectedBackground; SelectedShadow; SelectedHoverShadow; DisabledOpacity; WidthViewport; HeightViewport; MinWidthViewport; MaxWidthViewport; MinHeightViewport; MaxHeightViewport; FontFamily; TextDecoration; ListStyle; FlexWrap; HoverForeground; HoverBorder; Transition ] From 0cc7e8a6d7870133ac8c6013903969891e0dacbb Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 02:15:20 +0800 Subject: [PATCH 037/163] fix: honor typed menu slot layout in renderers Accept menu item gap values and apply selection order directly in the web renderer, including property removal. --- platform/web/melange/render/lui_web_props.ml | 7 +++++-- platform/web/src/lui.css | 5 ----- src/lui_protocol.ml | 1 + test/test_lui.ml | 7 +++++++ 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 1153d956..0ff7234b 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -799,7 +799,9 @@ and apply_secondary_property renderer queue_split node kind dom_node | IconPlacementValue, StringValue placement -> W.Element.setAttribute "data-icon-placement" placement dom_node | SelectionPlacementValue, StringValue placement -> - W.Element.setAttribute "data-selection-placement" placement dom_node + W.Element.setAttribute "data-selection-placement" placement dom_node; + set_style (Util.child_element dom_node 2) "order" + (if placement = "leading" then "-4" else "1") | Selected, BoolValue selected -> apply_selected renderer node kind dom_node selected | Autofocus, BoolValue autofocus -> apply_autofocus dom_node autofocus @@ -1039,7 +1041,8 @@ let remove_property renderer queue_split node kind dom_node property = | Some _current -> (match property with | SelectionPlacementValue -> - W.Element.removeAttribute "data-selection-placement" dom_node + W.Element.removeAttribute "data-selection-placement" dom_node; + set_style (Util.child_element dom_node 2) "order" "" | AppearEnabled -> Lui_web_events.release_appear renderer node | StyleValue -> W.Element.removeAttribute "data-list-style" dom_node | ScrollTarget | ScrollAnchor | ScrollToken | ScrollAnimated | TrackVisibleRange -> () diff --git a/platform/web/src/lui.css b/platform/web/src/lui.css index f5ac6bf7..30ffe41c 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -1148,11 +1148,6 @@ body { @apply invisible; } - .lui-menu-item[data-selection-placement="leading"] > .lui-menu-item-check { - display: flex; - order: -4; - } - .lui-menu-item-label { @apply flex-1; } diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index fcf013bf..be80873d 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -1158,6 +1158,7 @@ let common_property_supported kind property = || kind = Grid || kind = ListContainer || kind = VirtualList + || kind = MenuItem || kind = DropdownMenu || kind = Popover || kind = TableRow diff --git a/test/test_lui.ml b/test/test_lui.ml index d4550e8e..9337600b 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -2333,6 +2333,13 @@ let test_vocab_batch2 () = (property_value_supported FlexWrap (StringValue "nowrap")); Alcotest.(check bool) "flex-wrap rejects wrap-reverse" false (property_value_supported FlexWrap (StringValue "wrap-reverse")); + Alcotest.(check bool) "menu rows accept typed slot spacing" true + (property_supported MenuItem Gap); + Alcotest.(check bool) "menu rows accept leading selection placement" true + (property_supported MenuItem SelectionPlacementValue + && property_value_supported SelectionPlacementValue (StringValue "leading")); + Alcotest.(check bool) "selection placement rejects invalid values" false + (property_value_supported SelectionPlacementValue (StringValue "center")); (* ~tooltip/~shortcut_hint *) Alcotest.(check bool) "tooltip on button" true (property_supported Button TooltipText); From a9cd18e7394746eb99597d2d1444548aecd9310d Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 02:25:44 +0800 Subject: [PATCH 038/163] fix: exclude explicit padding from native text measurement Keep paint insets intact while leaving typed padding to flex layout. Preserve implicit control chrome and selection slots in intrinsic measurements. --- platform/native/lui_paint/lui_paint.ml | 23 +++++++++++++++---- .../native/lui_paint/test/lui_paint_test.ml | 13 +++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index fda2c407..3786e20a 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -994,7 +994,7 @@ type text_placement = { edge. The text-alignment and padding props win over the table. Every text engine feeds this into its pen math so all backends place glyphs identically. *) -let text_placement_of store id = +let text_placement ~layout_padding store id = let align, pad_x, pad_y, top = match Lui_store.kind store id with | "button" | "toggle-button" | "toggle" | "menu-trigger" @@ -1040,13 +1040,26 @@ let text_placement_of store id = (icon +. zone, 0.) else (icon, zone) in + let inset side axis default = + if not layout_padding + && (Lui_store.prop store id side <> None + || Lui_store.prop store id axis <> None + || Lui_store.prop store id "padding" <> None) then 0. + else Option.value ~default (pnum store id side) + in { tp_align = align; - tp_pad_left = Option.value ~default:pad_x (pnum store id "padding-left") +. leading; - tp_pad_right = Option.value ~default:pad_x (pnum store id "padding-right") +. trailing; - tp_pad_top = Option.value ~default:pad_y (pnum store id "padding-top"); - tp_pad_bottom = Option.value ~default:pad_y (pnum store id "padding-bottom"); + tp_pad_left = inset "padding-left" "padding-horizontal" pad_x +. leading; + tp_pad_right = inset "padding-right" "padding-horizontal" pad_x +. trailing; + tp_pad_top = inset "padding-top" "padding-vertical" pad_y; + tp_pad_bottom = inset "padding-bottom" "padding-vertical" pad_y; tp_top = top } +let text_placement_of store id = text_placement ~layout_padding:true store id + +(* Flex already adds explicit padding around its measured content box. Only + implicit control chrome and built-in slots belong in the text measure. *) +let text_measure_placement_of store id = text_placement ~layout_padding:false store id + (* ---------- secure-field masking ---------- *) (* The glyph a secure field substitutes for every grapheme of its diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index 5a5767e1..36b603dd 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -1161,6 +1161,18 @@ let test_menu_selection_indicator () = (Lui_paint.text_placement_of t 1).tp_pad_right) [ 1.; 2. ] +let test_menu_measure_padding () = + let t = store () in + apply t [CreateNode (1, MenuItem); SetProp (1, TextValue, StringValue "English"); + SetProp (1, Selected, BoolValue true); SetProp (1, SelectionPlacementValue, StringValue "leading"); + SetProp (1, PaddingHorizontal, IntValue 8); SetProp (1, PaddingVertical, IntValue 6)]; + let painted = Lui_paint.text_placement_of t 1 in + let measured = Lui_paint.text_measure_placement_of t 1 in + check (float 0.01) "paint includes the outer padding" 6. painted.tp_pad_top; + check (float 0.01) "measurement leaves vertical padding to flex" 0. measured.tp_pad_top; + check (float 0.01) "measurement retains only the selection slot" 24. measured.tp_pad_left; + check (float 0.01) "measurement leaves trailing padding to flex" 0. measured.tp_pad_right + let test_menu_selection_placement () = let property = List.find_opt @@ -1360,6 +1372,7 @@ let () = test_case "menu item single icon" `Quick test_menu_item_single_icon; test_case "menu selection indicator" `Quick test_menu_selection_indicator; test_case "menu selection placement" `Quick test_menu_selection_placement; + test_case "menu measure padding" `Quick test_menu_measure_padding; test_case "control single icon" `Quick test_control_single_icon; test_case "text edge placement" `Quick test_text_edge_placement ]); ("secure", From 38c19ef4abe59bc946c531b72ca31c6eb833df9f Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 02:44:55 +0800 Subject: [PATCH 039/163] fix: initialize native menu selection on open Highlight the enabled selected item or first visible action. Reveal it after layout, preserve subsequent navigation, and leave the trigger input session intact. --- platform/native/lui_input/lui_gesture.ml | 13 ++- platform/native/lui_window/lui_window.ml | 28 +++++- .../native/lui_window/test/lui_window_test.ml | 90 +++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/platform/native/lui_input/lui_gesture.ml b/platform/native/lui_input/lui_gesture.ml index d1d2e43b..d2f0187c 100644 --- a/platform/native/lui_input/lui_gesture.ml +++ b/platform/native/lui_input/lui_gesture.ml @@ -204,7 +204,9 @@ let menu_has_items store menu = let menu_items store menu = let rec collect acc id = - if id <> menu && Lui_query.presented_overlay store id then acc + if (id <> menu && Lui_query.presented_overlay store id) + || Lui_store.prop store id "visible" = Some (BoolValue false) + || Lui_store.string_prop store id "display" = Some "none" then acc else let acc = if @@ -217,6 +219,15 @@ let menu_items store menu = in List.rev (collect [] menu) +let menu_open store ~menu = + let items = menu_items store menu in + let hl = + match List.find_opt (fun id -> Lui_store.bool_prop store id "selected") items with + | Some id -> id + | None -> (match items with [] -> 0 | id :: _ -> id) + in + { list_scope = menu; hl; typeahead = "" } + let menu_state st menu = if st.list_scope = menu then st else { list_scope = menu; hl = 0; typeahead = "" } diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 5b4a82de..de489bd4 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -527,6 +527,7 @@ module Ui = struct mutable drag : Lui_drag.state; mutable overlay : Lui_overlay.state; mutable menu_scope : int; + mutable menu_reveal : int; (* Initial highlight waiting for layout geometry. *) (* Space activation may dispose the menu before its text commit arrives. *) mutable menu_space_commit : bool; (* Paint-hint channels for the retained fragment pass: scroll @@ -573,7 +574,7 @@ module Ui = struct scrollbar_deadlines = Hashtbl.create 8; drag = Lui_drag.initial; overlay = Lui_overlay.initial; - menu_scope = 0; + menu_scope = 0; menu_reveal = 0; menu_space_commit = false; scrolled = Hashtbl.create 8; scroll_metrics = Hashtbl.create 8; @@ -873,9 +874,18 @@ module Ui = struct t.overlay <- ov'; run m); (let id = Lui_overlay.top t.overlay in - t.menu_scope <- + let scope = if id <> 0 && Lui_paint.menu_kind store id (Lui_store.kind store id) - && Lui_gesture.menu_has_items store id then id else 0); + && Lui_gesture.menu_has_items store id then id else 0 + in + if scope <> t.menu_scope then begin + t.menu_scope <- scope; + t.gesture <- + if scope = 0 then Lui_gesture.initial + else Lui_gesture.menu_open store ~menu:scope; + t.menu_reveal <- Lui_gesture.hl t.gesture; + t.dirty <- true + end); (let f', cand = Lui_focus.scan_autofocus store t.focus ~scope:(Lui_overlay.top t.overlay) @@ -893,6 +903,18 @@ module Ui = struct set_scroll t s'; run m end; + (* New menu rows may not have geometry during the opening store sync. + Reveal once layout supplies it; later pointer/keyboard choices win. *) + if t.menu_reveal <> 0 then begin + let id = t.menu_reveal in + if id <> Lui_gesture.hl t.gesture || not (Lui_store.mem store id) then + t.menu_reveal <- 0 + else if not (rect_empty (t.content_rect id)) + && not (rect_empty (t.content_rect t.menu_scope)) then begin + t.menu_reveal <- 0; + run [`Scroll_show id] + end + end; (* visible-range reports follow scroll offsets, which live in the machine rather than the store — they run on every sync, memoized on their inputs *) diff --git a/platform/native/lui_window/test/lui_window_test.ml b/platform/native/lui_window/test/lui_window_test.ml index f7bebb9e..adb7aaa7 100644 --- a/platform/native/lui_window/test/lui_window_test.ml +++ b/platform/native/lui_window/test/lui_window_test.ml @@ -2874,6 +2874,95 @@ let test_assembly_menu_keyboard () = (Popover, [ SetProp (2, RoleValue, StringValue "menu") ]); ] +let test_menu_open_selection () = + List.iter + (fun (selected, navigate_early) -> + let asm = + assembly_of + [ + CreateNode (1, Root); + CreateNode (2, TextField); + SetProp (2, TextValue, StringValue "draft"); + InsertChild (1, 2, 0); + ] + in + let ui = Assembly.ui asm and store = Assembly.store asm in + Ui.set_focused ui store 2; + let rows = + List.init 20 (fun index -> + let id = index + 4 in + [ + CreateNode (id, MenuItem); + InsertChild (3, id, index); + SetProp + (id, TextValue, StringValue (Printf.sprintf "Option %d" index)); + SetProp (id, HeightValue, IntValue 32); + SetProp (id, Selected, BoolValue (id = selected)); + SetProp (id, Enabled, BoolValue (id <> 4)); + SetProp (id, Visible, BoolValue (id <> 5)); + ]) + in + Lui_store.apply_batch store + { + generation = 2; + ops = + [ + CreateNode (3, DropdownMenu); + InsertChild (1, 3, 1); + SetProp (3, HeightValue, IntValue 100); + ] + @ List.concat rows; + }; + ignore (Assembly.store_changed asm); + let expected = if selected = 23 then 23 else 6 in + Alcotest.(check int) + "open highlights the enabled selected row or first enabled row" expected + (Ui.hl_item ui); + Alcotest.(check bool) + "initial selection uses the menu highlight style" true + (Ui.state_of ui store expected).Lui_paint.hovered; + Alcotest.(check int) + "opening a menu preserves the trigger's input focus" 2 (Ui.focused ui); + let events = press_actions (Assembly.handle asm (key Input.Return)) in + Alcotest.(check bool) + "Enter activates the initial selection" true + (List.mem (Press expected) events); + if navigate_early then ignore (Assembly.handle asm (key Input.Home)); + ignore (Assembly.repaint asm); + ignore (Assembly.store_changed asm); + ignore (Assembly.repaint asm); + if expected = 23 && not navigate_early then begin + let panel = Rects.get (Assembly.rects asm) 3 in + let row = Rects.get (Assembly.rects asm) expected in + Alcotest.(check bool) + "opening reveals the entire selected row after layout" true + (Ui.scroll_offset ui 3 > 0. + && row.y >= panel.y && row.y +. row.h <= panel.y +. panel.h) + end; + if navigate_early then + Alcotest.(check (float 0.01)) + "navigation cancels the deferred initial reveal" 0. + (Ui.scroll_offset ui 3); + ignore (Assembly.handle asm (key Input.Arrow_down)); + let next = if expected = 23 && not navigate_early then 6 else 7 in + Alcotest.(check int) + "Down continues from the initial selection" next (Ui.hl_item ui); + Lui_store.apply_batch store + { + generation = 3; + ops = [ SetProp (3, BackgroundValue, StringValue "#fff") ]; + }; + ignore (Assembly.store_changed asm); + Alcotest.(check int) + "unrelated patches preserve keyboard navigation" next (Ui.hl_item ui); + Lui_store.apply_batch store { generation = 4; ops = [ DropNode 3 ] }; + ignore (Assembly.store_changed asm); + ignore (Assembly.handle asm (Input.Text_input "x")); + Alcotest.(check string) + "closing restores editing without losing its draft" "draftx" + (Ui.shadow ui)) + [ (23, false); (23, true); (4, false); (5, false); (0, false) ] + let test_menu_keyboard_scope () = let store = store_of @@ -3387,6 +3476,7 @@ let () = Alcotest.test_case "inset scroll caps" `Quick test_assembly_inset_scroll_caps; Alcotest.test_case "intrinsic menu scroll" `Quick test_assembly_menu_scroll; Alcotest.test_case "intrinsic menu keyboard" `Quick test_assembly_menu_keyboard; + Alcotest.test_case "menu initial selection" `Quick test_menu_open_selection; Alcotest.test_case "nested menu keyboard scope" `Quick test_menu_keyboard_scope; Alcotest.test_case "menu activation text commit" `Quick test_menu_activation_text_commit; Alcotest.test_case "scrollbar paint" `Quick From 4047e270b466e7ac56a51c6ccb362c3b6ff9e309 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 02:59:29 +0800 Subject: [PATCH 040/163] fix: align native select menus with their selected row Position picker labels over the trigger using existing text insets and final ancestor geometry. Preserve ordinary popup placement when alignment cannot fit or an explicit point is supplied. --- platform/native/lui_layout/lui_layout.ml | 61 +++++++++++- .../native/lui_layout/test/lui_layout_test.ml | 97 +++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/platform/native/lui_layout/lui_layout.ml b/platform/native/lui_layout/lui_layout.ml index c9f9c1ed..1d3b79e3 100644 --- a/platform/native/lui_layout/lui_layout.ml +++ b/platform/native/lui_layout/lui_layout.ml @@ -1319,6 +1319,61 @@ let refine_popups ctx = let dx,dy = displacement sid in {r with Lui_flex.x=r.x+.dx; y=r.y+.dy} in + let select_alignment sid parent (popup : Lui_flex.rect) = + if + Lui_store.kind ctx.store sid <> "dropdown-menu" + || has_prop ctx.store sid "x" || has_prop ctx.store sid "y" + then None + else + let picker = + List.find_opt + (fun id -> + Lui_store.kind ctx.store id = "select" && Hashtbl.mem ctx.nodes id) + (Lui_store.child_ids ctx.store parent) + in + let items = + List.filter + (fun id -> + Lui_store.kind ctx.store id = "menu-item" + && Lui_store.prop ctx.store id "enabled" <> Some (BoolValue false) + && Hashtbl.mem ctx.nodes id) + (Lui_store.child_ids ctx.store sid) + in + let selected = + match + List.find_opt + (fun id -> Lui_store.bool_prop ctx.store id "selected") + items + with + | Some id -> Some id + | None -> List.nth_opt items 0 + in + match (picker, selected) with + | Some picker, Some selected -> + let anchor = final_rect picker (Hashtbl.find ctx.nodes picker) + and item = Lui_flex.absolute_layout (Hashtbl.find ctx.nodes selected) in + let value = Lui_paint.text_placement_of ctx.store picker + and label = Lui_paint.text_placement_of ctx.store selected in + let s = ctx.scale in + let center (r : Lui_flex.rect) p = + r.y + +. ((r.h +. ((p.Lui_paint.tp_pad_top -. p.tp_pad_bottom) *. s)) /. 2.) + in + let x = + anchor.x +. (value.tp_pad_left *. s) + -. (item.x -. popup.x +. (label.tp_pad_left *. s)) + and y = center anchor value -. (center item label -. popup.y) in + if + anchor.y >= 20. *. s + && anchor.y +. anchor.h <= ctx.vh -. (20. *. s) + && x >= 8. *. s + && x +. popup.w <= ctx.vw -. (8. *. s) + && y >= 8. *. s + && y +. popup.h <= ctx.vh -. (8. *. s) + then Some (x, y) + else None + | _ -> None + in let refine (pnode, sid) = match Lui_flex.parent pnode with | None -> () @@ -1477,8 +1532,10 @@ let refine_popups ctx = let left, top = if anchored then abs_of - (anchored_xy ctx ctx.store sid - (anchor_rect_of ctx ctx.store sid ~cb pr) prp) + (match Option.bind psid_opt (fun parent -> select_alignment sid parent prp) with + | Some xy -> xy + | None -> anchored_xy ctx ctx.store sid + (anchor_rect_of ctx ctx.store sid ~cb pr) prp) else (* Unanchored popups take their platform placement — the web stylesheet's fixed rules: a context menu diff --git a/platform/native/lui_layout/test/lui_layout_test.ml b/platform/native/lui_layout/test/lui_layout_test.ml index eefe6967..76a79444 100644 --- a/platform/native/lui_layout/test/lui_layout_test.ml +++ b/platform/native/lui_layout/test/lui_layout_test.ml @@ -1354,6 +1354,102 @@ let test_split_gap_fraction () = [ 0.; 0.25; 0.7; 1. ]) [ 1.; 2. ] +let test_select_menu_alignment () = + List.iter + (fun scale -> + List.iter + (fun (selected, unavailable, expected) -> + let store = Lui_store.create () and layout = Lui_layout.create () in + apply store + [ + CreateNode (1, Root); + CreateNode (2, Dialog); + CreateNode (3, Box); + CreateNode (4, Select); + CreateNode (5, DropdownMenu); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); + InsertChild (3, 4, 0); + InsertChild (3, 5, 1); + SetProp (2, WidthValue, IntValue 400); + SetProp (2, HeightValue, IntValue 300); + SetProp (2, PaddingValue, IntValue 32); + SetProp (3, WidthValue, IntValue 256); + SetProp (3, HeightValue, IntValue 32); + SetProp (4, WidthValue, IntValue 256); + SetProp (4, HeightValue, IntValue 32); + SetProp (5, WidthValue, IntValue 190); + SetProp (5, PaddingValue, IntValue 4); + SetProp (5, BorderWidth, IntValue 1); + SetProp (5, AnchorValue, StringValue "below"); + ]; + for index = 0 to 3 do + let id = index + 6 in + apply store + [ + CreateNode (id, MenuItem); + InsertChild (5, id, index); + SetProp (id, HeightValue, IntValue 32); + SetProp (id, Selected, BoolValue (Some index = selected)); + SetProp (id, SelectionPlacementValue, StringValue "leading"); + ] + done; + Option.iter + (fun (property, value) -> + apply store [ SetProp (7, property, value) ]) + unavailable; + let rect id = Option.get (Lui_layout.rect layout id) in + let sync width height = + Lui_layout.sync ~scale ~width:(width *. scale) + ~height:(height *. scale) layout store + in + let label id = + let r = rect id and p = Lui_paint.text_placement_of store id in + ( r.x +. (p.tp_pad_left *. scale), + r.y + +. ((r.h +. ((p.tp_pad_top -. p.tp_pad_bottom) *. scale)) /. 2.) + ) + in + let aligned () = + let tx, ty = label 4 and ix, iy = label (6 + expected) in + check (float 0.6) "selected label aligns with the trigger" tx ix; + check (float 0.6) "selected row centers on the trigger" ty iy + in + sync 800. 600.; + aligned (); + sync 1000. 700.; + aligned (); + Lui_layout.invalidate layout; + sync 1000. 700.; + aligned (); + (* When overlapping cannot fit, retain the ordinary clamped popup. *) + sync 400. 320.; + let popup = rect 5 in + check bool "small viewport retains accessible menu bounds" true + (popup.x >= 0. && popup.y >= 0. + && popup.x +. popup.w <= 400. *. scale + && popup.y +. popup.h <= 320. *. scale); + check (float eps) "edge collision keeps below-trigger placement" + ((rect 4).y +. (rect 4).h +. (4. *. scale)) + popup.y; + apply store + [ + SetProp (5, Position, StringValue "fixed"); + SetProp (5, PopupX, FloatValue 100.); + SetProp (5, PopupY, FloatValue 200.); + ]; + sync 1000. 700.; + check (float eps) "explicit point keeps ordinary placement" + (204. *. scale) (rect 5).y) + [ + (None, None, 0); + (Some 0, None, 0); + (Some 1, None, 1); + (Some 3, None, 3); + (Some 1, Some (Enabled, BoolValue false), 0); + ]) + [ 1.; 2. ] + let test_menu_height_cap () = List.iter (fun scale -> @@ -1514,6 +1610,7 @@ let () = test_case "popup collision flip" `Quick test_popup_collision_flip; test_case "popup in centered dialog" `Quick test_popup_inside_centered_dialog; test_case "menu panel" `Quick test_menu_panel; + test_case "select menu alignment" `Quick test_select_menu_alignment; test_case "menu height cap" `Quick test_menu_height_cap; test_case "menu role layout" `Quick test_menu_role_layout; test_case "ac row" `Quick test_ac_row; From 6ae9ce040526169262f3b6ffa158e8bc1e44cb06 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 17 Oct 2026 04:51:58 +0800 Subject: [PATCH 041/163] fix: honor native surface transition timing Consume declared opacity and transform tracks with independent easing, delays and reversal timing. Keep detached exits until both tracks settle and reject unitless time values in the shared protocol. --- .../native/lui_host/test/lui_host_test.ml | 190 +++++++++++++ platform/native/lui_paint/lui_retained.ml | 261 +++++++++++++++--- src/lui_protocol.ml | 91 +++--- src/lui_protocol.mli | 3 + 4 files changed, 454 insertions(+), 91 deletions(-) diff --git a/platform/native/lui_host/test/lui_host_test.ml b/platform/native/lui_host/test/lui_host_test.ml index aa8b7a0b..f96f3a03 100644 --- a/platform/native/lui_host/test/lui_host_test.ml +++ b/platform/native/lui_host/test/lui_host_test.ml @@ -327,12 +327,202 @@ let test_popup_reused_id () = check (float 0.00001) "reused id starts a fresh presentation" 0. (popup_alpha h) +let transition_host transition = + let h = popup_host () in + ignore + (Lui_host.apply_batch h + (batch [ DropNode 5; SetProp (3, Transition, StringValue transition) ])); + h + +let motion_frame h now = + Lui_host.tick h ~now; + ignore (Lui_host.repaint h) + +let popup_width h = + match popup_fill h with Some fill -> fill.frect.w | None -> 0. + +let test_popup_declared_tracks () = + let h = transition_host "opacity 250ms linear, transform 0.5s linear 100ms" in + motion_frame h 0.; + motion_frame h 0.05; + check (float 0.00001) "declared opacity duration" 0.2 (popup_alpha h); + check (float 0.00001) "transform waits for its own delay" 22.8 (popup_width h); + motion_frame h 0.25; + check (float 0.00001) "opacity finishes independently" 1. (popup_alpha h); + check (float 0.00001) "zoom keeps its own progress" 23.16 (popup_width h); + check bool "unfinished transform still schedules frames" true + (Lui_host.wants_repaint h); + motion_frame h 0.601; + check (float 0.00001) "transform finishes at its declared deadline" 24. + (popup_width h); + check bool "finished tracks leave host idle" false (Lui_host.wants_repaint h); + ignore (Lui_host.apply_batch h (batch [ DetachSubtree 3 ])); + ignore (Lui_host.repaint h); + motion_frame h 0.651; + check (float 0.00001) "detached opacity uses declared exit timing" 0.8 + (popup_alpha h); + check (float 0.00001) "exit transform observes its delay" 24. (popup_width h); + check bool "detached shell has no interactive store node" false + (Lui_store.mem (Lui_host.store h) 3); + motion_frame h 1.202; + check bool "last exit track releases the shell" true (popup_fill h = None); + check bool "last exit track stops scheduled frames" false + (Lui_host.wants_repaint h) + +let test_popup_declared_easing () = + List.iter + (fun (easing, expected) -> + let h = transition_host ("opacity 1s " ^ easing) in + motion_frame h 0.; + motion_frame h 0.5; + check (float 0.00001) + (easing ^ " opacity matches CSS") + expected (popup_alpha h); + check (float 0.00001) "undeclared transform snaps to final geometry" 24. + (popup_width h)) + [ + ("linear", 0.5); + ("ease", 0.8024033876); + ("ease-in", 0.3153568125); + ("ease-out", 0.6846431875); + ("ease-in-out", 0.5); + ("cubic-bezier(0.25, 0.1, 0.25, 1)", 0.8024033876); + ("steps(4)", 0.5); + ("steps(4, start)", 0.75); + ("steps(4, jump-end)", 0.5); + ("steps(4, jump-none)", 2. /. 3.); + ("steps(4, jump-both)", 0.6); + ("step-start", 1.); + ("step-end", 0.); + ]; + let h = + transition_host + "opacity 1s cubic-bezier(0.2, 2, 0.2, 2), transform 1s cubic-bezier(0.2, \ + 2, 0.2, 2)" + in + motion_frame h 0.; + motion_frame h 0.5; + check (float 0.00001) "overshooting opacity is clamped" 1. (popup_alpha h); + check bool "transform can overshoot independently" true (popup_width h > 24.); + check bool "an early endpoint value does not finish the timeline" true + (Lui_host.wants_repaint h) + +let test_popup_transition_channels () = + List.iter + (fun declaration -> + let h = transition_host declaration in + motion_frame h 0.; + check (float 0.00001) "disabled channels are opaque immediately" 1. + (popup_alpha h); + check (float 0.00001) "disabled channels do not zoom" 24. (popup_width h); + check bool "disabled channels do not schedule frames" false + (Lui_host.wants_repaint h)) + [ "opacity 0ms, transform 0ms"; "background-color 200ms ease" ]; + let h = transition_host "transform 200ms linear" in + motion_frame h 0.; + motion_frame h 0.1; + check (float 0.00001) "undeclared opacity stays opaque" 1. (popup_alpha h); + check (float 0.00001) "transform-only declaration still zooms" 23.4 + (popup_width h); + let h = transition_host "all 240ms linear, opacity linear 120ms" in + motion_frame h 0.; + motion_frame h 0.06; + check (float 0.00001) "last matching declaration wins for opacity" 0.5 + (popup_alpha h); + check (float 0.00001) "all still controls transform" 23.1 (popup_width h); + let h = transition_host "opacity 120ms linear, all 240ms linear" in + motion_frame h 0.; + motion_frame h 0.06; + check (float 0.00001) "later all overrides a specific declaration" 0.25 + (popup_alpha h) + +let test_popup_transition_reversal () = + let h = transition_host "opacity 120ms linear, transform 120ms linear" in + motion_frame h 0.; + motion_frame h 0.06; + check (float 0.00001) "entrance reaches half" 0.5 (popup_alpha h); + Lui_host.set_hidden h (Some (fun id -> id = 3)); + ignore (Lui_host.repaint h); + check (float 0.00001) "close preserves displayed opacity" 0.5 (popup_alpha h); + motion_frame h 0.09; + check (float 0.00001) "reversed track shortens to the remaining distance" 0.25 + (popup_alpha h); + Lui_host.set_hidden h None; + ignore (Lui_host.repaint h); + check (float 0.00001) "reopen preserves displayed opacity" 0.25 + (popup_alpha h); + motion_frame h 0.181; + check (float 0.00001) "second reversal keeps its shortened deadline" 1. + (popup_alpha h); + check bool "reversal leaves no idle animation" false + (Lui_host.wants_repaint h); + let h = transition_host "opacity 0ms 50ms, transform 0ms 50ms" in + motion_frame h 0.; + motion_frame h 0.025; + check (float 0.00001) "zero duration retains a positive delay" 0. + (popup_alpha h); + check bool "delayed jump schedules its deadline" true + (Lui_host.wants_repaint h); + motion_frame h 0.051; + check (float 0.00001) "delayed jump finishes" 1. (popup_alpha h); + check bool "delayed jump settles" false (Lui_host.wants_repaint h); + let h = transition_host "opacity 10s 10s, transform 10s 10s" in + Lui_host.set_reduced_motion h true; + motion_frame h 0.; + check (float 0.00001) "reduced motion bypasses declared delay" 1. + (popup_alpha h); + check bool "reduced motion keeps the host idle" false + (Lui_host.wants_repaint h) + +let test_popup_transition_refresh () = + let h = transition_host "opacity 1s linear, transform 1s linear" in + motion_frame h 0.; + motion_frame h 0.25; + ignore + (Lui_host.apply_batch h + (batch + [ + SetProp + ( 3, + Transition, + StringValue "opacity 200ms linear, transform 200ms linear" ); + ])); + ignore (Lui_host.repaint h); + motion_frame h 0.5; + check (float 0.00001) "changing the recipe does not restart active tracks" 0.5 + (popup_alpha h); + Lui_host.set_hidden h (Some (fun id -> id = 3)); + ignore (Lui_host.repaint h); + motion_frame h 0.601; + check bool "exit uses the latest recipe and shortened distance" true + (popup_fill h = None); + List.iter + (fun easing -> + let h = transition_host ("opacity 100ms " ^ easing) in + check_raises "invalid native timing does not silently pick another curve" + (Invalid_argument ("Invalid native transition easing: " ^ easing)) + (fun () -> motion_frame h 0.)) + [ "nonsense"; "cubic-bezier(2, 0, 0, 1)"; "steps(1, jump-none)" ] + +let test_popup_invalid_time () = + List.iter + (fun declaration -> + check bool "unitless transition times are rejected by the shared protocol" false + (property_value_supported Transition (StringValue declaration))) + [ "opacity 0"; "opacity 0 50ms"; "opacity 100ms 0" ] + let () = run "lui_host" [ ("motion", [test_case "popup enter and exit" `Quick test_popup_motion; test_case "popup reversal and resize" `Quick test_popup_motion_reversal; test_case "popup reduced motion" `Quick test_popup_motion_reduced; test_case "popup reused id" `Quick test_popup_reused_id; + test_case "declared popup tracks" `Quick test_popup_declared_tracks; + test_case "declared popup easing" `Quick test_popup_declared_easing; + test_case "declared popup channels" `Quick test_popup_transition_channels; + test_case "declared popup reversal" `Quick test_popup_transition_reversal; + test_case "declared popup refresh" `Quick test_popup_transition_refresh; + test_case "invalid popup time" `Quick test_popup_invalid_time; test_case "split transition" `Quick test_split_transition; test_case "split reversal and removal" `Quick test_split_reversal_and_removal; test_case "reduced motion and drag" `Quick test_split_reduced_motion_and_drag]); diff --git a/platform/native/lui_paint/lui_retained.ml b/platform/native/lui_paint/lui_retained.ml index 293d9554..a95d0291 100644 --- a/platform/native/lui_paint/lui_retained.ml +++ b/platform/native/lui_paint/lui_retained.ml @@ -84,13 +84,32 @@ and frag = { mutable ebase : int; } +type surface_easing = + | Linear + | Bezier of float * float * float * float + | Steps of int * [ `Start | `End | `None | `Both ] + +type surface_timing = { + duration : float; + delay : float; + easing : surface_easing; +} + +type surface_track = { + from_value : float; + target_value : float; + started : float; + run_duration : float; + timing : surface_timing; +} + type surface_motion = { mutable snapshot : frag; mutable bounds : rect; - mutable from_alpha : float; - mutable target_alpha : float; - mutable started : float; - duration : float; + mutable opacity : surface_track; + mutable zoom : surface_track; + mutable timings : surface_timing * surface_timing; + mutable declaration : string option; } type retained = { @@ -138,21 +157,147 @@ let create () = dirty = false; resync = false; mgen = 0; stat_repaints = 0; stat_visits = 0; vwin = Hashtbl.create 4; mounted = (fun _ -> None) } +let surface_easing value = + let invalid () = invalid_arg ("Invalid native transition easing: " ^ value) in + let arguments prefix = + let start = String.length prefix in + String.sub value start (String.length value - start - 1) + |> String.split_on_char ',' |> List.map String.trim + in + match value with + | "linear" -> Linear + | "ease" -> Bezier (0.25, 0.1, 0.25, 1.) + | "ease-in" -> Bezier (0.42, 0., 1., 1.) + | "ease-out" -> Bezier (0., 0., 0.58, 1.) + | "ease-in-out" -> Bezier (0.42, 0., 0.58, 1.) + | "step-start" -> Steps (1, `Start) + | "step-end" -> Steps (1, `End) + | _ when String.starts_with ~prefix:"cubic-bezier(" value -> ( + match arguments "cubic-bezier(" |> List.map float_of_string_opt with + | [ Some x1; Some y1; Some x2; Some y2 ] + when List.for_all Float.is_finite [ x1; y1; x2; y2 ] + && x1 >= 0. && x1 <= 1. && x2 >= 0. && x2 <= 1. -> + Bezier (x1, y1, x2, y2) + | _ -> invalid ()) + | _ when String.starts_with ~prefix:"steps(" value -> ( + let parts = arguments "steps(" in + match parts with + | [ count ] | [ count; "end" ] | [ count; "jump-end" ] -> ( + match int_of_string_opt count with + | Some n when n > 0 -> Steps (n, `End) + | _ -> invalid ()) + | [ count; mode ] -> ( + match (int_of_string_opt count, mode) with + | Some n, ("start" | "jump-start") when n > 0 -> Steps (n, `Start) + | Some n, "jump-none" when n > 1 -> Steps (n, `None) + | Some n, "jump-both" when n > 0 -> Steps (n, `Both) + | _ -> invalid ()) + | _ -> invalid ()) + | _ -> invalid () + +let surface_timings declaration kind = + match declaration with + | None -> + let duration = + match kind with + | "dialog" | "alert" | "sheet" | "drawer" -> 0.2 + | _ -> 0.15 + in + let timing = + { duration; delay = 0.; easing = Bezier (0., 0., 0.58, 1.) } + in + (timing, timing) + | Some declaration -> + let immediate = { duration = 0.; delay = 0.; easing = Linear } in + List.fold_left + (fun (opacity, transform) -> function + | (("opacity" | "transform" | "all") as property) :: tokens -> + let is_time token = String.ends_with ~suffix:"s" token in + let times, easings = List.partition is_time tokens in + let seconds token = + let millis = String.ends_with ~suffix:"ms" token in + let suffix = if millis then 2 else 1 in + float_of_string + (String.sub token 0 (String.length token - suffix)) + /. if millis then 1000. else 1. + in + let duration, delay = + match times with + | [ duration ] -> (seconds duration, 0.) + | [ duration; delay ] -> (seconds duration, seconds delay) + | _ -> invalid_arg "Invalid native transition times" + in + let easing = + match easings with + | [] -> surface_easing "ease" + | [ value ] -> surface_easing value + | _ -> invalid_arg "Invalid native transition easing list" + in + let timing = { duration; delay; easing } in + ( (if property = "transform" then opacity else timing), + if property = "opacity" then transform else timing ) + | _ -> (opacity, transform)) + (immediate, immediate) + (Lui_protocol.transition_segments declaration) + +let easing_value easing progress = + match easing with + | Linear -> progress + | Steps (count, mode) -> + let step = Float.floor (progress *. float count) in + let step = + step +. match mode with `Start | `Both -> 1. | `End | `None -> 0. + in + let jumps = + count + match mode with `None -> -1 | `Both -> 1 | `Start | `End -> 0 + in + Float.max 0. (Float.min 1. (step /. float jumps)) + | Bezier (x1, y1, x2, y2) -> + if progress <= 0. then 0. + else if progress >= 1. then 1. + else + let coordinate t a b = + let other = 1. -. t in + (3. *. other *. other *. t *. a) + +. (3. *. other *. t *. t *. b) + +. (t *. t *. t) + in + let rec solve low high remaining = + let t = (low +. high) /. 2. in + if remaining = 0 then coordinate t y1 y2 + else if coordinate t x1 x2 < progress then solve t high (remaining - 1) + else solve low t (remaining - 1) + in + solve 0. 1. 20 + +let surface_track ~now ~from_value ~target_value timing = + { + from_value; + target_value; + started = now; + timing; + (* CSS reversal shortens the run by its remaining endpoint distance. *) + run_duration = + timing.duration *. Float.min 1. (Float.abs (target_value -. from_value)); + } + +let track_value now track = + let elapsed = now -. track.started -. track.timing.delay in + if elapsed < 0. then track.from_value + else if track.run_duration = 0. || elapsed >= track.run_duration then + track.target_value + else + let eased = + easing_value track.timing.easing (elapsed /. track.run_duration) + in + track.from_value +. ((track.target_value -. track.from_value) *. eased) + +let track_active now track = + track.from_value <> track.target_value + && now < track.started +. track.timing.delay +. track.run_duration + let surface_alpha now motion = - let progress = Float.min 1. ((now -. motion.started) /. motion.duration) in - if progress >= 1. then motion.target_alpha - else begin - (* CSS ease-out: cubic-bezier(0, 0, 0.58, 1). *) - let low = ref 0. and high = ref 1. in - for _ = 1 to 16 do - let t = (!low +. !high) /. 2. in - let x = (1.74 *. (1. -. t) *. t *. t) +. (t *. t *. t) in - if x < progress then low := t else high := t - done; - let t = (!low +. !high) /. 2. in - let eased = (3. *. (1. -. t) *. t *. t) +. (t *. t *. t) in - motion.from_alpha +. ((motion.target_alpha -. motion.from_alpha) *. eased) - end + Float.max 0. (Float.min 1. (track_value now motion.opacity)) let animating rt = match rt.now with @@ -160,7 +305,7 @@ let animating rt = | Some now -> Hashtbl.fold (fun _ motion active -> - active || surface_alpha now motion <> motion.target_alpha) + active || track_active now motion.opacity || track_active now motion.zoom) rt.surfaces false let tick rt ~now = @@ -791,8 +936,8 @@ let capture_surface rt scene m surface = snapshot.fx <- Array.of_list (List.rev local.fx_rev); snapshot -let emit_surface scene m viewport motion alpha = - let zoom = 0.95 +. (0.05 *. alpha) in +let emit_surface scene m viewport motion alpha zoom_progress = + let zoom = 0.95 +. (0.05 *. zoom_progress) in let cx = motion.bounds.x +. (motion.bounds.w /. 2.) and cy = motion.bounds.y +. (motion.bounds.h /. 2.) in let point_x x = cx +. ((x -. cx) *. zoom) @@ -853,7 +998,7 @@ let emit_surface scene m viewport motion alpha = in let snapshot = motion.snapshot in let visual = - if alpha = 1. then snapshot + if zoom_progress = 1. then snapshot else { snapshot with @@ -873,11 +1018,11 @@ let emit_surface scene m viewport motion alpha = push_buffers scene m visual; List.iter (fun op -> - let op = if alpha = 1. then op else transform op in + let op = if alpha = 1. && zoom_progress = 1. then op else transform op in scene.ops <- rebase visual op :: scene.ops) snapshot.pre -let present_surfaces rt scene m viewport ~refresh live = +let present_surfaces rt scene m viewport store ~refresh live = let now = Option.get rt.now in let seen = Hashtbl.create (List.length live) in List.iter @@ -886,37 +1031,64 @@ let present_surfaces rt scene m viewport ~refresh live = match Hashtbl.find_opt rt.surfaces surface.self with | None -> let snapshot = capture_surface rt scene m surface in - let duration = - match surface.kind with - | "dialog" | "alert" | "sheet" | "drawer" -> 0.2 - | _ -> 0.15 + let declaration = + Lui_store.string_prop store surface.self "transition" + in + let opacity_timing, zoom_timing = + surface_timings declaration surface.kind in Hashtbl.add rt.surfaces surface.self { snapshot; bounds = surface.resolved; - from_alpha = 0.; - target_alpha = 1.; - started = now; - duration; + opacity = + surface_track ~now ~from_value:0. ~target_value:1. + opacity_timing; + zoom = + surface_track ~now ~from_value:0. ~target_value:1. zoom_timing; + timings = (opacity_timing, zoom_timing); + declaration; } | Some motion -> - if refresh then motion.snapshot <- capture_surface rt scene m surface; + if refresh then begin + motion.snapshot <- capture_surface rt scene m surface; + let declaration = + Lui_store.string_prop store surface.self "transition" + in + if declaration <> motion.declaration then begin + motion.timings <- surface_timings declaration surface.kind; + motion.declaration <- declaration + end + end; motion.bounds <- surface.resolved; - if motion.target_alpha <> 1. then begin - motion.from_alpha <- surface_alpha now motion; - motion.target_alpha <- 1.; - motion.started <- now + if motion.opacity.target_value <> 1. then begin + let opacity_timing, zoom_timing = motion.timings in + motion.opacity <- + surface_track ~now ~from_value:(surface_alpha now motion) + ~target_value:1. opacity_timing; + motion.zoom <- + surface_track ~now + ~from_value:(track_value now motion.zoom) + ~target_value:1. zoom_timing end) live; Hashtbl.filter_map_inplace (fun id motion -> - if (not (Hashtbl.mem seen id)) && motion.target_alpha <> 0. then begin - motion.from_alpha <- surface_alpha now motion; - motion.target_alpha <- 0.; - motion.started <- now + if (not (Hashtbl.mem seen id)) && motion.opacity.target_value <> 0. then begin + let opacity_timing, zoom_timing = motion.timings in + motion.opacity <- + surface_track ~now ~from_value:(surface_alpha now motion) + ~target_value:0. opacity_timing; + motion.zoom <- + surface_track ~now + ~from_value:(track_value now motion.zoom) + ~target_value:0. zoom_timing end; - if motion.target_alpha = 0. && surface_alpha now motion = 0. then None + if + motion.opacity.target_value = 0. + && (not (track_active now motion.opacity)) + && not (track_active now motion.zoom) + then None else Some motion) rt.surfaces; (* Exiting shells remain behind the current live overlay stack. *) @@ -930,7 +1102,8 @@ let present_surfaces rt scene m viewport ~refresh live = List.iter (fun id -> let motion = Hashtbl.find rt.surfaces id in - emit_surface scene m viewport motion (surface_alpha now motion)) + emit_surface scene m viewport motion (surface_alpha now motion) + (track_value now motion.zoom)) rt.surface_order let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ?vlists @@ -1055,7 +1228,7 @@ let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ?vlists else emit_seg scene m g; List.iter (fun h -> Queue.add h drain) g.sub_pops done; - if motion_enabled then present_surfaces rt scene m vp ~refresh:refresh_surfaces (List.rev !live); + if motion_enabled then present_surfaces rt scene m vp store ~refresh:refresh_surfaces (List.rev !live); while not (Queue.is_empty m.m_ctx.Lui_paint.deferred) do (Queue.pop m.m_ctx.Lui_paint.deferred) () done; diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index be80873d..10fd3a54 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -1350,22 +1350,19 @@ let css_ident_supported value = in tail 1 -(* CSS