Skip to content

Core API

The nuiitivet package exposes the core primitives, layout widgets, and state management utilities.

nuiitivet package.

Core functionality and configuration primitives are exposed here.

Column

Bases: Widget

Layout children vertically.

Parameters - gap: pixels between children - cross_alignment: 'start'|'center'|'end' for cross-axis (horizontal) alignment - main_alignment: 'start'|'center'|'end'|'space-between'|'space-around'|'space-evenly' - padding: inner padding as int (all sides), (h, v), or (left, top, right, bottom) - overflow: 'visible'|'clip'|'scroll' - how to handle children that overflow the container - 'visible' (default): children may extend beyond container (Phase 1 behavior) - 'clip': children are clipped to container bounds - 'scroll': requires Scroller wrapper (Phase 3)

Source code in src/nuiitivet/layout/column.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
class Column(Widget):
    """Layout children vertically.

    Parameters
    - gap: pixels between children
    - cross_alignment: 'start'|'center'|'end' for cross-axis (horizontal) alignment
    - main_alignment: 'start'|'center'|'end'|'space-between'|'space-around'|'space-evenly'
    - padding: inner padding as int (all sides), (h, v), or (left, top, right, bottom)
    - overflow: 'visible'|'clip'|'scroll' - how to handle children that overflow the container
        - 'visible' (default): children may extend beyond container (Phase 1 behavior)
        - 'clip': children are clipped to container bounds
        - 'scroll': requires Scroller wrapper (Phase 3)
    """

    # Hint for ancestor-based layout resolution (used by ForEach and others)
    layout_axis = "vertical"

    def __init__(
        self,
        children: Optional[Sequence[Widget]] = None,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        gap: Union[int, ReadOnlyObservableProtocol] = 0,
        main_alignment: str = "start",
        cross_alignment: str = "start",
    ):
        """Initialize Column and configure layout.

        Args:
            children: List of child widgets to arrange vertically.
            width: Column width.
            height: Column height.
            padding: Padding around the content.
            gap: Space between children in pixels.
            main_alignment: Vertical alignment of children.
                'start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'.
            cross_alignment: Horizontal alignment of children.
                'start', 'center', 'end', 'stretch'.
        """
        super().__init__(width=width, height=height, padding=padding)
        if children:
            for c in children:
                self.add_child(c)

        self._gap = 0
        self.gap = gap  # type: ignore

        self.main_alignment = main_alignment
        self.cross_alignment = cross_alignment

    @property
    def gap(self) -> int:
        return getattr(self, "_gap", 0)

    @gap.setter
    def gap(self, value: Union[int, ReadOnlyObservableProtocol]) -> None:
        if isinstance(value, ReadOnlyObservableProtocol):
            if hasattr(self, "observe"):
                self.observe(value, lambda v: setattr(self, "gap", v))
            return
        self._gap = normalize_gap(value)
        self.mark_needs_layout()

    @classmethod
    def builder(
        cls,
        items: ItemsLike,
        builder: BuilderFn,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        gap: int = 0,
        main_alignment: str = "start",
        cross_alignment: str = "start",
    ) -> "Column":
        """Create a Column that materializes children from items via ForEach.

        Args:
            items: Source data collection.
            builder: Function to create a widget for each item.
            width: Column width.
            height: Column height.
            padding: Padding around the content.
            gap: Space between children.
            main_alignment: Vertical alignment of children.
            cross_alignment: Horizontal alignment of children.
        """
        provider = ForEach(items, builder)
        return cls(
            children=[provider],
            width=width,
            height=height,
            padding=padding,
            gap=gap,
            main_alignment=main_alignment,
            cross_alignment=cross_alignment,
        )

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        children = expand_layout_children(self.children_snapshot())

        l, t, r, b = self.padding
        inner_max_w: Optional[int] = None
        if max_width is not None:
            inner_max_w = max(0, int(max_width) - int(l) - int(r))
        elif self.width_sizing.kind == "fixed":
            inner_max_w = max(0, int(self.width_sizing.value) - int(l) - int(r))

        heights: List[int] = []
        max_w = 0
        for c in children:
            w, h = measure_preferred_size(c, max_width=inner_max_w)
            heights.append(int(h))
            if int(w) > max_w:
                max_w = int(w)

        total_h = sum(heights) + max(0, len(heights) - 1) * self.gap

        # Add padding to content size
        content_width = max_w
        content_height = total_h

        # Apply explicit sizing if provided
        w_dim = self.width_sizing
        h_dim = self.height_sizing

        if w_dim.kind == "fixed":
            width = int(w_dim.value)
        else:
            width = content_width + l + r
            if max_width is not None:
                width = min(width, int(max_width))

        if h_dim.kind == "fixed":
            height = int(h_dim.value)
        else:
            height = content_height + t + b
            if max_height is not None:
                height = min(height, int(max_height))

        return (width, height)

    def layout(self, width: int, height: int) -> None:
        super().layout(width, height)
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        # Calculate content area size (relative to self at 0,0)
        l, t, r, b = self.padding
        cw = max(0, width - l - r)
        ch = max(0, height - t - b)

        sizes = [measure_preferred_size(c, max_width=cw) for c in children]
        n = len(children)

        spacing = max(0, int(self.gap))
        spacing_total = max(0, n - 1) * spacing
        usable = max(0, ch - spacing_total)

        base_sizes: List[int] = []
        flex_weights: List[float] = []
        for idx, child in enumerate(children):
            pref_h = max(0, sizes[idx][1])
            dim = getattr(child, "height_sizing", None)
            if dim is None or dim.kind == "auto":
                base: int = pref_h
                flex: float = 0.0
            elif dim.kind == "fixed":
                base = max(0, int(dim.value))
                flex = 0.0
            else:  # flex
                base = 0
                flex = dim.value if dim.value > 0 else 1.0
            base_sizes.append(base)
            flex_weights.append(max(0.0, float(flex)))

        alloc = self._allocate_main_sizes(base_sizes, flex_weights, usable)
        row_offsets = compute_aligned_offsets(alloc, ch, spacing, self.main_alignment)

        # Content start offset (relative to self)
        cx, cy = l, t

        for idx, child in enumerate(children):
            h = alloc[idx]
            resolved_width = self._resolve_cross_size(child, sizes[idx][0], cw)

            # Check for child-specific cross-axis alignment override (CrossAligned/cross_align)
            child_align = getattr(child, "cross_align", None)
            alignment = str(child_align) if child_align else self.cross_alignment

            # Calculate relative position
            rel_x = cx + align_offset(cw, resolved_width, alignment)
            rel_y = cy + (row_offsets[idx] if idx < len(row_offsets) else 0)

            # Layout child and store geometry
            child.layout(resolved_width, h)
            child.set_layout_rect(rel_x, rel_y, resolved_width, h)

    def paint(self, canvas, x: int, y: int, width: int, height: int):
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        # Auto-layout fallback for tests or direct paint calls
        if any(c.layout_rect is None for c in children):
            self.layout(width, height)

        for child in children:
            rect = child.layout_rect
            if rect is None:
                continue

            rel_x, rel_y, w, h = rect
            abs_x = x + rel_x
            abs_y = y + rel_y

            child.set_last_rect(abs_x, abs_y, w, h)

            child.paint(canvas, abs_x, abs_y, w, h)

    @staticmethod
    def _allocate_main_sizes(base_sizes: List[int], flex_weights: List[float], usable: int) -> List[int]:
        """Allocate main axis sizes with new Phase 1 rules.

        Priority:
        1. fixed/auto elements get their base_size (minimum, non-shrinkable)
        2. stretch elements share remaining space by weight
        3. If not enough space, fixed/auto may overflow (spacing/padding are guaranteed)
        """
        n = len(base_sizes)
        if n == 0:
            return []
        usable = max(0, int(usable))
        base_sizes = [max(0, int(b)) for b in base_sizes]

        if usable == 0:
            return [0] * n

        # Phase 1: Calculate minimum requirements (fixed/auto)
        minimum_total = sum(base for base, flex in zip(base_sizes, flex_weights) if flex == 0)

        # Phase 2: Allocate to fixed/auto first (guaranteed)
        alloc = base_sizes[:]
        remaining = usable - minimum_total

        # Phase 3: Distribute remaining space to flex elements
        total_flex = sum(flex_weights)
        if remaining > 0 and total_flex > 0:
            extras = [0] * n
            used = 0
            for idx, weight in enumerate(flex_weights):
                if weight <= 0:
                    continue
                share = int(weight / total_flex * remaining)
                extras[idx] = share
                used += share

            # Distribute remainder
            remainder = remaining - used
            if remainder > 0:
                for idx, weight in enumerate(flex_weights):
                    if weight > 0 and remainder > 0:
                        extras[idx] += 1
                        remainder -= 1
                    if remainder == 0:
                        break

            for idx in range(n):
                alloc[idx] += extras[idx]

        # Note: If remaining < 0, fixed/auto will overflow (by design)
        # Phase 2 (overflow strategies) will handle clipping/scrolling

        return alloc

    @staticmethod
    def _resolve_cross_size(child: Widget, pref: int, available: int) -> int:
        pref = max(0, pref)
        available = max(0, available)
        dim = getattr(child, "width_sizing", None)
        if dim is None or dim.kind == "auto":
            target = pref
        elif dim.kind == "fixed":
            target = max(0, int(dim.value))
        else:  # flex
            target = available if available > 0 else pref
        if available > 0:
            target = min(target, available)
        return target

__init__(children=None, *, width=None, height=None, padding=0, gap=0, main_alignment='start', cross_alignment='start')

Initialize Column and configure layout.

Parameters:

Name Type Description Default
children Optional[Sequence[Widget]]

List of child widgets to arrange vertically.

None
width SizingLike

Column width.

None
height SizingLike

Column height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the content.

0
gap Union[int, ReadOnlyObservableProtocol]

Space between children in pixels.

0
main_alignment str

Vertical alignment of children. 'start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'.

'start'
cross_alignment str

Horizontal alignment of children. 'start', 'center', 'end', 'stretch'.

'start'
Source code in src/nuiitivet/layout/column.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(
    self,
    children: Optional[Sequence[Widget]] = None,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    gap: Union[int, ReadOnlyObservableProtocol] = 0,
    main_alignment: str = "start",
    cross_alignment: str = "start",
):
    """Initialize Column and configure layout.

    Args:
        children: List of child widgets to arrange vertically.
        width: Column width.
        height: Column height.
        padding: Padding around the content.
        gap: Space between children in pixels.
        main_alignment: Vertical alignment of children.
            'start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'.
        cross_alignment: Horizontal alignment of children.
            'start', 'center', 'end', 'stretch'.
    """
    super().__init__(width=width, height=height, padding=padding)
    if children:
        for c in children:
            self.add_child(c)

    self._gap = 0
    self.gap = gap  # type: ignore

    self.main_alignment = main_alignment
    self.cross_alignment = cross_alignment

builder(items, builder, *, width=None, height=None, padding=0, gap=0, main_alignment='start', cross_alignment='start') classmethod

Create a Column that materializes children from items via ForEach.

Parameters:

Name Type Description Default
items ItemsLike

Source data collection.

required
builder BuilderFn

Function to create a widget for each item.

required
width SizingLike

Column width.

None
height SizingLike

Column height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the content.

0
gap int

Space between children.

0
main_alignment str

Vertical alignment of children.

'start'
cross_alignment str

Horizontal alignment of children.

'start'
Source code in src/nuiitivet/layout/column.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
@classmethod
def builder(
    cls,
    items: ItemsLike,
    builder: BuilderFn,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    gap: int = 0,
    main_alignment: str = "start",
    cross_alignment: str = "start",
) -> "Column":
    """Create a Column that materializes children from items via ForEach.

    Args:
        items: Source data collection.
        builder: Function to create a widget for each item.
        width: Column width.
        height: Column height.
        padding: Padding around the content.
        gap: Space between children.
        main_alignment: Vertical alignment of children.
        cross_alignment: Horizontal alignment of children.
    """
    provider = ForEach(items, builder)
    return cls(
        children=[provider],
        width=width,
        height=height,
        padding=padding,
        gap=gap,
        main_alignment=main_alignment,
        cross_alignment=cross_alignment,
    )

Row

Bases: Widget

Layout children horizontally.

Parameters - gap: pixels between children - cross_alignment: 'start'|'center'|'end' for cross-axis (vertical) alignment - main_alignment: 'start'|'center'|'end'|'space-between'|'space-around'|'space-evenly' - padding: inner padding as int (all sides), (h, v), or (left, top, right, bottom)

