Skip to content

Primitives

Low-level widget implementations (Foundation layer).

Flat widget API for nuiitivet.

Expose the flat widget implementations under nuiitivet.widgets.

TextBase

Bases: Widget

Display text with optional Observable binding.

Parameters: - label: Text string or Observable - style: Text style for font size, color, alignment, overflow - width: Explicit width sizing - height: Explicit height sizing - padding: Space around text

Source code in src/nuiitivet/widgets/text.py
 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
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
class TextBase(Widget):
    """Display text with optional Observable binding.

    Parameters:
    - label: Text string or Observable
    - style: Text style for font size, color, alignment, overflow
    - width: Explicit width sizing
    - height: Explicit height sizing
    - padding: Space around text
    """

    # instance Disposable returned from subscribing to a label Observable
    _label_unsub: Optional["Disposable"] = None

    # Paint-time cache to avoid expensive repeated shaping/measurement.
    _paint_cache_key: Optional[tuple] = None
    _paint_cache_text: Optional[str] = None
    _paint_cache_advance_w: Optional[float] = None

    def __init__(
        self,
        label: Union[str, ReadOnlyObservableProtocol[Any]],
        style: Optional[TextStyleProtocol] = None,
        width: SizingLike = None,
        height: SizingLike = None,
        padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    ):
        super().__init__(width=width, height=height, padding=padding)
        self.label = label

        # Use provided style or None (resolved via property)
        self._style = style

        # instance attribute tracking a Disposable returned by subscribe
        self._label_unsub = None

        self._paint_cache_key = None
        self._paint_cache_text = None
        self._paint_cache_advance_w = None

    @property
    def style(self) -> TextStyleProtocol:
        """Return the current text style."""
        if self._style is not None:
            return self._style
        return TextStyle()

    def _resolve_font_candidates(self) -> Tuple[str, ...]:
        """Resolve font family candidates including Japanese fonts."""
        fallbacks = get_default_font_fallbacks()
        if self.style.font_family:
            return (self.style.font_family,) + fallbacks
        return fallbacks

    def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> tuple[int, int]:
        """Return the preferred (width, height) for this Text including padding (M3準拠).

        Use explicit sizing if provided, otherwise measure text content.
        """
        # Check for explicit sizing first
        w_dim = self.width_sizing
        h_dim = self.height_sizing

        # If both sizing are fixed, return them directly (plus padding)
        if w_dim.kind == "fixed" and h_dim.kind == "fixed":
            l, t, r, b = self.padding
            return (int(w_dim.value) + l + r, int(h_dim.value) + t + b)

        # Otherwise measure the text
        txt = self._resolve_label()
        # Use font size from style
        font_size = self.style.font_size

        try:
            tf = get_typeface(
                candidate_files=None,
                family_candidates=self._resolve_font_candidates(),
                pkg_font_dir=None,
                fallback_to_default=True,
            )
            left, top, right, bottom = measure_text_ink_bounds(tf, font_size, txt)
            measured_width = int(max(0.0, right - left))
            measured_height = int(max(0.0, bottom - top))

            if measured_width <= 0:
                measured_width = max(0, int(font_size * max(1, len(txt) * 0.6)))
            if measured_height <= 0:
                measured_height = font_size
        except Exception:
            exception_once(_logger, "text_preferred_size_measure_exc", "Text preferred_size measurement failed")
            # Fallback: approximate character width ~0.6 * font_size
            approx_char_w = int(font_size * 0.6)
            measured_width = len(txt) * approx_char_w
            measured_height = font_size

        # Apply explicit sizing where provided
        if w_dim.kind == "fixed":
            width = int(w_dim.value)
        else:
            width = measured_width

        if h_dim.kind == "fixed":
            height = int(h_dim.value)
        else:
            height = measured_height

        # Add padding (M3: space between UI elements)
        l, t, r, b = self.padding
        total_w = int(width) + int(l) + int(r)
        total_h = int(height) + int(t) + int(b)

        if max_width is not None:
            total_w = min(int(total_w), int(max_width))
        if max_height is not None:
            total_h = min(int(total_h), int(max_height))

        return (int(total_w), int(total_h))

    def paint(self, canvas, x: int, y: int, width: int, height: int):
        """Paint text with padding support (M3準拠)."""
        # Apply padding to get content area (M3: space between UI elements)
        cx, cy, cw, ch = self.content_rect(x, y, width, height)

        txt = self._resolve_label()
        tf = get_typeface(
            candidate_files=None,
            family_candidates=self._resolve_font_candidates(),
            pkg_font_dir=None,
            fallback_to_default=True,
        )
        # Use font size from style
        font = make_font(tf, self.style.font_size)

        def measure_text_w(text_value: str) -> float:
            return float(measure_text_width(tf, self.style.font_size, str(text_value)))

        # Cache overflow processing and alignment width.
        # Key must change when any factor affecting truncation/advance width changes.
        cache_key = (
            txt,
            int(cw),
            int(ch),
            float(self.style.font_size),
            str(self.style.overflow),
            str(self.style.text_alignment),
            tuple(self.padding),
        )
        if self._paint_cache_key == cache_key and self._paint_cache_text is not None:
            txt = self._paint_cache_text
            cached_advance = self._paint_cache_advance_w
        else:
            cached_advance = None

        # Overflow handling: ellipsis requires measurement. Clip can be done via canvas clipping.
        if self.style.overflow == "ellipsis" and cw > 0 and self._paint_cache_key != cache_key:
            text_width = measure_text_w(txt)
            if text_width > cw:
                ellipsis = "…"
                ellipsis_width = measure_text_w(ellipsis)

                left, right = 0, len(txt)
                while left < right:
                    mid = (left + right + 1) // 2
                    test_text = txt[:mid]
                    test_width = measure_text_w(test_text)
                    if test_width + ellipsis_width <= cw:
                        left = mid
                    else:
                        right = mid - 1

                txt = (txt[:left] + ellipsis) if left > 0 else ellipsis

        tp = make_text_blob(txt, font)
        # skia may return None for an empty or unrenderable blob (or missing backend); guard
        # against that to avoid calling .bounds() on None.
        if tp is None:
            # Nothing to draw for empty/unrenderable text or missing backend
            return

        ink_left, ink_top, ink_right, ink_bottom = measure_text_ink_bounds(tf, self.style.font_size, txt)
        ink_w = max(0.0, float(ink_right) - float(ink_left))
        ink_h = max(0.0, float(ink_bottom) - float(ink_top))

        # Use advance width for center/end alignment.
        alignment = str(self.style.text_alignment)
        if cached_advance is not None:
            advance_width = float(cached_advance)
        elif alignment in ("center", "end"):
            try:
                advance_width = float(measure_text_w(txt))
            except Exception:
                advance_width = 0.0
        else:
            advance_width = 0.0

        # Handle text alignment.
        # Use tight ink bounds for visual alignment.
        tx: float
        if alignment == "start":
            tx = float(cx) - float(ink_left)
        elif alignment == "center":
            tx = float(cx) + (cw - ink_w) / 2 - float(ink_left)
        elif alignment == "end":
            tx = float(cx) + cw - ink_w - float(ink_left)
        else:
            # Fallback to start
            tx = float(cx) - float(ink_left)

        # Vertical centering
        ty: float = float(cy) + (ch - ink_h) / 2 - float(ink_top)

        # Resolve text color from the theme to an RGBA tuple and convert
        # to a skia color when skia is available.
        from nuiitivet.theme.manager import manager as theme_manager

        rgba = resolve_color_to_rgba(self.style.color, default="#000000", theme=theme_manager.current)
        paint_color = rgba_to_skia_color(rgba)

        paint = make_paint(color=paint_color, style="fill", aa=True)
        if paint is not None and canvas is not None:
            # Apply clipping if overflow is "clip"
            if self.style.overflow == "clip":
                canvas.save()
                canvas.clipRect((cx, cy, cx + cw, cy + ch))
                canvas.drawTextBlob(tp, tx, ty, paint)
                canvas.restore()
            else:
                canvas.drawTextBlob(tp, tx, ty, paint)

        # Update cache for stable frames.
        self._paint_cache_key = cache_key
        self._paint_cache_text = txt
        if alignment in ("center", "end"):
            self._paint_cache_advance_w = float(advance_width)
        else:
            self._paint_cache_advance_w = None

    def _resolve_label(self) -> str:
        lbl = self.label
        if hasattr(lbl, "value"):
            try:
                return str(lbl.value)
            except Exception:
                exception_once(_logger, "text_label_value_str_exc", "Failed to stringify label.value")
                return str(lbl)
        return str(lbl)

    def on_mount(self) -> None:
        lbl = self.label
        if lbl is None:
            return
        subscribe = getattr(lbl, "subscribe", None)
        if callable(subscribe):

            def _cb(*_args, **_kwargs):
                try:
                    self._paint_cache_key = None
                    self._paint_cache_text = None
                    self._paint_cache_advance_w = None
                    # Label changes affect measured width/height, so request
                    # layout when possible and always schedule a redraw.
                    if self.needs_layout:
                        self.invalidate()
                    else:
                        self.mark_needs_layout()
                except Exception:
                    exception_once(_logger, "text_label_change_cb_exc", "Text label change callback failed")

            try:
                # subscribe is expected to return a Disposable with dispose()
                unsub = subscribe(_cb)
                # Accept only Disposable-style subscriptions. Store the
                # Disposable directly and call .dispose() on unmount.
                if hasattr(unsub, "dispose"):
                    self._label_unsub = unsub
                else:
                    # If something else is returned, be conservative and
                    # do not retain it (forces callers to update).
                    self._label_unsub = None
            except Exception:
                exception_once(_logger, "text_label_subscribe_exc", "Text label subscribe failed")
                self._label_unsub = None

    def on_unmount(self) -> None:
        unsub = getattr(self, "_label_unsub", None)
        if unsub is not None:
            try:
                # Expect a Disposable and call dispose()
                unsub.dispose()
            except Exception:
                exception_once(_logger, "text_label_unsub_dispose_exc", "Text label unsubscribe dispose failed")
            self._label_unsub = None

        self._paint_cache_key = None
        self._paint_cache_text = None
        self._paint_cache_advance_w = None

