yoga-python 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {yoga_python-0.1.2 → yoga_python-0.1.4}/.gitignore +1 -0
  2. {yoga_python-0.1.2 → yoga_python-0.1.4}/PKG-INFO +1 -1
  3. yoga_python-0.1.4/benchmarks/bench_bindings.py +109 -0
  4. {yoga_python-0.1.2 → yoga_python-0.1.4}/pyproject.toml +1 -1
  5. {yoga_python-0.1.2 → yoga_python-0.1.4}/src/yoga/__init__.pyi +43 -0
  6. {yoga_python-0.1.2 → yoga_python-0.1.4}/src/yoga/yoga.cpp +664 -10
  7. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_tree_mutation.py +15 -0
  8. {yoga_python-0.1.2 → yoga_python-0.1.4}/uv.lock +1 -1
  9. {yoga_python-0.1.2 → yoga_python-0.1.4}/.github/workflows/create-release.yml +0 -0
  10. {yoga_python-0.1.2 → yoga_python-0.1.4}/.github/workflows/quality.yml +0 -0
  11. {yoga_python-0.1.2 → yoga_python-0.1.4}/.github/workflows/release.yml +0 -0
  12. {yoga_python-0.1.2 → yoga_python-0.1.4}/.python-version +0 -0
  13. {yoga_python-0.1.2 → yoga_python-0.1.4}/CMakeLists.txt +0 -0
  14. {yoga_python-0.1.2 → yoga_python-0.1.4}/LICENSE +0 -0
  15. {yoga_python-0.1.2 → yoga_python-0.1.4}/README.md +0 -0
  16. {yoga_python-0.1.2 → yoga_python-0.1.4}/pyrightconfig.json +0 -0
  17. {yoga_python-0.1.2 → yoga_python-0.1.4}/src/yoga/__init__.py +0 -0
  18. {yoga_python-0.1.2 → yoga_python-0.1.4}/src/yoga/py.typed +0 -0
  19. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/conftest.py +0 -0
  20. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_absolute_position.py +0 -0
  21. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_align_content.py +0 -0
  22. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_align_items.py +0 -0
  23. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_align_self.py +0 -0
  24. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_android_news_feed.py +0 -0
  25. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_aspect_ratio.py +0 -0
  26. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_auto.py +0 -0
  27. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_baseline.py +0 -0
  28. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_baseline_func.py +0 -0
  29. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_border.py +0 -0
  30. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_box_sizing.py +0 -0
  31. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_clone_node.py +0 -0
  32. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_clone_nofree.py +0 -0
  33. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_computed_margin.py +0 -0
  34. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_computed_padding.py +0 -0
  35. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_config.py +0 -0
  36. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_default_values.py +0 -0
  37. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_dimension.py +0 -0
  38. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_dirtied.py +0 -0
  39. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_dirty_marking.py +0 -0
  40. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_display.py +0 -0
  41. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_display_contents.py +0 -0
  42. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_edge.py +0 -0
  43. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_events.py +0 -0
  44. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_flex.py +0 -0
  45. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_flex_direction.py +0 -0
  46. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_flex_gap.py +0 -0
  47. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_flex_wrap.py +0 -0
  48. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_float_optional.py +0 -0
  49. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_free_order.py +0 -0
  50. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_gap.py +0 -0
  51. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_had_overflow.py +0 -0
  52. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_intrinsic_size.py +0 -0
  53. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_justify_content.py +0 -0
  54. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_layoutable_children.py +0 -0
  55. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_margin.py +0 -0
  56. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_measure.py +0 -0
  57. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_measure_cache.py +0 -0
  58. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_measure_mode.py +0 -0
  59. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_min_max_dimension.py +0 -0
  60. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_no_free.py +0 -0
  61. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_node_callback.py +0 -0
  62. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_node_child.py +0 -0
  63. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_ordinals.py +0 -0
  64. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_padding.py +0 -0
  65. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_percentage.py +0 -0
  66. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_persistence.py +0 -0
  67. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_persistent_node_cloning.py +0 -0
  68. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_relayout.py +0 -0
  69. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_rounding.py +0 -0
  70. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_rounding_function.py +0 -0
  71. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_rounding_measure.py +0 -0
  72. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_scale_change.py +0 -0
  73. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_size_overflow.py +0 -0
  74. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_small_value_buffer.py +0 -0
  75. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_static_position.py +0 -0
  76. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_style.py +0 -0
  77. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_style_value_pool.py +0 -0
  78. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_value.py +0 -0
  79. {yoga_python-0.1.2 → yoga_python-0.1.4}/tests/test_zero_out_layout.py +0 -0
@@ -20,6 +20,7 @@ wheels/
20
20
  CMakeFiles/
21
21
  CMakeCache.txt
22
22
  build_test/
