pythonnative 0.20.0__py3-none-any.whl → 0.22.0__py3-none-any.whl

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 (33) hide show
  1. pythonnative/__init__.py +14 -3
  2. pythonnative/animated.py +420 -135
  3. pythonnative/cli/pn.py +450 -956
  4. pythonnative/components.py +519 -235
  5. pythonnative/events.py +210 -0
  6. pythonnative/gestures.py +875 -0
  7. pythonnative/layout.py +463 -149
  8. pythonnative/mutations.py +130 -0
  9. pythonnative/native_views/__init__.py +161 -97
  10. pythonnative/native_views/android.py +1050 -1124
  11. pythonnative/native_views/base.py +108 -18
  12. pythonnative/native_views/desktop.py +460 -417
  13. pythonnative/native_views/ios.py +1918 -1916
  14. pythonnative/project/__init__.py +68 -0
  15. pythonnative/project/android.py +504 -0
  16. pythonnative/project/builder.py +555 -0
  17. pythonnative/project/config.py +642 -0
  18. pythonnative/project/doctor.py +233 -0
  19. pythonnative/project/icons.py +247 -0
  20. pythonnative/project/ios.py +344 -0
  21. pythonnative/project/permissions.py +343 -0
  22. pythonnative/project/runtime_assets.py +272 -0
  23. pythonnative/reconciler.py +540 -470
  24. pythonnative/screen.py +5 -2
  25. pythonnative/sdk/_components.py +2 -2
  26. pythonnative/templates/android_template/app/build.gradle +2 -0
  27. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
  28. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
  29. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
  30. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
  31. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
  32. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
  33. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