style property

Return the current text style.

preferred_size(max_width=None, max_height=None)

Return the preferred (width, height) for this Text including padding (M3準拠).

Use explicit sizing if provided, otherwise measure text content.

Source code in src/nuiitivet/widgets/text.py
 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
def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> tuple[int, int]:
    """Return the preferred (width, height) for this Text including padding (M3準拠).

    Use explicit sizing if provided, otherwise measure text content.
    """
    # Check for explicit sizing first
    w_dim = self.width_sizing
    h_dim = self.height_sizing

    # If both sizing are fixed, return them directly (plus padding)
    if w_dim.kind == "fixed" and h_dim.kind == "fixed":
        l, t, r, b = self.padding
        return (int(w_dim.value) + l + r, int(h_dim.value) + t + b)

    # Otherwise measure the text
    txt = self._resolve_label()
    # Use font size from style
    font_size = self.style.font_size

    try:
        tf = get_typeface(
            candidate_files=None,
            family_candidates=self._resolve_font_candidates(),
            pkg_font_dir=None,
            fallback_to_default=True,
        )
        left, top, right, bottom = measure_text_ink_bounds(tf, font_size, txt)
        measured_width = int(max(0.0, right - left))
        measured_height = int(max(0.0, bottom - top))

        if measured_width <= 0:
            measured_width = max(0, int(font_size * max(1, len(txt) * 0.6)))
        if measured_height <= 0:
            measured_height = font_size
    except Exception:
        exception_once(_logger, "text_preferred_size_measure_exc", "Text preferred_size measurement failed")
        # Fallback: approximate character width ~0.6 * font_size
        approx_char_w = int(font_size * 0.6)
        measured_width = len(txt) * approx_char_w
        measured_height = font_size

    # Apply explicit sizing where provided
    if w_dim.kind == "fixed":
        width = int(w_dim.value)
    else:
        width = measured_width

    if h_dim.kind == "fixed":
        height = int(h_dim.value)
    else:
        height = measured_height

    # Add padding (M3: space between UI elements)
    l, t, r, b = self.padding
    total_w = int(width) + int(l) + int(r)
    total_h = int(height) + int(t) + int(b)

    if max_width is not None:
        total_w = min(int(total_w), int(max_width))
    if max_height is not None:
        total_h = min(int(total_h), int(max_height))

    return (int(total_w), int(total_h))

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

