yoga-python 0.1.2__tar.gz → 0.1.3__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.3}/PKG-INFO +1 -1
  2. yoga_python-0.1.3/benchmarks/bench_bindings.py +109 -0
  3. {yoga_python-0.1.2 → yoga_python-0.1.3}/pyproject.toml +1 -1
  4. {yoga_python-0.1.2 → yoga_python-0.1.3}/src/yoga/__init__.pyi +43 -0
  5. {yoga_python-0.1.2 → yoga_python-0.1.3}/src/yoga/yoga.cpp +658 -9
  6. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_tree_mutation.py +15 -0
  7. {yoga_python-0.1.2 → yoga_python-0.1.3}/uv.lock +1 -1
  8. {yoga_python-0.1.2 → yoga_python-0.1.3}/.github/workflows/create-release.yml +0 -0
  9. {yoga_python-0.1.2 → yoga_python-0.1.3}/.github/workflows/quality.yml +0 -0
  10. {yoga_python-0.1.2 → yoga_python-0.1.3}/.github/workflows/release.yml +0 -0
  11. {yoga_python-0.1.2 → yoga_python-0.1.3}/.gitignore +0 -0
  12. {yoga_python-0.1.2 → yoga_python-0.1.3}/.python-version +0 -0
  13. {yoga_python-0.1.2 → yoga_python-0.1.3}/CMakeLists.txt +0 -0
  14. {yoga_python-0.1.2 → yoga_python-0.1.3}/LICENSE +0 -0
  15. {yoga_python-0.1.2 → yoga_python-0.1.3}/README.md +0 -0
  16. {yoga_python-0.1.2 → yoga_python-0.1.3}/pyrightconfig.json +0 -0
  17. {yoga_python-0.1.2 → yoga_python-0.1.3}/src/yoga/__init__.py +0 -0
  18. {yoga_python-0.1.2 → yoga_python-0.1.3}/src/yoga/py.typed +0 -0
  19. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/conftest.py +0 -0
  20. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_absolute_position.py +0 -0
  21. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_align_content.py +0 -0
  22. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_align_items.py +0 -0
  23. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_align_self.py +0 -0
  24. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_android_news_feed.py +0 -0
  25. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_aspect_ratio.py +0 -0
  26. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_auto.py +0 -0
  27. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_baseline.py +0 -0
  28. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_baseline_func.py +0 -0
  29. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_border.py +0 -0
  30. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_box_sizing.py +0 -0
  31. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_clone_node.py +0 -0
  32. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_clone_nofree.py +0 -0
  33. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_computed_margin.py +0 -0
  34. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_computed_padding.py +0 -0
  35. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_config.py +0 -0
  36. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_default_values.py +0 -0
  37. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_dimension.py +0 -0
  38. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_dirtied.py +0 -0
  39. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_dirty_marking.py +0 -0
  40. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_display.py +0 -0
  41. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_display_contents.py +0 -0
  42. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_edge.py +0 -0
  43. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_events.py +0 -0
  44. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_flex.py +0 -0
  45. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_flex_direction.py +0 -0
  46. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_flex_gap.py +0 -0
  47. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_flex_wrap.py +0 -0
  48. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_float_optional.py +0 -0
  49. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_free_order.py +0 -0
  50. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_gap.py +0 -0
  51. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_had_overflow.py +0 -0
  52. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_intrinsic_size.py +0 -0
  53. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_justify_content.py +0 -0
  54. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_layoutable_children.py +0 -0
  55. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_margin.py +0 -0
  56. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_measure.py +0 -0
  57. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_measure_cache.py +0 -0
  58. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_measure_mode.py +0 -0
  59. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_min_max_dimension.py +0 -0
  60. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_no_free.py +0 -0
  61. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_node_callback.py +0 -0
  62. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_node_child.py +0 -0
  63. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_ordinals.py +0 -0
  64. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_padding.py +0 -0
  65. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_percentage.py +0 -0
  66. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_persistence.py +0 -0
  67. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_persistent_node_cloning.py +0 -0
  68. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_relayout.py +0 -0
  69. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_rounding.py +0 -0
  70. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_rounding_function.py +0 -0
  71. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_rounding_measure.py +0 -0
  72. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_scale_change.py +0 -0
  73. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_size_overflow.py +0 -0
  74. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_small_value_buffer.py +0 -0
  75. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_static_position.py +0 -0
  76. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_style.py +0 -0
  77. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_style_value_pool.py +0 -0
  78. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_value.py +0 -0
  79. {yoga_python-0.1.2 → yoga_python-0.1.3}/tests/test_zero_out_layout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yoga-python