pythonnative/layout.py CHANGED
@@ -1,23 +1,23 @@
1
1
  """Pure-Python flexbox layout engine.
2
2
 
3
- Computes absolute positions and sizes for every node in a layout tree
4
- based on CSS flexbox-inspired style properties. Inspired by Facebook's
5
- Yoga and React Native's layout system, but implemented entirely in
6
- Python so PythonNative does not depend on a native layout library.
3
+ Computes positions and sizes for every node in a layout tree based on
4
+ CSS flexbox-inspired style properties. Inspired by Facebook's Yoga and
5
+ React Native's layout system, but implemented entirely in Python so
6
+ PythonNative does not depend on a native layout library.
7
7
 
8
8
  The engine is invoked by the reconciler after each commit pass:
9
9
 
10
- 1. The reconciler walks the committed VNode tree and builds a parallel
11
- [`LayoutNode`][pythonnative.layout.LayoutNode] tree, copying the
12
- layout-relevant style props onto each node and attaching a
13
- ``measure`` callback to leaves whose natural size depends on their
14
- content (text, images).
10
+ 1. The reconciler maintains a parallel
11
+ [`LayoutNode`][pythonnative.layout.LayoutNode] tree (cached across
12
+ passes clean subtrees keep their nodes, dirty ones are rebuilt).
15
13
  2. [`calculate_layout`][pythonnative.layout.calculate_layout] is called
16
14
  with the viewport size; it recursively determines each node's
17
15
  ``(x, y, width, height)`` relative to its parent's coordinate space.
18
- 3. The reconciler walks the tree again and applies each computed frame
19
- to the corresponding native view via the backend's
20
- ``set_frame`` method.
16
+ Nodes that are **not dirty** and are measured under the same
17
+ constraints as the previous pass return their memoized size without
18
+ recursing, which turns full-tree layout into dirty-subtree layout.
19
+ 3. The reconciler walks the tree again and emits a frame op for each
20
+ native view whose frame actually changed.
21
21
 
22
22
  The algorithm supports:
23
23
 
@@ -26,6 +26,12 @@ The algorithm supports:
26
26
  ``flex_end`` / ``space_between`` / ``space_around`` / ``space_evenly``),
27
27
  ``align_items`` (``stretch`` / ``flex_start`` / ``center`` /
28
28
  ``flex_end``), and ``align_self`` overrides per child.
29
+ - **Wrapping**: ``flex_wrap`` (``nowrap`` / ``wrap`` / ``wrap_reverse``)
30
+ with ``align_content`` controlling how lines share leftover
31
+ cross-axis space (``stretch`` default, plus the justify palette).
32
+ - **Direction**: ``direction: "rtl"`` flips row layouts, ``start`` /
33
+ ``end`` edge keys (``margin_start``, ``padding_end``, absolute
34
+ ``start`` / ``end`` insets) resolve against the inherited direction.
29
35
  - **Sizing**: explicit ``width`` / ``height`` (numbers or percentages),
30
36
  ``min_width`` / ``max_width`` / ``min_height`` / ``max_height``
31
37
  constraints, ``aspect_ratio``, and content-based sizing via the
@@ -33,13 +39,12 @@ The algorithm supports:
33
39
  - **Flex distribution**: ``flex`` (RN shorthand for grow factor with
34
40
  ``flex_basis: 0``), ``flex_grow``, ``flex_shrink``, ``flex_basis``.
35
41
  - **Absolute positioning**: ``position: "absolute"`` with ``top``,
36
- ``right``, ``bottom``, ``left`` insets (numbers or percentages).
42
+ ``right``, ``bottom``, ``left`` (and ``start`` / ``end``) insets.
37
43
  Absolute children are positioned relative to the parent's padding box
38
44
  and do not participate in flex distribution.
39
- - **Spacing**: ``padding`` (scalar, dict with ``horizontal`` /
40
- ``vertical`` / ``all`` / per-edge keys, or per-edge keys directly),
41
- ``margin`` (same shape as ``padding``), and inter-child ``spacing``
42
- (alias: ``gap``).
45
+ - **Spacing**: ``padding`` / ``margin`` (scalar, dict, or per-edge
46
+ keys), inter-child ``spacing`` (aliases: ``gap``, ``column_gap`` /
47
+ ``row_gap`` per axis).
43
48
 
44
49
  Example:
45
50
  ```python
@@ -79,17 +84,23 @@ LAYOUT_STYLE_KEYS = frozenset(
79
84
  "flex_grow",
80
85
  "flex_shrink",
81
86
  "flex_basis",
87
+ "flex_wrap",
82
88
  "align_self",
89
+ "align_content",
83
90
  "position",
84
91
  "top",
85
92
  "right",
86
93
  "bottom",
87
94
  "left",
95
+ "start",
96
+ "end",
88
97
  "margin",
89
98
  "margin_top",
90
99
  "margin_bottom",
91
100
  "margin_left",
92
101
  "margin_right",
102
+ "margin_start",
103
+ "margin_end",
93
104
  "margin_horizontal",
94
105
  "margin_vertical",
95
106
  "padding",
@@ -97,6 +108,8 @@ LAYOUT_STYLE_KEYS = frozenset(
97
108
  "padding_bottom",
98
109
  "padding_left",
99
110
  "padding_right",
111
+ "padding_start",
112
+ "padding_end",
100
113
  "padding_horizontal",
101
114
  "padding_vertical",
102
115
  "flex_direction",
@@ -104,7 +117,10 @@ LAYOUT_STYLE_KEYS = frozenset(
104
117
  "align_items",
105
118
  "spacing",
106
119
  "gap",
120
+ "row_gap",
121
+ "column_gap",
107
122
  "aspect_ratio",
123
+ "direction",
108
124
  }
109
125
  )
110
126
  """Style keys that affect layout (and are consumed by the layout engine)."""
@@ -115,7 +131,12 @@ FLEX_DIRECTION_COLUMN_REVERSE = "column_reverse"
115
131
  FLEX_DIRECTION_ROW = "row"
116
132
  FLEX_DIRECTION_ROW_REVERSE = "row_reverse"
117
133
 
118
- # justify_content values
134
+ # flex_wrap values
135
+ WRAP_NOWRAP = "nowrap"
136
+ WRAP_WRAP = "wrap"
137
+ WRAP_REVERSE = "wrap_reverse"
138
+
139
+ # justify_content / align_content values
119
140
  JUSTIFY_FLEX_START = "flex_start"
120
141
  JUSTIFY_CENTER = "center"
121
142
  JUSTIFY_FLEX_END = "flex_end"
@@ -134,6 +155,10 @@ ALIGN_STRETCH = "stretch"
134
155
  POSITION_RELATIVE = "relative"
135
156
  POSITION_ABSOLUTE = "absolute"
136
157
 
158
+ # direction values
159
+ DIRECTION_LTR = "ltr"
160
+ DIRECTION_RTL = "rtl"
161
+
137
162
  # Friendly aliases on cross-axis alignment props.
138
163
  _ALIGN_ALIASES = {
139
164
  "start": ALIGN_FLEX_START,
@@ -256,8 +281,14 @@ def _resolve_padding_for(
256
281
  parent_w: float,
257
282
  parent_h: float,
258
283
  prefix: str,
284
+ direction: str = DIRECTION_LTR,
259
285
  ) -> Tuple[float, float, float, float]:
260
- """Resolve padding/margin from `style`, honoring per-edge overrides."""
286
+ """Resolve padding/margin from `style`, honoring per-edge overrides.
287
+
288
+ ``{prefix}_start`` / ``{prefix}_end`` resolve to the left/right
289
+ edge according to ``direction`` and take precedence over the
290
+ physical ``left`` / ``right`` keys (matching React Native).
291
+ """
261
292
  base_l, base_t, base_r, base_b = _padding_edges(style.get(prefix), parent_w, parent_h)
262
293
 
263
294
  h_override = _resolve_value(style.get(f"{prefix}_horizontal"), parent_w)
@@ -281,6 +312,19 @@ def _resolve_padding_for(
281
312
  base_t = top
282
313
  if bottom is not None:
283
314
  base_b = bottom
315
+
316
+ start = _resolve_value(style.get(f"{prefix}_start"), parent_w)
317
+ end = _resolve_value(style.get(f"{prefix}_end"), parent_w)
318
+ if start is not None:
319
+ if direction == DIRECTION_RTL:
320
+ base_r = start
321
+ else:
322
+ base_l = start
323
+ if end is not None:
324
+ if direction == DIRECTION_RTL:
325
+ base_l = end
326
+ else:
327
+ base_r = end
284
328
  return (base_l, base_t, base_r, base_b)
285
329
 
286
330
 
@@ -362,13 +406,50 @@ def _resolve_align(value: Any, default: str = ALIGN_STRETCH) -> str:
362
406
 
363
407
 
364
408
  def _resolve_justify(value: Any) -> str:
365
- """Normalize a `justify_content` value, applying aliases."""
409
+ """Normalize a `justify_content` / `align_content` value, applying aliases."""
366
410
  if value is None:
367
411
  return JUSTIFY_FLEX_START
368
412
  s = str(value)
369
413
  return _JUSTIFY_ALIASES.get(s, s)
370
414
 
371
415
 
416
+ def _resolve_gaps(style: Dict[str, Any], is_row: bool) -> Tuple[float, float]:
417
+ """Return ``(main_gap, cross_gap)`` for a container.
418
+
419
+ ``column_gap`` is the horizontal gap and ``row_gap`` the vertical
420
+ gap (CSS semantics); ``spacing`` / ``gap`` set the main-axis gap
421
+ when the specific key is absent.
422
+ """
423
+ generic = _to_float(style.get("spacing"))
424
+ if generic is None:
425
+ generic = _to_float(style.get("gap"))
426
+ col = _to_float(style.get("column_gap"))
427
+ row = _to_float(style.get("row_gap"))
428
+ if is_row:
429
+ main = col if col is not None else generic
430
+ cross = row
431
+ else:
432
+ main = row if row is not None else generic
433
+ cross = col
434
+ return (main or 0.0, cross or 0.0)
435
+
436
+
437
+ def _wrap_mode(style: Dict[str, Any]) -> str:
438
+ mode = style.get("flex_wrap")
439
+ if mode in (WRAP_WRAP, WRAP_REVERSE):
440
+ return str(mode)
441
+ if mode == "wrap-reverse":
442
+ return WRAP_REVERSE
443
+ return WRAP_NOWRAP
444
+
445
+
446
+ def _resolve_direction(style: Dict[str, Any], inherited: str) -> str:
447
+ own = style.get("direction")
448
+ if own in (DIRECTION_LTR, DIRECTION_RTL):
449
+ return str(own)
450
+ return inherited
451
+
452
+
372
453
  # ======================================================================
373
454
  # LayoutNode
374
455
  # ======================================================================
@@ -393,6 +474,9 @@ class LayoutNode:
393
474
  user_data: Free-form attribute the caller may use to associate
394
475
  each layout node with the corresponding native view; the
395
476
  engine itself does not inspect it.
477
+ dirty: When ``False``, the node may serve repeat measurements
478
+ from its memo (set by the reconciler's incremental-layout
479
+ cache). Fresh nodes start dirty.
396
480
  x: Computed x-coordinate relative to the parent's coordinate
397
481
  space.
398
482
  y: Computed y-coordinate relative to the parent's coordinate
@@ -406,11 +490,15 @@ class LayoutNode:
406
490
  "children",
407
491
  "measure",
408
492
  "user_data",
493
+ "dirty",
409
494
  "x",
410
495
  "y",
411
496
  "width",
412
497
  "height",
413
498
  "_pn_scroll_axis",
499
+ "_measure_memo",
500
+ "_lines",
501
+ "_direction",
414
502
  )
415
503
 
416
504
  def __init__(
@@ -424,6 +512,7 @@ class LayoutNode:
424
512
  self.children = list(children) if children else []
425
513
  self.measure = measure
426
514
  self.user_data = user_data
515
+ self.dirty: bool = True
427
516
  self.x: float = 0.0
428
517
  self.y: float = 0.0
429
518
  self.width: float = 0.0
@@ -436,6 +525,16 @@ class LayoutNode:
436
525
  # actually scroll). The reconciler stamps this when building the
437
526
  # layout tree for ``ScrollView`` elements.
438
527
  self._pn_scroll_axis: Optional[str] = None
528
+ # Measurement memo: ``(avail_w, avail_h, forced_w, forced_h,
529
+ # direction, out_w, out_h)`` from the most recent measurement.
530
+ # Served when the node is clean (``dirty == False``) and the
531
+ # inputs match, skipping the entire subtree's flex math.
532
+ self._measure_memo: Optional[Tuple[float, float, Optional[float], Optional[float], str, float, float]] = None
533
+ # Flex lines computed during measurement, consumed by the
534
+ # positioning pass (single-line containers store one line).
535
+ self._lines: Optional[List["_FlexLine"]] = None
536
+ # Resolved direction ("ltr" / "rtl") inherited at measure time.
537
+ self._direction: str = DIRECTION_LTR
439
538
 
440
539
  def __repr__(self) -> str:
441
540
  return (
@@ -445,12 +544,28 @@ class LayoutNode:
445
544
  )
446
545
 
447
546
 
547
+ class _FlexLine:
548
+ """One row/column of children produced by line partitioning."""
549
+
550
+ __slots__ = ("children", "main_used", "cross_size")
551
+
552
+ def __init__(self, children: List[LayoutNode]) -> None:
553
+ self.children = children
554
+ self.main_used: float = 0.0
555
+ self.cross_size: float = 0.0
556
+
557
+
448
558
  # ======================================================================
449
559
  # Public entry point
450
560
  # ======================================================================
451
561
 
452
562
 
453
- def calculate_layout(node: LayoutNode, available_width: float, available_height: float) -> None:
563
+ def calculate_layout(
564
+ node: LayoutNode,
565
+ available_width: float,
566
+ available_height: float,
567
+ direction: str = DIRECTION_LTR,
568
+ ) -> None:
454
569
  """Compute layout for `node` and all descendants, in place.