Paint text with padding support (M3準拠).

Source code in src/nuiitivet/widgets/text.py
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
def paint(self, canvas, x: int, y: int, width: int, height: int):
    """Paint text with padding support (M3準拠)."""
    # Apply padding to get content area (M3: space between UI elements)
    cx, cy, cw, ch = self.content_rect(x, y, width, height)

    txt = self._resolve_label()
    tf = get_typeface(
        candidate_files=None,
        family_candidates=self._resolve_font_candidates(),
        pkg_font_dir=None,
        fallback_to_default=True,
    )
    # Use font size from style
    font = make_font(tf, self.style.font_size)

    def measure_text_w(text_value: str) -> float:
        return float(measure_text_width(tf, self.style.font_size, str(text_value)))

    # Cache overflow processing and alignment width.
    # Key must change when any factor affecting truncation/advance width changes.
    cache_key = (
        txt,
        int(cw),
        int(ch),
        float(self.style.font_size),
        str(self.style.overflow),
        str(self.style.text_alignment),
        tuple(self.padding),
    )
    if self._paint_cache_key == cache_key and self._paint_cache_text is not None:
        txt = self._paint_cache_text
        cached_advance = self._paint_cache_advance_w
    else:
        cached_advance = None

    # Overflow handling: ellipsis requires measurement. Clip can be done via canvas clipping.
    if self.style.overflow == "ellipsis" and cw > 0 and self._paint_cache_key != cache_key:
        text_width = measure_text_w(txt)
        if text_width > cw:
            ellipsis = "…"
            ellipsis_width = measure_text_w(ellipsis)

            left, right = 0, len(txt)
            while left < right:
                mid = (left + right + 1) // 2
                test_text = txt[:mid]
                test_width = measure_text_w(test_text)
                if test_width + ellipsis_width <= cw:
                    left = mid
                else:
                    right = mid - 1

            txt = (txt[:left] + ellipsis) if left > 0 else ellipsis

    tp = make_text_blob(txt, font)
    # skia may return None for an empty or unrenderable blob (or missing backend); guard
    # against that to avoid calling .bounds() on None.
    if tp is None:
        # Nothing to draw for empty/unrenderable text or missing backend
        return

    ink_left, ink_top, ink_right, ink_bottom = measure_text_ink_bounds(tf, self.style.font_size, txt)
    ink_w = max(0.0, float(ink_right) - float(ink_left))
    ink_h = max(0.0, float(ink_bottom) - float(ink_top))

    # Use advance width for center/end alignment.
    alignment = str(self.style.text_alignment)
    if cached_advance is not None:
        advance_width = float(cached_advance)
    elif alignment in ("center", "end"):
        try:
            advance_width = float(measure_text_w(txt))
        except Exception:
            advance_width = 0.0
    else:
        advance_width = 0.0

    # Handle text alignment.
    # Use tight ink bounds for visual alignment.
    tx: float
    if alignment == "start":
        tx = float(cx) - float(ink_left)
    elif alignment == "center":
        tx = float(cx) + (cw - ink_w) / 2 - float(ink_left)
    elif alignment == "end":
        tx = float(cx) + cw - ink_w - float(ink_left)
    else:
        # Fallback to start
        tx = float(cx) - float(ink_left)

    # Vertical centering
    ty: float = float(cy) + (ch - ink_h) / 2 - float(ink_top)

    # Resolve text color from the theme to an RGBA tuple and convert
    # to a skia color when skia is available.
    from nuiitivet.theme.manager import manager as theme_manager

    rgba = resolve_color_to_rgba(self.style.color, default="#000000", theme=theme_manager.current)
    paint_color = rgba_to_skia_color(rgba)

    paint = make_paint(color=paint_color, style="fill", aa=True)
    if paint is not None and canvas is not None:
        # Apply clipping if overflow is "clip"
        if self.style.overflow == "clip":
            canvas.save()
            canvas.clipRect((cx, cy, cx + cw, cy + ch))
            canvas.drawTextBlob(tp, tx, ty, paint)
            canvas.restore()
        else:
            canvas.drawTextBlob(tp, tx, ty, paint)

    # Update cache for stable frames.
    self._paint_cache_key = cache_key
    self._paint_cache_text = txt
    if alignment in ("center", "end"):
        self._paint_cache_advance_w = float(advance_width)
    else:
        self._paint_cache_advance_w = None