Source code in src/nuiitivet/layout/row.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
class Row(Widget):
    """Layout children horizontally.

    Parameters
    - gap: pixels between children
    - cross_alignment: 'start'|'center'|'end' for cross-axis (vertical) alignment
    - main_alignment: 'start'|'center'|'end'|'space-between'|'space-around'|'space-evenly'
    - padding: inner padding as int (all sides), (h, v), or (left, top, right, bottom)
    """

    # Hint for ancestor-based layout resolution (used by ForEach and others)
    layout_axis = "horizontal"

    def __init__(
        self,
        children: Optional[List[Widget]] = None,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        gap: Union[int, ReadOnlyObservableProtocol] = 0,
        main_alignment: str = "start",
        cross_alignment: str = "start",
    ):
        """Initialize Row and configure layout.

        Args:
            children: List of child widgets to arrange horizontally.
            width: Row width. Defaults to None (shrinkwrap).
            height: Row height. Defaults to None (shrinkwrap).
            padding: Padding around the content.
            gap: Space between children in pixels.
            main_alignment: Horizontal alignment of children.
                'start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'.
            cross_alignment: Vertical alignment of children.
                'start', 'center', 'end', 'stretch'.
        """
        super().__init__(width=width, height=height, padding=padding)
        if children:
            for c in children:
                self.add_child(c)
        self._gap = 0
        self.gap = gap  # type: ignore
        self.main_alignment = main_alignment
        self.cross_alignment = cross_alignment

    @property
    def gap(self) -> int:
        return getattr(self, "_gap", 0)

    @gap.setter
    def gap(self, value: Union[int, ReadOnlyObservableProtocol]) -> None:
        if isinstance(value, ReadOnlyObservableProtocol):
            if hasattr(self, "observe"):
                self.observe(value, lambda v: setattr(self, "gap", v))
            return
        self._gap = normalize_gap(value)
        self.mark_needs_layout()

    @classmethod
    def builder(
        cls,
        items: ItemsLike,
        builder: BuilderFn,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        gap: int = 0,
        main_alignment: str = "start",
        cross_alignment: str = "start",
    ) -> "Row":
        """Create a Row that materializes children from items via ForEach.

        Args:
            items: Source data collection.
            builder: Function to create a widget for each item.
            width: Row width.
            height: Row height.
            padding: Padding around the content.
            gap: Space between children.
            main_alignment: Horizontal alignment of children.
            cross_alignment: Vertical alignment of children.
        """
        provider = ForEach(items, builder)
        return cls(
            children=[provider],
            width=width,
            height=height,
            padding=padding,
            gap=gap,
            main_alignment=main_alignment,
            cross_alignment=cross_alignment,
        )

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        children = expand_layout_children(self.children_snapshot())

        l, t, r, b = self.padding
        inner_max_h: Optional[int] = None
        if max_height is not None:
            inner_max_h = max(0, int(max_height) - int(t) - int(b))
        elif self.height_sizing.kind == "fixed":
            inner_max_h = max(0, int(self.height_sizing.value) - int(t) - int(b))

        widths: List[int] = []
        max_h = 0
        for c in children:
            w, h = measure_preferred_size(c, max_height=inner_max_h)
            widths.append(int(w))
            if int(h) > max_h:
                max_h = int(h)

        total_w = sum(widths) + max(0, len(widths) - 1) * self.gap

        # Add padding to content size
        content_width = total_w
        content_height = max_h

        # Apply explicit sizing if provided
        w_dim = self.width_sizing
        h_dim = self.height_sizing

        if w_dim.kind == "fixed":
            width = int(w_dim.value)
        else:
            width = content_width + l + r
            if max_width is not None:
                width = min(width, int(max_width))

        if h_dim.kind == "fixed":
            height = int(h_dim.value)
        else:
            height = content_height + t + b
            if max_height is not None:
                height = min(height, int(max_height))

        return (width, height)

    def layout(self, width: int, height: int) -> None:
        super().layout(width, height)
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        # Calculate content area size (relative to self at 0,0)
        l, t, r, b = self.padding
        cw = max(0, width - l - r)
        ch = max(0, height - t - b)

        sizes = [measure_preferred_size(c, max_height=ch) for c in children]
        n = len(children)

        spacing = max(0, int(self.gap))
        spacing_total = max(0, n - 1) * spacing
        usable = max(0, cw - spacing_total)

        base_sizes: List[int] = []
        flex_weights: List[float] = []
        for idx, child in enumerate(children):
            pref_w = max(0, sizes[idx][0])
            dim = getattr(child, "width_sizing", None)
            if dim is None or dim.kind == "auto":
                base: int = pref_w
                flex: float = 0.0
            elif dim.kind == "fixed":
                base = max(0, int(dim.value))
                flex = 0.0
            else:  # flex
                base = 0
                flex = dim.value if dim.value > 0 else 1.0
            base_sizes.append(base)
            flex_weights.append(max(0.0, float(flex)))

        alloc = self._allocate_main_sizes(base_sizes, flex_weights, usable)
        col_offsets = compute_aligned_offsets(alloc, cw, spacing, self.main_alignment)

        # Content start offset (relative to self)
        cx, cy = l, t

        for idx, child in enumerate(children):
            w = alloc[idx]
            resolved_height = self._resolve_cross_size(child, sizes[idx][1], ch)

            # Check for child-specific cross-axis alignment override (CrossAligned/cross_align)
            child_align = getattr(child, "cross_align", None)
            alignment = str(child_align) if child_align else self.cross_alignment

            # Calculate relative position
            rel_y = cy + align_offset(ch, resolved_height, alignment)
            rel_x = cx + (col_offsets[idx] if idx < len(col_offsets) else 0)

            # Layout child and store geometry
            child.layout(w, resolved_height)
            child.set_layout_rect(rel_x, rel_y, w, resolved_height)

    def paint(self, canvas, x: int, y: int, width: int, height: int):
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        # Auto-layout fallback for tests or direct paint calls
        if any(c.layout_rect is None for c in children):
            self.layout(width, height)

        for child in children:
            rect = child.layout_rect
            if rect is None:
                continue

            rel_x, rel_y, w, h = rect
            abs_x = x + rel_x
            abs_y = y + rel_y

            child.set_last_rect(abs_x, abs_y, w, h)

            child.paint(canvas, abs_x, abs_y, w, h)

    @staticmethod
    def _allocate_main_sizes(base_sizes: List[int], flex_weights: List[float], usable: int) -> List[int]:
        """Allocate main axis sizes with new Phase 1 rules.

        Priority:
        1. fixed/auto elements get their base_size (minimum, non-shrinkable)
        2. stretch elements share remaining space by weight
        3. If not enough space, fixed/auto may overflow (spacing/padding are guaranteed)
        """
        n = len(base_sizes)
        if n == 0:
            return []
        usable = max(0, int(usable))
        base_sizes = [max(0, int(b)) for b in base_sizes]

        if usable == 0:
            return [0] * n

        # Phase 1: Calculate minimum requirements (fixed/auto)
        minimum_total = sum(base for base, flex in zip(base_sizes, flex_weights) if flex == 0)

        # Phase 2: Allocate to fixed/auto first (guaranteed)
        alloc = base_sizes[:]
        remaining = usable - minimum_total

        # Phase 3: Distribute remaining space to flex elements
        total_flex = sum(flex_weights)
        if remaining > 0 and total_flex > 0:
            extras = [0] * n
            used = 0
            for idx, weight in enumerate(flex_weights):
                if weight <= 0:
                    continue
                share = int(weight / total_flex * remaining)
                extras[idx] = share
                used += share

            # Distribute remainder
            remainder = remaining - used
            if remainder > 0:
                for idx, weight in enumerate(flex_weights):
                    if weight > 0 and remainder > 0:
                        extras[idx] += 1
                        remainder -= 1
                    if remainder == 0:
                        break

            for idx in range(n):
                alloc[idx] += extras[idx]

        # Note: If remaining < 0, fixed/auto will overflow (by design)
        # Phase 2 (overflow strategies) will handle clipping/scrolling

        return alloc

    @staticmethod
    def _resolve_cross_size(child: Widget, pref: int, available: int) -> int:
        pref = max(0, pref)
        available = max(0, available)
        dim = getattr(child, "height_sizing", None)
        if dim is None or dim.kind == "auto":
            target = pref
        elif dim.kind == "fixed":
            target = max(0, int(dim.value))
        else:  # flex
            target = available if available > 0 else pref
        if available > 0:
            target = min(target, available)
        return target

__init__(children=None, *, width=None, height=None, padding=0, gap=0, main_alignment='start', cross_alignment='start')

Initialize Row and configure layout.

Parameters:

Name Type Description Default
children Optional[List[Widget]]

List of child widgets to arrange horizontally.

None
width SizingLike

Row width. Defaults to None (shrinkwrap).

None
height SizingLike

Row height. Defaults to None (shrinkwrap).

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the content.

0
gap Union[int, ReadOnlyObservableProtocol]

Space between children in pixels.

0
main_alignment str

Horizontal alignment of children. 'start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'.

'start'
cross_alignment str

Vertical alignment of children. 'start', 'center', 'end', 'stretch'.

'start'
Source code in src/nuiitivet/layout/row.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def __init__(
    self,
    children: Optional[List[Widget]] = None,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    gap: Union[int, ReadOnlyObservableProtocol] = 0,
    main_alignment: str = "start",
    cross_alignment: str = "start",
):
    """Initialize Row and configure layout.

    Args:
        children: List of child widgets to arrange horizontally.
        width: Row width. Defaults to None (shrinkwrap).
        height: Row height. Defaults to None (shrinkwrap).
        padding: Padding around the content.
        gap: Space between children in pixels.
        main_alignment: Horizontal alignment of children.
            'start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'.
        cross_alignment: Vertical alignment of children.
            'start', 'center', 'end', 'stretch'.
    """
    super().__init__(width=width, height=height, padding=padding)
    if children:
        for c in children:
            self.add_child(c)
    self._gap = 0
    self.gap = gap  # type: ignore
    self.main_alignment = main_alignment
    self.cross_alignment = cross_alignment

builder(items, builder, *, width=None, height=None, padding=0, gap=0, main_alignment='start', cross_alignment='start') classmethod

Create a Row that materializes children from items via ForEach.

Parameters:

Name Type Description Default
items ItemsLike

Source data collection.

required
builder BuilderFn

Function to create a widget for each item.

required
width SizingLike

Row width.

None
height SizingLike

Row height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the content.

0
gap int

Space between children.

0
main_alignment str

Horizontal alignment of children.

'start'
cross_alignment str

Vertical alignment of children.

'start'
Source code in src/nuiitivet/layout/row.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
@classmethod
def builder(
    cls,
    items: ItemsLike,
    builder: BuilderFn,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    gap: int = 0,
    main_alignment: str = "start",
    cross_alignment: str = "start",
) -> "Row":
    """Create a Row that materializes children from items via ForEach.

    Args:
        items: Source data collection.
        builder: Function to create a widget for each item.
        width: Row width.
        height: Row height.
        padding: Padding around the content.
        gap: Space between children.
        main_alignment: Horizontal alignment of children.
        cross_alignment: Vertical alignment of children.
    """
    provider = ForEach(items, builder)
    return cls(
        children=[provider],
        width=width,
        height=height,
        padding=padding,
        gap=gap,
        main_alignment=main_alignment,
        cross_alignment=cross_alignment,
    )

Stack

Bases: Widget

Layout children on top of each other.

Parameters - children: List of widgets to stack. - alignment: How to align children within the stack.

Source code in src/nuiitivet/layout/stack.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
class Stack(Widget):
    """Layout children on top of each other.

    Parameters
    - children: List of widgets to stack.
    - alignment: How to align children within the stack.
    """

    def __init__(
        self,
        children: Sequence[Widget],
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        alignment: AlignmentLike = "top-left",
    ) -> None:
        """Initialize the Stack layout.

        Args:
            children: List of widgets to stack on top of each other.
            width: Stack width.
            height: Stack height.
            padding: Padding around the content.
            alignment: Default alignment for children.
                (horizontal, vertical) tuple or string like "top-left", "center".
        """
        super().__init__(width=width, height=height, padding=padding)
        for c in children:
            self.add_child(c)
        self.alignment = normalize_alignment(alignment, default=("start", "start"))

    @classmethod
    def builder(
        cls,
        items: ItemsLike,
        builder: BuilderFn,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        alignment: AlignmentLike = "center",
    ) -> "Stack":
        """Create a Stack that materializes children from items via ForEach.

        Args:
            items: Source data collection.
            builder: Function to create a widget for each item.
            width: Stack width.
            height: Stack height.
            padding: Padding around the content.
            alignment: Default alignment for children.
        """
        provider = ForEach(items, builder)
        return cls(
            children=[provider],
            width=width,
            height=height,
            padding=padding,
            alignment=alignment,
        )

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        children = expand_layout_children(self.children_snapshot())
        max_w = 0
        max_h = 0

        l, t, r, b = self.padding
        inner_max_w: Optional[int] = None
        inner_max_h: Optional[int] = None
        if max_width is not None:
            inner_max_w = max(0, int(max_width) - int(l) - int(r))
        elif self.width_sizing.kind == "fixed":
            inner_max_w = max(0, int(self.width_sizing.value) - int(l) - int(r))
        if max_height is not None:
            inner_max_h = max(0, int(max_height) - int(t) - int(b))
        elif self.height_sizing.kind == "fixed":
            inner_max_h = max(0, int(self.height_sizing.value) - int(t) - int(b))

        for child in children:
            w, h = measure_preferred_size(child, max_width=inner_max_w, max_height=inner_max_h)
            if int(w) > max_w:
                max_w = int(w)
            if int(h) > max_h:
                max_h = int(h)

        content_width = max_w
        content_height = max_h

        w_dim = self.width_sizing
        h_dim = self.height_sizing

        if w_dim.kind == "fixed":
            width = int(w_dim.value)
        else:
            width = content_width + l + r
            if max_width is not None:
                width = min(width, int(max_width))

        if h_dim.kind == "fixed":
            height = int(h_dim.value)
        else:
            height = content_height + t + b
            if max_height is not None:
                height = min(height, int(max_height))

        return (width, height)

    def layout(self, width: int, height: int) -> None:
        super().layout(width, height)
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        l, t, r, b = self.padding

        content_w = max(0, width - l - r)
        content_h = max(0, height - t - b)

        # Alignment
        ax, ay = self.alignment

        def get_pos(align: str, parent_size: int, child_size: int) -> int:
            if align == "center":
                return (parent_size - child_size) // 2
            elif align == "end":
                return parent_size - child_size
            return 0  # start

        for child in children:
            cw, ch = measure_preferred_size(child, max_width=content_w, max_height=content_h)

            target_w = cw
            target_h = ch

            # If child has percentage sizing (flex),
            # resolve it against stack size
            if hasattr(child, "width_sizing") and child.width_sizing.kind == "flex":
                target_w = int(content_w * (child.width_sizing.value / 100.0))

            if hasattr(child, "height_sizing") and child.height_sizing.kind == "flex":
                target_h = int(content_h * (child.height_sizing.value / 100.0))

            # Calculate position based on alignment
            x = get_pos(ax, content_w, target_w)
            y = get_pos(ay, content_h, target_h)

            child.layout(target_w, target_h)
            child.set_layout_rect(l + x, t + y, target_w, target_h)

    def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
        children = expand_layout_children(self.children_snapshot())
        for child in children:
            rect = child.layout_rect
            if rect is None:
                continue

            rx, ry, rw, rh = rect
            child.paint(canvas, x + rx, y + ry, rw, rh)

__init__(children, *, width=None, height=None, padding=0, alignment='top-left')

Initialize the Stack layout.

Parameters:

Name Type Description Default
children Sequence[Widget]

List of widgets to stack on top of each other.

required
width SizingLike

Stack width.

None
height SizingLike

Stack height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the content.

0
alignment AlignmentLike

Default alignment for children. (horizontal, vertical) tuple or string like "top-left", "center".

'top-left'
Source code in src/nuiitivet/layout/stack.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def __init__(
    self,
    children: Sequence[Widget],
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    alignment: AlignmentLike = "top-left",
) -> None:
    """Initialize the Stack layout.

    Args:
        children: List of widgets to stack on top of each other.
        width: Stack width.
        height: Stack height.
        padding: Padding around the content.
        alignment: Default alignment for children.
            (horizontal, vertical) tuple or string like "top-left", "center".
    """
    super().__init__(width=width, height=height, padding=padding)
    for c in children:
        self.add_child(c)
    self.alignment = normalize_alignment(alignment, default=("start", "start"))

builder(items, builder, *, width=None, height=None, padding=0, alignment='center') classmethod

Create a Stack that materializes children from items via ForEach.

Parameters:

Name Type Description Default
items ItemsLike

Source data collection.

required
builder BuilderFn

Function to create a widget for each item.

required
width SizingLike

Stack width.

None
height SizingLike

Stack height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the content.

0
alignment AlignmentLike

Default alignment for children.

'center'
Source code in src/nuiitivet/layout/stack.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@classmethod
def builder(
    cls,
    items: ItemsLike,
    builder: BuilderFn,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    alignment: AlignmentLike = "center",
) -> "Stack":
    """Create a Stack that materializes children from items via ForEach.

    Args:
        items: Source data collection.
        builder: Function to create a widget for each item.
        width: Stack width.
        height: Stack height.
        padding: Padding around the content.
        alignment: Default alignment for children.
    """
    provider = ForEach(items, builder)
    return cls(
        children=[provider],
        width=width,
        height=height,
        padding=padding,
        alignment=alignment,
    )

Container

Bases: Widget

Lightweight layout-only Container.

This Container is a minimal single-child layout box. It intentionally does not perform background/shadow/border drawing or clipping.