455
570
 
456
571
  Sizes the root from its own style and content. Callers that want
@@ -465,8 +580,10 @@ def calculate_layout(node: LayoutNode, available_width: float, available_height:
465
580
  ``math.inf`` for unbounded (e.g., horizontal scroll).
466
581
  available_height: Available height in points. Pass
467
582
  ``math.inf`` for unbounded (e.g., vertical scroll).
583
+ direction: Base writing direction (``"ltr"`` or ``"rtl"``)
584
+ inherited by nodes that don't set their own.
468
585
  """
469
- _measure_node(node, available_width, available_height)
586
+ _measure_node(node, available_width, available_height, direction=direction)
470
587
  node.x = 0.0
471
588
  node.y = 0.0
472
589
  _position_children(node)
@@ -483,6 +600,7 @@ def _measure_node(
483
600
  avail_h: float,
484
601
  forced_w: Optional[float] = None,
485
602
  forced_h: Optional[float] = None,
603
+ direction: str = DIRECTION_LTR,
486
604
  ) -> None:
487
605
  """Compute ``node.width`` / ``node.height`` and recursively size children.
488
606
 
@@ -494,8 +612,29 @@ def _measure_node(
494
612
  content. Used by parents to enforce flex distribution or
495
613
  cross-axis stretch.
496
614
  forced_h: As ``forced_w`` but for height.
615
+ direction: Writing direction inherited from the parent.
497
616
  """
498
617
  style = node.style
618
+ resolved_direction = _resolve_direction(style, direction)
619
+
620
+ # Incremental-layout memo: a clean node measured under identical
621
+ # inputs reuses its previous result without recursing — its whole
622
+ # subtree keeps the sizes from the prior pass.
623
+ memo = node._measure_memo
624
+ if (
625
+ memo is not None
626
+ and not node.dirty
627
+ and memo[0] == avail_w
628
+ and memo[1] == avail_h
629
+ and memo[2] == forced_w
630
+ and memo[3] == forced_h
631
+ and memo[4] == resolved_direction
632
+ ):
633
+ node.width = memo[5]
634
+ node.height = memo[6]
635
+ return
636
+
637
+ node._direction = resolved_direction
499
638
 
500
639
  explicit_w = forced_w if forced_w is not None else _resolve_value(style.get("width"), avail_w)
501
640
  explicit_h = forced_h if forced_h is not None else _resolve_value(style.get("height"), avail_h)
@@ -531,6 +670,7 @@ def _measure_node(
531
670
 
532
671
  node.width = max(width, 0.0)
533
672
  node.height = max(height, 0.0)
673
+ node._measure_memo = (avail_w, avail_h, forced_w, forced_h, resolved_direction, node.width, node.height)
534
674
 
535
675
 
536
676
  def _measure_leaf(
@@ -566,9 +706,10 @@ def _measure_container(
566
706
  ) -> Tuple[float, float]:
567
707
  """Layout flex children and determine the container's own size."""
568
708
  style = node.style
709
+ direction = node._direction
569
710
  base_w = explicit_w if explicit_w is not None else avail_w
570
711
  base_h = explicit_h if explicit_h is not None else avail_h
571
- pad_l, pad_t, pad_r, pad_b = _resolve_padding_for(style, base_w, base_h, "padding")
712
+ pad_l, pad_t, pad_r, pad_b = _resolve_padding_for(style, base_w, base_h, "padding", direction)
572
713
  pad_x = pad_l + pad_r
573
714
  pad_y = pad_t + pad_b
574
715
 
@@ -626,15 +767,36 @@ def _child_cross_size(child: LayoutNode, is_row: bool) -> float:
626
767
  return child.height if is_row else child.width
627
768
 
628
769
 
629
- def _child_outer_main(child: LayoutNode, is_row: bool, parent_w: float, parent_h: float) -> float:
770
+ def _child_margins(
771
+ child: LayoutNode,
772
+ parent_w: float,
773
+ parent_h: float,
774
+ direction: str,
775
+ ) -> Tuple[float, float, float, float]:
776
+ return _resolve_padding_for(child.style, parent_w, parent_h, "margin", direction)
777
+
778
+
779
+ def _child_outer_main(
780
+ child: LayoutNode,
781
+ is_row: bool,
782
+ parent_w: float,
783
+ parent_h: float,
784
+ direction: str,
785
+ ) -> float:
630
786
  """Main-axis extent including margins."""
631
- margins = _resolve_padding_for(child.style, parent_w, parent_h, "margin")
787
+ margins = _child_margins(child, parent_w, parent_h, direction)
632
788
  margin_main = (margins[0] + margins[2]) if is_row else (margins[1] + margins[3])
633
789
  return _child_main_size(child, is_row) + margin_main
634
790
 
635
791
 
636
- def _child_outer_cross(child: LayoutNode, is_row: bool, parent_w: float, parent_h: float) -> float:
637
- margins = _resolve_padding_for(child.style, parent_w, parent_h, "margin")
792
+ def _child_outer_cross(
793
+ child: LayoutNode,
794
+ is_row: bool,
795
+ parent_w: float,
796
+ parent_h: float,
797
+ direction: str,
798
+ ) -> float:
799
+ margins = _child_margins(child, parent_w, parent_h, direction)
638
800
  margin_cross = (margins[1] + margins[3]) if is_row else (margins[0] + margins[2])
639
801
  return _child_cross_size(child, is_row) + margin_cross
640
802
 
@@ -646,15 +808,16 @@ def _measure_child_flexed(
646
808
  cross_force: Optional[float],
647
809
  is_row: bool,
648
810
  main_bounded: bool,
811
+ direction: str,
649
812
  ) -> None:
650
813
  """Re-measure a child with optional forced main-axis size and cross hint."""
651
814
  fallback_main = math.inf if not main_bounded else cross_avail
652
815
  if is_row:
653
816
  avail_w = main_size if main_size is not None else fallback_main
654
- _measure_node(child, avail_w, cross_avail, forced_w=main_size, forced_h=cross_force)
817
+ _measure_node(child, avail_w, cross_avail, forced_w=main_size, forced_h=cross_force, direction=direction)
655
818
  else:
656
819
  avail_h = main_size if main_size is not None else fallback_main
657
- _measure_node(child, cross_avail, avail_h, forced_w=cross_force, forced_h=main_size)
820
+ _measure_node(child, cross_avail, avail_h, forced_w=cross_force, forced_h=main_size, direction=direction)
658
821
 
659
822
 
660
823
  def _resolve_cross_force(
@@ -663,6 +826,7 @@ def _resolve_cross_force(
663
826
  cross_avail: float,
664
827
  cross_bounded: bool,
665
828
  is_row: bool,
829
+ direction: str,
666
830
  ) -> Optional[float]:
667
831
  """Compute the cross-axis size to force on a child, or ``None`` to let it size naturally.
668
832
 
@@ -680,7 +844,7 @@ def _resolve_cross_force(
680
844
  cross_key = "height" if is_row else "width"
681
845
  if cross_key in child.style and child.style.get(cross_key) is not None:
682
846
  return None
683
- margins = _resolve_padding_for(child.style, cross_avail, cross_avail, "margin")
847
+ margins = _child_margins(child, cross_avail, cross_avail, direction)
684
848
  margin_cross = (margins[1] + margins[3]) if is_row else (margins[0] + margins[2])
685
849
  return max(0.0, cross_avail - margin_cross)
686
850
 
@@ -694,21 +858,26 @@ def _layout_flex_children(
694
858
  ) -> Tuple[float, float]:
695
859
  """Layout the in-flow children of `parent` along the flex axes.
696
860
 
861
+ Children are measured, partitioned into flex lines (one line unless
862
+ ``flex_wrap`` is enabled), grown/shrunk per line, and stretched to
863
+ their line's cross size. The computed line structure is stored on
864
+ ``parent._lines`` for the positioning pass.
865
+
697
866
  Returns ``(used_main, used_cross)`` — the total content size used
698
- by the in-flow children, including inter-child spacing but
699
- excluding the parent's own padding. The caller adds padding back
700
- in for the container's outer size.
867
+ by the in-flow children, including inter-child gaps but excluding
868
+ the parent's own padding. The caller adds padding back in for the
869
+ container's outer size.
701
870
  """
702
871
  style = parent.style
703
- direction = style.get("flex_direction", FLEX_DIRECTION_COLUMN)
704
- is_row = _is_row(direction)
872
+ flex_direction = style.get("flex_direction", FLEX_DIRECTION_COLUMN)
873
+ is_row = _is_row(flex_direction)
874
+ direction = parent._direction
705
875
 
706
876
  main_avail = content_w if is_row else content_h
707
877
  cross_avail = content_h if is_row else content_w
708
- spacing_v = _to_float(style.get("spacing"))
709
- if spacing_v is None:
710
- spacing_v = _to_float(style.get("gap"))
711
- spacing = spacing_v or 0.0
878
+ main_gap, cross_gap = _resolve_gaps(style, is_row)
879
+ wrap = _wrap_mode(style)
880
+ wrapping = wrap != WRAP_NOWRAP and main_bounded and math.isfinite(main_avail)
712
881
 
713
882
  in_flow: List[LayoutNode] = []
714
883
  absolute: List[LayoutNode] = []
@@ -720,87 +889,170 @@ def _layout_flex_children(
720
889
 
721
890
  align_items = _resolve_align(style.get("align_items"), default=ALIGN_STRETCH)
722
891
 
723
- flex_total = 0.0
724
- flex_entries: List[Tuple[LayoutNode, float, Optional[float]]] = []
725
-
892
+ # ------------------------------------------------------------------
893
+ # Pass 1: initial measurement (basis for grow children, natural
894
+ # size otherwise). Single-line containers stretch against the full
895
+ # cross axis here (matching the nowrap fast path); wrapping
896
+ # containers defer stretching until line cross sizes are known.
897
+ # ------------------------------------------------------------------
726
898
  for child in in_flow:
727
899
  grow = _flex_grow(child.style)
728
900
  basis = _flex_basis(child.style, main_avail)
729
- cross_force = _resolve_cross_force(child, align_items, cross_avail, cross_bounded, is_row)
901
+ cross_force = (
902
+ None
903
+ if wrapping
904
+ else _resolve_cross_force(child, align_items, cross_avail, cross_bounded, is_row, direction)
905
+ )
730
906
 
731
907
  if grow > 0:
732
908
  initial_main = basis if basis is not None else 0.0
733
- _measure_child_flexed(child, initial_main, cross_avail, cross_force, is_row, main_bounded)
734
- flex_total += grow
735
- flex_entries.append((child, grow, basis))
909
+ _measure_child_flexed(child, initial_main, cross_avail, cross_force, is_row, main_bounded, direction)
736
910
  elif basis is not None:
737
- _measure_child_flexed(child, basis, cross_avail, cross_force, is_row, main_bounded)
911
+ _measure_child_flexed(child, basis, cross_avail, cross_force, is_row, main_bounded, direction)
738
912
  else:
739
913
  avail_for_child_main = math.inf if not main_bounded else main_avail
740
914
  if is_row:
741
- _measure_node(child, avail_for_child_main, cross_avail, forced_h=cross_force)
915
+ _measure_node(child, avail_for_child_main, cross_avail, forced_h=cross_force, direction=direction)
742
916
  else:
743
- _measure_node(child, cross_avail, avail_for_child_main, forced_w=cross_force)
917
+ _measure_node(child, cross_avail, avail_for_child_main, forced_w=cross_force, direction=direction)
918
+
919
+ # ------------------------------------------------------------------
920
+ # Pass 2: partition children into flex lines.
921
+ # ------------------------------------------------------------------
922
+ lines: List[_FlexLine] = []
923
+ if wrapping:
924
+ current: List[LayoutNode] = []
925
+ current_main = 0.0
926
+ for child in in_flow:
927
+ outer = _child_outer_main(child, is_row, content_w, content_h, direction)
928
+ extra = outer if not current else outer + main_gap
929
+ if current and current_main + extra > main_avail + 1e-9:
930
+ lines.append(_FlexLine(current))
931
+ current = [child]
932
+ current_main = outer
933
+ else:
934
+ current.append(child)
935
+ current_main += extra
936
+ if current:
937
+ lines.append(_FlexLine(current))
938
+ elif in_flow:
939
+ lines.append(_FlexLine(list(in_flow)))
940
+
941
+ # ------------------------------------------------------------------
942
+ # Pass 3: per-line grow / shrink along the main axis.
943
+ # ------------------------------------------------------------------
944
+ for line in lines:
945
+ flex_total = 0.0
946
+ fixed_main_total = 0.0
947
+ flex_basis_total = 0.0
948
+ flex_entries: List[Tuple[LayoutNode, float, Optional[float]]] = []
949
+ for child in line.children:
950
+ grow = _flex_grow(child.style)
951
+ if grow > 0:
952
+ basis = _flex_basis(child.style, main_avail) or 0.0
953
+ margins = _child_margins(child, content_w, content_h, direction)
954
+ margin_main = (margins[0] + margins[2]) if is_row else (margins[1] + margins[3])
955
+ flex_total += grow
956
+ flex_basis_total += basis + margin_main
957
+ flex_entries.append((child, grow, basis))
958
+ else:
959
+ fixed_main_total += _child_outer_main(child, is_row, content_w, content_h, direction)
960
+ if len(line.children) > 1:
961
+ fixed_main_total += main_gap * (len(line.children) - 1)
962
+
963
+ if flex_total > 0 and main_bounded and math.isfinite(main_avail):
964
+ remaining = max(0.0, main_avail - fixed_main_total - flex_basis_total)
965
+ for child, grow, basis in flex_entries:
966
+ extra = (grow / flex_total) * remaining
967
+ child_main = (basis or 0.0) + extra
968
+ cross_force = (
969
+ None
970
+ if wrapping
971
+ else _resolve_cross_force(child, align_items, cross_avail, cross_bounded, is_row, direction)
972
+ )
973
+ _measure_child_flexed(child, child_main, cross_avail, cross_force, is_row, main_bounded, direction)
974
+
975
+ if main_bounded and math.isfinite(main_avail):
976
+ total_main = sum(_child_outer_main(c, is_row, content_w, content_h, direction) for c in line.children)
977
+ if len(line.children) > 1:
978
+ total_main += main_gap * (len(line.children) - 1)
979
+ overflow = total_main - main_avail
980
+ if overflow > 0:
981
+ shrinks = [(c, _flex_shrink(c.style)) for c in line.children]
982
+ total_shrink = sum(s * _child_main_size(c, is_row) for c, s in shrinks)
983
+ if total_shrink > 0:
984
+ for child, shrink in shrinks:
985
+ if shrink <= 0:
986
+ continue
987
+ take = (shrink * _child_main_size(child, is_row) / total_shrink) * overflow
988
+ new_main = max(0.0, _child_main_size(child, is_row) - take)
989
+ cross_force = (
990
+ None
991
+ if wrapping
992
+ else _resolve_cross_force(child, align_items, cross_avail, cross_bounded, is_row, direction)
993
+ )
994
+ _measure_child_flexed(
995
+ child, new_main, cross_avail, cross_force, is_row, main_bounded, direction
996
+ )
997
+
998
+ line.main_used = sum(_child_outer_main(c, is_row, content_w, content_h, direction) for c in line.children)
999
+ if len(line.children) > 1:
1000
+ line.main_used += main_gap * (len(line.children) - 1)
1001
+
1002
+ # ------------------------------------------------------------------
1003
+ # Pass 4: line cross sizes (+ align_content stretch) and per-line
1004
+ # cross stretching for wrapped containers.
1005
+ # ------------------------------------------------------------------
1006
+ for line in lines:
1007
+ line.cross_size = max(
1008
+ (_child_outer_cross(c, is_row, content_w, content_h, direction) for c in line.children),
1009
+ default=0.0,
1010
+ )
744
1011
 
745
- fixed_main_total = 0.0
746
- flex_basis_total = 0.0
747
- for child in in_flow:
748
- if _flex_grow(child.style) > 0:
749
- basis = _flex_basis(child.style, main_avail) or 0.0
750
- margins = _resolve_padding_for(child.style, content_w, content_h, "margin")
751
- margin_main = (margins[0] + margins[2]) if is_row else (margins[1] + margins[3])
752
- flex_basis_total += basis + margin_main
753
- else:
754
- fixed_main_total += _child_outer_main(child, is_row, content_w, content_h)
755
-
756
- if len(in_flow) > 1:
757
- fixed_main_total += spacing * (len(in_flow) - 1)
758
-
759
- if flex_total > 0 and main_bounded and math.isfinite(main_avail):
760
- remaining = max(0.0, main_avail - fixed_main_total - flex_basis_total)
761
- for child, grow, basis in flex_entries:
762
- extra = (grow / flex_total) * remaining
763
- child_main = (basis or 0.0) + extra
764
- cross_force = _resolve_cross_force(child, align_items, cross_avail, cross_bounded, is_row)
765
- _measure_child_flexed(child, child_main, cross_avail, cross_force, is_row, main_bounded)
766
-
767
- if main_bounded and math.isfinite(main_avail):
768
- total_main = sum(_child_outer_main(c, is_row, content_w, content_h) for c in in_flow)
769
- if len(in_flow) > 1:
770
- total_main += spacing * (len(in_flow) - 1)
771
- overflow = total_main - main_avail
772
- if overflow > 0:
773
- shrinks = [(c, _flex_shrink(c.style)) for c in in_flow]
774
- total_shrink = sum(s * _child_main_size(c, is_row) for c, s in shrinks)
775
- if total_shrink > 0:
776
- for child, shrink in shrinks:
777
- if shrink <= 0:
778
- continue
779
- take = (shrink * _child_main_size(child, is_row) / total_shrink) * overflow
780
- new_main = max(0.0, _child_main_size(child, is_row) - take)
781
- cross_force = _resolve_cross_force(child, align_items, cross_avail, cross_bounded, is_row)
782
- _measure_child_flexed(child, new_main, cross_avail, cross_force, is_row, main_bounded)
1012
+ if wrapping and lines:
1013
+ align_content = _resolve_justify(style.get("align_content", "stretch"))
1014
+ if align_content == ALIGN_STRETCH and cross_bounded and math.isfinite(cross_avail):
1015
+ total_cross = sum(line.cross_size for line in lines) + cross_gap * (len(lines) - 1)
1016
+ if total_cross < cross_avail:
1017
+ extra_each = (cross_avail - total_cross) / len(lines)
1018
+ for line in lines:
1019
+ line.cross_size += extra_each
1020
+ for line in lines:
1021
+ for child in line.children:
1022
+ cross_force = _resolve_cross_force(child, align_items, line.cross_size, True, is_row, direction)
1023
+ if cross_force is not None and abs(cross_force - _child_cross_size(child, is_row)) > 1e-9:
1024
+ _measure_child_flexed(
1025
+ child,
1026
+ _child_main_size(child, is_row),
1027
+ line.cross_size,
1028
+ cross_force,
1029
+ is_row,
1030
+ main_bounded,
1031
+ direction,
1032
+ )
783
1033
 
784
1034
  for child in absolute:
785
- _measure_absolute(child, content_w, content_h)
786
-
787
- used_main = sum(_child_outer_main(c, is_row, content_w, content_h) for c in in_flow)
788
- if len(in_flow) > 1:
789
- used_main += spacing * (len(in_flow) - 1)
790
- used_cross = max(
791
- (_child_outer_cross(c, is_row, content_w, content_h) for c in in_flow),
792
- default=0.0,
793
- )
1035
+ _measure_absolute(child, content_w, content_h, direction)
1036
+
1037
+ parent._lines = lines
1038
+
1039
+ if wrapping:
1040
+ used_main = max((line.main_used for line in lines), default=0.0)
1041
+ used_cross = sum(line.cross_size for line in lines)
1042
+ if len(lines) > 1:
1043
+ used_cross += cross_gap * (len(lines) - 1)
1044
+ else:
1045
+ used_main = lines[0].main_used if lines else 0.0
1046
+ used_cross = lines[0].cross_size if lines else 0.0
794
1047
  return used_main, used_cross
795
1048
 
796
1049
 
797
- def _measure_absolute(child: LayoutNode, parent_w: float, parent_h: float) -> None:
798
- """Measure an absolutely-positioned child using `top` / `left` / etc."""
1050
+ def _measure_absolute(child: LayoutNode, parent_w: float, parent_h: float, direction: str) -> None:
1051
+ """Measure an absolutely-positioned child using `top` / `left` / `start` / etc."""
799
1052
  style = child.style
800
1053
  explicit_w = _resolve_value(style.get("width"), parent_w)
801
1054
  explicit_h = _resolve_value(style.get("height"), parent_h)
802
- left = _resolve_value(style.get("left"), parent_w)
803
- right = _resolve_value(style.get("right"), parent_w)
1055
+ left, right = _absolute_horizontal_insets(style, parent_w, direction)
804
1056
  top = _resolve_value(style.get("top"), parent_h)
805
1057
  bottom = _resolve_value(style.get("bottom"), parent_h)
806
1058
 
@@ -811,7 +1063,30 @@ def _measure_absolute(child: LayoutNode, parent_w: float, parent_h: float) -> No
811
1063
 
812
1064
  avail_w = explicit_w if explicit_w is not None else parent_w
813
1065
  avail_h = explicit_h if explicit_h is not None else parent_h
814
- _measure_node(child, avail_w, avail_h, forced_w=explicit_w, forced_h=explicit_h)
1066
+ _measure_node(child, avail_w, avail_h, forced_w=explicit_w, forced_h=explicit_h, direction=direction)
1067
+
1068
+
1069
+ def _absolute_horizontal_insets(
1070
+ style: Dict[str, Any],
1071
+ parent_w: float,
1072
+ direction: str,
1073
+ ) -> Tuple[Optional[float], Optional[float]]:
1074
+ """Resolve ``left`` / ``right``, honoring ``start`` / ``end`` overrides."""
1075
+ left = _resolve_value(style.get("left"), parent_w)
1076
+ right = _resolve_value(style.get("right"), parent_w)
1077
+ start = _resolve_value(style.get("start"), parent_w)
1078
+ end = _resolve_value(style.get("end"), parent_w)
1079
+ if start is not None:
1080
+ if direction == DIRECTION_RTL:
1081
+ right = start
1082
+ else:
1083
+ left = start
1084
+ if end is not None:
1085
+ if direction == DIRECTION_RTL:
1086
+ left = end
1087
+ else:
1088
+ right = end
1089
+ return left, right
815
1090
 
816
1091
 
817
1092
  # ======================================================================
@@ -824,21 +1099,23 @@ def _position_children(parent: LayoutNode) -> None:
824
1099
 
825
1100
  Sizes are already computed by [`_measure_node`][pythonnative.layout._measure_node];
826
1101
  this pass walks the same tree and applies ``justify_content`` /
827
- ``align_items`` / absolute positioning to determine concrete
828
- coordinates relative to each parent's coordinate space.
1102
+ ``align_items`` / ``align_content`` / absolute positioning to
1103
+ determine concrete coordinates relative to each parent's
1104
+ coordinate space.
829
1105
  """
830
1106
  if not parent.children:
831
1107
  return
832
1108
 
833
1109
  style = parent.style
834
- direction = style.get("flex_direction", FLEX_DIRECTION_COLUMN)
835
- is_row = _is_row(direction)
836
- reverse = _is_reverse(direction)
837
- pad_l, pad_t, pad_r, pad_b = _resolve_padding_for(style, parent.width, parent.height, "padding")
838
- spacing_v = _to_float(style.get("spacing"))
839
- if spacing_v is None:
840
- spacing_v = _to_float(style.get("gap"))
841
- spacing = spacing_v or 0.0
1110
+ flex_direction = style.get("flex_direction", FLEX_DIRECTION_COLUMN)
1111
+ is_row = _is_row(flex_direction)
1112
+ direction = parent._direction
1113
+ rtl = direction == DIRECTION_RTL
1114
+ # In RTL, rows flip their visual order; ``row_reverse`` flips back.
1115
+ reverse = _is_reverse(flex_direction) != (rtl and is_row)
1116
+ pad_l, pad_t, pad_r, pad_b = _resolve_padding_for(style, parent.width, parent.height, "padding", direction)
1117
+ main_gap, cross_gap = _resolve_gaps(style, is_row)
1118
+ wrap = _wrap_mode(style)
842
1119
  align_items = _resolve_align(style.get("align_items"), default=ALIGN_STRETCH)
843
1120
  justify = _resolve_justify(style.get("justify_content"))
844
1121
 
@@ -855,45 +1132,75 @@ def _position_children(parent: LayoutNode) -> None:
855
1132
  main_size = content_w if is_row else content_h
856
1133
  cross_size = content_h if is_row else content_w
857
1134
 
858
- used_main = sum(_child_outer_main(c, is_row, content_w, content_h) for c in in_flow)
859
- if len(in_flow) > 1:
860
- used_main += spacing * (len(in_flow) - 1)
861
- free_main = max(0.0, main_size - used_main)
862
-
863
- main_offset, between = _justify_offsets(justify, free_main, len(in_flow))
864
-
865
- cursor = main_offset
866
- ordered = list(reversed(in_flow)) if reverse else in_flow
867
- for i, child in enumerate(ordered):
868
- cm_l, cm_t, cm_r, cm_b = _resolve_padding_for(child.style, content_w, content_h, "margin")
869
- margin_main_start = cm_l if is_row else cm_t
870
- margin_cross_start = cm_t if is_row else cm_l
871
- margin_cross_end = cm_b if is_row else cm_r
872
-
873
- cross_pos = _align_offset(
874
- child,
875
- align_items,
876
- cross_size,
877
- is_row,
878
- margin_cross_start,
879
- margin_cross_end,
880
- )
1135
+ lines = parent._lines
1136
+ if not lines:
1137
+ lines = [_FlexLine(list(in_flow))] if in_flow else []
1138
+ for line in lines:
1139
+ line.main_used = sum(_child_outer_main(c, is_row, content_w, content_h, direction) for c in line.children)
1140
+ if len(line.children) > 1:
1141
+ line.main_used += main_gap * (len(line.children) - 1)
1142
+ line.cross_size = cross_size
1143
+
1144
+ multi_line = wrap != WRAP_NOWRAP and len(lines) >= 1 and parent._lines is not None
1145
+
1146
+ # Cross-axis placement of the lines themselves.
1147
+ if multi_line:
1148
+ total_lines_cross = sum(line.cross_size for line in lines) + cross_gap * max(0, len(lines) - 1)
1149
+ align_content = _resolve_justify(style.get("align_content", "stretch"))
1150
+ if align_content == ALIGN_STRETCH:
1151
+ align_content = JUSTIFY_FLEX_START
1152
+ free_cross = max(0.0, cross_size - total_lines_cross)
1153
+ cross_offset, cross_between = _justify_offsets(align_content, free_cross, len(lines))
1154
+ else:
1155
+ cross_offset, cross_between = 0.0, 0.0
1156
+
1157
+ ordered_lines = list(lines)
1158
+ if wrap == WRAP_REVERSE and multi_line:
1159
+ ordered_lines.reverse()
1160
+
1161
+ cross_cursor = cross_offset
1162
+ for line_index, line in enumerate(ordered_lines):
1163
+ line_cross = line.cross_size if multi_line else cross_size
1164
+ free_main = max(0.0, main_size - line.main_used)
1165
+ main_offset, between = _justify_offsets(justify, free_main, len(line.children))
1166
+
1167
+ cursor = main_offset
1168
+ ordered = list(reversed(line.children)) if reverse else list(line.children)
1169
+ for i, child in enumerate(ordered):
1170
+ cm_l, cm_t, cm_r, cm_b = _child_margins(child, content_w, content_h, direction)
1171
+ margin_main_start = (cm_r if rtl else cm_l) if is_row else cm_t
1172
+ margin_cross_start = cm_t if is_row else (cm_r if rtl else cm_l)
1173
+ margin_cross_end = cm_b if is_row else (cm_l if rtl else cm_r)
1174
+
1175
+ cross_pos = _align_offset(
1176
+ child,
1177
+ align_items,
1178
+ line_cross,
1179
+ is_row,
1180
+ margin_cross_start,
1181
+ margin_cross_end,
1182
+ rtl,
1183
+ )
881
1184
 
882
- if is_row:
883
- child.x = pad_l + cursor + margin_main_start
884
- child.y = pad_t + cross_pos
885
- else:
886
- child.x = pad_l + cross_pos
887
- child.y = pad_t + cursor + margin_main_start
1185
+ if is_row:
1186
+ child.x = pad_l + cursor + margin_main_start
1187
+ child.y = pad_t + cross_cursor + cross_pos
1188
+ else:
1189
+ child.x = pad_l + cross_cursor + cross_pos
1190
+ child.y = pad_t + cursor + margin_main_start
888
1191
 
889
- cursor += _child_outer_main(child, is_row, content_w, content_h)
890
- if i < len(ordered) - 1:
891
- cursor += spacing + between
1192
+ cursor += _child_outer_main(child, is_row, content_w, content_h, direction)
1193
+ if i < len(ordered) - 1:
1194
+ cursor += main_gap + between
892
1195
 
893
- _position_children(child)
1196
+ _position_children(child)
1197
+
1198
+ cross_cursor += line_cross
1199
+ if line_index < len(ordered_lines) - 1:
1200
+ cross_cursor += cross_gap + cross_between
894
1201
 
895
1202
  for child in absolute:
896
- _position_absolute(child, content_w, content_h, pad_l, pad_t)
1203
+ _position_absolute(child, content_w, content_h, pad_l, pad_t, direction)
897
1204
  _position_children(child)
898
1205
 
899
1206
 
@@ -925,11 +1232,18 @@ def _align_offset(
925
1232
  is_row: bool,
926
1233
  margin_start: float,
927
1234
  margin_end: float,
1235
+ rtl: bool = False,
928
1236
  ) -> float:
929
- """Return the cross-axis offset for ``child`` inside its parent."""
1237
+ """Return the cross-axis offset for ``child`` inside its line."""
930
1238
  align = _resolve_align(child.style.get("align_self"), default=parent_align)
931
1239
  if align == ALIGN_AUTO:
932
1240
  align = parent_align
1241
+ # A column's cross axis is horizontal, so RTL flips start/end.
1242
+ if rtl and not is_row:
1243
+ if align == ALIGN_FLEX_START:
1244
+ align = ALIGN_FLEX_END
1245
+ elif align == ALIGN_FLEX_END:
1246
+ align = ALIGN_FLEX_START
933
1247
 
934
1248
  child_cross = _child_cross_size(child, is_row)
935
1249
  margin_cross = margin_start + margin_end
@@ -946,11 +1260,11 @@ def _position_absolute(
946
1260
  content_h: float,
947
1261
  pad_l: float,
948
1262
  pad_t: float,
1263
+ direction: str,
949
1264
  ) -> None:
950
- """Position an absolutely-positioned child via `top` / `left` / etc."""
1265
+ """Position an absolutely-positioned child via `top` / `left` / `start` / etc."""
951
1266
  style = child.style
952
- left = _resolve_value(style.get("left"), content_w)
953
- right = _resolve_value(style.get("right"), content_w)
1267
+ left, right = _absolute_horizontal_insets(style, content_w, direction)
954
1268
  top = _resolve_value(style.get("top"), content_h)
955
1269
  bottom = _resolve_value(style.get("bottom"), content_h)
956
1270