IconBase

Bases: Widget

Base class for icon widgets.

Provides utilities for rendering text blobs centered in the widget area.

Source code in src/nuiitivet/widgets/icon.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
class IconBase(Widget):
    """Base class for icon widgets.

    Provides utilities for rendering text blobs centered in the widget area.
    """

    def __init__(self, size: SizingLike = 24, padding: Tuple[int, int, int, int] | Tuple[int, int] | int = 0, **kwargs):
        super().__init__(width=size, height=size, padding=padding, **kwargs)

    def draw_blob(self, canvas: Any, blob: Any, color: ColorSpec, x: int, y: int, width: int, height: int):
        """Draw a text blob centered in the content area."""
        if blob is None:
            return

        cx, cy, cw, ch = self.content_rect(x, y, width, height)

        try:
            bounds = blob.bounds()
            tx = cx + (cw - bounds.width()) / 2 - bounds.left()
            ty = cy + (ch - bounds.height()) / 2 - bounds.top()
        except Exception:
            exception_once(logger, "icon_base_blob_bounds_exc", "Failed to get blob bounds")
            return

        try:
            from nuiitivet.theme.manager import manager as theme_manager

            rgba = resolve_color_to_rgba(color, theme=theme_manager.current)
            paint = make_paint(color=rgba, style="fill", aa=True)
        except Exception:
            exception_once(logger, "icon_base_resolve_color_exc", "Failed to resolve icon color")
            return

        if paint is None:
            return

        try:
            canvas.drawTextBlob(blob, tx, ty, paint)
        except Exception:
            exception_once(logger, "icon_base_draw_text_blob_exc", "drawTextBlob failed")