Source code in src/nuiitivet/layout/container.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
class Container(Widget):
    """Lightweight layout-only Container.

    This Container is a minimal single-child layout box. It intentionally
    does not perform background/shadow/border drawing or clipping.
    """

    def __init__(
        self,
        child: Optional[Widget] = None,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        alignment: Union[str, Tuple[str, str]] = "start",
    ):
        """Initialize the Container.

        Args:
            child: The child widget to be placed inside the container.
            width: The preferred width of the container. Defaults to None (shrinkwrap).
            height: The preferred height of the container. Defaults to None (shrinkwrap).
            padding: Padding to apply around the child. Can be a single integer,
                a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).
            alignment: How to align the child within the container. Defaults to "start".
                Can be a string (e.g., "center") or a tuple (horizontal, vertical).
        """
        super().__init__(
            width=width,
            height=height,
            padding=padding,
            max_children=1,
            overflow_policy="replace_last",
        )

        self._align = normalize_alignment(alignment, default=("start", "start"))

        # child management + layout engine
        if child is not None:
            self.add_child(child)
        self._layout = LayoutEngine(self)

    def add_child(self, w: "Widget"):
        """Keep at most one child; call ChildContainerMixin directly to bypass overrides."""
        ChildContainerMixin.add_child(self, w)

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        child_max_w: Optional[int] = None
        child_max_h: Optional[int] = None

        w_dim = self.width_sizing
        h_dim = self.height_sizing

        if w_dim.kind == "fixed":
            child_max_w = int(w_dim.value)
        elif max_width is not None:
            child_max_w = int(max_width)

        if h_dim.kind == "fixed":
            child_max_h = int(h_dim.value)
        elif max_height is not None:
            child_max_h = int(max_height)

        if child_max_w is not None or child_max_h is not None:
            pad = self.padding
            # Container is lightweight and has no border; use Box if borders are needed.
            if child_max_w is not None:
                child_max_w = max(0, int(child_max_w) - int(pad[0]) - int(pad[2]))
            if child_max_h is not None:
                child_max_h = max(0, int(child_max_h) - int(pad[1]) - int(pad[3]))

        if len(self.children) == 0:
            inner_w, inner_h = 0, 0
        else:
            inner_w, inner_h = measure_preferred_size(self.children[0], max_width=child_max_w, max_height=child_max_h)

        layout_w, layout_h = self._layout.preferred_size(int(inner_w or 0), int(inner_h or 0))

        if w_dim.kind == "fixed":
            width = int(w_dim.value)
        else:
            width = int(layout_w)
            if max_width is not None:
                width = min(width, int(max_width))

        if h_dim.kind == "fixed":
            height = int(h_dim.value)
        else:
            height = int(layout_h)
            if max_height is not None:
                height = min(height, int(max_height))

        return (width, height)

    def layout(self, width: int, height: int) -> None:
        super().layout(width, height)
        if not self.children:
            return

        child = self.children[0]
        # Calculate layout relative to self (0, 0)
        ix, iy, iw, ih = self._layout.compute_inner_rect(0, 0, width, height)
        cx, cy, child_w, child_h = self._layout.resolve_child_geometry(child, ix, iy, iw, ih)

        child.layout(child_w, child_h)
        child.set_layout_rect(cx, cy, child_w, child_h)

    def paint(self, canvas, x: int, y: int, width: int, height: int):
        # Minimal paint: update last rect and delegate to child paint.
        self.set_last_rect(x, y, width, height)

        if len(self.children) == 0:
            return

        child = self.children[0]

        # Auto-layout fallback for tests or direct paint calls
        if child.layout_rect is None:
            self.layout(width, height)

        rect = child.layout_rect
        if rect:
            rel_x, rel_y, child_w, child_h = rect
            cx = x + rel_x
            cy = y + rel_y
        else:
            # Fallback if layout failed
            ix, iy, iw, ih = self._layout.compute_inner_rect(x, y, width, height)
            cx, cy, child_w, child_h = self._layout.resolve_child_geometry(child, ix, iy, iw, ih)

        # Container does not clip; child is painted as-is.
        try:
            child.set_last_rect(cx, cy, child_w, child_h)
            child.paint(canvas, cx, cy, child_w, child_h)
        except Exception:
            exception_once(_logger, "container_child_paint_exc", "Container child paint failed")

__init__(child=None, *, width=None, height=None, padding=0, alignment='start')

Initialize the Container.

Parameters:

Name Type Description Default
child Optional[Widget]

The child widget to be placed inside the container.

None
width SizingLike

The preferred width of the container. Defaults to None (shrinkwrap).

None
height SizingLike

The preferred height of the container. Defaults to None (shrinkwrap).

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding to apply around the child. Can be a single integer, a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).

0
alignment Union[str, Tuple[str, str]]

How to align the child within the container. Defaults to "start". Can be a string (e.g., "center") or a tuple (horizontal, vertical).

'start'
Source code in src/nuiitivet/layout/container.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def __init__(
    self,
    child: Optional[Widget] = None,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    alignment: Union[str, Tuple[str, str]] = "start",
):
    """Initialize the Container.

    Args:
        child: The child widget to be placed inside the container.
        width: The preferred width of the container. Defaults to None (shrinkwrap).
        height: The preferred height of the container. Defaults to None (shrinkwrap).
        padding: Padding to apply around the child. Can be a single integer,
            a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).
        alignment: How to align the child within the container. Defaults to "start".
            Can be a string (e.g., "center") or a tuple (horizontal, vertical).
    """
    super().__init__(
        width=width,
        height=height,
        padding=padding,
        max_children=1,
        overflow_policy="replace_last",
    )

    self._align = normalize_alignment(alignment, default=("start", "start"))

    # child management + layout engine
    if child is not None:
        self.add_child(child)
    self._layout = LayoutEngine(self)

add_child(w)

Keep at most one child; call ChildContainerMixin directly to bypass overrides.

Source code in src/nuiitivet/layout/container.py
58
59
60
def add_child(self, w: "Widget"):
    """Keep at most one child; call ChildContainerMixin directly to bypass overrides."""
    ChildContainerMixin.add_child(self, w)

Flow

Bases: Widget

Layout children in rows that wrap.

Arranges children in a horizontal run, wrapping to a new line when the line runs out of space.

Source code in src/nuiitivet/layout/flow.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
class Flow(Widget):
    """Layout children in rows that wrap.

    Arranges children in a horizontal run, wrapping to a new line when the line
    runs out of space.
    """

    def __init__(
        self,
        children: Optional[Sequence[Widget]] = None,
        *,
        main_gap: int = 0,
        cross_gap: int = 0,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        main_alignment: str = "start",
        run_alignment: str = "start",
        cross_alignment: str = "start",
        width: SizingLike = None,
        height: SizingLike = None,
    ) -> None:
        super().__init__(width=width, height=height, padding=padding)
        if children:
            for child in children:
                self.add_child(child)

        self.main_gap = normalize_gap(main_gap)
        self.cross_gap = normalize_gap(cross_gap)
        self.main_alignment = main_alignment or "start"
        self.run_alignment = run_alignment or "start"
        self.cross_alignment = cross_alignment or "start"

    @classmethod
    def builder(
        cls,
        items: ItemsLike,
        builder: BuilderFn,
        *,
        main_gap: int = 0,
        cross_gap: int = 0,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        main_alignment: str = "start",
        run_alignment: str = "start",
        cross_alignment: str = "start",
        width: SizingLike = None,
        height: SizingLike = None,
    ) -> "Flow":
        """Create a Flow that materializes children from items via ForEach."""

        provider = ForEach(items, builder)
        return cls(
            [provider],
            main_gap=main_gap,
            cross_gap=cross_gap,
            padding=padding,
            main_alignment=main_alignment,
            run_alignment=run_alignment,
            cross_alignment=cross_alignment,
            width=width,
            height=height,
        )

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        children = expand_layout_children(self.children_snapshot())
        pad = self.padding

        if not children:
            width = pad[0] + pad[2]
            height = pad[1] + pad[3]
            if max_width is not None and self.width_sizing.kind != "fixed":
                width = min(width, int(max_width))
            if max_height is not None and self.height_sizing.kind != "fixed":
                height = min(height, int(max_height))
            return (width, height)

        inner_max_w: Optional[int] = None
        if max_width is not None:
            inner_max_w = max(0, int(max_width) - int(pad[0]) - int(pad[2]))
        elif self.width_sizing.kind == "fixed":
            inner_max_w = max(0, int(self.width_sizing.value) - int(pad[0]) - int(pad[2]))

        width, height = self._preferred_size_flow(children, inner_max_w)

        width = self._resolve_sizing(self.width_sizing, width + pad[0] + pad[2])
        height = self._resolve_sizing(self.height_sizing, height + pad[1] + pad[3])

        if max_width is not None and self.width_sizing.kind != "fixed":
            width = min(width, int(max_width))
        if max_height is not None and self.height_sizing.kind != "fixed":
            height = min(height, int(max_height))

        return (int(width), int(height))

    def _preferred_size_flow(self, children: List[Widget], inner_max_w: Optional[int]) -> Tuple[int, int]:
        if inner_max_w is None or inner_max_w <= 0:
            total_w = 0
            max_h = 0
            for idx, child in enumerate(children):
                pref_w, pref_h = child.preferred_size()
                if idx > 0:
                    total_w += self.main_gap
                total_w += max(0, int(pref_w))
                max_h = max(max_h, max(0, int(pref_h)))
            return (int(total_w), int(max_h))

        row_w = 0
        row_h = 0
        max_row_w = 0
        total_h = 0
        first_in_row = True

        for child in children:
            pref_w, pref_h = measure_preferred_size(child, max_width=inner_max_w)
            cw = max(0, int(pref_w))
            ch = max(0, int(pref_h))

            extra_gap = 0 if first_in_row else self.main_gap
            next_w = row_w + extra_gap + cw

            if not first_in_row and next_w > inner_max_w:
                total_h += row_h
                total_h += self.cross_gap
                max_row_w = max(max_row_w, row_w)
                row_w = cw
                row_h = ch
                first_in_row = False
                continue

            row_w = next_w if not first_in_row else cw
            row_h = max(row_h, ch)
            first_in_row = False

        if not first_in_row:
            total_h += row_h
            max_row_w = max(max_row_w, row_w)

        return (min(int(inner_max_w), int(max_row_w or inner_max_w)), int(total_h))

    @staticmethod
    def _resolve_sizing(dim, fallback: int) -> int:
        if dim.kind == "fixed":
            return int(dim.value)
        return fallback

    def layout(self, width: int, height: int) -> None:
        super().layout(width, height)
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        pad = self.padding
        # Content area relative to self (0,0)
        inner_x = pad[0]
        inner_y = pad[1]
        inner_w = max(0, width - pad[0] - pad[2])
        inner_h = max(0, height - pad[1] - pad[3])

        self._layout_flow(children, inner_x, inner_y, inner_w, inner_h)

    def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        # Auto-layout fallback for tests or direct paint calls
        if any(c.layout_rect is None for c in children):
            self.layout(width, height)

        for child in children:
            rect = child.layout_rect
            if rect is None:
                continue

            rel_x, rel_y, w, h = rect
            abs_x = x + rel_x
            abs_y = y + rel_y

            child.set_last_rect(abs_x, abs_y, w, h)

            child.paint(canvas, abs_x, abs_y, w, h)

    def _layout_flow(self, children: List[Widget], x: int, y: int, w: int, h: int) -> None:
        # Simple flow layout implementation
        current_x = x

        # First pass: measure and group into rows
        rows: list[tuple[list[tuple[Widget, int, int]], int, int]] = []
        current_row: list[tuple[Widget, int, int]] = []
        current_row_width = 0
        current_row_height = 0

        for child in children:
            cw, ch = measure_preferred_size(child, max_width=w, max_height=h)

            if current_row and (current_x + cw > x + w) and w > 0:
                # Wrap to next row
                rows.append((current_row, current_row_width, current_row_height))
                current_row = []
                current_x = x
                current_row_width = 0
                current_row_height = 0

            current_row.append((child, cw, ch))
            current_row_width += cw + (self.main_gap if len(current_row) > 1 else 0)
            current_row_height = max(current_row_height, ch)
            current_x += cw + self.main_gap

        if current_row:
            rows.append((current_row, current_row_width, current_row_height))

        # Second pass: position rows and children
        # Calculate total content height for run_alignment
        total_content_h = sum(r[2] for r in rows) + max(0, len(rows) - 1) * self.cross_gap
        start_y = y + align_offset(h, total_content_h, self.run_alignment)

        current_y = start_y
        for row_items, row_w, row_h in rows:
            # Calculate row start x based on main_alignment
            start_x = x + align_offset(w, row_w, self.main_alignment)
            current_x = start_x

            for child, cw, ch in row_items:
                # Apply item alignment within the row height using cross_alignment
                child_y = current_y + align_offset(row_h, ch, self.cross_alignment)

                child.layout(cw, ch)
                child.set_layout_rect(int(current_x), int(child_y), int(cw), int(ch))

                current_x += cw + self.main_gap

            current_y += row_h + self.cross_gap

builder(items, builder, *, main_gap=0, cross_gap=0, padding=0, main_alignment='start', run_alignment='start', cross_alignment='start', width=None, height=None) classmethod

Create a Flow that materializes children from items via ForEach.

Source code in src/nuiitivet/layout/flow.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@classmethod
def builder(
    cls,
    items: ItemsLike,
    builder: BuilderFn,
    *,
    main_gap: int = 0,
    cross_gap: int = 0,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    main_alignment: str = "start",
    run_alignment: str = "start",
    cross_alignment: str = "start",
    width: SizingLike = None,
    height: SizingLike = None,
) -> "Flow":
    """Create a Flow that materializes children from items via ForEach."""

    provider = ForEach(items, builder)
    return cls(
        [provider],
        main_gap=main_gap,
        cross_gap=cross_gap,
        padding=padding,
        main_alignment=main_alignment,
        run_alignment=run_alignment,
        cross_alignment=cross_alignment,
        width=width,
        height=height,
    )

UniformFlow

Bases: Widget

Layout children in a uniform grid.

This layout arranges children into columns with equal width.

