Skip to content

Primitives

Low-level widget implementations (Foundation layer).

widgets

Flat widget API for nuiitivet.

Expose the flat widget implementations under nuiitivet.widgets.

TextBase

TextBase(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, *, type_scale: Optional[TypeScaleToken] = None, alignment: Literal['start', 'center', 'end'] = 'start', max_lines: Optional[int] = None, overflow: Literal['visible', 'clip', 'ellipsis'] = 'visible', truncation: Literal['tail', 'head', 'middle'] = 'tail', soft_wrap: bool = True)

Bases: Widget

Display text with optional Observable binding.

Parameters: - label: Text string or Observable - style: Visual style (color, font_family) — not typography or alignment - type_scale: MD3 type-scale token supplying typography (font size, line height, weight, tracking). Defaults to Body Medium. - alignment: Horizontal text alignment within the box ("start", "center", "end"). - width: Explicit width sizing - height: Explicit height sizing - padding: Space around text - max_lines: Maximum number of lines (None = unbounded). Hard line breaks (\n) and soft wrapping both count toward this limit. - overflow: What to do when text exceeds the layout box: "visible" (draw beyond bounds), "clip" (cut at the edge), or "ellipsis" (truncate the last visible line with ). - truncation: Where the ellipsis is placed — "tail", "head" or "middle". Only meaningful when overflow="ellipsis". - soft_wrap: Whether text wraps at soft line breaks when the width is bounded. Hard breaks (\n) always break regardless of this flag.

Source code in src/nuiitivet/widgets/text.py
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,
    *,
    type_scale: Optional[TypeScaleToken] = None,
    alignment: Literal["start", "center", "end"] = "start",
    max_lines: Optional[int] = None,
    overflow: Literal["visible", "clip", "ellipsis"] = "visible",
    truncation: Literal["tail", "head", "middle"] = "tail",
    soft_wrap: bool = True,
):
    super().__init__(width=width, height=height, padding=padding)
    self.label = label

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

    # Typography comes from the type-scale token (not the style).
    self._type_scale = type_scale

    # Alignment is a layout/flow concern and lives on the widget.
    self._alignment: str = alignment if alignment in ("start", "center", "end") else "start"

    # Overflow / wrapping behavior lives on the widget, not the style.
    self._max_lines = self._normalize_max_lines(max_lines)
    self._overflow: str = overflow if overflow in ("visible", "clip", "ellipsis") else "visible"
    self._truncation: str = truncation if truncation in ("tail", "head", "middle") else "tail"
    self._soft_wrap: bool = bool(soft_wrap)

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

    self._paint_cache_key = None
    self._paint_cache_lines = None

style property

style: TextStyleProtocol

Return the current text style.

type_scale property

type_scale: TypeScaleToken

Return the active type-scale token (defaults to Body Medium).

preferred_size

preferred_size(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.

Source code in src/nuiitivet/widgets/text.py
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()
    # Typography comes from the type-scale token.
    font_size = self.type_scale.font_size
    weight = self.type_scale.weight
    tracking = self.type_scale.tracking

    try:
        tf = get_typeface(
            candidate_files=None,
            family_candidates=self._resolve_font_candidates(),
            pkg_font_dir=None,
            fallback_to_default=True,
            weight=weight,
        )

        # Available content width for wrapping: explicit width if fixed,
        # otherwise the constraint handed down by the parent (P0-C). ``0``
        # means unbounded, so soft wrapping stays off.
        l, t, r, b = self.padding
        if w_dim.kind == "fixed":
            avail_w = float(w_dim.value)
        elif max_width is not None:
            avail_w = max(0.0, float(max_width) - float(l) - float(r))
        else:
            avail_w = 0.0

        def measure_w(s: str) -> float:
            return float(measure_text_width(tf, font_size, str(s), tracking))

        lines, _ = self._layout_lines(txt, avail_w, measure_w)

        # Use advance width (the same metric paint uses for wrapping) and
        # round up, so a Text allocated its own preferred width never wraps
        # against itself due to an ink-vs-advance rounding gap.
        measured_width = 0
        for ln in lines:
            measured_width = max(measured_width, int(math.ceil(measure_w(ln))))
        if measured_width <= 0:
            measured_width = max(0, int(font_size * max(1, len(txt) * 0.6)))

        n_lines = max(1, len(lines))
        if n_lines == 1:
            # Preserve prior single-line ink-based height.
            sl, st, sr, sb = measure_text_ink_bounds(tf, font_size, lines[0] if lines else txt, tracking)
            measured_height = int(max(0.0, sb - st))
            if measured_height <= 0:
                measured_height = int(font_size)
        else:
            line_h = float(self.type_scale.line_height)
            measured_height = int(round(line_h * n_lines))
    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 = int(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

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

Paint text with padding, multi-line layout and overflow support.

Source code in src/nuiitivet/widgets/text.py
def paint(self, canvas, x: int, y: int, width: int, height: int):
    """Paint text with padding, multi-line layout and overflow support."""
    # 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()
    font_size = self.type_scale.font_size
    weight = self.type_scale.weight
    tracking = self.type_scale.tracking
    tf = get_typeface(
        candidate_files=None,
        family_candidates=self._resolve_font_candidates(),
        pkg_font_dir=None,
        fallback_to_default=True,
        weight=weight,
    )
    font = make_font(tf, font_size)

    def measure_text_w(text_value: str) -> float:
        return float(measure_text_width(tf, font_size, str(text_value), tracking))

    alignment = self._alignment
    avail_w = float(cw)

    # Cache the resolved line list. The key must change when any factor
    # affecting line breaking or truncation changes.
    cache_key = (
        txt,
        int(cw),
        int(ch),
        float(font_size),
        int(weight),
        float(tracking),
        self._overflow,
        self._truncation,
        bool(self._soft_wrap),
        self._max_lines if self._max_lines is not None else -1,
        alignment,
        tuple(self.padding),
    )
    if self._paint_cache_key == cache_key and self._paint_cache_lines is not None:
        lines = self._paint_cache_lines
    else:
        laid, overflowed = self._layout_lines(txt, avail_w, measure_text_w)
        lines = self._apply_ellipsis(laid, overflowed, avail_w, measure_text_w)
        self._paint_cache_key = cache_key
        self._paint_cache_lines = lines

    if not lines or font is None or canvas is None:
        return

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

    rgba = resolve_color_to_rgba(self.style.color, default="#000000", theme=Theme.of(self))
    paint = make_paint(color=rgba_to_skia_color(rgba), style="fill", aa=True)
    if paint is None:
        return

    clip = self._overflow == "clip"
    if clip:
        canvas.save()
        canvas.clipRect((cx, cy, cx + cw, cy + ch))

    if len(lines) == 1:
        self._paint_single_line(
            canvas, font, tf, font_size, tracking, lines[0], cx, cy, cw, ch, alignment, paint
        )
    else:
        self._paint_multi_line(
            canvas, font, tf, font_size, tracking, lines, cx, cy, cw, ch, alignment, paint
        )

    if clip:
        canvas.restore()

IconBase

IconBase(size: SizingLike = 24, padding: Tuple[int, int, int, int] | Tuple[int, int] | int = 0, **kwargs)

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
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)

draw_blob

draw_blob(canvas: Any, blob: Any, color: ColorSpec, x: int, y: int, width: int, height: int)

Draw a text blob centered in the content area.

Source code in src/nuiitivet/widgets/icon.py
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.theme import Theme

        rgba = resolve_color_to_rgba(color, theme=Theme.of(self))
        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")