draw_blob(canvas, blob, color, x, y, width, height)

Draw a text blob centered in the content area.

Source code in src/nuiitivet/widgets/icon.py
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
def draw_blob(self, canvas: Any, blob: Any, color: ColorSpec, x: int, y: int, width: int, height: int):
    """Draw a text blob centered in the content area."""
    if blob is None:
        return

    cx, cy, cw, ch = self.content_rect(x, y, width, height)

    try:
        bounds = blob.bounds()
        tx = cx + (cw - bounds.width()) / 2 - bounds.left()
        ty = cy + (ch - bounds.height()) / 2 - bounds.top()
    except Exception:
        exception_once(logger, "icon_base_blob_bounds_exc", "Failed to get blob bounds")
        return

    try:
        from nuiitivet.theme.manager import manager as theme_manager

        rgba = resolve_color_to_rgba(color, theme=theme_manager.current)
        paint = make_paint(color=rgba, style="fill", aa=True)
    except Exception:
        exception_once(logger, "icon_base_resolve_color_exc", "Failed to resolve icon color")
        return

    if paint is None:
        return

    try:
        canvas.drawTextBlob(blob, tx, ty, paint)
    except Exception:
        exception_once(logger, "icon_base_draw_text_blob_exc", "drawTextBlob failed")

Image

Bases: Widget

Display a raster image from in-memory bytes.

Parameters:

Name Type Description Default
source bytes | None | ReadOnlyObservableProtocol[bytes | None]

Encoded image bytes, None, or an Observable that provides them.

required
fit Fit

Content fit mode. One of "contain", "cover", "fill", "none".

'contain'
alignment AlignmentLike

Content alignment in the allocated content rect.

'center'
width SizingLike

Width sizing.

None
height SizingLike

Height sizing.

None
padding int | tuple[int, int] | tuple[int, int, int, int]

Space around content.

0
Source code in src/nuiitivet/widgets/image.py
 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
class Image(Widget):
    """Display a raster image from in-memory bytes.

    Args:
        source: Encoded image bytes, ``None``, or an Observable that provides them.
        fit: Content fit mode. One of ``"contain"``, ``"cover"``, ``"fill"``, ``"none"``.
        alignment: Content alignment in the allocated content rect.
        width: Width sizing.
        height: Height sizing.
        padding: Space around content.
    """

    def __init__(
        self,
        source: bytes | None | ReadOnlyObservableProtocol[bytes | None],
        *,
        fit: Fit = "contain",
        width: SizingLike = None,
        height: SizingLike = None,
        padding: int | tuple[int, int] | tuple[int, int, int, int] = 0,
        alignment: AlignmentLike = "center",
    ) -> None:
        """Initialize an Image widget.

        Args:
            source: Encoded image bytes, ``None``, or an Observable that provides them.
            fit: Content fit mode. One of ``"contain"``, ``"cover"``, ``"fill"``, ``"none"``.
            width: Width sizing.
            height: Height sizing.
            padding: Space around content.
            alignment: Content alignment in the allocated content rect.
        """
        super().__init__(width=width, height=height, padding=padding)
        self._fit: Fit = self._normalize_fit(fit)
        self._align_raw: AlignmentLike = alignment
        self._alignment: tuple[str, str] = normalize_alignment(alignment, default=("center", "center"))

        self._source: bytes | None | ReadOnlyObservableProtocol[bytes | None] = source
        self._resolved_source: bytes | None = None
        self._decoded_image: Any | None = None
        self._decoded_token: tuple[int, int] | None = None

        if isinstance(source, ReadOnlyObservableProtocol):
            self.observe(source, self._on_source_change)
        else:
            self._on_source_change(source)

    @property
    def fit(self) -> Fit:
        return self._fit

    @fit.setter
    def fit(self, value: Fit) -> None:
        normalized = self._normalize_fit(value)
        if normalized == self._fit:
            return
        self._fit = normalized
        self.invalidate()

    @property
    def alignment(self) -> AlignmentLike:
        return self._align_raw

    @alignment.setter
    def alignment(self, value: AlignmentLike) -> None:
        self._align_raw = value
        self._alignment = normalize_alignment(value, default=("center", "center"))
        self.invalidate()

    def preferred_size(self, max_width: int | None = None, max_height: int | None = None) -> tuple[int, int]:
        """Return preferred size based on intrinsic image size and explicit sizing."""
        w_dim = self.width_sizing
        h_dim = self.height_sizing

        if w_dim.kind == "fixed" and h_dim.kind == "fixed":
            l, t, r, b = self.padding
            return (int(w_dim.value) + l + r, int(h_dim.value) + t + b)

        image = self._decoded_image
        intrinsic_w, intrinsic_h = self._image_size(image)

        width = int(w_dim.value) if w_dim.kind == "fixed" else intrinsic_w
        height = int(h_dim.value) if h_dim.kind == "fixed" else intrinsic_h

        l, t, r, b = self.padding
        total_w = int(width) + int(l) + int(r)
        total_h = int(height) + int(t) + int(b)

        if max_width is not None:
            total_w = min(total_w, int(max_width))
        if max_height is not None:
            total_h = min(total_h, int(max_height))

        return (max(0, total_w), max(0, total_h))

    def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
        """Paint the image into the given rect according to fit and alignment."""
        self.set_last_rect(x, y, width, height)

        if canvas is None:
            return

        image = self._decoded_image
        if image is None:
            return

        img_w, img_h = self._image_size(image)
        if img_w <= 0 or img_h <= 0:
            return

        cx, cy, cw, ch = self.content_rect(x, y, width, height)
        if cw <= 0 or ch <= 0:
            return

        fit = self._fit
        align_x, align_y = self._alignment
        fx = self._align_factor(align_x)
        fy = self._align_factor(align_y)

        if fit == "fill":
            self._draw_image_rect(
                canvas,
                image,
                (0.0, 0.0, float(img_w), float(img_h)),
                (float(cx), float(cy), float(cw), float(ch)),
            )
            return

        if fit == "cover":
            src = self._compute_cover_source(img_w, img_h, cw, ch, fx, fy)
            self._draw_image_rect(canvas, image, src, (float(cx), float(cy), float(cw), float(ch)))
            return

        if fit == "contain":
            scale = min(float(cw) / float(img_w), float(ch) / float(img_h))
            draw_w = max(0.0, float(img_w) * scale)
            draw_h = max(0.0, float(img_h) * scale)
        else:  # "none"
            draw_w = float(img_w)
            draw_h = float(img_h)

        dx = float(cx) + max(0.0, float(cw) - draw_w) * fx
        dy = float(cy) + max(0.0, float(ch) - draw_h) * fy

        if fit == "none" and (draw_w > float(cw) or draw_h > float(ch)):
            save = getattr(canvas, "save", None)
            restore = getattr(canvas, "restore", None)
            clip = getattr(canvas, "clipRect", None)
            if callable(save) and callable(restore) and callable(clip):
                save()
                clip((float(cx), float(cy), float(cx + cw), float(cy + ch)))
                self._draw_image_rect(canvas, image, (0.0, 0.0, float(img_w), float(img_h)), (dx, dy, draw_w, draw_h))
                restore()
                return

        self._draw_image_rect(canvas, image, (0.0, 0.0, float(img_w), float(img_h)), (dx, dy, draw_w, draw_h))

    def _on_source_change(self, value: bytes | None) -> None:
        if value is not None and not isinstance(value, bytes):
            exception_once(
                logger,
                "image_source_type_exc",
                "Image source must be bytes | None, got %s",
                type(value).__name__,
            )
            self._resolved_source = None
        else:
            self._resolved_source = value

        self._decoded_image = None
        self._decoded_token = None
        self._decode_image_if_needed()
        self.mark_needs_layout()

    def _decode_image_if_needed(self) -> Any | None:
        source = self._resolved_source
        if source is None:
            self._decoded_image = None
            self._decoded_token = None
            return None

        token = (id(source), len(source))
        if self._decoded_token == token and self._decoded_image is not None:
            return self._decoded_image

        backend = get_skia(raise_if_missing=False)
        if backend is None:
            self._decoded_image = None
            self._decoded_token = None
            return None

        image_cls = getattr(backend, "Image", None)
        make_from_encoded = getattr(image_cls, "MakeFromEncoded", None) if image_cls is not None else None
        if not callable(make_from_encoded):
            self._decoded_image = None
            self._decoded_token = None
            return None

        try:
            decoded = make_from_encoded(source)
        except Exception:
            exception_once(logger, "image_decode_exc", "Failed to decode image bytes")
            decoded = None

        self._decoded_image = decoded
        # Keep token even when decode failed to avoid repeated decode attempts
        # for the same bytes on every frame.
        self._decoded_token = token
        return decoded

    def _image_size(self, image: Any | None) -> tuple[int, int]:
        if image is None:
            return (0, 0)

        try:
            width_attr = getattr(image, "width", None)
            w = width_attr() if callable(width_attr) else width_attr
            height_attr = getattr(image, "height", None)
            h = height_attr() if callable(height_attr) else height_attr
            return (max(0, int(w or 0)), max(0, int(h or 0)))
        except Exception:
            exception_once(logger, "image_size_exc", "Failed to read decoded image size")
            return (0, 0)

    def _draw_image_rect(
        self,
        canvas,
        image: Any,
        src: tuple[float, float, float, float],
        dst: tuple[float, float, float, float],
    ) -> None:
        src_rect = make_rect(src[0], src[1], src[2], src[3])
        dst_rect = make_rect(dst[0], dst[1], dst[2], dst[3])

        if src_rect is not None and dst_rect is not None and hasattr(canvas, "drawImageRect"):
            try:
                canvas.drawImageRect(image, src_rect, dst_rect)
                return
            except Exception:
                exception_once(logger, "image_draw_rect_exc", "drawImageRect failed")

        # Fallback for canvases without drawImageRect support.
        if src[0] == 0.0 and src[1] == 0.0 and dst[2] == src[2] and dst[3] == src[3] and hasattr(canvas, "drawImage"):
            try:
                canvas.drawImage(image, dst[0], dst[1])
            except Exception:
                exception_once(logger, "image_draw_image_exc", "drawImage fallback failed")

    def _compute_cover_source(
        self,
        img_w: int,
        img_h: int,
        dst_w: int,
        dst_h: int,
        fx: float,
        fy: float,
    ) -> tuple[float, float, float, float]:
        src_ar = float(img_w) / float(img_h)
        dst_ar = float(dst_w) / float(dst_h)

        if src_ar > dst_ar:
            crop_h = float(img_h)
            crop_w = crop_h * dst_ar
            sx = max(0.0, float(img_w) - crop_w) * fx
            sy = 0.0
        else:
            crop_w = float(img_w)
            crop_h = crop_w / dst_ar
            sx = 0.0
            sy = max(0.0, float(img_h) - crop_h) * fy

        return (sx, sy, crop_w, crop_h)

    def _normalize_fit(self, value: Fit | str) -> Fit:
        key = str(value).strip().lower()
        if key in ("contain", "cover", "fill", "none"):
            return key  # type: ignore[return-value]
        return "contain"

    def _align_factor(self, axis_align: str) -> float:
        if axis_align == "center":
            return 0.5
        if axis_align == "end":
            return 1.0
        return 0.0