Source code in src/nuiitivet/layout/uniform_flow.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
class UniformFlow(Widget):
    """Layout children in a uniform grid.

    This layout arranges children into columns with equal width.
    """

    def __init__(
        self,
        children: Optional[Sequence[Widget]] = None,
        *,
        columns: Optional[int] = None,
        max_column_width: Optional[int] = None,
        aspect_ratio: Optional[float] = None,
        main_gap: int = 0,
        cross_gap: int = 0,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        main_alignment: str = "start",
        run_alignment: str = "start",
        item_alignment: AlignValue = "stretch",
        width: SizingLike = None,
        height: SizingLike = None,
    ) -> None:
        super().__init__(width=width, height=height, padding=padding)
        if children:
            for child in children:
                self.add_child(child)

        self.columns = self._normalize_positive(columns)
        self.max_column_width = self._normalize_positive(max_column_width)
        self.aspect_ratio = float(aspect_ratio) if aspect_ratio else None
        self.main_gap = normalize_gap(main_gap)
        self.cross_gap = normalize_gap(cross_gap)
        self.main_alignment = main_alignment or "start"
        self.run_alignment = run_alignment or "start"
        self.item_alignment = self._normalize_align_pair(item_alignment)

    @classmethod
    def builder(
        cls,
        items: ItemsLike,
        builder: BuilderFn,
        *,
        columns: Optional[int] = None,
        max_column_width: Optional[int] = None,
        aspect_ratio: Optional[float] = None,
        main_gap: int = 0,
        cross_gap: int = 0,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        main_alignment: str = "start",
        run_alignment: str = "start",
        item_alignment: AlignValue = "stretch",
        width: SizingLike = None,
        height: SizingLike = None,
    ) -> "UniformFlow":
        """Create a UniformFlow that materializes children from items via ForEach."""
        provider = ForEach(items, builder)
        return cls(
            [provider],
            columns=columns,
            max_column_width=max_column_width,
            aspect_ratio=aspect_ratio,
            main_gap=main_gap,
            cross_gap=cross_gap,
            padding=padding,
            main_alignment=main_alignment,
            run_alignment=run_alignment,
            item_alignment=item_alignment,
            width=width,
            height=height,
        )

    @staticmethod
    def _normalize_positive(value: Optional[int]) -> Optional[int]:
        if value is None:
            return None
        iv = int(value)
        return iv if iv > 0 else None

    @staticmethod
    def _normalize_align_pair(value: AlignValue) -> Tuple[str, str]:
        if isinstance(value, (tuple, list)) and len(value) == 2:
            return (str(value[0]), str(value[1]))
        if isinstance(value, str):
            return (value, value)
        return ("stretch", "stretch")

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        children = expand_layout_children(self.children_snapshot())
        pad = self.padding

        if not children:
            width = pad[0] + pad[2]
            height = pad[1] + pad[3]
            if max_width is not None and self.width_sizing.kind != "fixed":
                width = min(width, int(max_width))
            if max_height is not None and self.height_sizing.kind != "fixed":
                height = min(height, int(max_height))
            return (width, height)

        inner_max_w: Optional[int] = None
        if max_width is not None:
            inner_max_w = max(0, int(max_width) - int(pad[0]) - int(pad[2]))
        elif self.width_sizing.kind == "fixed":
            inner_max_w = max(0, int(self.width_sizing.value) - int(pad[0]) - int(pad[2]))

        width, height = self._preferred_size_content(children, inner_max_w)

        width = self._resolve_sizing(self.width_sizing, width + pad[0] + pad[2])
        height = self._resolve_sizing(self.height_sizing, height + pad[1] + pad[3])

        if max_width is not None and self.width_sizing.kind != "fixed":
            width = min(width, int(max_width))
        if max_height is not None and self.height_sizing.kind != "fixed":
            height = min(height, int(max_height))

        return (int(width), int(height))

    def _preferred_size_content(self, children: List[Widget], inner_max_w: Optional[int]) -> Tuple[int, int]:
        count = len(children)
        col_limit: Optional[int] = inner_max_w
        if inner_max_w is not None and inner_max_w > 0:
            cols = self._resolve_columns(count, inner_max_w)
            # Calculate implied column width
            usable = max(0, inner_max_w - max(0, cols - 1) * self.main_gap)
            if usable > 0:
                col_limit = usable // cols
        else:
            cols = self._intrinsic_columns(count)
        rows = max(1, math.ceil(count / max(1, cols)))

        max_w = 0
        max_h = 0
        for child in children:
            pref_w, pref_h = measure_preferred_size(child, max_width=col_limit)
            max_w = max(max_w, max(0, pref_w))
            max_h = max(max_h, max(0, pref_h))

        # If we have a constrained column width, use it for aspect ratio and size calculation
        # This matches layout() logic where columns expand to fill available width
        if col_limit is not None and col_limit > 0:
            max_w = max(max_w, col_limit)

        if self.aspect_ratio and max_w > 0:
            max_h = max(max_h, self._height_from_aspect(max_w))

        content_w = cols * max_w + max(0, cols - 1) * self.main_gap
        content_h = rows * max_h + max(0, rows - 1) * self.cross_gap
        return (int(content_w), int(content_h))

    @staticmethod
    def _resolve_sizing(dim, fallback: int) -> int:
        if dim.kind == "fixed":
            return int(dim.value)
        return fallback

    def layout(self, width: int, height: int) -> None:
        super().layout(width, height)
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        pad = self.padding
        inner_w = max(0, width - pad[0] - pad[2])
        inner_h = max(0, height - pad[1] - pad[3])

        count = len(children)
        cols = self._resolve_columns(count, inner_w)
        cols = max(1, cols)
        rows = max(1, math.ceil(count / cols))

        col_widths = self._resolve_column_widths(cols, inner_w, children)
        row_heights = self._resolve_row_heights(rows, col_widths, children)

        content_w = sum(col_widths) + max(0, cols - 1) * self.main_gap
        content_h = sum(row_heights) + max(0, rows - 1) * self.cross_gap

        start_x = pad[0] + align_offset(inner_w, content_w, self.main_alignment)
        start_y = pad[1] + align_offset(inner_h, content_h, self.run_alignment)

        col_offsets = compute_prefix_offsets(col_widths, self.main_gap)
        row_offsets = compute_prefix_offsets(row_heights, self.cross_gap)

        for i, child in enumerate(children):
            r = i // cols
            c = i % cols

            cell_w = col_widths[c]
            cell_h = row_heights[r]

            cell_x = start_x + col_offsets[c]
            cell_y = start_y + row_offsets[r]

            pref_w, pref_h = measure_preferred_size(child, max_width=cell_w)

            child_w = cell_w
            child_h = cell_h

            align_x, align_y = self.item_alignment

            if align_x != "stretch":
                child_w = min(pref_w, cell_w)
                cell_x += align_offset(cell_w, child_w, align_x)

            if align_y != "stretch":
                child_h = min(pref_h, cell_h)
                cell_y += align_offset(cell_h, child_h, align_y)

            child.layout(child_w, child_h)
            child.set_layout_rect(int(cell_x), int(cell_y), int(child_w), int(child_h))

    def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        # Auto-layout fallback for tests or direct paint calls
        if any(c.layout_rect is None for c in children):
            self.layout(width, height)

        for child in children:
            rect = child.layout_rect
            if rect is None:
                continue

            rel_x, rel_y, w, h = rect
            abs_x = x + rel_x
            abs_y = y + rel_y

            child.set_last_rect(abs_x, abs_y, w, h)

            child.paint(canvas, abs_x, abs_y, w, h)

    def _intrinsic_columns(self, child_count: int) -> int:
        if self.columns:
            return max(1, min(child_count, self.columns))
        if self.max_column_width:
            guess = int(math.sqrt(child_count)) or 1
            return max(1, min(child_count, guess))
        return max(1, child_count)

    def _resolve_columns(self, child_count: int, available_width: int) -> int:
        if self.columns:
            return max(1, min(child_count, self.columns))
        if self.max_column_width and available_width > 0:
            denom = self.max_column_width + self.main_gap
            if denom > 0:
                cols = max(1, (available_width + self.main_gap) // denom)
                return max(1, min(child_count, cols))
        return max(1, child_count)

    def _resolve_column_widths(self, cols: int, inner_w: int, children: List[Widget]) -> List[int]:
        if cols <= 0:
            return []
        usable = max(0, inner_w - max(0, cols - 1) * self.main_gap)
        if usable > 0:
            base = usable // cols
            rem = usable - base * cols
            widths = [base] * cols
            for i in range(rem):
                widths[i % cols] += 1
            return widths
        widths = [0] * cols
        for idx, child in enumerate(children):
            col = idx % cols
            pref_w, _ = child.preferred_size()
            widths[col] = max(widths[col], max(0, pref_w))
        return widths

    def _resolve_row_heights(self, rows: int, col_widths: List[int], children: List[Widget]) -> List[int]:
        if rows <= 0:
            return []
        heights = [0] * rows
        cols = max(1, len(col_widths))
        for idx, child in enumerate(children):
            col = idx % cols
            cw = col_widths[col] if col < len(col_widths) else None
            pref_w, pref_h = measure_preferred_size(child, max_width=cw)
            row = min(idx // cols, rows - 1)
            if self.aspect_ratio and col_widths:
                # col is already defined above
                tile_h = self._height_from_aspect(col_widths[col])
                heights[row] = max(heights[row], tile_h)
            else:
                heights[row] = max(heights[row], max(0, pref_h))
        if self.aspect_ratio and col_widths:
            default_h = self._height_from_aspect(col_widths[0])
            heights = [h if h > 0 else default_h for h in heights]
        return heights

    def _height_from_aspect(self, width: int) -> int:
        if not self.aspect_ratio or self.aspect_ratio <= 0:
            return max(0, width)
        return max(1, int(round(width / self.aspect_ratio)))

builder(items, builder, *, columns=None, max_column_width=None, aspect_ratio=None, main_gap=0, cross_gap=0, padding=0, main_alignment='start', run_alignment='start', item_alignment='stretch', width=None, height=None) classmethod

Create a UniformFlow that materializes children from items via ForEach.

Source code in src/nuiitivet/layout/uniform_flow.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
@classmethod
def builder(
    cls,
    items: ItemsLike,
    builder: BuilderFn,
    *,
    columns: Optional[int] = None,
    max_column_width: Optional[int] = None,
    aspect_ratio: Optional[float] = None,
    main_gap: int = 0,
    cross_gap: int = 0,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    main_alignment: str = "start",
    run_alignment: str = "start",
    item_alignment: AlignValue = "stretch",
    width: SizingLike = None,
    height: SizingLike = None,
) -> "UniformFlow":
    """Create a UniformFlow that materializes children from items via ForEach."""
    provider = ForEach(items, builder)
    return cls(
        [provider],
        columns=columns,
        max_column_width=max_column_width,
        aspect_ratio=aspect_ratio,
        main_gap=main_gap,
        cross_gap=cross_gap,
        padding=padding,
        main_alignment=main_alignment,
        run_alignment=run_alignment,
        item_alignment=item_alignment,
        width=width,
        height=height,
    )

Grid

Bases: Widget

Two-dimensional layout container with explicit tracks.

Source code in src/nuiitivet/layout/grid.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
class Grid(Widget):
    """Two-dimensional layout container with explicit tracks."""

    def __init__(
        self,
        children: Optional[Sequence[Widget]],
        rows: Optional[Sequence[SizingLike]],
        columns: Optional[Sequence[SizingLike]],
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        row_gap: int = 0,
        column_gap: int = 0,
    ):
        """Initialize the Grid layout.

        Args:
            children: List of GridItems to display.
            rows: List of row sizes (e.g. [100, "1fr", "auto"]).
            columns: List of column sizes.
            width: Grid container width.
            height: Grid container height.
            padding: Padding around the grid content.
            row_gap: Vertical gap between rows.
            column_gap: Horizontal gap between columns.
        """
        super().__init__(width=width, height=height, padding=padding)

        self._rows: List[Sizing] = [parse_sizing(d) for d in rows] if rows else []
        self._columns: List[Sizing] = [parse_sizing(d) for d in columns] if columns else []
        self.areas: Optional[List[List[str]]] = None
        self.row_gap = normalize_gap(row_gap)
        self.column_gap = normalize_gap(column_gap)

        if children:
            for child in children:
                self.add_child(child)

    @classmethod
    def named_areas(
        cls,
        children: Sequence[Widget],
        areas: Sequence[Sequence[str]],
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        row_gap: int = 0,
        column_gap: int = 0,
        rows: Optional[Sequence[SizingLike]] = None,
        columns: Optional[Sequence[SizingLike]] = None,
    ) -> Grid:
        """Create a Grid using named template areas.

        Args:
            children: List of child widgets (usually GridItem.named_area).
            areas: 2D list of area names (e.g. [["header", "header"], ["sidebar", "content"]]).
            width: Grid width.
            height: Grid height.
            padding: Grid padding.
            row_gap: Gap between rows.
            column_gap: Gap between columns.
            rows: Explicit row sizing overrides.
            columns: Explicit column sizing overrides.

        Returns:
            Grid: A configured Grid instance.
        """
        grid = cls(
            children=children,
            width=width,
            height=height,
            padding=padding,
            row_gap=row_gap,
            column_gap=column_gap,
            rows=rows,
            columns=columns,
        )
        grid.areas = grid._normalize_areas(areas)
        return grid

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        l, t, r, b = self.padding
        child_max_w: Optional[int] = None
        child_max_h: Optional[int] = None
        if max_width is not None:
            child_max_w = max(0, int(max_width) - int(l) - int(r))
        elif self.width_sizing.kind == "fixed":
            child_max_w = max(0, int(self.width_sizing.value) - int(l) - int(r))
        if max_height is not None:
            child_max_h = max(0, int(max_height) - int(t) - int(b))
        elif self.height_sizing.kind == "fixed":
            child_max_h = max(0, int(self.height_sizing.value) - int(t) - int(b))

        placements, rows, columns = self._prepare_layout(child_max_w, child_max_h)
        if not rows:
            rows = [Sizing.auto()]
        if not columns:
            columns = [Sizing.auto()]

        row_sizes = self._measure_axis(rows, placements, "row", "row_span", "pref_height", self.row_gap)
        col_sizes = self._measure_axis(columns, placements, "column", "column_span", "pref_width", self.column_gap)

        content_w = sum(col_sizes) + self.column_gap * max(0, len(col_sizes) - 1)
        content_h = sum(row_sizes) + self.row_gap * max(0, len(row_sizes) - 1)

        w_dim = self.width_sizing
        h_dim = self.height_sizing

        width = int(w_dim.value) if w_dim.kind == "fixed" else content_w + l + r
        height = int(h_dim.value) if h_dim.kind == "fixed" else content_h + t + b

        if max_width is not None and w_dim.kind != "fixed":
            width = min(int(width), int(max_width))
        if max_height is not None and h_dim.kind != "fixed":
            height = min(int(height), int(max_height))
        return (int(width), int(height))

    def layout(self, width: int, height: int) -> None:
        super().layout(width, height)
        placements, rows, columns = self._prepare_layout()
        if not placements and not rows and not columns:
            return

        row_sizes = self._measure_axis(rows, placements, "row", "row_span", "pref_height", self.row_gap)
        col_sizes = self._measure_axis(columns, placements, "column", "column_span", "pref_width", self.column_gap)

        # Calculate content area size (relative to self at 0,0)
        l, t, r, b = self.padding
        cw = max(0, width - l - r)
        ch = max(0, height - t - b)

        row_sizes = self._apply_stretch(rows, row_sizes, self.row_gap, ch)
        col_sizes = self._apply_stretch(columns, col_sizes, self.column_gap, cw)

        # Start positions relative to self
        row_positions = self._accumulate_positions(t, row_sizes, self.row_gap)
        col_positions = self._accumulate_positions(l, col_sizes, self.column_gap)

        for placement in placements:
            row = placement.row
            col = placement.column
            if row >= len(row_sizes) or col >= len(col_sizes):
                continue
            row_end = min(row + placement.row_span, len(row_sizes))
            col_end = min(col + placement.column_span, len(col_sizes))

            # Calculate relative coordinates
            cell_y = row_positions[row]
            cell_x = col_positions[col]
            cell_h = sum(row_sizes[row:row_end]) + self.row_gap * max(0, row_end - row - 1)
            cell_w = sum(col_sizes[col:col_end]) + self.column_gap * max(0, col_end - col - 1)

            rect_x, rect_y = int(cell_x), int(cell_y)
            rect_w, rect_h = int(cell_w), int(cell_h)

            placement.child.layout(rect_w, rect_h)
            placement.child.set_layout_rect(rect_x, rect_y, rect_w, rect_h)

    def paint(self, canvas, x: int, y: int, width: int, height: int):
        self.set_last_rect(x, y, width, height)

        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        # Auto-layout fallback for tests or direct paint calls
        if any(c.layout_rect is None for c in children):
            self.layout(width, height)

        for child in children:
            rect = child.layout_rect
            if rect is None:
                continue

            rel_x, rel_y, w, h = rect
            abs_x = x + rel_x
            abs_y = y + rel_y

            child.set_last_rect(abs_x, abs_y, w, h)

            child.paint(canvas, abs_x, abs_y, w, h)

    # --- helpers -------------------------------------------------
    def _prepare_layout(
        self,
        child_max_w: Optional[int] = None,
        child_max_h: Optional[int] = None,
    ) -> Tuple[List[_ResolvedPlacement], List[Sizing], List[Sizing]]:
        children = expand_layout_children(self.children_snapshot())
        rows = list(self._rows)
        columns = list(self._columns)
        placements: List[_ResolvedPlacement] = []

        for child in children:
            placement = self._resolve_child_placement(child, rows, columns)
            if placement is None:
                continue
            pref_w, pref_h = self._safe_preferred_size(child, child_max_w, child_max_h)
            placement.pref_width = max(0, int(pref_w))
            placement.pref_height = max(0, int(pref_h))
            placements.append(placement)

        return placements, rows, columns

    def _resolve_child_placement(
        self,
        child: Widget,
        rows: List[Sizing],
        columns: List[Sizing],
    ) -> Optional[_ResolvedPlacement]:
        if isinstance(child, GridItem):
            if child.area:
                row, column, row_span, column_span = self._area_to_rect(child.area)
            else:
                row_start, row_span = child.resolve_row()
                col_start, col_span = child.resolve_column()
                if row_start is None or col_start is None:
                    raise ValueError("GridItem requires 'area' or both 'row' and 'column' to be set")
                row = row_start
                column = col_start
                row_span = max(1, row_span)
                column_span = max(1, col_span)
        else:
            raise TypeError("Grid children must be GridItem instances with explicit placement")

        if row < 0 or column < 0:
            raise ValueError("Grid indices must be non-negative")

        self._ensure_track_length(rows, row + row_span)
        self._ensure_track_length(columns, column + column_span)
        return _ResolvedPlacement(child, row, column, row_span, column_span)

    def _safe_preferred_size(
        self,
        child: Widget,
        max_width: Optional[int] = None,
        max_height: Optional[int] = None,
    ) -> Tuple[int, int]:
        from .measure import preferred_size as measure_preferred_size

        try:
            return measure_preferred_size(child, max_width=max_width, max_height=max_height)
        except Exception:
            exception_once(
                logger,
                "grid_preferred_size_exc",
                "child.preferred_size() failed (child=%s)",
                type(child).__name__,
            )
            return (0, 0)

    def _ensure_track_length(self, tracks: List[Sizing], required: int) -> None:
        while len(tracks) < required:
            tracks.append(Sizing.auto())

    def _measure_axis(
        self,
        dims: List[Sizing],
        placements: List[_ResolvedPlacement],
        start_attr: str,
        span_attr: str,
        pref_attr: str,
        spacing: int,
    ) -> List[float]:
        sizes = [dim.value if dim.kind == "fixed" else 0.0 for dim in dims]
        for placement in placements:
            start = getattr(placement, start_attr)
            span = getattr(placement, span_attr)
            pref = getattr(placement, pref_attr)
            if span <= 0:
                continue
            end = min(start + span, len(dims))
            if end <= start:
                continue

            fixed_total = 0.0
            dynamic_indices: List[int] = []
            for idx in range(start, end):
                dim = dims[idx]
                if dim.kind == "fixed":
                    fixed_total += sizes[idx]
                else:
                    dynamic_indices.append(idx)

            target = max(0.0, pref - spacing * max(0, span - 1) - fixed_total)
            if not dynamic_indices:
                continue
            current = sum(sizes[idx] for idx in dynamic_indices)
            if current >= target:
                continue
            extra = target - current
            per = extra / len(dynamic_indices)
            for idx in dynamic_indices:
                sizes[idx] += per

        return sizes

    def _apply_stretch(
        self,
        dims: List[Sizing],
        base_sizes: List[float],
        spacing: int,
        available: int,
    ) -> List[float]:
        if not base_sizes:
            return []
        total_spacing = spacing * max(0, len(base_sizes) - 1)
        usable = available - total_spacing
        sizes = list(base_sizes)
        if usable <= 0:
            return sizes

        current = sum(sizes)
        remaining = usable - current
        if remaining <= 0:
            # Not enough room; leave sizes as-is (children may overflow)
            return sizes

        weights = [dim.value if dim.kind == "flex" and dim.value > 0 else 0.0 for dim in dims]
        total_weight = sum(weights)
        if total_weight <= 0:
            return sizes

        unit = remaining / total_weight
        for idx, weight in enumerate(weights):
            if weight > 0:
                sizes[idx] += weight * unit
        return sizes

    def _accumulate_positions(self, start: int, sizes: List[float], spacing: int) -> List[float]:
        positions: List[float] = []
        cursor = float(start)
        for size in sizes:
            positions.append(cursor)
            cursor += size + spacing
        return positions

    def _normalize_areas(self, areas: Optional[Sequence[Sequence[str]]]) -> Optional[List[List[str]]]:
        if areas is None:
            return None
        normalized = [list(row) for row in areas]
        if not normalized:
            return None
        width = len(normalized[0])
        for row in normalized:
            if len(row) != width:
                raise ValueError("All area rows must have the same number of columns")
        return normalized

    def _area_to_rect(self, name: str) -> Tuple[int, int, int, int]:
        if not self.areas:
            raise ValueError("Grid has no areas defined")
        min_r, max_r = None, None
        min_c, max_c = None, None
        for r_idx, row in enumerate(self.areas):
            for c_idx, cell in enumerate(row):
                if cell == name:
                    if min_r is None or r_idx < min_r:
                        min_r = r_idx
                    if max_r is None or r_idx > max_r:
                        max_r = r_idx
                    if min_c is None or c_idx < min_c:
                        min_c = c_idx
                    if max_c is None or c_idx > max_c:
                        max_c = c_idx
        if min_r is None or min_c is None or max_r is None or max_c is None:
            raise ValueError(f"Grid area '{name}' is not defined")
        return min_r, min_c, max_r - min_r + 1, max_c - min_c + 1

    __all__ = ["Grid", "GridItem"]

__init__(children, rows, columns, *, width=None, height=None, padding=0, row_gap=0, column_gap=0)

Initialize the Grid layout.

Parameters:

Name Type Description Default
children Optional[Sequence[Widget]]

List of GridItems to display.

required
rows Optional[Sequence[SizingLike]]

List of row sizes (e.g. [100, "1fr", "auto"]).

required
columns Optional[Sequence[SizingLike]]

List of column sizes.

required
width SizingLike

Grid container width.

None
height SizingLike

Grid container height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the grid content.

0
row_gap int

Vertical gap between rows.

0
column_gap int

Horizontal gap between columns.

0
Source code in src/nuiitivet/layout/grid.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def __init__(
    self,
    children: Optional[Sequence[Widget]],
    rows: Optional[Sequence[SizingLike]],
    columns: Optional[Sequence[SizingLike]],
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    row_gap: int = 0,
    column_gap: int = 0,
):
    """Initialize the Grid layout.

    Args:
        children: List of GridItems to display.
        rows: List of row sizes (e.g. [100, "1fr", "auto"]).
        columns: List of column sizes.
        width: Grid container width.
        height: Grid container height.
        padding: Padding around the grid content.
        row_gap: Vertical gap between rows.
        column_gap: Horizontal gap between columns.
    """
    super().__init__(width=width, height=height, padding=padding)

    self._rows: List[Sizing] = [parse_sizing(d) for d in rows] if rows else []
    self._columns: List[Sizing] = [parse_sizing(d) for d in columns] if columns else []
    self.areas: Optional[List[List[str]]] = None
    self.row_gap = normalize_gap(row_gap)
    self.column_gap = normalize_gap(column_gap)

    if children:
        for child in children:
            self.add_child(child)

named_areas(children, areas, *, width=None, height=None, padding=0, row_gap=0, column_gap=0, rows=None, columns=None) classmethod

Create a Grid using named template areas.

Parameters:

Name Type Description Default
children Sequence[Widget]

List of child widgets (usually GridItem.named_area).

required
areas Sequence[Sequence[str]]

2D list of area names (e.g. [["header", "header"], ["sidebar", "content"]]).

required
width SizingLike

Grid width.

None
height SizingLike

Grid height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Grid padding.

0
row_gap int

Gap between rows.

0
column_gap int

Gap between columns.

0
rows Optional[Sequence[SizingLike]]

Explicit row sizing overrides.

None
columns Optional[Sequence[SizingLike]]

Explicit column sizing overrides.

None

Returns:

Name Type Description
Grid Grid

A configured Grid instance.

Source code in src/nuiitivet/layout/grid.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
@classmethod
def named_areas(
    cls,
    children: Sequence[Widget],
    areas: Sequence[Sequence[str]],
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    row_gap: int = 0,
    column_gap: int = 0,
    rows: Optional[Sequence[SizingLike]] = None,
    columns: Optional[Sequence[SizingLike]] = None,
) -> Grid:
    """Create a Grid using named template areas.

    Args:
        children: List of child widgets (usually GridItem.named_area).
        areas: 2D list of area names (e.g. [["header", "header"], ["sidebar", "content"]]).
        width: Grid width.
        height: Grid height.
        padding: Grid padding.
        row_gap: Gap between rows.
        column_gap: Gap between columns.
        rows: Explicit row sizing overrides.
        columns: Explicit column sizing overrides.

    Returns:
        Grid: A configured Grid instance.
    """
    grid = cls(
        children=children,
        width=width,
        height=height,
        padding=padding,
        row_gap=row_gap,
        column_gap=column_gap,
        rows=rows,
        columns=columns,
    )
    grid.areas = grid._normalize_areas(areas)
    return grid

GridItem

Bases: Container

Annotate a child with explicit grid placement data.

Source code in src/nuiitivet/layout/grid.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
class GridItem(Container):
    """Annotate a child with explicit grid placement data."""

    def __init__(
        self,
        child: Widget,
        row: Optional[GridIndex] = None,
        column: Optional[GridIndex] = None,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        alignment: Union[str, Tuple[str, str]] = "start",
    ):
        """Initialize the GridItem wrapper.

        Args:
            child: The widget to place in the grid.
            row: Row index or (start, end) tuple. 0-based.
            column: Column index or (start, end) tuple. 0-based.
            width: Override width.
            height: Override height.
            padding: Padding around the child within the grid cell.
            alignment: Alignment within the grid cell. Defaults to "start".
        """
        super().__init__(
            child=child,
            width=width,
            height=height,
            padding=padding,
            alignment=alignment,
        )
        self._row_spec = row
        self._column_spec = column
        self.area: Optional[str] = None

    @classmethod
    def named_area(
        cls,
        child: Widget,
        name: str,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
        alignment: Union[str, Tuple[str, str]] = "start",
    ) -> GridItem:
        """Create a GridItem placed in a named template area.

        Args:
            child: The child widget to place.
            name: The area name matching a name in Grid.named_areas().
            width: Override width.
            height: Override height.
            padding: Padding around the child within the grid cell.
            alignment: Alignment within the grid cell. Defaults to "start".

        Returns:
            GridItem: The wrapped widget with area information.
        """
        item = cls(
            child=child,
            width=width,
            height=height,
            padding=padding,
            alignment=alignment,
        )
        item.area = name
        return item

    def resolve_row(self) -> Tuple[Optional[int], int]:
        return _normalize_index(self._row_spec)

    def resolve_column(self) -> Tuple[Optional[int], int]:
        return _normalize_index(self._column_spec)

__init__(child, row=None, column=None, *, width=None, height=None, padding=0, alignment='start')

Initialize the GridItem wrapper.

Parameters:

Name Type Description Default
child Widget

The widget to place in the grid.

required
row Optional[GridIndex]

Row index or (start, end) tuple. 0-based.

None
column Optional[GridIndex]

Column index or (start, end) tuple. 0-based.

None
width SizingLike

Override width.

None
height SizingLike

Override height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the child within the grid cell.

0
alignment Union[str, Tuple[str, str]]

Alignment within the grid cell. Defaults to "start".

'start'
Source code in src/nuiitivet/layout/grid.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def __init__(
    self,
    child: Widget,
    row: Optional[GridIndex] = None,
    column: Optional[GridIndex] = None,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    alignment: Union[str, Tuple[str, str]] = "start",
):
    """Initialize the GridItem wrapper.

    Args:
        child: The widget to place in the grid.
        row: Row index or (start, end) tuple. 0-based.
        column: Column index or (start, end) tuple. 0-based.
        width: Override width.
        height: Override height.
        padding: Padding around the child within the grid cell.
        alignment: Alignment within the grid cell. Defaults to "start".
    """
    super().__init__(
        child=child,
        width=width,
        height=height,
        padding=padding,
        alignment=alignment,
    )
    self._row_spec = row
    self._column_spec = column
    self.area: Optional[str] = None

named_area(child, name, *, width=None, height=None, padding=0, alignment='start') classmethod

Create a GridItem placed in a named template area.

Parameters:

Name Type Description Default
child Widget

The child widget to place.

required
name str

The area name matching a name in Grid.named_areas().

required
width SizingLike

Override width.

None
height SizingLike

Override height.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the child within the grid cell.

0
alignment Union[str, Tuple[str, str]]

Alignment within the grid cell. Defaults to "start".

'start'

Returns:

Name Type Description
GridItem GridItem

The wrapped widget with area information.

Source code in src/nuiitivet/layout/grid.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
@classmethod
def named_area(
    cls,
    child: Widget,
    name: str,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    alignment: Union[str, Tuple[str, str]] = "start",
) -> GridItem:
    """Create a GridItem placed in a named template area.

    Args:
        child: The child widget to place.
        name: The area name matching a name in Grid.named_areas().
        width: Override width.
        height: Override height.
        padding: Padding around the child within the grid cell.
        alignment: Alignment within the grid cell. Defaults to "start".

    Returns:
        GridItem: The wrapped widget with area information.
    """
    item = cls(
        child=child,
        width=width,
        height=height,
        padding=padding,
        alignment=alignment,
    )
    item.area = name
    return item

Spacer

Bases: Widget

Invisible widget that reserves space.

This single Spacer supports both fixed-size and flexible behavior.

Parameters:

Name Type Description Default
width SizingLike

preferred width (int, "auto", "{f}%", or Sizing)

0
height SizingLike

preferred height (same accepted formats as width)

0
Source code in src/nuiitivet/layout/spacer.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Spacer(Widget):
    """Invisible widget that reserves space.

    This single Spacer supports both fixed-size and flexible behavior.

    Args:
        width: preferred width (int, "auto", "{f}%", or Sizing)
        height: preferred height (same accepted formats as width)
    """

    def __init__(self, *, width: SizingLike = 0, height: SizingLike = 0):
        """Initialize a Spacer.

        Args:
            width: Preferred width. Use Sizing.flex() or 0 for flexible space.
            height: Preferred height. Use Sizing.flex() or 0 for flexible space.
        """
        super().__init__(width=width, height=height)

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        """Return preferred size based on Sizings.

        - fixed: return the fixed value
        - auto/flex: return 0 (minimum size, parent will allocate)
        """
        w_dim = self.width_sizing
        h_dim = self.height_sizing

        pref_w = int(w_dim.value) if w_dim.kind == "fixed" else 0
        pref_h = int(h_dim.value) if h_dim.kind == "fixed" else 0

        _ = (max_width, max_height)

        return (pref_w, pref_h)

    def paint(self, canvas, x: int, y: int, width: int, height: int):
        # record last rect (invisible widget)
        self.set_last_rect(x, y, width, height)

__init__(*, width=0, height=0)

Initialize a Spacer.

Parameters:

Name Type Description Default
width SizingLike

Preferred width. Use Sizing.flex() or 0 for flexible space.

0
height SizingLike

Preferred height. Use Sizing.flex() or 0 for flexible space.

0
Source code in src/nuiitivet/layout/spacer.py
23
24
25
26
27
28
29
30
def __init__(self, *, width: SizingLike = 0, height: SizingLike = 0):
    """Initialize a Spacer.

    Args:
        width: Preferred width. Use Sizing.flex() or 0 for flexible space.
        height: Preferred height. Use Sizing.flex() or 0 for flexible space.
    """
    super().__init__(width=width, height=height)

preferred_size(max_width=None, max_height=None)

Return preferred size based on Sizings.

  • fixed: return the fixed value
  • auto/flex: return 0 (minimum size, parent will allocate)
Source code in src/nuiitivet/layout/spacer.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
    """Return preferred size based on Sizings.

    - fixed: return the fixed value
    - auto/flex: return 0 (minimum size, parent will allocate)
    """
    w_dim = self.width_sizing
    h_dim = self.height_sizing

    pref_w = int(w_dim.value) if w_dim.kind == "fixed" else 0
    pref_h = int(h_dim.value) if h_dim.kind == "fixed" else 0

    _ = (max_width, max_height)

    return (pref_w, pref_h)

CrossAligned

Bases: Widget

Layout wrapper that overrides cross-axis alignment in Row/Column.

This sets cross_align metadata on the wrapper. Row/Column may read this to override their cross_alignment for this child only.

Source code in src/nuiitivet/layout/cross_aligned.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
class CrossAligned(Widget):
    """Layout wrapper that overrides cross-axis alignment in Row/Column.

    This sets `cross_align` metadata on the wrapper. Row/Column may read
    this to override their `cross_alignment` for this child only.
    """

    def __init__(
        self,
        child: Optional[Widget],
        alignment: str,
    ) -> None:
        """Initialize the CrossAligned wrapper.

        Args:
            child: The child widget to wrap.
            alignment: The cross-axis alignment to apply to this child.
                Common values: "start", "center", "end", "stretch".
        """
        super().__init__(
            width=child.width_sizing if child is not None else None,
            height=child.height_sizing if child is not None else None,
            padding=0,
            max_children=1,
            overflow_policy="replace_last",
        )
        self.cross_align = str(alignment)
        if child is not None:
            self.add_child(child)

    def add_child(self, w: "Widget") -> None:
        ChildContainerMixin.add_child(self, w)

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        if not self.children:
            return (0, 0)
        return measure_preferred_size(self.children[0], max_width=max_width, max_height=max_height)

    def layout(self, width: int, height: int) -> None:
        super().layout(width, height)
        if not self.children:
            return
        child = self.children[0]
        child.layout(width, height)
        child.set_layout_rect(0, 0, width, height)

    def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
        self.set_last_rect(x, y, width, height)
        if not self.children:
            return

        child = self.children[0]
        if child.layout_rect is None:
            self.layout(width, height)

        rect = child.layout_rect
        if rect is None:
            abs_x, abs_y, child_w, child_h = x, y, width, height
        else:
            rel_x, rel_y, child_w, child_h = rect
            abs_x = x + int(rel_x)
            abs_y = y + int(rel_y)

        try:
            child.set_last_rect(abs_x, abs_y, child_w, child_h)
            child.paint(canvas, abs_x, abs_y, child_w, child_h)
        except Exception:
            exception_once(_logger, "cross_aligned_child_paint_exc", "CrossAligned child paint failed")

    def paint_outsets(self) -> Tuple[int, int, int, int]:
        if not self.children:
            return super().paint_outsets()
        try:
            return self.children[0].paint_outsets()
        except Exception:
            exception_once(_logger, "cross_aligned_child_paint_outsets_exc", "CrossAligned child paint_outsets failed")
            return super().paint_outsets()

__init__(child, alignment)

Initialize the CrossAligned wrapper.

Parameters:

Name Type Description Default
child Optional[Widget]

The child widget to wrap.

required
alignment str

The cross-axis alignment to apply to this child. Common values: "start", "center", "end", "stretch".

required
Source code in src/nuiitivet/layout/cross_aligned.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def __init__(
    self,
    child: Optional[Widget],
    alignment: str,
) -> None:
    """Initialize the CrossAligned wrapper.

    Args:
        child: The child widget to wrap.
        alignment: The cross-axis alignment to apply to this child.
            Common values: "start", "center", "end", "stretch".
    """
    super().__init__(
        width=child.width_sizing if child is not None else None,
        height=child.height_sizing if child is not None else None,
        padding=0,
        max_children=1,
        overflow_policy="replace_last",
    )
    self.cross_align = str(alignment)
    if child is not None:
        self.add_child(child)

Deck

Bases: Widget

Display only one child at a time.

All children remain mounted (state preserved), but only the selected child is visible and rendered.

Usage

Deck(children=[HomeTab(), SearchTab(), ProfileTab()], index=0)

For type-safe index, use IntEnum: class Section(IntEnum): HOME = 0 SEARCH = 1 Deck(children=[...], index=Section.HOME)

For animated tabs with gesture support, see DeckController.

Source code in src/nuiitivet/layout/deck.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
class Deck(Widget):
    """Display only one child at a time.

    All children remain mounted (state preserved), but only the selected
    child is visible and rendered.

    Usage:
        Deck(children=[HomeTab(), SearchTab(), ProfileTab()], index=0)

    For type-safe index, use IntEnum:
        class Section(IntEnum):
            HOME = 0
            SEARCH = 1
        Deck(children=[...], index=Section.HOME)

    For animated tabs with gesture support, see DeckController.
    """

    def __init__(
        self,
        children: Optional[Sequence[Widget]] = None,
        index: Union[int, _ObservableValue[int]] = 0,
        *,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    ) -> None:
        """Initialize the Deck layout.

        Args:
            children: A list of child widgets. All are mounted, but only one is visible.
            index: The index of the child to display. Can be an integer or an Observable[int].
                Defaults to 0.
            width: The preferred width of the container. Defaults to None.
            height: The preferred height of the container. Defaults to None.
            padding: Padding to apply around the visible child. Defaults to 0.
        """
        super().__init__(width=width, height=height, padding=padding)

        # Add all children
        if children:
            for child in children:
                self.add_child(child)

        # Handle index (Observable or plain int)
        self._index_observable: Optional[_ObservableValue[int]] = None
        self._index_subscription = None
        if isinstance(index, _ObservableValue):
            self._index_observable = index
            self._current_index = index.value
            # Subscribe to changes
            self._index_subscription = index.subscribe(self._on_index_changed)
        else:
            self._current_index = int(index)

        # Validate initial index
        self._validate_index()

    def _on_index_changed(self, new_index: int) -> None:
        """Handle Observable index changes."""
        old_index = self._current_index
        self._current_index = new_index
        self._validate_index()
        if old_index != self._current_index:
            self.mark_needs_layout()

    def _validate_index(self) -> None:
        """Ensure index is within valid range."""
        children = expand_layout_children(self.children_snapshot())
        if not children:
            self._current_index = 0
            return

        if self._current_index < 0 or self._current_index >= len(children):
            # Clamp to valid range
            self._current_index = max(0, min(self._current_index, len(children) - 1))

    @property
    def current_index(self) -> int:
        """Get the currently selected child index."""
        return self._current_index

    def set_index(self, index: int) -> None:
        """Set the selected child index (for non-Observable usage)."""
        if self._index_observable is not None:
            raise ValueError("Cannot set_index when using Observable index")
        old_index = self._current_index
        self._current_index = index
        self._validate_index()
        if old_index != self._current_index:
            self.mark_needs_layout()

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
        children = expand_layout_children(self.children_snapshot())
        if not children or self._current_index >= len(children):
            # No children or invalid index
            pad_left, pad_top, pad_right, pad_bottom = self.padding
            width = pad_left + pad_right
            height = pad_top + pad_bottom
            if max_width is not None and self.width_sizing.kind != "fixed":
                width = min(width, int(max_width))
            if max_height is not None and self.height_sizing.kind != "fixed":
                height = min(height, int(max_height))
            return (width, height)

        # Get size from currently selected child
        selected_child = children[self._current_index]
        pad_left, pad_top, pad_right, pad_bottom = self.padding
        child_max_w: Optional[int] = None
        child_max_h: Optional[int] = None
        if max_width is not None:
            child_max_w = max(0, int(max_width) - int(pad_left) - int(pad_right))
        elif self.width_sizing.kind == "fixed":
            child_max_w = max(0, int(self.width_sizing.value) - int(pad_left) - int(pad_right))
        if max_height is not None:
            child_max_h = max(0, int(max_height) - int(pad_top) - int(pad_bottom))
        elif self.height_sizing.kind == "fixed":
            child_max_h = max(0, int(self.height_sizing.value) - int(pad_top) - int(pad_bottom))
        child_w, child_h = measure_preferred_size(selected_child, max_width=child_max_w, max_height=child_max_h)

        width = int(child_w) + int(pad_left) + int(pad_right)
        height = int(child_h) + int(pad_top) + int(pad_bottom)

        w_dim = self.width_sizing
        h_dim = self.height_sizing
        if w_dim.kind == "fixed":
            width = int(w_dim.value)
        elif max_width is not None:
            width = min(width, int(max_width))

        if h_dim.kind == "fixed":
            height = int(h_dim.value)
        elif max_height is not None:
            height = min(height, int(max_height))

        return (width, height)

    def layout(self, width: int, height: int) -> None:
        """Layout all children (to preserve state), position selected child."""
        super().layout(width, height)
        children = expand_layout_children(self.children_snapshot())
        if not children:
            return

        pad_left, pad_top, pad_right, pad_bottom = self.padding
        available_w = max(0, width - pad_left - pad_right)
        available_h = max(0, height - pad_top - pad_bottom)

        # Layout ALL children so they maintain state
        for child in children:
            child.layout(available_w, available_h)

        # Position selected child (others will not be painted)
        if 0 <= self._current_index < len(children):
            selected_child = children[self._current_index]
            selected_child.set_layout_rect(pad_left, pad_top, available_w, available_h)

    def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
        """Paint only the selected child."""
        children = expand_layout_children(self.children_snapshot())
        if not children or self._current_index >= len(children):
            return

        # Auto-layout fallback for Tests or direct paint calls
        if any(c.layout_rect is None for c in children):
            self.layout(width, height)

        # Only paint the selected child
        selected_child = children[self._current_index]
        rect = selected_child.layout_rect
        if rect is None:
            return

        rel_x, rel_y, w, h = rect
        abs_x = x + rel_x
        abs_y = y + rel_y

        selected_child.set_last_rect(abs_x, abs_y, w, h)
        selected_child.paint(canvas, abs_x, abs_y, w, h)

    def hit_test(self, x: int, y: int) -> bool:
        """Only allow hit testing on the selected child."""
        children = expand_layout_children(self.children_snapshot())
        if not children or self._current_index >= len(children):
            return False

        # Only the selected child should participate in hit testing
        selected_child = children[self._current_index]
        return selected_child.hit_test(x, y)

    def dispose(self) -> None:
        """Clean up subscriptions."""
        if self._index_subscription is not None:
            self._index_subscription.dispose()
            self._index_subscription = None

current_index property

Get the currently selected child index.

__init__(children=None, index=0, *, width=None, height=None, padding=0)

Initialize the Deck layout.

Parameters:

Name Type Description Default
children Optional[Sequence[Widget]]

A list of child widgets. All are mounted, but only one is visible.

None
index Union[int, _ObservableValue[int]]

The index of the child to display. Can be an integer or an Observable[int]. Defaults to 0.

0
width SizingLike

The preferred width of the container. Defaults to None.

None
height SizingLike

The preferred height of the container. Defaults to None.

None
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding to apply around the visible child. Defaults to 0.

0
Source code in src/nuiitivet/layout/deck.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    children: Optional[Sequence[Widget]] = None,
    index: Union[int, _ObservableValue[int]] = 0,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
) -> None:
    """Initialize the Deck layout.

    Args:
        children: A list of child widgets. All are mounted, but only one is visible.
        index: The index of the child to display. Can be an integer or an Observable[int].
            Defaults to 0.
        width: The preferred width of the container. Defaults to None.
        height: The preferred height of the container. Defaults to None.
        padding: Padding to apply around the visible child. Defaults to 0.
    """
    super().__init__(width=width, height=height, padding=padding)

    # Add all children
    if children:
        for child in children:
            self.add_child(child)

    # Handle index (Observable or plain int)
    self._index_observable: Optional[_ObservableValue[int]] = None
    self._index_subscription = None
    if isinstance(index, _ObservableValue):
        self._index_observable = index
        self._current_index = index.value
        # Subscribe to changes
        self._index_subscription = index.subscribe(self._on_index_changed)
    else:
        self._current_index = int(index)

    # Validate initial index
    self._validate_index()

set_index(index)

Set the selected child index (for non-Observable usage).

Source code in src/nuiitivet/layout/deck.py
 94
 95
 96
 97
 98
 99
100
101
102
def set_index(self, index: int) -> None:
    """Set the selected child index (for non-Observable usage)."""
    if self._index_observable is not None:
        raise ValueError("Cannot set_index when using Observable index")
    old_index = self._current_index
    self._current_index = index
    self._validate_index()
    if old_index != self._current_index:
        self.mark_needs_layout()

layout(width, height)

Layout all children (to preserve state), position selected child.

Source code in src/nuiitivet/layout/deck.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def layout(self, width: int, height: int) -> None:
    """Layout all children (to preserve state), position selected child."""
    super().layout(width, height)
    children = expand_layout_children(self.children_snapshot())
    if not children:
        return

    pad_left, pad_top, pad_right, pad_bottom = self.padding
    available_w = max(0, width - pad_left - pad_right)
    available_h = max(0, height - pad_top - pad_bottom)

    # Layout ALL children so they maintain state
    for child in children:
        child.layout(available_w, available_h)

    # Position selected child (others will not be painted)
    if 0 <= self._current_index < len(children):
        selected_child = children[self._current_index]
        selected_child.set_layout_rect(pad_left, pad_top, available_w, available_h)

paint(canvas, x, y, width, height)

Paint only the selected child.

Source code in src/nuiitivet/layout/deck.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
    """Paint only the selected child."""
    children = expand_layout_children(self.children_snapshot())
    if not children or self._current_index >= len(children):
        return

    # Auto-layout fallback for Tests or direct paint calls
    if any(c.layout_rect is None for c in children):
        self.layout(width, height)

    # Only paint the selected child
    selected_child = children[self._current_index]
    rect = selected_child.layout_rect
    if rect is None:
        return

    rel_x, rel_y, w, h = rect
    abs_x = x + rel_x
    abs_y = y + rel_y

    selected_child.set_last_rect(abs_x, abs_y, w, h)
    selected_child.paint(canvas, abs_x, abs_y, w, h)

hit_test(x, y)

Only allow hit testing on the selected child.

Source code in src/nuiitivet/layout/deck.py
192
193
194
195
196
197
198
199
200
def hit_test(self, x: int, y: int) -> bool:
    """Only allow hit testing on the selected child."""
    children = expand_layout_children(self.children_snapshot())
    if not children or self._current_index >= len(children):
        return False

    # Only the selected child should participate in hit testing
    selected_child = children[self._current_index]
    return selected_child.hit_test(x, y)

dispose()

Clean up subscriptions.

Source code in src/nuiitivet/layout/deck.py
202
203
204
205
206
def dispose(self) -> None:
    """Clean up subscriptions."""
    if self._index_subscription is not None:
        self._index_subscription.dispose()
        self._index_subscription = None

Widget

Bases: BindingHostMixin, LifecycleHostMixin, InputHubMixin, ChildContainerMixin, WidgetKernel

Leaf-friendly widget base composed from mixins.

This class intentionally does not participate in build/recomposition. Widgets that need build()/rebuild()/scope() must inherit ComposableWidget.

Source code in src/nuiitivet/widgeting/widget.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
class Widget(
    BindingHostMixin,
    LifecycleHostMixin,
    InputHubMixin,
    ChildContainerMixin,
    WidgetKernel,
):
    """Leaf-friendly widget base composed from mixins.

    This class intentionally does not participate in build/recomposition.
    Widgets that need `build()`/`rebuild()`/`scope()` must inherit
    `ComposableWidget`.
    """

    _layout_dependencies: Tuple[str, ...] = ()
    _paint_dependencies: Tuple[str, ...] = ()

    def __init__(
        self,
        *,
        width: Union[SizingLike, ReadOnlyObservableProtocol] = None,
        height: Union[SizingLike, ReadOnlyObservableProtocol] = None,
        padding: Union[PaddingLike, ReadOnlyObservableProtocol] = None,
        max_children: Optional[int] = None,
        overflow_policy: str = "none",
    ) -> None:
        self._layout_cache_token = 0
        self._needs_layout = True
        super().__init__(
            width=width,
            height=height,
            padding=padding,
            max_children=max_children,
            overflow_policy=overflow_policy,
        )

    @property
    def layout_cache_token(self) -> int:
        return int(self._layout_cache_token)

    def mark_needs_layout(self) -> None:
        """Mark this widget as needing layout recalculation."""
        already_dirty = self._needs_layout
        self._needs_layout = True
        parent = getattr(self, "_parent", None)
        if isinstance(parent, Widget):
            # Always propagate to root.  An early-return guard ("if already
            # dirty, skip") would be valid only if the invariant "every dirty
            # node's ancestors are also dirty" were globally maintained.
            # However, clear_needs_layout() is called only on AppScope (the
            # root), leaving intermediate nodes dirty.  When the next animation
            # tick fires, a selective guard would stop propagation at the first
            # already-dirty intermediate, never reaching the cleared root.
            # Walking all the way to the root on every call is O(tree-depth)
            # – the same cost as the original code when no ancestor was dirty.
            try:
                parent.mark_needs_layout()
            except Exception:
                exception_once(
                    logger,
                    f"widget_mark_needs_layout_parent_exc:{type(parent).__name__}",
                    "Widget.mark_needs_layout() failed for parent=%s",
                    type(parent).__name__,
                )
        if not already_dirty:
            self.invalidate()

    def invalidate(self, immediate: bool = False) -> None:
        app = getattr(self, "_app", None)
        if app is None:
            return
        try:
            app.invalidate(immediate=immediate)
        except TypeError:
            try:
                app.invalidate()
            except Exception:
                exception_once(
                    logger,
                    f"widget_invalidate_fallback_exc:{type(app).__name__}",
                    "App.invalidate() fallback call raised for app=%s",
                    type(app).__name__,
                )
        except Exception:
            exception_once(
                logger,
                f"widget_invalidate_exc:{type(app).__name__}",
                "App.invalidate(immediate=%s) raised for app=%s",
                immediate,
                type(app).__name__,
            )

    # --- Context lookup ----------------------------------------------------
    def find_ancestor(self, widget_type: Type[T]) -> Optional[T]:
        """Find the nearest ancestor of the specified type.

        Traverses up the widget tree looking for an ancestor that matches the given type.
        This is used to implement context lookup patterns like Navigator.of(context).

        Args:
            widget_type: The type of widget to find.

        Returns:
            The nearest ancestor of the specified type, or None if not found.

        Example:
            navigator = self.find_ancestor(Navigator)
            if navigator:
                navigator.push(...)
        """
        current = self._parent
        while current is not None:
            if isinstance(current, widget_type):
                return current  # type: ignore
            current = getattr(current, "_parent", None)
        return None

    # --- Modifiers ---------------------------------------------------------
    def modifier(self, modifier: Union[Modifier, ModifierElement]) -> Widget:
        """Apply a modifier to this widget, returning the wrapped result.

        Args:
            modifier: The modifier (or modifier element) to apply.

        Returns:
            The wrapped widget (e.g. ModifierBox) or the widget itself if modified in-place.
        """
        return modifier.apply(self)

    # --- Dependency invalidation ------------------------------------------
    def _invalidate_layout_cache(self) -> None:
        """Clear layout-related cached state (override in subclasses)."""

        self._layout_cache_token += 1
        self.mark_needs_layout()
        layout = getattr(self, "_layout", None)
        invalidate = getattr(layout, "invalidate_cache", None)
        if callable(invalidate):
            try:
                invalidate()
            except Exception:
                exception_once(
                    logger,
                    f"widget_invalidate_layout_cache_exc:{type(layout).__name__}",
                    "layout.invalidate_cache() failed for layout=%s",
                    type(layout).__name__,
                )

    def _invalidate_paint_cache(self) -> None:
        """Clear paint-related cached state (override in subclasses)."""

    def _handle_dependency_invalidation(self, dependency: Optional[str]) -> bool:
        layout_deps = getattr(type(self), "_layout_dependencies", ())
        paint_deps = getattr(type(self), "_paint_dependencies", ())
        if dependency is None:
            self._invalidate_layout_cache()
            self._invalidate_paint_cache()
            return True
        if isinstance(dependency, str):
            if dependency.startswith("scope:"):
                handler = getattr(self, "_handle_scope_invalidation", None)
                if callable(handler):
                    try:
                        handled = handler(dependency)
                    except Exception:
                        exception_once(
                            logger,
                            f"widget_handle_scope_invalidation_exc:{type(self).__name__}",
                            "_handle_scope_invalidation(dep=%s) raised for widget=%s",
                            dependency,
                            type(self).__name__,
                        )
                        handled = False
                    if handled:
                        return True
                return False
            scope_router = getattr(self, "_handle_scope_dependency", None)
            if callable(scope_router):
                try:
                    if scope_router(dependency):
                        return True
                except Exception:
                    exception_once(
                        logger,
                        f"widget_handle_scope_dependency_exc:{type(self).__name__}",
                        "_handle_scope_dependency(dep=%s) raised for widget=%s",
                        dependency,
                        type(self).__name__,
                    )
        handled = False
        if dependency in layout_deps:
            self._invalidate_layout_cache()
            handled = True
        if dependency in paint_deps:
            self._invalidate_paint_cache()
            handled = True
        if not handled:
            self._invalidate_layout_cache()
            self._invalidate_paint_cache()
        return True

    def invalidate_paint_cache(self) -> None:
        self._invalidate_paint_cache()
        self.invalidate()

    def paint_outsets(self) -> Tuple[int, int, int, int]:
        return (0, 0, 0, 0)

mark_needs_layout()

Mark this widget as needing layout recalculation.

Source code in src/nuiitivet/widgeting/widget.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def mark_needs_layout(self) -> None:
    """Mark this widget as needing layout recalculation."""
    already_dirty = self._needs_layout
    self._needs_layout = True
    parent = getattr(self, "_parent", None)
    if isinstance(parent, Widget):
        # Always propagate to root.  An early-return guard ("if already
        # dirty, skip") would be valid only if the invariant "every dirty
        # node's ancestors are also dirty" were globally maintained.
        # However, clear_needs_layout() is called only on AppScope (the
        # root), leaving intermediate nodes dirty.  When the next animation
        # tick fires, a selective guard would stop propagation at the first
        # already-dirty intermediate, never reaching the cleared root.
        # Walking all the way to the root on every call is O(tree-depth)
        # – the same cost as the original code when no ancestor was dirty.
        try:
            parent.mark_needs_layout()
        except Exception:
            exception_once(
                logger,
                f"widget_mark_needs_layout_parent_exc:{type(parent).__name__}",
                "Widget.mark_needs_layout() failed for parent=%s",
                type(parent).__name__,
            )
    if not already_dirty:
        self.invalidate()

find_ancestor(widget_type)

Find the nearest ancestor of the specified type.

Traverses up the widget tree looking for an ancestor that matches the given type. This is used to implement context lookup patterns like Navigator.of(context).

Parameters:

Name Type Description Default
widget_type Type[T]

The type of widget to find.

required

Returns:

Type Description
Optional[T]

The nearest ancestor of the specified type, or None if not found.

Example

navigator = self.find_ancestor(Navigator) if navigator: navigator.push(...)

Source code in src/nuiitivet/widgeting/widget.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def find_ancestor(self, widget_type: Type[T]) -> Optional[T]:
    """Find the nearest ancestor of the specified type.

    Traverses up the widget tree looking for an ancestor that matches the given type.
    This is used to implement context lookup patterns like Navigator.of(context).

    Args:
        widget_type: The type of widget to find.

    Returns:
        The nearest ancestor of the specified type, or None if not found.

    Example:
        navigator = self.find_ancestor(Navigator)
        if navigator:
            navigator.push(...)
    """
    current = self._parent
    while current is not None:
        if isinstance(current, widget_type):
            return current  # type: ignore
        current = getattr(current, "_parent", None)
    return None

modifier(modifier)

Apply a modifier to this widget, returning the wrapped result.

Parameters:

Name Type Description Default
modifier Union[Modifier, ModifierElement]

The modifier (or modifier element) to apply.

required

Returns:

Type Description
Widget

The wrapped widget (e.g. ModifierBox) or the widget itself if modified in-place.

Source code in src/nuiitivet/widgeting/widget.py
145
146
147
148
149
150
151
152
153
154
def modifier(self, modifier: Union[Modifier, ModifierElement]) -> Widget:
    """Apply a modifier to this widget, returning the wrapped result.

    Args:
        modifier: The modifier (or modifier element) to apply.

    Returns:
        The wrapped widget (e.g. ModifierBox) or the widget itself if modified in-place.
    """
    return modifier.apply(self)

ComposableWidget

Bases: BuilderHostMixin, Widget

Widget base that participates in build/recomposition.

Composition widgets can override build() and use scope() / render_scope() for fine-grained recomposition.

Source code in src/nuiitivet/widgeting/widget.py
236
237
238
239
240
241
class ComposableWidget(BuilderHostMixin, Widget):
    """Widget base that participates in build/recomposition.

    Composition widgets can override `build()` and use `scope()` /
    `render_scope()` for fine-grained recomposition.
    """

Navigator

Bases: ComposableWidget

A minimal navigation stack.

Initialization forms
  • Navigator(screen): start with a single screen (Route or Widget).
  • Navigator.routes([...]): pre-populated stack (e.g. deep linking).
  • Navigator.intents(initial_route=..., routes={...}): Intent-based routing.
Features
  • push/pop
  • root()/set_root()
  • of(context)
  • optional fade-in on push
Source code in src/nuiitivet/navigation/navigator.py
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
class Navigator(ComposableWidget):
    """A minimal navigation stack.

    Initialization forms:
        - ``Navigator(screen)``: start with a single screen (``Route`` or ``Widget``).
        - ``Navigator.routes([...])``: pre-populated stack (e.g. deep linking).
        - ``Navigator.intents(initial_route=..., routes={...})``: Intent-based routing.

    Features:
        - push/pop
        - root()/set_root()
        - of(context)
        - optional fade-in on push
    """

    _root: ClassVar[Navigator | None] = None

    def __init__(
        self,
        screen: Route | Widget | None = None,
        *,
        layer_composer: NavigationLayerComposer | None = None,
    ) -> None:
        """Initialize a Navigator with a single initial screen.

        Args:
            screen: The initial screen as a ``Route`` or ``Widget``. If ``None``,
                the navigator starts with an empty stack (use :meth:`routes` or
                :meth:`intents` factories for alternative initialization).
            layer_composer: Optional custom layer composer.
        """
        super().__init__()
        self._intent_routes: Mapping[type[Any], Callable[[Any], Route | Widget]] = {}
        self._transition: _NavTransition | None = None
        self._transition_handle: TransitionHandle | None = None
        self._transition_engine = TransitionEngine()
        self._pending_pop_requests: int = 0
        self._exiting_route: Route | None = None
        self._layer_composer: NavigationLayerComposer = layer_composer or _DefaultNavigationLayerComposer()

        initial_routes: list[Route] = []
        if screen is not None:
            initial_routes.append(self._to_initial_route(screen))
        self._stack = RouteStackRuntime(initial_routes=initial_routes)

    def _to_initial_route(self, value: Route | Widget) -> Route:
        """Convert a ``Route`` or ``Widget`` into a ``Route`` for initial stack construction."""
        if isinstance(value, Route):
            return value
        if isinstance(value, Widget):
            return self._route_from_widget(value)
        raise TypeError(f"Navigator initial screen must be a Route or Widget, got {type(value).__name__}")

    @classmethod
    def routes(
        cls,
        screens: Sequence[Route | Widget],
        *,
        layer_composer: NavigationLayerComposer | None = None,
    ) -> Navigator:
        """Create a Navigator with a pre-populated stack.

        Use this when the navigator should start with multiple screens already
        on the stack (e.g. deep linking, state restoration).

        Args:
            screens: Sequence of ``Route`` or ``Widget`` instances. The last item
                becomes the top of the stack.
            layer_composer: Optional custom layer composer.
        """
        if not screens:
            raise ValueError("Navigator.routes(...) requires at least one screen")
        instance = cls(layer_composer=layer_composer)
        initial_routes = [instance._to_initial_route(s) for s in screens]
        instance._stack = RouteStackRuntime(initial_routes=initial_routes)
        return instance

    @classmethod
    def intents(
        cls,
        *,
        initial_route: Any,
        routes: Mapping[type[Any], Callable[[Any], Route | Widget]],
        layer_composer: NavigationLayerComposer | None = None,
    ) -> Navigator:
        """Create a Navigator configured for Intent-based routing.

        Args:
            initial_route: The initial Intent instance used to resolve the first route.
            routes: Mapping of Intent types to route builder functions. Each
                builder returns a ``Route`` or ``Widget``.
            layer_composer: Optional custom layer composer.
        """
        instance = cls(layer_composer=layer_composer)
        instance._intent_routes = dict(routes)
        initial = instance._resolve_intent_to_route(initial_route)
        instance._stack = RouteStackRuntime(initial_routes=[initial])
        return instance

    @classmethod
    def set_root(cls, navigator: Navigator) -> None:
        cls._root = navigator

    @classmethod
    def root(cls) -> Navigator:
        if cls._root is None:
            raise RuntimeError("Navigator root is not set")
        return cls._root

    @classmethod
    def of(cls, context: Widget) -> Navigator:
        navigator = context.find_ancestor(Navigator)
        if navigator is None:
            raise RuntimeError("Navigator not found in ancestors")
        return navigator

    def can_pop(self) -> bool:
        return self._stack.can_pop(min_routes=1)

    def build(self) -> Widget:
        return self

    def _cancel_transition(self) -> None:
        handle = self._transition_handle
        self._transition_handle = None
        self._transition = None
        exiting = self._exiting_route
        self._exiting_route = None
        if exiting is not None:
            self._stack.mark_active(exiting)
        if handle is None:
            return
        cancel = getattr(handle, "cancel", None)
        if callable(cancel):
            try:
                cancel()
            except Exception:
                exception_once(
                    _logger,
                    "navigator_cancel_transition_exc",
                    "Failed to cancel transition animation handle",
                )

    def _top_route(self) -> Route | None:
        return self._stack.top()

    def _route_widget(self, route: Route) -> Widget:
        widget = route.build_widget()
        if widget not in self.children_snapshot():
            self.add_child(widget)
        return widget

    def _route_from_widget(self, widget: Widget) -> Route:
        """Wrap a widget into a page route for navigator runtime."""
        return PageRoute(builder=lambda: widget)

    def _resolve_intent_to_route(self, intent: Any) -> Route:
        """Resolve an intent and normalize the result to a Route."""
        factory = self._intent_routes.get(type(intent))
        if factory is None:
            raise RuntimeError(f"No route is registered for intent: {type(intent).__name__}")
        resolved = factory(intent)
        if isinstance(resolved, Route):
            return resolved
        return self._route_from_widget(resolved)

    def _normalize_to_route(self, route_or_widget_or_intent: Route | Widget | Any) -> Route:
        """Normalize external push input to a Route.

        This is the single boundary adapter for `push(...)` input polymorphism.
        Internal navigator runtime must only operate on `Route`.
        """
        if isinstance(route_or_widget_or_intent, Route):
            return route_or_widget_or_intent

        if isinstance(route_or_widget_or_intent, Widget):
            return self._route_from_widget(route_or_widget_or_intent)

        return self._resolve_intent_to_route(route_or_widget_or_intent)

    def _is_animated_transition(self, route: Route) -> bool:
        return not isinstance(route.transition_spec, EmptyTransitionSpec)

    def _get_motion(self, route: Route, phase: TransitionPhase) -> Any | None:
        try:
            definition = getattr(route.transition_spec, phase.value, None)
            if definition is None:
                return None
            return getattr(definition, "motion", None)
        except Exception:
            return None

    def push(self, route_or_widget_or_intent: Route | Widget | Any) -> None:
        self._cancel_transition()

        previous_route = self._top_route()
        previous_widget = None if previous_route is None else self._route_widget(previous_route)

        route = self._normalize_to_route(route_or_widget_or_intent)

        self._stack.push(route)
        self._stack.mark_active(route)
        new_widget = self._route_widget(route)

        if (
            previous_widget is not None
            and self._is_animated_transition(route)
            and getattr(self, "_app", None) is not None
        ):
            assert previous_route is not None
            self._transition = _NavTransition(
                kind="push",
                from_route=previous_route,
                to_route=route,
                from_widget=previous_widget,
                to_widget=new_widget,
                progress=0.0,
            )
            self._transition_handle = self._transition_engine.start(
                start=0.0,
                target=1.0,
                apply=lambda v: setattr(self._transition, "progress", float(v)) if self._transition else None,
                on_complete=self._finish_transition,
                motion=self._get_motion(route, TransitionPhase.ENTER),
            )
        else:
            self._transition = None

        self.mark_needs_layout()
        self.invalidate()

    def pop(self) -> None:
        self.request_back()

    def request_back(self) -> bool:
        """Request a single back action.

        This API is designed for user back inputs (Esc/back button).
        If a pop transition is already running, the request is queued and the
        current transition is completed immediately.

        Queue consumption policy:
        - Intermediate queued pops are performed without animation.
        - The last queued pop (if any) uses the normal pop behavior.
        """

        if not self.can_pop():
            return False

        transition = self._transition
        handle = self._transition_handle

        if transition is not None and handle is not None and transition.kind == "pop":
            self._pending_pop_requests += 1
            self._force_finish_pop_transition()
            return True

        if transition is not None and handle is not None and transition.kind == "push":
            # Finish push quickly, then pop once.
            self._force_finish_push_transition()

        did_pop = self._pop_once(skip_animation=False)
        if not did_pop:
            # will_pop canceled; treat as handled.
            return True
        return True

    def _force_finish_push_transition(self) -> None:
        transition = self._transition
        handle = self._transition_handle
        if transition is None or handle is None or transition.kind != "push":
            return
        try:
            transition.progress = 1.0
        except Exception:
            exception_once(_logger, "navigator_force_finish_push_set_progress_exc", "Failed to set push progress")
        cancel = getattr(handle, "cancel", None)
        if callable(cancel):
            try:
                cancel()
            except Exception:
                exception_once(_logger, "navigator_force_finish_push_cancel_exc", "Failed to cancel push transition")
        self._finish_transition()

    def _force_finish_pop_transition(self) -> None:
        transition = self._transition
        handle = self._transition_handle
        if transition is None or handle is None or transition.kind != "pop":
            return
        try:
            transition.progress = 0.0
        except Exception:
            exception_once(_logger, "navigator_force_finish_pop_set_progress_exc", "Failed to set pop progress")
        cancel = getattr(handle, "cancel", None)
        if callable(cancel):
            try:
                cancel()
            except Exception:
                exception_once(_logger, "navigator_force_finish_pop_cancel_exc", "Failed to cancel pop transition")
        self._finish_pop()

    def _drain_pending_pops(self) -> None:
        while self._pending_pop_requests > 0 and self.can_pop():
            self._pending_pop_requests -= 1
            skip_animation = self._pending_pop_requests > 0
            did = self._pop_once(skip_animation=skip_animation)
            if not did:
                self._pending_pop_requests = 0
                return

            # If we started an animated pop, wait for completion.
            if self._transition is not None and self._transition_handle is not None and self._transition.kind == "pop":
                return

        if not self.can_pop():
            self._pending_pop_requests = 0

    def _pop_once(self, *, skip_animation: bool) -> bool:
        if not self.can_pop():
            return False

        self._cancel_transition()

        routes = self._stack.routes
        outgoing = routes[-1]
        incoming = routes[-2]
        outgoing_widget = self._route_widget(outgoing)
        incoming_widget = self._route_widget(incoming)

        back_handler = getattr(outgoing_widget, "handle_back_event", None)
        if callable(back_handler):
            try:
                if not bool(back_handler()):
                    self._pending_pop_requests = 0
                    return False
            except Exception:
                # Fail open to avoid trapping navigation.
                exception_once(_logger, "navigator_back_handler_exc", "Route handle_back_event raised")

        app = getattr(self, "_app", None)
        if not skip_animation and self._is_animated_transition(outgoing) and app is not None:
            self._stack.mark_exiting(outgoing)
            self._exiting_route = outgoing
            self._transition = _NavTransition(
                kind="pop",
                from_route=outgoing,
                to_route=incoming,
                from_widget=outgoing_widget,
                to_widget=incoming_widget,
                progress=1.0,
            )
            self._transition_handle = self._transition_engine.start(
                start=1.0,
                target=0.0,
                apply=lambda v: setattr(self._transition, "progress", float(v)) if self._transition else None,
                on_complete=self._finish_pop,
                motion=self._get_motion(outgoing, TransitionPhase.EXIT),
            )
            self.mark_needs_layout()
            self.invalidate()
            return True

        self._stack.mark_exiting(outgoing)
        self._exiting_route = outgoing
        self._finish_pop_once()
        self._drain_pending_pops()
        return True

    def _finish_transition(self) -> None:
        self._transition_handle = None
        self._transition = None
        self.invalidate()

    def _finish_pop_once(self) -> None:
        self._transition_handle = None
        self._transition = None
        route = self._exiting_route
        self._exiting_route = None
        if route is None:
            route = self._stack.begin_pop()

        if route is None:
            self.invalidate()
            return

        widget = route.build_widget()
        self._stack.complete_exit(route)
        try:
            self.remove_child(widget)
        except Exception:
            exception_once(_logger, "navigator_remove_child_exc", "Failed to remove popped route widget")
        self.mark_needs_layout()
        self.invalidate()

    def _finish_pop(self) -> None:
        self._finish_pop_once()
        self._drain_pending_pops()

    def layout(self, width: int, height: int) -> None:
        self.clear_needs_layout()
        self.set_layout_rect(0, 0, width, height)

        # Layout all cached route widgets so hit_test coordinate translation works.
        for route in self._stack.routes:
            widget = route.build_widget() if route._widget is not None else None
            if widget is None:
                continue
            try:
                widget.layout(width, height)
                widget.set_layout_rect(0, 0, width, height)
            except Exception:
                exception_once(_logger, "navigator_layout_route_widget_exc", "Route widget layout failed")

    def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
        self.set_last_rect(x, y, width, height)

        routes = self._stack.routes
        if not routes:
            return

        transition = self._transition
        if transition is None:
            top_widget = self._route_widget(routes[-1])
            self._layer_composer.paint_static(canvas=canvas, widget=top_widget, x=x, y=y, width=width, height=height)
            return

        if transition.kind in ("push", "pop"):
            phase_progress = _transition_phase_progress(transition)
            if phase_progress is not None:
                from_phase, to_phase, p = phase_progress
                context = NavigationLayerCompositionContext(
                    canvas=canvas,
                    x=x,
                    y=y,
                    width=width,
                    height=height,
                    kind=transition.kind,
                    from_widget=transition.from_widget,
                    to_widget=transition.to_widget,
                    from_phase=from_phase,
                    to_phase=to_phase,
                    progress=p,
                    from_transition_spec=transition.from_route.transition_spec,
                    to_transition_spec=transition.to_route.transition_spec,
                )
                self._layer_composer.paint_transition(context)
                return

        # Unknown transition kind: paint top.
        top_widget = self._route_widget(routes[-1])
        self._layer_composer.paint_static(canvas=canvas, widget=top_widget, x=x, y=y, width=width, height=height)

    def hit_test(self, x: int, y: int):
        transition = self._transition
        if transition is None:
            routes = self._stack.routes
            if not routes:
                return None
            return self._route_widget(routes[-1]).hit_test(x, y)

        # During transitions, prefer the visually top-most widget.
        if transition.kind == "push":
            hit = transition.to_widget.hit_test(x, y)
            if hit:
                return hit
            return transition.from_widget.hit_test(x, y)

        if transition.kind == "pop":
            hit = transition.from_widget.hit_test(x, y)
            if hit:
                return hit
            return transition.to_widget.hit_test(x, y)

        return super().hit_test(x, y)

    def on_unmount(self) -> None:
        self._transition_engine.dispose()
        super().on_unmount()

__init__(screen=None, *, layer_composer=None)

Initialize a Navigator with a single initial screen.

Parameters:

Name Type Description Default
screen Route | Widget | None

The initial screen as a Route or Widget. If None, the navigator starts with an empty stack (use :meth:routes or :meth:intents factories for alternative initialization).

None
layer_composer NavigationLayerComposer | None

Optional custom layer composer.

None
Source code in src/nuiitivet/navigation/navigator.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def __init__(
    self,
    screen: Route | Widget | None = None,
    *,
    layer_composer: NavigationLayerComposer | None = None,
) -> None:
    """Initialize a Navigator with a single initial screen.

    Args:
        screen: The initial screen as a ``Route`` or ``Widget``. If ``None``,
            the navigator starts with an empty stack (use :meth:`routes` or
            :meth:`intents` factories for alternative initialization).
        layer_composer: Optional custom layer composer.
    """
    super().__init__()
    self._intent_routes: Mapping[type[Any], Callable[[Any], Route | Widget]] = {}
    self._transition: _NavTransition | None = None
    self._transition_handle: TransitionHandle | None = None
    self._transition_engine = TransitionEngine()
    self._pending_pop_requests: int = 0
    self._exiting_route: Route | None = None
    self._layer_composer: NavigationLayerComposer = layer_composer or _DefaultNavigationLayerComposer()

    initial_routes: list[Route] = []
    if screen is not None:
        initial_routes.append(self._to_initial_route(screen))
    self._stack = RouteStackRuntime(initial_routes=initial_routes)

routes(screens, *, layer_composer=None) classmethod

Create a Navigator with a pre-populated stack.

Use this when the navigator should start with multiple screens already on the stack (e.g. deep linking, state restoration).

Parameters:

Name Type Description Default
screens Sequence[Route | Widget]

Sequence of Route or Widget instances. The last item becomes the top of the stack.

required
layer_composer NavigationLayerComposer | None

Optional custom layer composer.

None
Source code in src/nuiitivet/navigation/navigator.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
@classmethod
def routes(
    cls,
    screens: Sequence[Route | Widget],
    *,
    layer_composer: NavigationLayerComposer | None = None,
) -> Navigator:
    """Create a Navigator with a pre-populated stack.

    Use this when the navigator should start with multiple screens already
    on the stack (e.g. deep linking, state restoration).

    Args:
        screens: Sequence of ``Route`` or ``Widget`` instances. The last item
            becomes the top of the stack.
        layer_composer: Optional custom layer composer.
    """
    if not screens:
        raise ValueError("Navigator.routes(...) requires at least one screen")
    instance = cls(layer_composer=layer_composer)
    initial_routes = [instance._to_initial_route(s) for s in screens]
    instance._stack = RouteStackRuntime(initial_routes=initial_routes)
    return instance

intents(*, initial_route, routes, layer_composer=None) classmethod

Create a Navigator configured for Intent-based routing.

Parameters:

Name Type Description Default
initial_route Any

The initial Intent instance used to resolve the first route.

required
routes Mapping[type[Any], Callable[[Any], Route | Widget]]

Mapping of Intent types to route builder functions. Each builder returns a Route or Widget.

required
layer_composer NavigationLayerComposer | None

Optional custom layer composer.

None
Source code in src/nuiitivet/navigation/navigator.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
@classmethod
def intents(
    cls,
    *,
    initial_route: Any,
    routes: Mapping[type[Any], Callable[[Any], Route | Widget]],
    layer_composer: NavigationLayerComposer | None = None,
) -> Navigator:
    """Create a Navigator configured for Intent-based routing.

    Args:
        initial_route: The initial Intent instance used to resolve the first route.
        routes: Mapping of Intent types to route builder functions. Each
            builder returns a ``Route`` or ``Widget``.
        layer_composer: Optional custom layer composer.
    """
    instance = cls(layer_composer=layer_composer)
    instance._intent_routes = dict(routes)
    initial = instance._resolve_intent_to_route(initial_route)
    instance._stack = RouteStackRuntime(initial_routes=[initial])
    return instance

request_back()

Request a single back action.

This API is designed for user back inputs (Esc/back button). If a pop transition is already running, the request is queued and the current transition is completed immediately.

Queue consumption policy: - Intermediate queued pops are performed without animation. - The last queued pop (if any) uses the normal pop behavior.

Source code in src/nuiitivet/navigation/navigator.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def request_back(self) -> bool:
    """Request a single back action.

    This API is designed for user back inputs (Esc/back button).
    If a pop transition is already running, the request is queued and the
    current transition is completed immediately.

    Queue consumption policy:
    - Intermediate queued pops are performed without animation.
    - The last queued pop (if any) uses the normal pop behavior.
    """

    if not self.can_pop():
        return False

    transition = self._transition
    handle = self._transition_handle

    if transition is not None and handle is not None and transition.kind == "pop":
        self._pending_pop_requests += 1
        self._force_finish_pop_transition()
        return True

    if transition is not None and handle is not None and transition.kind == "push":
        # Finish push quickly, then pop once.
        self._force_finish_push_transition()

    did_pop = self._pop_once(skip_animation=False)
    if not did_pop:
        # will_pop canceled; treat as handled.
        return True
    return True

PageRoute

Bases: Route

Route for a page widget.

Source code in src/nuiitivet/navigation/route.py
38
39
40
41
42
class PageRoute(Route):
    """Route for a page widget."""

    def __init__(self, builder: Callable[[], Widget], transition_spec: TransitionSpec | None = None) -> None:
        super().__init__(builder=builder, transition_spec=transition_spec or Transitions.empty())

Observable

Bases: _ObservableValue[T]

Descriptor for a per-instance observable that can also be used standalone.

Source code in src/nuiitivet/observable/value.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class Observable(_ObservableValue[T]):
    """Descriptor for a per-instance observable that can also be used standalone."""

    def __init__(self, default: T, *, compare: Optional[CompareFunc[T]] = None):
        super().__init__(initial=default, owner=None, name=None, compare=compare)
        self.default = default
        self.name: Optional[str] = None
        self.compare = compare

    def __set_name__(self, owner, name):
        self._name = name
        self.name = name

    def _ensure(self, instance) -> _ObservableValue[T]:
        storage_name = "_obs_" + (self.name if self.name is not None else "")
        storage = instance.__dict__.get(storage_name)
        if storage is None:
            storage = _ObservableValue(self.default, owner=instance, name=self.name, compare=self.compare)
            instance.__dict__[storage_name] = storage
        return storage

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return self._ensure(instance)

    def __set__(self, instance, value: T) -> None:
        self._ensure(instance).value = value

    @staticmethod
    def compute(fn: Callable[[], T], *, dispatch_to_ui: bool = False) -> "ComputedObservable[T]":
        from .computed import ComputedObservable

        return ComputedObservable(fn, dispatch_to_ui=dispatch_to_ui)

DefaultTitleBar

Bases: TitleBar

Configuration for the default OS title bar.

Attributes:

Name Type Description
title

The window title.

icon

Path to the window icon.

Source code in src/nuiitivet/runtime/title_bar.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class DefaultTitleBar(TitleBar):
    """Configuration for the default OS title bar.

    Attributes:
        title: The window title.
        icon: Path to the window icon.
    """

    def __init__(
        self,
        title: Optional[str] = None,
        icon: Optional[str] = None,
    ):
        super().__init__(title)
        self.icon = icon

    def __repr__(self) -> str:
        return f"DefaultTitleBar(title={self.title!r}, icon={self.icon!r})"

CustomTitleBar

Bases: TitleBar

Configuration for a custom title bar.

Source code in src/nuiitivet/runtime/title_bar.py
41
42
43
44
45
46
47
48
49
50
51
52
class CustomTitleBar(TitleBar):
    """Configuration for a custom title bar."""

    def __init__(self, content: "Widget", title: Optional[str] = None):
        """Initialize with custom content.

        Args:
            content: The widget to render as the title bar.
            title: The window title (used for taskbar/dock, but not rendered by OS).
        """
        super().__init__(title)
        self.content = content

__init__(content, title=None)

Initialize with custom content.

Parameters:

Name Type Description Default
content Widget

The widget to render as the title bar.

required
title Optional[str]

The window title (used for taskbar/dock, but not rendered by OS).

None
Source code in src/nuiitivet/runtime/title_bar.py
44
45
46
47
48
49
50
51
52
def __init__(self, content: "Widget", title: Optional[str] = None):
    """Initialize with custom content.

    Args:
        content: The widget to render as the title bar.
        title: The window title (used for taskbar/dock, but not rendered by OS).
    """
    super().__init__(title)
    self.content = content

batch()

Context manager for batching Observable updates.

Source code in src/nuiitivet/observable/batching.py
77
78
79
80
81
82
def batch() -> BatchContext:
    """Context manager for batching Observable updates."""
    current: Optional[Any] = _batch_context.get()
    if current is not None:
        return current
    return BatchContext()

set_default_font_family(family_name)

Set the system-wide default font family.

This font will be prioritized over locale-based defaults. Pass None to reset to automatic locale detection.

Source code in src/nuiitivet/rendering/skia/font.py
32
33
34
35
36
37
38
39
def set_default_font_family(family_name: Optional[str]) -> None:
    """Set the system-wide default font family.

    This font will be prioritized over locale-based defaults.
    Pass None to reset to automatic locale detection.
    """
    global _USER_DEFAULT_FONT_FAMILY
    _USER_DEFAULT_FONT_FAMILY = family_name