23
+ build_manual/
23
24
  src/yoga/*.so
24
25
  src/yoga/CMakeInit.txt
25
26
  src/yoga/install_manifest.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yoga-python
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Python bindings for Facebook Yoga layout engine (CSS Flexbox)
5
5
  Keywords: yoga,flexbox,layout,css,react-native
6
6
  License-Expression: MIT
@@ -0,0 +1,109 @@
1
+ """Focused microbenchmarks for yoga-python binding hot paths.
2
+
3
+ Run with:
4
+ uv run python benchmarks/bench_bindings.py
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import statistics
10
+ import time
11
+
12
+ import yoga
13
+
14
+
15
+ def _bench(fn, *, iterations: int, warmup: int) -> dict[str, int]:
16
+ for _ in range(warmup):
17
+ fn()
18
+ times: list[int] = []
19
+ for _ in range(iterations):
20
+ start = time.perf_counter_ns()
21
+ fn()
22
+ times.append(time.perf_counter_ns() - start)
23
+ times.sort()
24
+ return {
25
+ "median_ns": times[len(times) // 2],
26
+ "mean_ns": int(statistics.mean(times)),
27
+ }
28
+
29
+
30
+ def main() -> None:
31
+ node = yoga.Node()
32
+ vals = {
33
+ "width": 80,
34
+ "height": 24,
35
+ "flex_grow": 1.0,
36
+ "flex_shrink": 1.0,
37
+ "flex_direction": "row",
38
+ "justify_content": "space-between",
39
+ "align_items": "center",
40
+ "overflow": "hidden",
41
+ "position_type": "relative",
42
+ "padding_top": 1.0,
43
+ "padding_right": 2.0,
44
+ "padding_bottom": 1.0,
45
+ "padding_left": 2.0,
46
+ }
47
+
48
+ def fast_same() -> None:
49
+ yoga.configure_node_fast(node, **vals)
50
+
51
+ def fast_toggle() -> None:
52
+ fast_toggle.state = not fast_toggle.state
53
+ yoga.configure_node_fast(node, **(vals if fast_toggle.state else {**vals, "width": 81}))
54
+
55
+ fast_toggle.state = False
56
+
57
+ def python_setters_same() -> None:
58
+ node.width = 80
59
+ node.height = 24
60
+ node.flex_grow = 1.0
61
+ node.flex_shrink = 1.0
62
+ node.flex_direction = yoga.FlexDirection.Row
63
+ node.justify_content = yoga.Justify.SpaceBetween
64
+ node.align_items = yoga.Align.Center
65
+ node.overflow = yoga.Overflow.Hidden
66
+ node.position_type = yoga.PositionType.Relative
67
+ node.set_padding(yoga.Edge.Top, 1.0)
68
+ node.set_padding(yoga.Edge.Right, 2.0)
69
+ node.set_padding(yoga.Edge.Bottom, 1.0)
70
+ node.set_padding(yoga.Edge.Left, 2.0)
71
+
72
+ def python_setters_toggle() -> None:
73
+ python_setters_toggle.state = not python_setters_toggle.state
74
+ node.width = 80 if python_setters_toggle.state else 81
75
+ python_setters_same()
76
+
77
+ python_setters_toggle.state = False
78
+
79
+ parent = yoga.Node()
80
+ children = [yoga.Node() for _ in range(500)]
81
+ for child in children:
82
+ child.width = 1
83
+
84
+ def set_children_batch() -> None:
85
+ parent.set_children(children)
86
+
87
+ def rebuild_insertions() -> None:
88
+ parent.remove_all_children()
89
+ for i, child in enumerate(children):
90
+ parent.insert_child(child, i)
91
+
92
+ cases = [
93
+ ("configure_node_fast same", fast_same, 5000, 200),
94
+ ("configure_node_fast toggled", fast_toggle, 5000, 200),
95
+ ("python setters same", python_setters_same, 5000, 200),
96
+ ("python setters toggled", python_setters_toggle, 5000, 200),
97
+ ("set_children(500)", set_children_batch, 200, 20),
98
+ ("remove_all+insert 500", rebuild_insertions, 200, 20),
99
+ ]
100
+ for label, fn, iterations, warmup in cases:
101
+ result = _bench(fn, iterations=iterations, warmup=warmup)
102
+ print(f"{label:<28} median={result['median_ns']:>8,}ns mean={result['mean_ns']:>8,}ns")
103
+
104
+ parent.free_recursive()
105
+ node.free()
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yoga-python"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Python bindings for Facebook Yoga layout engine (CSS Flexbox)"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,8 @@
1
1
  from collections.abc import Callable
2
2
  from typing import Any
3
3
 
4
+ type LayoutRepaintFact = tuple[object, int, bool, bool, int, int, int, int, int, int, int, int]
5
+
4
6
  # Enums (nanobind enum types)
5
7
  class Direction:
6
8
  Inherit: Direction
@@ -577,6 +579,47 @@ class Node:
577
579
  def layout_padding(self, edge: Edge | int) -> float: ...
578
580
  def layout_border(self, edge: Edge | int) -> float: ...
579
581
 
582
+ # Batch layout helpers
583
+ def configure_node_fast(
584
+ node: Node,
585
+ *,
586
+ width: float | str | None = None,
587
+ height: float | str | None = None,
588
+ min_width: float | str | None = None,
589
+ min_height: float | str | None = None,
590
+ max_width: float | str | None = None,
591
+ max_height: float | str | None = None,
592
+ flex_grow: float | None = None,
593
+ flex_shrink: float | None = None,
594
+ flex_basis: float | str | None = None,
595
+ flex_direction: str | None = None,
596
+ flex_wrap: str | None = None,
597
+ justify_content: str | None = None,
598
+ align_items: str | None = None,
599
+ align_self: str | None = None,
600
+ gap: float | None = None,
601
+ overflow: str | None = None,
602
+ position_type: str | None = None,
603
+ padding_top: float = 0.0,
604
+ padding_right: float = 0.0,
605
+ padding_bottom: float = 0.0,
606
+ padding_left: float = 0.0,
607
+ margin: float | None = None,
608
+ margin_top: float | None = None,
609
+ margin_right: float | None = None,
610
+ margin_bottom: float | None = None,
611
+ margin_left: float | None = None,
612
+ pos_top: float | str | None = None,
613
+ pos_right: float | str | None = None,
614
+ pos_bottom: float | str | None = None,
615
+ pos_left: float | str | None = None,
616
+ ) -> None: ...
617
+ def clear_node_cache(node: Node) -> None: ...
618
+ def apply_layout_tree(
619
+ root: object, offsets: dict[str, int], origin_x: int = 0, origin_y: int = 0
620
+ ) -> list[LayoutRepaintFact]: ...
621
+ def get_layout_batch(node: Node) -> tuple[int, int, int, int]: ...
622
+
580
623
  # Event system
581
624
  def event_subscribe(callback: Callable[[int, EventType, Any], None]) -> None: ...
582
625
  def event_reset() -> None: ...
@@ -1,7 +1,10 @@
1
1
  #include <nanobind/nanobind.h>
2
2
  #include <nanobind/stl/vector.h>
3
+ #include <nanobind/stl/string.h>
3
4
 
5
+ #include <cmath>
4
6
  #include <string>
7
+ #include <unordered_map>
5
8
  #include <unordered_set>
6
9
 
7
10
  #include <yoga/Yoga.h>
@@ -12,6 +15,58 @@
12
15
  namespace nb = nanobind;
13
16
  namespace yoga = facebook::yoga;
14
17
 
18
+ // Interned Python strings for fast pointer comparison in configure_node_fast().
19
+ // Initialized in NB_MODULE; pointer compare is ~10x faster than nb::cast<std::string>.
20
+ namespace interned {
21
+ static PyObject* row = nullptr;
22
+ static PyObject* column = nullptr;
23
+ static PyObject* column_reverse = nullptr;
24
+ static PyObject* row_reverse = nullptr;
25
+ static PyObject* wrap = nullptr;
26
+ static PyObject* wrap_reverse = nullptr;
27
+ static PyObject* nowrap = nullptr;
28
+ static PyObject* flex_start = nullptr;
29
+ static PyObject* flex_end = nullptr;
30
+ static PyObject* center = nullptr;
31
+ static PyObject* space_between = nullptr;
32
+ static PyObject* space_around = nullptr;
33
+ static PyObject* space_evenly = nullptr;
34
+ static PyObject* stretch = nullptr;
35
+ static PyObject* baseline = nullptr;
36
+ static PyObject* auto_ = nullptr;
37
+ static PyObject* visible = nullptr;
38
+ static PyObject* hidden = nullptr;
39
+ static PyObject* scroll = nullptr;
40
+ static PyObject* relative = nullptr;
41
+ static PyObject* absolute = nullptr;
42
+ static PyObject* static_ = nullptr;
43
+
44
+ static void init() {
45
+ row = PyUnicode_InternFromString("row");
46
+ column = PyUnicode_InternFromString("column");
47
+ column_reverse = PyUnicode_InternFromString("column-reverse");
48
+ row_reverse = PyUnicode_InternFromString("row-reverse");
49
+ wrap = PyUnicode_InternFromString("wrap");
50
+ wrap_reverse = PyUnicode_InternFromString("wrap-reverse");
51
+ nowrap = PyUnicode_InternFromString("nowrap");
52
+ flex_start = PyUnicode_InternFromString("flex-start");
53
+ flex_end = PyUnicode_InternFromString("flex-end");
54
+ center = PyUnicode_InternFromString("center");
55
+ space_between = PyUnicode_InternFromString("space-between");
56
+ space_around = PyUnicode_InternFromString("space-around");
57
+ space_evenly = PyUnicode_InternFromString("space-evenly");
58
+ stretch = PyUnicode_InternFromString("stretch");
59
+ baseline = PyUnicode_InternFromString("baseline");
60
+ auto_ = PyUnicode_InternFromString("auto");
61
+ visible = PyUnicode_InternFromString("visible");
62
+ hidden = PyUnicode_InternFromString("hidden");
63
+ scroll = PyUnicode_InternFromString("scroll");
64
+ relative = PyUnicode_InternFromString("relative");
65
+ absolute = PyUnicode_InternFromString("absolute");
66
+ static_ = PyUnicode_InternFromString("static");
67
+ }
68
+ }
69
+
15
70
  // Unified context for all node callbacks + user context.
16
71
  // Stored in node->getContext() as a single allocation.
17
72
  struct NodeContext {
@@ -37,6 +92,27 @@ static std::unordered_set<yoga::Node*> nanobindManagedNodes;
37
92
  static std::unordered_set<NodeContext*> allNodeContexts;
38
93
  static std::unordered_set<CloneContext*> allCloneContexts;
39
94
 
95
+ // Per-node field cache for configure_node_fast.
96
+ // Caches previous PyObject* pointers and float values per yoga::Node.
97
+ // On each configure call, fields are compared by pointer identity first;
98
+ // yoga setters are only called when the value actually changed.
99
+ enum CacheSlot : int {
100
+ CS_WIDTH=0, CS_HEIGHT, CS_MIN_WIDTH, CS_MIN_HEIGHT,
101
+ CS_MAX_WIDTH, CS_MAX_HEIGHT,
102
+ CS_FLEX_GROW, CS_FLEX_SHRINK, CS_FLEX_BASIS,
103
+ CS_FLEX_DIRECTION, CS_FLEX_WRAP,
104
+ CS_JUSTIFY_CONTENT, CS_ALIGN_ITEMS, CS_ALIGN_SELF,
105
+ CS_GAP, CS_OVERFLOW, CS_POSITION_TYPE,
106
+ CS_MARGIN, CS_MARGIN_TOP, CS_MARGIN_RIGHT, CS_MARGIN_BOTTOM, CS_MARGIN_LEFT,
107
+ CS_POS_TOP, CS_POS_RIGHT, CS_POS_BOTTOM, CS_POS_LEFT,
108
+ CS_COUNT // = 26
109
+ };
110
+ struct NodeCache {
111
+ PyObject* ptrs[CS_COUNT] = {};
112
+ float padding[4] = {-1.f, -1.f, -1.f, -1.f}; // TRBL
113
+ };
114
+ static std::unordered_map<yoga::Node*, NodeCache> node_cache_;
115
+
40
116
  // --- NodeContext helpers ---
41
117
 
42
118
  static NodeContext* getNodeContext(yoga::Node* node) {
@@ -74,6 +150,9 @@ static void cleanupCloneContext(yoga::Config& config) {
74
150
  // --- Safe free functions ---
75
151
 
76
152
  static void safeNodeFree(yoga::Node& self) {
153
+ // Evict from configure_node_fast cache
154
+ node_cache_.erase(&self);
155
+
77
156
  // Clean up Python context
78
157
  cleanupNodeContext(&self);
79
158
 
@@ -227,6 +306,9 @@ static void yogaDirtiedCallback(YGNodeConstRef node) {
227
306
  NB_MODULE(yoga, m) {
228
307
  m.doc() = "Python binding for Facebook Yoga layout engine (using nanobind)";
229
308
 
309
+ // Initialize interned strings for fast pointer comparison
310
+ interned::init();
311
+
230
312
  nb::enum_<YGDirection>(m, "Direction")
231
313
  .value("Inherit", YGDirectionInherit)
232
314
  .value("LTR", YGDirectionLTR)
@@ -486,11 +568,13 @@ NB_MODULE(yoga, m) {
486
568
  .def("__init__", [](yoga::Node *t) {
487
569
  new (t) yoga::Node();
488
570
  nanobindManagedNodes.insert(t);
571
+ node_cache_.erase(t); // evict stale cache from address reuse
489
572
  yoga::Event::publish<yoga::Event::NodeAllocation>(t, {YGNodeGetConfig(t)});
490
573
  })
491
574
  .def("__init__", [](yoga::Node *t, yoga::Config* config) {
492
575
  new (t) yoga::Node(config);
493
576
  nanobindManagedNodes.insert(t);
577
+ node_cache_.erase(t); // evict stale cache from address reuse
494
578
  yoga::Event::publish<yoga::Event::NodeAllocation>(t, {YGNodeGetConfig(t)});
495
579
  }, nb::arg("config"))
496
580
  .def("__len__", [](yoga::Node& self) { return YGNodeGetChildCount(&self); })
@@ -504,7 +588,10 @@ NB_MODULE(yoga, m) {
504
588
  })
505
589
  .def("free", [](yoga::Node& self) { safeNodeFree(self); })
506
590
  .def("free_recursive", [](yoga::Node& self) { safeNodeFreeRecursive(self); })
507
- .def("reset", [](yoga::Node& self) { YGNodeReset(&self); })
591
+ .def("reset", [](yoga::Node& self) {
592
+ node_cache_.erase(&self); // reset clears all styles; cache must follow
593
+ YGNodeReset(&self);
594
+ })
508
595
  .def("copy_style", [](yoga::Node& self, const yoga::Node& src) { YGNodeCopyStyle(&self, &src); })
509
596
  .def("set_context", [](yoga::Node& self, nb::object context) {
510
597
  if (context.is_none()) {
@@ -841,17 +928,27 @@ NB_MODULE(yoga, m) {
841
928
  .def("remove_child", [](yoga::Node& self, yoga::Node& child) { YGNodeRemoveChild(&self, &child); }, nb::arg("child"))
842
929
  .def("remove_all_children", [](yoga::Node& self) { YGNodeRemoveAllChildren(&self); })
843
930
  .def("set_children", [](yoga::Node& self, const std::vector<yoga::Node*>& children) {
931
+ const size_t currentCount = self.getChildCount();
932
+ if (currentCount == children.size()) {
933
+ bool identical = true;
934
+ for (size_t i = 0; i < currentCount; i++) {
935
+ if (self.getChild(i) != children[i]) {
936
+ identical = false;
937
+ break;
938
+ }
939
+ }
940
+ if (identical) {
941
+ return;
942
+ }
943
+ }
944
+
945
+ std::unordered_set<yoga::Node*> nextChildren(children.begin(), children.end());
946
+
844
947
  // Clear owner for old children that are owned by self and not in the new list
845
- for (size_t i = 0; i < self.getChildCount(); i++) {
948
+ for (size_t i = 0; i < currentCount; i++) {
846
949
  auto* oldChild = self.getChild(i);
847
- if (oldChild->getOwner() == &self) {
848
- bool inNewList = false;
849
- for (auto* newChild : children) {
850
- if (newChild == oldChild) { inNewList = true; break; }
851
- }
852
- if (!inNewList) {
853
- oldChild->setOwner(nullptr);
854
- }
950
+ if (oldChild->getOwner() == &self && !nextChildren.contains(oldChild)) {
951
+ oldChild->setOwner(nullptr);
855
952
  }
856
953
  }
857
954
  self.setChildren(children);
@@ -997,6 +1094,562 @@ NB_MODULE(yoga, m) {
997
1094
  .def("set_flex_basis_stretch", [](yoga::Node& self) { YGNodeStyleSetFlexBasisStretch(&self); })
998
1095
  .def("_node_id", [](yoga::Node& self) -> uintptr_t { return reinterpret_cast<uintptr_t>(&self); });
999
1096
 
1097
+ // ── configure_node_fast: bulk yoga configuration in one C++ call ──
1098
+ //
1099
+ // Replaces ~6 individual Python→C++ calls + 24 Python None-checks with
1100
+ // a single call. All dimension parsing, enum mapping, and None handling
1101
+ // happens in C++. Field-level caching skips unchanged properties.
1102
+ //
1103
+ // Dimension args: None=auto/skip, int/float=point, str "50%"=percent, str "auto"=auto
1104
+ // Enum args: None=skip, str=mapped to yoga enum
1105
+ // Numeric args: None=skip, int/float=applied directly
1106
+ // Padding: always applied (float, includes caller's border offset)
1107
+
1108
+ m.def("configure_node_fast", [](
1109
+ yoga::Node& node,
1110
+ // Dimensions
1111
+ nb::object width, nb::object height,
1112
+ nb::object min_width, nb::object min_height,
1113
+ nb::object max_width, nb::object max_height,
1114
+ // Flex
1115
+ nb::object flex_grow, nb::object flex_shrink, nb::object flex_basis,
1116
+ // Enums (None = skip)
1117
+ nb::object flex_direction, nb::object flex_wrap,
1118
+ nb::object justify_content, nb::object align_items, nb::object align_self,
1119
+ // Gap, overflow, position, display
1120
+ nb::object gap, nb::object overflow, nb::object position_type,
1121
+ // Padding (always set, includes border offset from caller)
1122
+ float padding_top, float padding_right,
1123
+ float padding_bottom, float padding_left,
1124
+ // Margin
1125
+ nb::object margin, nb::object margin_top, nb::object margin_right,
1126
+ nb::object margin_bottom, nb::object margin_left,
1127
+ // Position edges
1128
+ nb::object pos_top, nb::object pos_right,
1129
+ nb::object pos_bottom, nb::object pos_left
1130
+ ) {
1131
+ // ── Helper lambdas ──
1132
+
1133
+ // Parse dimension: returns (value, unit) where unit is 0=auto, 1=point, 2=percent
1134
+ auto parse_dim = [](nb::handle obj, float& out_val) -> int {
1135
+ if (obj.is_none()) return 0; // auto/unset
1136
+ if (nb::isinstance<nb::int_>(obj) || nb::isinstance<nb::float_>(obj)) {
1137
+ out_val = nb::cast<float>(obj);
1138
+ return 1; // point
1139
+ }
1140
+ if (nb::isinstance<nb::str>(obj)) {
1141
+ std::string s = nb::cast<std::string>(obj);
1142
+ if (s == "auto") return 0;
1143
+ try {
1144
+ if (!s.empty() && s.back() == '%') {
1145
+ s.pop_back();
1146
+ out_val = std::stof(s);
1147
+ return 2; // percent
1148
+ }
1149
+ out_val = std::stof(s);
1150
+ return 1; // point
1151
+ } catch (const std::exception&) {
1152
+ return 0; // malformed string → treat as auto/unset
1153
+ }
1154
+ }
1155
+ return 0;
1156
+ };
1157
+
1158
+ // ── Field-level cache: skip unchanged yoga setter calls ──
1159
+ auto& cache = node_cache_[&node];
1160
+
1161
+ // Macro: skip if PyObject* identity unchanged (most common case for
1162
+ // interned strings and small ints which Python caches globally)
1163
+ #define CACHE_CHECK(slot, obj) \
1164
+ if (obj.ptr() == cache.ptrs[slot]) goto skip_##slot; \
1165
+ cache.ptrs[slot] = obj.ptr();
1166
+ #define CACHE_SKIP(slot) skip_##slot: (void)0;
1167
+
1168
+ // ── Dimensions (width/height: None → auto) ──
1169
+ float val;
1170
+ int unit;
1171
+
1172
+ CACHE_CHECK(CS_WIDTH, width)
1173
+ unit = parse_dim(width, val);
1174
+ if (unit == 1) YGNodeStyleSetWidth(&node, val);
1175
+ else if (unit == 2) YGNodeStyleSetWidthPercent(&node, val);
1176
+ else YGNodeStyleSetWidthAuto(&node);
1177
+ CACHE_SKIP(CS_WIDTH)
1178
+
1179
+ CACHE_CHECK(CS_HEIGHT, height)
1180
+ unit = parse_dim(height, val);
1181
+ if (unit == 1) YGNodeStyleSetHeight(&node, val);
1182
+ else if (unit == 2) YGNodeStyleSetHeightPercent(&node, val);
1183
+ else YGNodeStyleSetHeightAuto(&node);
1184
+ CACHE_SKIP(CS_HEIGHT)
1185
+
1186
+ // min/max: None → skip (don't reset)
1187
+ CACHE_CHECK(CS_MIN_WIDTH, min_width)
1188
+ unit = parse_dim(min_width, val);
1189
+ if (unit == 1) YGNodeStyleSetMinWidth(&node, val);
1190
+ else if (unit == 2) YGNodeStyleSetMinWidthPercent(&node, val);
1191
+ CACHE_SKIP(CS_MIN_WIDTH)
1192
+
1193
+ CACHE_CHECK(CS_MIN_HEIGHT, min_height)
1194
+ unit = parse_dim(min_height, val);
1195
+ if (unit == 1) YGNodeStyleSetMinHeight(&node, val);
1196
+ else if (unit == 2) YGNodeStyleSetMinHeightPercent(&node, val);
1197
+ CACHE_SKIP(CS_MIN_HEIGHT)
1198
+
1199
+ CACHE_CHECK(CS_MAX_WIDTH, max_width)
1200
+ unit = parse_dim(max_width, val);
1201
+ if (unit == 1) YGNodeStyleSetMaxWidth(&node, val);
1202
+ else if (unit == 2) YGNodeStyleSetMaxWidthPercent(&node, val);
1203
+ CACHE_SKIP(CS_MAX_WIDTH)
1204
+
1205
+ CACHE_CHECK(CS_MAX_HEIGHT, max_height)
1206
+ unit = parse_dim(max_height, val);
1207
+ if (unit == 1) YGNodeStyleSetMaxHeight(&node, val);
1208
+ else if (unit == 2) YGNodeStyleSetMaxHeightPercent(&node, val);
1209
+ CACHE_SKIP(CS_MAX_HEIGHT)
1210
+
1211
+ // ── Flex ──
1212
+ CACHE_CHECK(CS_FLEX_GROW, flex_grow)
1213
+ if (!flex_grow.is_none())
1214
+ YGNodeStyleSetFlexGrow(&node, nb::cast<float>(flex_grow));
1215
+ CACHE_SKIP(CS_FLEX_GROW)
1216
+
1217
+ CACHE_CHECK(CS_FLEX_SHRINK, flex_shrink)
1218
+ if (!flex_shrink.is_none())
1219
+ YGNodeStyleSetFlexShrink(&node, nb::cast<float>(flex_shrink));
1220
+ CACHE_SKIP(CS_FLEX_SHRINK)
1221
+
1222
+ CACHE_CHECK(CS_FLEX_BASIS, flex_basis)
1223
+ if (!flex_basis.is_none()) {
1224
+ unit = parse_dim(flex_basis, val);
1225
+ if (unit == 1) YGNodeStyleSetFlexBasis(&node, val);
1226
+ else if (unit == 2) YGNodeStyleSetFlexBasisPercent(&node, val);
1227
+ else YGNodeStyleSetFlexBasisAuto(&node);
1228
+ }
1229
+ CACHE_SKIP(CS_FLEX_BASIS)
1230
+
1231
+ // ── Enum props (interned pointer compare first, string fallback) ──
1232
+ CACHE_CHECK(CS_FLEX_DIRECTION, flex_direction)
1233
+ if (!flex_direction.is_none() && nb::isinstance<nb::str>(flex_direction)) {
1234
+ PyObject* p = flex_direction.ptr();
1235
+ YGFlexDirection fd = YGFlexDirectionColumn;
1236
+ if (p == interned::row) fd = YGFlexDirectionRow;
1237
+ else if (p == interned::column_reverse) fd = YGFlexDirectionColumnReverse;
1238
+ else if (p == interned::row_reverse) fd = YGFlexDirectionRowReverse;
1239
+ else if (p != interned::column) {
1240
+ // Fallback: non-interned string
1241
+ std::string s = nb::cast<std::string>(flex_direction);
1242
+ if (s == "row") fd = YGFlexDirectionRow;
1243
+ else if (s == "column-reverse") fd = YGFlexDirectionColumnReverse;
1244
+ else if (s == "row-reverse") fd = YGFlexDirectionRowReverse;
1245
+ }
1246
+ YGNodeStyleSetFlexDirection(&node, fd);
1247
+ }
1248
+ CACHE_SKIP(CS_FLEX_DIRECTION)
1249
+
1250
+ CACHE_CHECK(CS_FLEX_WRAP, flex_wrap)
1251
+ if (!flex_wrap.is_none() && nb::isinstance<nb::str>(flex_wrap)) {
1252
+ PyObject* p = flex_wrap.ptr();
1253
+ YGWrap w = YGWrapNoWrap;
1254
+ if (p == interned::wrap) w = YGWrapWrap;
1255
+ else if (p == interned::wrap_reverse) w = YGWrapWrapReverse;
1256
+ else if (p != interned::nowrap) {
1257
+ std::string s = nb::cast<std::string>(flex_wrap);
1258
+ if (s == "wrap") w = YGWrapWrap;
1259
+ else if (s == "wrap-reverse") w = YGWrapWrapReverse;
1260
+ }
1261
+ YGNodeStyleSetFlexWrap(&node, w);
1262
+ }
1263
+ CACHE_SKIP(CS_FLEX_WRAP)
1264
+
1265
+ CACHE_CHECK(CS_JUSTIFY_CONTENT, justify_content)
1266
+ if (!justify_content.is_none() && nb::isinstance<nb::str>(justify_content)) {
1267
+ PyObject* p = justify_content.ptr();
1268
+ YGJustify j = YGJustifyFlexStart;
1269
+ if (p == interned::flex_end) j = YGJustifyFlexEnd;
1270
+ else if (p == interned::center) j = YGJustifyCenter;
1271
+ else if (p == interned::space_between) j = YGJustifySpaceBetween;
1272
+ else if (p == interned::space_around) j = YGJustifySpaceAround;
1273
+ else if (p == interned::space_evenly) j = YGJustifySpaceEvenly;
1274
+ else if (p != interned::flex_start) {
1275
+ std::string s = nb::cast<std::string>(justify_content);
1276
+ if (s == "flex-end") j = YGJustifyFlexEnd;
1277
+ else if (s == "center") j = YGJustifyCenter;
1278
+ else if (s == "space-between") j = YGJustifySpaceBetween;
1279
+ else if (s == "space-around") j = YGJustifySpaceAround;
1280
+ else if (s == "space-evenly") j = YGJustifySpaceEvenly;
1281
+ }
1282
+ YGNodeStyleSetJustifyContent(&node, j);
1283
+ }
1284
+ CACHE_SKIP(CS_JUSTIFY_CONTENT)
1285
+
1286
+ CACHE_CHECK(CS_ALIGN_ITEMS, align_items)
1287
+ if (!align_items.is_none() && nb::isinstance<nb::str>(align_items)) {
1288
+ PyObject* p = align_items.ptr();
1289
+ YGAlign a = YGAlignStretch;
1290
+ if (p == interned::flex_start) a = YGAlignFlexStart;
1291
+ else if (p == interned::flex_end) a = YGAlignFlexEnd;
1292
+ else if (p == interned::center) a = YGAlignCenter;
1293
+ else if (p == interned::baseline) a = YGAlignBaseline;
1294
+ else if (p == interned::auto_) a = YGAlignAuto;
1295
+ else if (p != interned::stretch) {
1296
+ std::string s = nb::cast<std::string>(align_items);
1297
+ if (s == "flex-start") a = YGAlignFlexStart;
1298
+ else if (s == "flex-end") a = YGAlignFlexEnd;
1299
+ else if (s == "center") a = YGAlignCenter;
1300
+ else if (s == "baseline") a = YGAlignBaseline;
1301
+ else if (s == "auto") a = YGAlignAuto;
1302
+ }
1303
+ YGNodeStyleSetAlignItems(&node, a);
1304
+ }
1305
+ CACHE_SKIP(CS_ALIGN_ITEMS)
1306
+
1307
+ CACHE_CHECK(CS_ALIGN_SELF, align_self)
1308
+ if (!align_self.is_none() && nb::isinstance<nb::str>(align_self)) {
1309
+ PyObject* p = align_self.ptr();
1310
+ YGAlign a = YGAlignAuto;
1311
+ if (p == interned::stretch) a = YGAlignStretch;
1312
+ else if (p == interned::flex_start) a = YGAlignFlexStart;
1313
+ else if (p == interned::flex_end) a = YGAlignFlexEnd;
1314
+ else if (p == interned::center) a = YGAlignCenter;
1315
+ else if (p == interned::baseline) a = YGAlignBaseline;
1316
+ else if (p != interned::auto_) {
1317
+ std::string s = nb::cast<std::string>(align_self);
1318
+ if (s == "stretch") a = YGAlignStretch;
1319
+ else if (s == "flex-start") a = YGAlignFlexStart;
1320
+ else if (s == "flex-end") a = YGAlignFlexEnd;
1321
+ else if (s == "center") a = YGAlignCenter;
1322
+ else if (s == "baseline") a = YGAlignBaseline;
1323
+ }
1324
+ YGNodeStyleSetAlignSelf(&node, a);
1325
+ }
1326
+ CACHE_SKIP(CS_ALIGN_SELF)
1327
+
1328
+ // ── Gap ──
1329
+ CACHE_CHECK(CS_GAP, gap)
1330
+ if (!gap.is_none())
1331
+ YGNodeStyleSetGap(&node, YGGutterAll, nb::cast<float>(gap));
1332
+ CACHE_SKIP(CS_GAP)
1333
+
1334
+ // ── Overflow ──
1335
+ CACHE_CHECK(CS_OVERFLOW, overflow)
1336
+ if (!overflow.is_none() && nb::isinstance<nb::str>(overflow)) {
1337
+ PyObject* p = overflow.ptr();
1338
+ YGOverflow o = YGOverflowVisible;
1339
+ if (p == interned::hidden) o = YGOverflowHidden;
1340
+ else if (p == interned::scroll) o = YGOverflowScroll;
1341
+ else if (p != interned::visible) {
1342
+ std::string s = nb::cast<std::string>(overflow);
1343
+ if (s == "hidden") o = YGOverflowHidden;
1344
+ else if (s == "scroll") o = YGOverflowScroll;
1345
+ }
1346
+ YGNodeStyleSetOverflow(&node, o);
1347
+ }
1348
+ CACHE_SKIP(CS_OVERFLOW)
1349
+
1350
+ // ── Position type ──
1351
+ CACHE_CHECK(CS_POSITION_TYPE, position_type)
1352
+ if (!position_type.is_none() && nb::isinstance<nb::str>(position_type)) {
1353
+ PyObject* p = position_type.ptr();
1354
+ YGPositionType pt = YGPositionTypeRelative;
1355
+ if (p == interned::absolute) pt = YGPositionTypeAbsolute;
1356
+ else if (p == interned::static_) pt = YGPositionTypeStatic;
1357
+ else if (p != interned::relative) {
1358
+ std::string s = nb::cast<std::string>(position_type);
1359
+ if (s == "absolute") pt = YGPositionTypeAbsolute;
1360
+ }
1361
+ YGNodeStyleSetPositionType(&node, pt);
1362
+ }
1363
+ CACHE_SKIP(CS_POSITION_TYPE)
1364
+
1365
+ // ── Padding (always set, caller includes border offset) ──
1366
+ if (padding_top != cache.padding[0]) {
1367
+ cache.padding[0] = padding_top;
1368
+ YGNodeStyleSetPadding(&node, YGEdgeTop, padding_top);
1369
+ }
1370
+ if (padding_right != cache.padding[1]) {
1371
+ cache.padding[1] = padding_right;
1372
+ YGNodeStyleSetPadding(&node, YGEdgeRight, padding_right);
1373
+ }
1374
+ if (padding_bottom != cache.padding[2]) {
1375
+ cache.padding[2] = padding_bottom;
1376
+ YGNodeStyleSetPadding(&node, YGEdgeBottom, padding_bottom);
1377
+ }
1378
+ if (padding_left != cache.padding[3]) {
1379
+ cache.padding[3] = padding_left;
1380
+ YGNodeStyleSetPadding(&node, YGEdgeLeft, padding_left);
1381
+ }
1382
+
1383
+ // ── Margin ──
1384
+ CACHE_CHECK(CS_MARGIN, margin)
1385
+ if (!margin.is_none())
1386
+ YGNodeStyleSetMargin(&node, YGEdgeAll, nb::cast<float>(margin));
1387
+ CACHE_SKIP(CS_MARGIN)
1388
+
1389
+ CACHE_CHECK(CS_MARGIN_TOP, margin_top)
1390
+ if (!margin_top.is_none())
1391
+ YGNodeStyleSetMargin(&node, YGEdgeTop, nb::cast<float>(margin_top));
1392
+ CACHE_SKIP(CS_MARGIN_TOP)
1393
+
1394
+ CACHE_CHECK(CS_MARGIN_RIGHT, margin_right)
1395
+ if (!margin_right.is_none())
1396
+ YGNodeStyleSetMargin(&node, YGEdgeRight, nb::cast<float>(margin_right));
1397
+ CACHE_SKIP(CS_MARGIN_RIGHT)
1398
+
1399
+ CACHE_CHECK(CS_MARGIN_BOTTOM, margin_bottom)
1400
+ if (!margin_bottom.is_none())
1401
+ YGNodeStyleSetMargin(&node, YGEdgeBottom, nb::cast<float>(margin_bottom));
1402
+ CACHE_SKIP(CS_MARGIN_BOTTOM)
1403
+
1404
+ CACHE_CHECK(CS_MARGIN_LEFT, margin_left)
1405
+ if (!margin_left.is_none())
1406
+ YGNodeStyleSetMargin(&node, YGEdgeLeft, nb::cast<float>(margin_left));
1407
+ CACHE_SKIP(CS_MARGIN_LEFT)
1408
+
1409
+ // ── Position edges ──
1410
+ CACHE_CHECK(CS_POS_TOP, pos_top)
1411
+ unit = parse_dim(pos_top, val);
1412
+ if (unit == 1) YGNodeStyleSetPosition(&node, YGEdgeTop, val);
1413
+ else if (unit == 2) YGNodeStyleSetPositionPercent(&node, YGEdgeTop, val);
1414
+ CACHE_SKIP(CS_POS_TOP)
1415
+
1416
+ CACHE_CHECK(CS_POS_RIGHT, pos_right)
1417
+ unit = parse_dim(pos_right, val);
1418
+ if (unit == 1) YGNodeStyleSetPosition(&node, YGEdgeRight, val);
1419
+ else if (unit == 2) YGNodeStyleSetPositionPercent(&node, YGEdgeRight, val);
1420
+ CACHE_SKIP(CS_POS_RIGHT)
1421
+
1422
+ CACHE_CHECK(CS_POS_BOTTOM, pos_bottom)
1423
+ unit = parse_dim(pos_bottom, val);
1424
+ if (unit == 1) YGNodeStyleSetPosition(&node, YGEdgeBottom, val);
1425
+ else if (unit == 2) YGNodeStyleSetPositionPercent(&node, YGEdgeBottom, val);
1426
+ CACHE_SKIP(CS_POS_BOTTOM)
1427
+
1428
+ CACHE_CHECK(CS_POS_LEFT, pos_left)
1429
+ unit = parse_dim(pos_left, val);
1430
+ if (unit == 1) YGNodeStyleSetPosition(&node, YGEdgeLeft, val);
1431
+ else if (unit == 2) YGNodeStyleSetPositionPercent(&node, YGEdgeLeft, val);
1432
+ CACHE_SKIP(CS_POS_LEFT)
1433
+
1434
+ #undef CACHE_CHECK
1435
+ #undef CACHE_SKIP
1436
+ },
1437
+ nb::arg("node"),
1438
+ nb::arg("width") = nb::none(), nb::arg("height") = nb::none(),
1439
+ nb::arg("min_width") = nb::none(), nb::arg("min_height") = nb::none(),
1440
+ nb::arg("max_width") = nb::none(), nb::arg("max_height") = nb::none(),
1441
+ nb::arg("flex_grow") = nb::none(), nb::arg("flex_shrink") = nb::none(),
1442
+ nb::arg("flex_basis") = nb::none(),
1443
+ nb::arg("flex_direction") = nb::none(), nb::arg("flex_wrap") = nb::none(),
1444
+ nb::arg("justify_content") = nb::none(), nb::arg("align_items") = nb::none(),
1445
+ nb::arg("align_self") = nb::none(),
1446
+ nb::arg("gap") = nb::none(), nb::arg("overflow") = nb::none(),
1447
+ nb::arg("position_type") = nb::none(),
1448
+ nb::arg("padding_top") = 0.0f, nb::arg("padding_right") = 0.0f,
1449
+ nb::arg("padding_bottom") = 0.0f, nb::arg("padding_left") = 0.0f,
1450
+ nb::arg("margin") = nb::none(), nb::arg("margin_top") = nb::none(),
1451
+ nb::arg("margin_right") = nb::none(), nb::arg("margin_bottom") = nb::none(),
1452
+ nb::arg("margin_left") = nb::none(),
1453
+ nb::arg("pos_top") = nb::none(), nb::arg("pos_right") = nb::none(),
1454
+ nb::arg("pos_bottom") = nb::none(), nb::arg("pos_left") = nb::none(),
1455
+ "Configure all layout properties on a yoga node in a single C++ call.");
1456
+
1457
+ // Evict a node from the configure_node_fast cache (called on node destruction).
1458
+ m.def("clear_node_cache", [](yoga::Node& node) {
1459
+ node_cache_.erase(&node);
1460
+ }, nb::arg("node"),
1461
+ "Remove a node from the configure_node_fast field cache.");
1462
+
1463
+ // ── apply_layout_tree: walk a widget tree, apply yoga layout, collect deltas ──
1464
+ //
1465
+ // Accelerator for widget frameworks that maintain a tree of Python objects
1466
+ // with yoga nodes attached. Walks the tree in C++, reads yoga layout
1467
+ // results, writes absolute geometry back to Python __slots__, clears dirty
1468
+ // flags, and returns a list of "repaint facts" for nodes whose geometry
1469
+ // changed or were dirty.
1470
+ //
1471
+ // The `offsets` dict maps slot names to byte offsets (from tp_basicsize or
1472
+ // __slots__) so the function can read/write arbitrary Python objects without
1473
+ // knowing their class. Required keys:
1474
+ // _x, _y, _layout_width, _layout_height — int slots for absolute geometry
1475
+ // _dirty, _subtree_dirty — bool slots for dirty flags
1476
+ // _children — list slot for tree traversal
1477
+ // _parent — slot for parent reference
1478
+ // _yoga_node — slot holding the yoga.Node
1479
+ // _on_size_change — optional callable(width, height)
1480
+ //
1481
+ // Returns list of tuples:
1482
+ // (node, parent_id, has_children, was_dirty,
1483
+ // old_x, old_y, old_w, old_h, new_x, new_y, new_w, new_h)
1484
+ m.def("apply_layout_tree", [](nb::object root, nb::dict offsets_dict, int origin_x, int origin_y) {
1485
+ struct Offsets {
1486
+ Py_ssize_t x, y, layout_width, layout_height;
1487
+ Py_ssize_t dirty, subtree_dirty, children, parent, yoga_node;
1488
+ Py_ssize_t on_size_change;
1489
+ } off;
1490
+ auto get_off = [&](const char* name) -> Py_ssize_t {
1491
+ return nb::cast<Py_ssize_t>(offsets_dict[name]);
1492
+ };
1493
+ off.x = get_off("_x");
1494
+ off.y = get_off("_y");
1495
+ off.layout_width = get_off("_layout_width");
1496
+ off.layout_height = get_off("_layout_height");
1497
+ off.dirty = get_off("_dirty");
1498
+ off.subtree_dirty = get_off("_subtree_dirty");
1499
+ off.children = get_off("_children");
1500
+ off.parent = get_off("_parent");
1501
+ off.yoga_node = get_off("_yoga_node");
1502
+ off.on_size_change = get_off("_on_size_change");
1503
+
1504
+ auto read_slot = [](PyObject* obj, Py_ssize_t offset) -> PyObject* {
1505
+ return offset < 0 ? nullptr : *(PyObject**)((char*)obj + offset);
1506
+ };
1507
+
1508
+ auto write_slot = [](PyObject* obj, Py_ssize_t offset, PyObject* new_val) {
1509
+ PyObject** slot = (PyObject**)((char*)obj + offset);
1510
+ PyObject* old = *slot;
1511
+ Py_INCREF(new_val);
1512
+ *slot = new_val;
1513
+ Py_XDECREF(old);
1514
+ };
1515
+
1516
+ auto read_long_slot = [&](PyObject* obj, Py_ssize_t offset) -> int {
1517
+ if (offset < 0) return 0;
1518
+ PyObject* value = read_slot(obj, offset);
1519
+ if (!value || value == Py_None) return 0;
1520
+ long result = PyLong_AsLong(value);
1521
+ if (PyErr_Occurred()) {
1522
+ PyErr_Clear();
1523
+ return 0;
1524
+ }
1525
+ return static_cast<int>(result);
1526
+ };
1527
+
1528
+ nb::list facts;
1529
+ struct Walker {
1530
+ Offsets& off;
1531
+ decltype(read_slot)& read;
1532
+ decltype(write_slot)& write;
1533
+ decltype(read_long_slot)& read_long;
1534
+ nb::list& facts;
1535
+
1536
+ void walk(PyObject* node, int parent_x, int parent_y) {
1537
+ PyObject* yoga_py = read(node, off.yoga_node);
1538
+ if (!yoga_py || yoga_py == Py_None) return;
1539
+
1540
+ yoga::Node* yoga_node;
1541
+ try {
1542
+ yoga_node = nb::cast<yoga::Node*>(nb::handle(yoga_py));
1543
+ } catch (...) {
1544
+ return;
1545
+ }
1546
+
1547
+ int lx = static_cast<int>(YGNodeLayoutGetLeft(yoga_node));
1548
+ int ly = static_cast<int>(YGNodeLayoutGetTop(yoga_node));
1549
+ int lw = static_cast<int>(YGNodeLayoutGetWidth(yoga_node));
1550
+ int lh = static_cast<int>(YGNodeLayoutGetHeight(yoga_node));
1551
+
1552
+ const int old_x = read_long(node, off.x);
1553
+ const int old_y = read_long(node, off.y);
1554
+ const int old_w = read_long(node, off.layout_width);
1555
+ const int old_h = read_long(node, off.layout_height);
1556
+ const bool was_dirty = off.dirty >= 0 && read(node, off.dirty) == Py_True;
1557
+
1558
+ PyObject* old_w_obj = read(node, off.layout_width);
1559
+ PyObject* old_h_obj = read(node, off.layout_height);
1560
+ Py_XINCREF(old_w_obj);
1561
+ Py_XINCREF(old_h_obj);
1562
+
1563
+ const int abs_x = lx + parent_x;
1564
+ const int abs_y = ly + parent_y;
1565
+ const bool geom_changed = old_x != abs_x || old_y != abs_y || old_w != lw || old_h != lh;
1566
+
1567
+ if (geom_changed) {
1568
+ PyObject* py_x = PyLong_FromLong(abs_x);
1569
+ PyObject* py_y = PyLong_FromLong(abs_y);
1570
+ PyObject* py_w = PyLong_FromLong(lw);
1571
+ PyObject* py_h = PyLong_FromLong(lh);
1572
+ write(node, off.x, py_x);
1573
+ write(node, off.y, py_y);
1574
+ write(node, off.layout_width, py_w);
1575
+ write(node, off.layout_height, py_h);
1576
+ Py_DECREF(py_x);
1577
+ Py_DECREF(py_y);
1578
+ Py_DECREF(py_w);
1579
+ Py_DECREF(py_h);
1580
+ }
1581
+ if (was_dirty) write(node, off.dirty, Py_False);
1582
+ if (off.subtree_dirty >= 0 && read(node, off.subtree_dirty) == Py_True) {
1583
+ write(node, off.subtree_dirty, Py_False);
1584
+ }
1585
+
1586
+ PyObject* children = read(node, off.children);
1587
+ const bool has_children =
1588
+ children && PyList_Check(children) && PyList_GET_SIZE(children) > 0;
1589
+ if (was_dirty || geom_changed) {
1590
+ PyObject* parent = read(node, off.parent);
1591
+ facts.append(nb::make_tuple(
1592
+ nb::borrow<nb::object>(node),
1593
+ parent && parent != Py_None ? reinterpret_cast<uintptr_t>(parent) : 0,
1594
+ has_children,
1595
+ was_dirty,
1596
+ old_x,
1597
+ old_y,
1598
+ old_w,
1599
+ old_h,
1600
+ abs_x,
1601
+ abs_y,
1602
+ lw,
1603
+ lh
1604
+ ));
1605
+ }
1606
+
1607
+ PyObject* size_cb = off.on_size_change >= 0 ? read(node, off.on_size_change) : nullptr;
1608
+ if (size_cb && size_cb != Py_None) {
1609
+ const bool w_changed = !old_w_obj || PyLong_AsLong(old_w_obj) != lw;
1610
+ const bool h_changed = !old_h_obj || PyLong_AsLong(old_h_obj) != lh;
1611
+ if (w_changed || h_changed) {
1612
+ PyObject* result = PyObject_CallFunction(size_cb, "ii", lw, lh);
1613
+ if (result) Py_DECREF(result);
1614
+ else PyErr_Clear();
1615
+ }
1616
+ }
1617
+ Py_XDECREF(old_w_obj);
1618
+ Py_XDECREF(old_h_obj);
1619
+
1620
+ if (has_children) {
1621
+ Py_ssize_t n = PyList_GET_SIZE(children);
1622
+ for (Py_ssize_t i = 0; i < n; ++i) {
1623
+ walk(PyList_GET_ITEM(children, i), abs_x, abs_y);
1624
+ }
1625
+ }
1626
+ }
1627
+ };
1628
+
1629
+ Walker walker{off, read_slot, write_slot, read_long_slot, facts};
1630
+ walker.walk(root.ptr(), origin_x, origin_y);
1631
+ return facts;
1632
+ }, nb::arg("root"), nb::arg("offsets"), nb::arg("origin_x") = 0, nb::arg("origin_y") = 0,
1633
+ "Walk a widget tree in C++, apply yoga layout results to Python __slots__,\n"
1634
+ "clear dirty flags, and return repaint facts for changed nodes.\n\n"
1635
+ "The offsets dict maps slot names (_x, _y, _layout_width, _layout_height,\n"
1636
+ "_dirty, _subtree_dirty, _children, _parent, _yoga_node, _on_size_change)\n"
1637
+ "to byte offsets so arbitrary Python objects can be read/written by slot.");
1638
+
1639
+ // ── get_layout_batch: read all 4 layout results in one C++ call ──
1640
+ //
1641
+ // Returns (left, top, width, height) as integers, avoiding 4 separate
1642
+ // Python→C++ property getter round-trips.
1643
+ m.def("get_layout_batch", [](yoga::Node& node) -> nb::tuple {
1644
+ return nb::make_tuple(
1645
+ static_cast<int>(YGNodeLayoutGetLeft(&node)),
1646
+ static_cast<int>(YGNodeLayoutGetTop(&node)),
1647
+ static_cast<int>(YGNodeLayoutGetWidth(&node)),
1648
+ static_cast<int>(YGNodeLayoutGetHeight(&node))
1649
+ );
1650
+ }, nb::arg("node"),
1651
+ "Get (left, top, width, height) layout results as integers in one call.");
1652
+
1000
1653
  // Clean up all Python-side resources during module teardown.
1001
1654
  // Using a capsule ensures cleanup runs when the module dict is cleared,
1002
1655
  // before nanobind's leak checker fires.
@@ -1017,5 +1670,6 @@ NB_MODULE(yoga, m) {
1017
1670
  allCloneContexts.clear();
1018
1671
 
1019
1672
  nanobindManagedNodes.clear();
1673
+ node_cache_.clear();
1020
1674
  });
1021
1675
  }
@@ -91,3 +91,18 @@ class TestTreeMutation:
91
91
 
92
92
  root.free_recursive()
93
93
  root_child0.free()
94
+
95
+ def test_set_children_same_order_is_noop(self):
96
+ config = Config()
97
+ root = Node(config)
98
+ root_child0 = Node(config)
99
+ root_child1 = Node(config)
100
+
101
+ root.set_children([root_child0, root_child1])
102
+ root.set_children([root_child0, root_child1])
103
+
104
+ assert get_children(root) == [root_child0, root_child1]
105
+ assert root_child0.owner is root
106
+ assert root_child1.owner is root
107
+
108
+ root.free_recursive()
@@ -219,7 +219,7 @@ wheels = [
219
219
 
220
220
  [[package]]
221
221
  name = "yoga-python"
222
- version = "0.1.1"
222
+ version = "0.1.3"
223
223
  source = { editable = "." }
224
224
 
225
225
  [package.optional-dependencies]
File without changes
File without changes
File without changes
File without changes