__init__(source, *, fit='contain', width=None, height=None, padding=0, alignment='center')

Initialize an Image widget.

Parameters:

Name Type Description Default
source bytes | None | ReadOnlyObservableProtocol[bytes | None]

Encoded image bytes, None, or an Observable that provides them.

required
fit Fit

Content fit mode. One of "contain", "cover", "fill", "none".

'contain'
width SizingLike

Width sizing.

None
height SizingLike

Height sizing.

None
padding int | tuple[int, int] | tuple[int, int, int, int]

Space around content.

0
alignment AlignmentLike

Content alignment in the allocated content rect.

'center'
Source code in src/nuiitivet/widgets/image.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
def __init__(
    self,
    source: bytes | None | ReadOnlyObservableProtocol[bytes | None],
    *,
    fit: Fit = "contain",
    width: SizingLike = None,
    height: SizingLike = None,
    padding: int | tuple[int, int] | tuple[int, int, int, int] = 0,
    alignment: AlignmentLike = "center",
) -> None:
    """Initialize an Image widget.

    Args:
        source: Encoded image bytes, ``None``, or an Observable that provides them.
        fit: Content fit mode. One of ``"contain"``, ``"cover"``, ``"fill"``, ``"none"``.
        width: Width sizing.
        height: Height sizing.
        padding: Space around content.
        alignment: Content alignment in the allocated content rect.
    """
    super().__init__(width=width, height=height, padding=padding)
    self._fit: Fit = self._normalize_fit(fit)
    self._align_raw: AlignmentLike = alignment
    self._alignment: tuple[str, str] = normalize_alignment(alignment, default=("center", "center"))

    self._source: bytes | None | ReadOnlyObservableProtocol[bytes | None] = source
    self._resolved_source: bytes | None = None
    self._decoded_image: Any | None = None
    self._decoded_token: tuple[int, int] | None = None

    if isinstance(source, ReadOnlyObservableProtocol):
        self.observe(source, self._on_source_change)
    else:
        self._on_source_change(source)

preferred_size(max_width=None, max_height=None)

Return preferred size based on intrinsic image size and explicit sizing.

Source code in src/nuiitivet/widgets/image.py
 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
def preferred_size(self, max_width: int | None = None, max_height: int | None = None) -> tuple[int, int]:
    """Return preferred size based on intrinsic image size and explicit sizing."""
    w_dim = self.width_sizing
    h_dim = self.height_sizing

    if w_dim.kind == "fixed" and h_dim.kind == "fixed":
        l, t, r, b = self.padding
        return (int(w_dim.value) + l + r, int(h_dim.value) + t + b)

    image = self._decoded_image
    intrinsic_w, intrinsic_h = self._image_size(image)

    width = int(w_dim.value) if w_dim.kind == "fixed" else intrinsic_w
    height = int(h_dim.value) if h_dim.kind == "fixed" else intrinsic_h

    l, t, r, b = self.padding
    total_w = int(width) + int(l) + int(r)
    total_h = int(height) + int(t) + int(b)

    if max_width is not None:
        total_w = min(total_w, int(max_width))
    if max_height is not None:
        total_h = min(total_h, int(max_height))

    return (max(0, total_w), max(0, total_h))

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

Paint the image into the given rect according to fit and alignment.

Source code in src/nuiitivet/widgets/image.py
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
def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
    """Paint the image into the given rect according to fit and alignment."""
    self.set_last_rect(x, y, width, height)

    if canvas is None:
        return

    image = self._decoded_image
    if image is None:
        return

    img_w, img_h = self._image_size(image)
    if img_w <= 0 or img_h <= 0:
        return

    cx, cy, cw, ch = self.content_rect(x, y, width, height)
    if cw <= 0 or ch <= 0:
        return

    fit = self._fit
    align_x, align_y = self._alignment
    fx = self._align_factor(align_x)
    fy = self._align_factor(align_y)

    if fit == "fill":
        self._draw_image_rect(
            canvas,
            image,
            (0.0, 0.0, float(img_w), float(img_h)),
            (float(cx), float(cy), float(cw), float(ch)),
        )
        return

    if fit == "cover":
        src = self._compute_cover_source(img_w, img_h, cw, ch, fx, fy)
        self._draw_image_rect(canvas, image, src, (float(cx), float(cy), float(cw), float(ch)))
        return

    if fit == "contain":
        scale = min(float(cw) / float(img_w), float(ch) / float(img_h))
        draw_w = max(0.0, float(img_w) * scale)
        draw_h = max(0.0, float(img_h) * scale)
    else:  # "none"
        draw_w = float(img_w)
        draw_h = float(img_h)

    dx = float(cx) + max(0.0, float(cw) - draw_w) * fx
    dy = float(cy) + max(0.0, float(ch) - draw_h) * fy

    if fit == "none" and (draw_w > float(cw) or draw_h > float(ch)):
        save = getattr(canvas, "save", None)
        restore = getattr(canvas, "restore", None)
        clip = getattr(canvas, "clipRect", None)
        if callable(save) and callable(restore) and callable(clip):
            save()
            clip((float(cx), float(cy), float(cx + cw), float(cy + ch)))
            self._draw_image_rect(canvas, image, (0.0, 0.0, float(img_w), float(img_h)), (dx, dy, draw_w, draw_h))
            restore()
            return

    self._draw_image_rect(canvas, image, (0.0, 0.0, float(img_w), float(img_h)), (dx, dy, draw_w, draw_h))