3
- Version: 0.1.2
3
+ Version: 0.1.3
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.3"
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)
@@ -841,17 +923,27 @@ NB_MODULE(yoga, m) {
841
923
  .def("remove_child", [](yoga::Node& self, yoga::Node& child) { YGNodeRemoveChild(&self, &child); }, nb::arg("child"))
842
924
  .def("remove_all_children", [](yoga::Node& self) { YGNodeRemoveAllChildren(&self); })
843
925
  .def("set_children", [](yoga::Node& self, const std::vector<yoga::Node*>& children) {
926
+ const size_t currentCount = self.getChildCount();
927
+ if (currentCount == children.size()) {
928
+ bool identical = true;
929
+ for (size_t i = 0; i < currentCount; i++) {
930
+ if (self.getChild(i) != children[i]) {
931
+ identical = false;
932
+ break;
933
+ }
934
+ }
935
+ if (identical) {
936
+ return;
937
+ }
938
+ }
939
+
940
+ std::unordered_set<yoga::Node*> nextChildren(children.begin(), children.end());
941
+
844
942
  // 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++) {
943
+ for (size_t i = 0; i < currentCount; i++) {
846
944
  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
- }
945
+ if (oldChild->getOwner() == &self && !nextChildren.contains(oldChild)) {
946
+ oldChild->setOwner(nullptr);
855
947
  }
856
948
  }
857
949
  self.setChildren(children);
@@ -997,6 +1089,562 @@ NB_MODULE(yoga, m) {
997
1089
  .def("set_flex_basis_stretch", [](yoga::Node& self) { YGNodeStyleSetFlexBasisStretch(&self); })
998
1090
  .def("_node_id", [](yoga::Node& self) -> uintptr_t { return reinterpret_cast<uintptr_t>(&self); });
999
1091
 
1092
+ // ── configure_node_fast: bulk yoga configuration in one C++ call ──
1093
+ //
1094
+ // Replaces ~6 individual Python→C++ calls + 24 Python None-checks with
1095
+ // a single call. All dimension parsing, enum mapping, and None handling
1096
+ // happens in C++. Field-level caching skips unchanged properties.
1097
+ //
1098
+ // Dimension args: None=auto/skip, int/float=point, str "50%"=percent, str "auto"=auto
1099
+ // Enum args: None=skip, str=mapped to yoga enum
1100
+ // Numeric args: None=skip, int/float=applied directly
1101
+ // Padding: always applied (float, includes caller's border offset)
1102
+
1103
+ m.def("configure_node_fast", [](
1104
+ yoga::Node& node,
1105
+ // Dimensions
1106
+ nb::object width, nb::object height,
1107
+ nb::object min_width, nb::object min_height,
1108
+ nb::object max_width, nb::object max_height,
1109
+ // Flex
1110
+ nb::object flex_grow, nb::object flex_shrink, nb::object flex_basis,
1111
+ // Enums (None = skip)
1112
+ nb::object flex_direction, nb::object flex_wrap,
1113
+ nb::object justify_content, nb::object align_items, nb::object align_self,
1114
+ // Gap, overflow, position, display
1115
+ nb::object gap, nb::object overflow, nb::object position_type,
1116
+ // Padding (always set, includes border offset from caller)
1117
+ float padding_top, float padding_right,
1118
+ float padding_bottom, float padding_left,
1119
+ // Margin
1120
+ nb::object margin, nb::object margin_top, nb::object margin_right,
1121
+ nb::object margin_bottom, nb::object margin_left,
1122
+ // Position edges
1123
+ nb::object pos_top, nb::object pos_right,
1124
+ nb::object pos_bottom, nb::object pos_left
1125
+ ) {
1126
+ // ── Helper lambdas ──
1127
+
1128
+ // Parse dimension: returns (value, unit) where unit is 0=auto, 1=point, 2=percent
1129
+ auto parse_dim = [](nb::handle obj, float& out_val) -> int {
1130
+ if (obj.is_none()) return 0; // auto/unset
1131
+ if (nb::isinstance<nb::int_>(obj) || nb::isinstance<nb::float_>(obj)) {
1132
+ out_val = nb::cast<float>(obj);
1133
+ return 1; // point
1134
+ }
1135
+ if (nb::isinstance<nb::str>(obj)) {
1136
+ std::string s = nb::cast<std::string>(obj);
1137
+ if (s == "auto") return 0;
1138
+ try {
1139
+ if (!s.empty() && s.back() == '%') {
1140
+ s.pop_back();
1141
+ out_val = std::stof(s);
1142
+ return 2; // percent
1143
+ }
1144
+ out_val = std::stof(s);
1145
+ return 1; // point
1146
+ } catch (const std::exception&) {
1147
+ return 0; // malformed string → treat as auto/unset
1148
+ }
1149
+ }
1150
+ return 0;
1151
+ };
1152
+
1153
+ // ── Field-level cache: skip unchanged yoga setter calls ──
1154
+ auto& cache = node_cache_[&node];
1155
+
1156
+ // Macro: skip if PyObject* identity unchanged (most common case for
1157
+ // interned strings and small ints which Python caches globally)
1158
+ #define CACHE_CHECK(slot, obj) \
1159
+ if (obj.ptr() == cache.ptrs[slot]) goto skip_##slot; \
1160
+ cache.ptrs[slot] = obj.ptr();
1161
+ #define CACHE_SKIP(slot) skip_##slot: (void)0;
1162
+
1163
+ // ── Dimensions (width/height: None → auto) ──
1164
+ float val;
1165
+ int unit;
1166
+
1167
+ CACHE_CHECK(CS_WIDTH, width)
1168
+ unit = parse_dim(width, val);
1169
+ if (unit == 1) YGNodeStyleSetWidth(&node, val);
1170
+ else if (unit == 2) YGNodeStyleSetWidthPercent(&node, val);
1171
+ else YGNodeStyleSetWidthAuto(&node);
1172
+ CACHE_SKIP(CS_WIDTH)
1173
+
1174
+ CACHE_CHECK(CS_HEIGHT, height)
1175
+ unit = parse_dim(height, val);
1176
+ if (unit == 1) YGNodeStyleSetHeight(&node, val);
1177
+ else if (unit == 2) YGNodeStyleSetHeightPercent(&node, val);
1178
+ else YGNodeStyleSetHeightAuto(&node);
1179
+ CACHE_SKIP(CS_HEIGHT)
1180
+
1181
+ // min/max: None → skip (don't reset)
1182
+ CACHE_CHECK(CS_MIN_WIDTH, min_width)
1183
+ unit = parse_dim(min_width, val);
1184
+ if (unit == 1) YGNodeStyleSetMinWidth(&node, val);
1185
+ else if (unit == 2) YGNodeStyleSetMinWidthPercent(&node, val);
1186
+ CACHE_SKIP(CS_MIN_WIDTH)
1187
+
1188
+ CACHE_CHECK(CS_MIN_HEIGHT, min_height)
1189
+ unit = parse_dim(min_height, val);
1190
+ if (unit == 1) YGNodeStyleSetMinHeight(&node, val);
1191
+ else if (unit == 2) YGNodeStyleSetMinHeightPercent(&node, val);
1192
+ CACHE_SKIP(CS_MIN_HEIGHT)
1193
+
1194
+ CACHE_CHECK(CS_MAX_WIDTH, max_width)
1195
+ unit = parse_dim(max_width, val);
1196
+ if (unit == 1) YGNodeStyleSetMaxWidth(&node, val);
1197
+ else if (unit == 2) YGNodeStyleSetMaxWidthPercent(&node, val);
1198
+ CACHE_SKIP(CS_MAX_WIDTH)
1199
+
1200
+ CACHE_CHECK(CS_MAX_HEIGHT, max_height)
1201
+ unit = parse_dim(max_height, val);
1202
+ if (unit == 1) YGNodeStyleSetMaxHeight(&node, val);
1203
+ else if (unit == 2) YGNodeStyleSetMaxHeightPercent(&node, val);
1204
+ CACHE_SKIP(CS_MAX_HEIGHT)
1205
+
1206
+ // ── Flex ──
1207
+ CACHE_CHECK(CS_FLEX_GROW, flex_grow)
1208
+ if (!flex_grow.is_none())
1209
+ YGNodeStyleSetFlexGrow(&node, nb::cast<float>(flex_grow));
1210
+ CACHE_SKIP(CS_FLEX_GROW)
1211
+
1212
+ CACHE_CHECK(CS_FLEX_SHRINK, flex_shrink)
1213
+ if (!flex_shrink.is_none())
1214
+ YGNodeStyleSetFlexShrink(&node, nb::cast<float>(flex_shrink));
1215
+ CACHE_SKIP(CS_FLEX_SHRINK)
1216
+
1217
+ CACHE_CHECK(CS_FLEX_BASIS, flex_basis)
1218
+ if (!flex_basis.is_none()) {
1219
+ unit = parse_dim(flex_basis, val);
1220
+ if (unit == 1) YGNodeStyleSetFlexBasis(&node, val);
1221
+ else if (unit == 2) YGNodeStyleSetFlexBasisPercent(&node, val);
1222
+ else YGNodeStyleSetFlexBasisAuto(&node);
1223
+ }
1224
+ CACHE_SKIP(CS_FLEX_BASIS)
1225
+
1226
+ // ── Enum props (interned pointer compare first, string fallback) ──
1227
+ CACHE_CHECK(CS_FLEX_DIRECTION, flex_direction)
1228
+ if (!flex_direction.is_none() && nb::isinstance<nb::str>(flex_direction)) {
1229
+ PyObject* p = flex_direction.ptr();
1230
+ YGFlexDirection fd = YGFlexDirectionColumn;
1231
+ if (p == interned::row) fd = YGFlexDirectionRow;
1232
+ else if (p == interned::column_reverse) fd = YGFlexDirectionColumnReverse;
1233
+ else if (p == interned::row_reverse) fd = YGFlexDirectionRowReverse;
1234
+ else if (p != interned::column) {
1235
+ // Fallback: non-interned string
1236
+ std::string s = nb::cast<std::string>(flex_direction);
1237
+ if (s == "row") fd = YGFlexDirectionRow;
1238
+ else if (s == "column-reverse") fd = YGFlexDirectionColumnReverse;
1239
+ else if (s == "row-reverse") fd = YGFlexDirectionRowReverse;
1240
+ }
1241
+ YGNodeStyleSetFlexDirection(&node, fd);
1242
+ }
1243
+ CACHE_SKIP(CS_FLEX_DIRECTION)
1244
+
1245
+ CACHE_CHECK(CS_FLEX_WRAP, flex_wrap)
1246
+ if (!flex_wrap.is_none() && nb::isinstance<nb::str>(flex_wrap)) {
1247
+ PyObject* p = flex_wrap.ptr();
1248
+ YGWrap w = YGWrapNoWrap;
1249
+ if (p == interned::wrap) w = YGWrapWrap;
1250
+ else if (p == interned::wrap_reverse) w = YGWrapWrapReverse;
1251
+ else if (p != interned::nowrap) {
1252
+ std::string s = nb::cast<std::string>(flex_wrap);
1253
+ if (s == "wrap") w = YGWrapWrap;
1254
+ else if (s == "wrap-reverse") w = YGWrapWrapReverse;
1255
+ }
1256
+ YGNodeStyleSetFlexWrap(&node, w);
1257
+ }
1258
+ CACHE_SKIP(CS_FLEX_WRAP)
1259
+
1260
+ CACHE_CHECK(CS_JUSTIFY_CONTENT, justify_content)
1261
+ if (!justify_content.is_none() && nb::isinstance<nb::str>(justify_content)) {
1262
+ PyObject* p = justify_content.ptr();
1263
+ YGJustify j = YGJustifyFlexStart;
1264
+ if (p == interned::flex_end) j = YGJustifyFlexEnd;
1265
+ else if (p == interned::center) j = YGJustifyCenter;
1266
+ else if (p == interned::space_between) j = YGJustifySpaceBetween;
1267
+ else if (p == interned::space_around) j = YGJustifySpaceAround;
1268
+ else if (p == interned::space_evenly) j = YGJustifySpaceEvenly;
1269
+ else if (p != interned::flex_start) {
1270
+ std::string s = nb::cast<std::string>(justify_content);
1271
+ if (s == "flex-end") j = YGJustifyFlexEnd;
1272
+ else if (s == "center") j = YGJustifyCenter;
1273
+ else if (s == "space-between") j = YGJustifySpaceBetween;
1274
+ else if (s == "space-around") j = YGJustifySpaceAround;
1275
+ else if (s == "space-evenly") j = YGJustifySpaceEvenly;
1276
+ }
1277
+ YGNodeStyleSetJustifyContent(&node, j);
1278
+ }
1279
+ CACHE_SKIP(CS_JUSTIFY_CONTENT)
1280
+
1281
+ CACHE_CHECK(CS_ALIGN_ITEMS, align_items)
1282
+ if (!align_items.is_none() && nb::isinstance<nb::str>(align_items)) {
1283
+ PyObject* p = align_items.ptr();
1284
+ YGAlign a = YGAlignStretch;
1285
+ if (p == interned::flex_start) a = YGAlignFlexStart;
1286
+ else if (p == interned::flex_end) a = YGAlignFlexEnd;
1287
+ else if (p == interned::center) a = YGAlignCenter;
1288
+ else if (p == interned::baseline) a = YGAlignBaseline;
1289
+ else if (p == interned::auto_) a = YGAlignAuto;
1290
+ else if (p != interned::stretch) {
1291
+ std::string s = nb::cast<std::string>(align_items);
1292
+ if (s == "flex-start") a = YGAlignFlexStart;
1293
+ else if (s == "flex-end") a = YGAlignFlexEnd;
1294
+ else if (s == "center") a = YGAlignCenter;
1295
+ else if (s == "baseline") a = YGAlignBaseline;
1296
+ else if (s == "auto") a = YGAlignAuto;
1297
+ }
1298
+ YGNodeStyleSetAlignItems(&node, a);
1299
+ }
1300
+ CACHE_SKIP(CS_ALIGN_ITEMS)
1301
+
1302
+ CACHE_CHECK(CS_ALIGN_SELF, align_self)
1303
+ if (!align_self.is_none() && nb::isinstance<nb::str>(align_self)) {
1304
+ PyObject* p = align_self.ptr();
1305
+ YGAlign a = YGAlignAuto;
1306
+ if (p == interned::stretch) a = YGAlignStretch;
1307
+ else if (p == interned::flex_start) a = YGAlignFlexStart;
1308
+ else if (p == interned::flex_end) a = YGAlignFlexEnd;
1309
+ else if (p == interned::center) a = YGAlignCenter;
1310
+ else if (p == interned::baseline) a = YGAlignBaseline;
1311
+ else if (p != interned::auto_) {
1312
+ std::string s = nb::cast<std::string>(align_self);
1313
+ if (s == "stretch") a = YGAlignStretch;
1314
+ else if (s == "flex-start") a = YGAlignFlexStart;
1315
+ else if (s == "flex-end") a = YGAlignFlexEnd;
1316
+ else if (s == "center") a = YGAlignCenter;
1317
+ else if (s == "baseline") a = YGAlignBaseline;
1318
+ }
1319
+ YGNodeStyleSetAlignSelf(&node, a);
1320
+ }
1321
+ CACHE_SKIP(CS_ALIGN_SELF)
1322
+
1323
+ // ── Gap ──
1324
+ CACHE_CHECK(CS_GAP, gap)
1325
+ if (!gap.is_none())
1326
+ YGNodeStyleSetGap(&node, YGGutterAll, nb::cast<float>(gap));
1327
+ CACHE_SKIP(CS_GAP)
1328
+
1329
+ // ── Overflow ──
1330
+ CACHE_CHECK(CS_OVERFLOW, overflow)
1331
+ if (!overflow.is_none() && nb::isinstance<nb::str>(overflow)) {
1332
+ PyObject* p = overflow.ptr();
1333
+ YGOverflow o = YGOverflowVisible;
1334
+ if (p == interned::hidden) o = YGOverflowHidden;
1335
+ else if (p == interned::scroll) o = YGOverflowScroll;
1336
+ else if (p != interned::visible) {
1337
+ std::string s = nb::cast<std::string>(overflow);
1338
+ if (s == "hidden") o = YGOverflowHidden;
1339
+ else if (s == "scroll") o = YGOverflowScroll;
1340
+ }
1341
+ YGNodeStyleSetOverflow(&node, o);
1342
+ }
1343
+ CACHE_SKIP(CS_OVERFLOW)
1344
+
1345
+ // ── Position type ──
1346
+ CACHE_CHECK(CS_POSITION_TYPE, position_type)
1347
+ if (!position_type.is_none() && nb::isinstance<nb::str>(position_type)) {
1348
+ PyObject* p = position_type.ptr();
1349
+ YGPositionType pt = YGPositionTypeRelative;
1350
+ if (p == interned::absolute) pt = YGPositionTypeAbsolute;
1351
+ else if (p == interned::static_) pt = YGPositionTypeStatic;
1352
+ else if (p != interned::relative) {
1353
+ std::string s = nb::cast<std::string>(position_type);
1354
+ if (s == "absolute") pt = YGPositionTypeAbsolute;
1355
+ }
1356
+ YGNodeStyleSetPositionType(&node, pt);
1357
+ }
1358
+ CACHE_SKIP(CS_POSITION_TYPE)
1359
+
1360
+ // ── Padding (always set, caller includes border offset) ──
1361
+ if (padding_top != cache.padding[0]) {
1362
+ cache.padding[0] = padding_top;
1363
+ YGNodeStyleSetPadding(&node, YGEdgeTop, padding_top);
1364
+ }
1365
+ if (padding_right != cache.padding[1]) {
1366
+ cache.padding[1] = padding_right;
1367
+ YGNodeStyleSetPadding(&node, YGEdgeRight, padding_right);
1368
+ }
1369
+ if (padding_bottom != cache.padding[2]) {
1370
+ cache.padding[2] = padding_bottom;
1371
+ YGNodeStyleSetPadding(&node, YGEdgeBottom, padding_bottom);
1372
+ }
1373
+ if (padding_left != cache.padding[3]) {
1374
+ cache.padding[3] = padding_left;
1375
+ YGNodeStyleSetPadding(&node, YGEdgeLeft, padding_left);
1376
+ }
1377
+
1378
+ // ── Margin ──
1379
+ CACHE_CHECK(CS_MARGIN, margin)
1380
+ if (!margin.is_none())
1381
+ YGNodeStyleSetMargin(&node, YGEdgeAll, nb::cast<float>(margin));
1382
+ CACHE_SKIP(CS_MARGIN)
1383
+
1384
+ CACHE_CHECK(CS_MARGIN_TOP, margin_top)
1385
+ if (!margin_top.is_none())
1386
+ YGNodeStyleSetMargin(&node, YGEdgeTop, nb::cast<float>(margin_top));
1387
+ CACHE_SKIP(CS_MARGIN_TOP)
1388
+
1389
+ CACHE_CHECK(CS_MARGIN_RIGHT, margin_right)
1390
+ if (!margin_right.is_none())
1391
+ YGNodeStyleSetMargin(&node, YGEdgeRight, nb::cast<float>(margin_right));
1392
+ CACHE_SKIP(CS_MARGIN_RIGHT)
1393
+
1394
+ CACHE_CHECK(CS_MARGIN_BOTTOM, margin_bottom)
1395
+ if (!margin_bottom.is_none())
1396
+ YGNodeStyleSetMargin(&node, YGEdgeBottom, nb::cast<float>(margin_bottom));
1397
+ CACHE_SKIP(CS_MARGIN_BOTTOM)
1398
+
1399
+ CACHE_CHECK(CS_MARGIN_LEFT, margin_left)
1400
+ if (!margin_left.is_none())
1401
+ YGNodeStyleSetMargin(&node, YGEdgeLeft, nb::cast<float>(margin_left));
1402
+ CACHE_SKIP(CS_MARGIN_LEFT)
1403
+
1404
+ // ── Position edges ──
1405
+ CACHE_CHECK(CS_POS_TOP, pos_top)
1406
+ unit = parse_dim(pos_top, val);
1407
+ if (unit == 1) YGNodeStyleSetPosition(&node, YGEdgeTop, val);
1408
+ else if (unit == 2) YGNodeStyleSetPositionPercent(&node, YGEdgeTop, val);
1409
+ CACHE_SKIP(CS_POS_TOP)
1410
+
1411
+ CACHE_CHECK(CS_POS_RIGHT, pos_right)
1412
+ unit = parse_dim(pos_right, val);
1413
+ if (unit == 1) YGNodeStyleSetPosition(&node, YGEdgeRight, val);
1414
+ else if (unit == 2) YGNodeStyleSetPositionPercent(&node, YGEdgeRight, val);
1415
+ CACHE_SKIP(CS_POS_RIGHT)
1416
+
1417
+ CACHE_CHECK(CS_POS_BOTTOM, pos_bottom)
1418
+ unit = parse_dim(pos_bottom, val);
1419
+ if (unit == 1) YGNodeStyleSetPosition(&node, YGEdgeBottom, val);
1420
+ else if (unit == 2) YGNodeStyleSetPositionPercent(&node, YGEdgeBottom, val);
1421
+ CACHE_SKIP(CS_POS_BOTTOM)
1422
+
1423
+ CACHE_CHECK(CS_POS_LEFT, pos_left)
1424
+ unit = parse_dim(pos_left, val);
1425
+ if (unit == 1) YGNodeStyleSetPosition(&node, YGEdgeLeft, val);
1426
+ else if (unit == 2) YGNodeStyleSetPositionPercent(&node, YGEdgeLeft, val);
1427
+ CACHE_SKIP(CS_POS_LEFT)
1428
+
1429
+ #undef CACHE_CHECK
1430
+ #undef CACHE_SKIP
1431
+ },
1432
+ nb::arg("node"),
1433
+ nb::arg("width") = nb::none(), nb::arg("height") = nb::none(),
1434
+ nb::arg("min_width") = nb::none(), nb::arg("min_height") = nb::none(),
1435
+ nb::arg("max_width") = nb::none(), nb::arg("max_height") = nb::none(),
1436
+ nb::arg("flex_grow") = nb::none(), nb::arg("flex_shrink") = nb::none(),
1437
+ nb::arg("flex_basis") = nb::none(),
1438
+ nb::arg("flex_direction") = nb::none(), nb::arg("flex_wrap") = nb::none(),
1439
+ nb::arg("justify_content") = nb::none(), nb::arg("align_items") = nb::none(),
1440
+ nb::arg("align_self") = nb::none(),
1441
+ nb::arg("gap") = nb::none(), nb::arg("overflow") = nb::none(),
1442
+ nb::arg("position_type") = nb::none(),
1443
+ nb::arg("padding_top") = 0.0f, nb::arg("padding_right") = 0.0f,
1444
+ nb::arg("padding_bottom") = 0.0f, nb::arg("padding_left") = 0.0f,
1445
+ nb::arg("margin") = nb::none(), nb::arg("margin_top") = nb::none(),
1446
+ nb::arg("margin_right") = nb::none(), nb::arg("margin_bottom") = nb::none(),
1447
+ nb::arg("margin_left") = nb::none(),
1448
+ nb::arg("pos_top") = nb::none(), nb::arg("pos_right") = nb::none(),
1449
+ nb::arg("pos_bottom") = nb::none(), nb::arg("pos_left") = nb::none(),
1450
+ "Configure all layout properties on a yoga node in a single C++ call.");
1451
+
1452
+ // Evict a node from the configure_node_fast cache (called on node destruction).
1453
+ m.def("clear_node_cache", [](yoga::Node& node) {
1454
+ node_cache_.erase(&node);
1455
+ }, nb::arg("node"),
1456
+ "Remove a node from the configure_node_fast field cache.");
1457
+
1458
+ // ── apply_layout_tree: walk a widget tree, apply yoga layout, collect deltas ──
1459
+ //
1460
+ // Accelerator for widget frameworks that maintain a tree of Python objects
1461
+ // with yoga nodes attached. Walks the tree in C++, reads yoga layout
1462
+ // results, writes absolute geometry back to Python __slots__, clears dirty
1463
+ // flags, and returns a list of "repaint facts" for nodes whose geometry
1464
+ // changed or were dirty.
1465
+ //
1466
+ // The `offsets` dict maps slot names to byte offsets (from tp_basicsize or
1467
+ // __slots__) so the function can read/write arbitrary Python objects without
1468
+ // knowing their class. Required keys:
1469
+ // _x, _y, _layout_width, _layout_height — int slots for absolute geometry
1470
+ // _dirty, _subtree_dirty — bool slots for dirty flags
1471
+ // _children — list slot for tree traversal
1472
+ // _parent — slot for parent reference
1473
+ // _yoga_node — slot holding the yoga.Node
1474
+ // _on_size_change — optional callable(width, height)
1475
+ //
1476
+ // Returns list of tuples:
1477
+ // (node, parent_id, has_children, was_dirty,
1478
+ // old_x, old_y, old_w, old_h, new_x, new_y, new_w, new_h)
1479
+ m.def("apply_layout_tree", [](nb::object root, nb::dict offsets_dict, int origin_x, int origin_y) {
1480
+ struct Offsets {
1481
+ Py_ssize_t x, y, layout_width, layout_height;
1482
+ Py_ssize_t dirty, subtree_dirty, children, parent, yoga_node;
1483
+ Py_ssize_t on_size_change;
1484
+ } off;
1485
+ auto get_off = [&](const char* name) -> Py_ssize_t {
1486
+ return nb::cast<Py_ssize_t>(offsets_dict[name]);
1487
+ };
1488
+ off.x = get_off("_x");
1489
+ off.y = get_off("_y");
1490
+ off.layout_width = get_off("_layout_width");
1491
+ off.layout_height = get_off("_layout_height");
1492
+ off.dirty = get_off("_dirty");
1493
+ off.subtree_dirty = get_off("_subtree_dirty");
1494
+ off.children = get_off("_children");
1495
+ off.parent = get_off("_parent");
1496
+ off.yoga_node = get_off("_yoga_node");
1497
+ off.on_size_change = get_off("_on_size_change");
1498
+
1499
+ auto read_slot = [](PyObject* obj, Py_ssize_t offset) -> PyObject* {
1500
+ return offset < 0 ? nullptr : *(PyObject**)((char*)obj + offset);
1501
+ };
1502
+
1503
+ auto write_slot = [](PyObject* obj, Py_ssize_t offset, PyObject* new_val) {
1504
+ PyObject** slot = (PyObject**)((char*)obj + offset);
1505
+ PyObject* old = *slot;
1506
+ Py_INCREF(new_val);
1507
+ *slot = new_val;
1508
+ Py_XDECREF(old);
1509
+ };
1510
+
1511
+ auto read_long_slot = [&](PyObject* obj, Py_ssize_t offset) -> int {
1512
+ if (offset < 0) return 0;
1513
+ PyObject* value = read_slot(obj, offset);
1514
+ if (!value || value == Py_None) return 0;
1515
+ long result = PyLong_AsLong(value);
1516
+ if (PyErr_Occurred()) {
1517
+ PyErr_Clear();
1518
+ return 0;
1519
+ }
1520
+ return static_cast<int>(result);
1521
+ };
1522
+
1523
+ nb::list facts;
1524
+ struct Walker {
1525
+ Offsets& off;
1526
+ decltype(read_slot)& read;
1527
+ decltype(write_slot)& write;
1528
+ decltype(read_long_slot)& read_long;
1529
+ nb::list& facts;
1530
+
1531
+ void walk(PyObject* node, int parent_x, int parent_y) {
1532
+ PyObject* yoga_py = read(node, off.yoga_node);
1533
+ if (!yoga_py || yoga_py == Py_None) return;
1534
+
1535
+ yoga::Node* yoga_node;
1536
+ try {
1537
+ yoga_node = nb::cast<yoga::Node*>(nb::handle(yoga_py));
1538
+ } catch (...) {
1539
+ return;
1540
+ }
1541
+
1542
+ int lx = static_cast<int>(YGNodeLayoutGetLeft(yoga_node));
1543
+ int ly = static_cast<int>(YGNodeLayoutGetTop(yoga_node));
1544
+ int lw = static_cast<int>(YGNodeLayoutGetWidth(yoga_node));
1545
+ int lh = static_cast<int>(YGNodeLayoutGetHeight(yoga_node));
1546
+
1547
+ const int old_x = read_long(node, off.x);
1548
+ const int old_y = read_long(node, off.y);
1549
+ const int old_w = read_long(node, off.layout_width);
1550
+ const int old_h = read_long(node, off.layout_height);
1551
+ const bool was_dirty = off.dirty >= 0 && read(node, off.dirty) == Py_True;
1552
+
1553
+ PyObject* old_w_obj = read(node, off.layout_width);
1554
+ PyObject* old_h_obj = read(node, off.layout_height);
1555
+ Py_XINCREF(old_w_obj);
1556
+ Py_XINCREF(old_h_obj);
1557
+
1558
+ const int abs_x = lx + parent_x;
1559
+ const int abs_y = ly + parent_y;
1560
+ const bool geom_changed = old_x != abs_x || old_y != abs_y || old_w != lw || old_h != lh;
1561
+
1562
+ if (geom_changed) {
1563
+ PyObject* py_x = PyLong_FromLong(abs_x);
1564
+ PyObject* py_y = PyLong_FromLong(abs_y);
1565
+ PyObject* py_w = PyLong_FromLong(lw);
1566
+ PyObject* py_h = PyLong_FromLong(lh);
1567
+ write(node, off.x, py_x);
1568
+ write(node, off.y, py_y);
1569
+ write(node, off.layout_width, py_w);
1570
+ write(node, off.layout_height, py_h);
1571
+ Py_DECREF(py_x);
1572
+ Py_DECREF(py_y);
1573
+ Py_DECREF(py_w);
1574
+ Py_DECREF(py_h);
1575
+ }
1576
+ if (was_dirty) write(node, off.dirty, Py_False);
1577
+ if (off.subtree_dirty >= 0 && read(node, off.subtree_dirty) == Py_True) {
1578
+ write(node, off.subtree_dirty, Py_False);
1579
+ }
1580
+
1581
+ PyObject* children = read(node, off.children);
1582
+ const bool has_children =
1583
+ children && PyList_Check(children) && PyList_GET_SIZE(children) > 0;
1584
+ if (was_dirty || geom_changed) {
1585
+ PyObject* parent = read(node, off.parent);
1586
+ facts.append(nb::make_tuple(
1587
+ nb::borrow<nb::object>(node),
1588
+ parent && parent != Py_None ? reinterpret_cast<uintptr_t>(parent) : 0,
1589
+ has_children,
1590
+ was_dirty,
1591
+ old_x,
1592
+ old_y,
1593
+ old_w,
1594
+ old_h,
1595
+ abs_x,
1596
+ abs_y,
1597
+ lw,
1598
+ lh
1599
+ ));
1600
+ }
1601
+
1602
+ PyObject* size_cb = off.on_size_change >= 0 ? read(node, off.on_size_change) : nullptr;
1603
+ if (size_cb && size_cb != Py_None) {
1604
+ const bool w_changed = !old_w_obj || PyLong_AsLong(old_w_obj) != lw;
1605
+ const bool h_changed = !old_h_obj || PyLong_AsLong(old_h_obj) != lh;
1606
+ if (w_changed || h_changed) {
1607
+ PyObject* result = PyObject_CallFunction(size_cb, "ii", lw, lh);
1608
+ if (result) Py_DECREF(result);
1609
+ else PyErr_Clear();
1610
+ }
1611
+ }
1612
+ Py_XDECREF(old_w_obj);
1613
+ Py_XDECREF(old_h_obj);
1614
+
1615
+ if (has_children) {
1616
+ Py_ssize_t n = PyList_GET_SIZE(children);
1617
+ for (Py_ssize_t i = 0; i < n; ++i) {
1618
+ walk(PyList_GET_ITEM(children, i), abs_x, abs_y);
1619
+ }
1620
+ }
1621
+ }
1622
+ };
1623
+
1624
+ Walker walker{off, read_slot, write_slot, read_long_slot, facts};
1625
+ walker.walk(root.ptr(), origin_x, origin_y);
1626
+ return facts;
1627
+ }, nb::arg("root"), nb::arg("offsets"), nb::arg("origin_x") = 0, nb::arg("origin_y") = 0,
1628
+ "Walk a widget tree in C++, apply yoga layout results to Python __slots__,\n"
1629
+ "clear dirty flags, and return repaint facts for changed nodes.\n\n"
1630
+ "The offsets dict maps slot names (_x, _y, _layout_width, _layout_height,\n"
1631
+ "_dirty, _subtree_dirty, _children, _parent, _yoga_node, _on_size_change)\n"
1632
+ "to byte offsets so arbitrary Python objects can be read/written by slot.");
1633
+
1634
+ // ── get_layout_batch: read all 4 layout results in one C++ call ──
1635
+ //
1636
+ // Returns (left, top, width, height) as integers, avoiding 4 separate
1637
+ // Python→C++ property getter round-trips.
1638
+ m.def("get_layout_batch", [](yoga::Node& node) -> nb::tuple {
1639
+ return nb::make_tuple(
1640
+ static_cast<int>(YGNodeLayoutGetLeft(&node)),
1641
+ static_cast<int>(YGNodeLayoutGetTop(&node)),
1642
+ static_cast<int>(YGNodeLayoutGetWidth(&node)),
1643
+ static_cast<int>(YGNodeLayoutGetHeight(&node))
1644
+ );
1645
+ }, nb::arg("node"),
1646
+ "Get (left, top, width, height) layout results as integers in one call.");
1647
+
1000
1648
  // Clean up all Python-side resources during module teardown.
1001
1649
  // Using a capsule ensures cleanup runs when the module dict is cleared,
1002
1650
  // before nanobind's leak checker fires.
@@ -1017,5 +1665,6 @@ NB_MODULE(yoga, m) {
1017
1665
  allCloneContexts.clear();
1018
1666
 
1019
1667
  nanobindManagedNodes.clear();
1668
+ node_cache_.clear();
1020
1669
  });
1021
1670
  }
@@ -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.2"
223
223
  source = { editable = "." }
224
224
 
225
225
  [package.optional-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes