Skip to content

Material Components

High-level widgets implementing Material Design 3.

material

Material design system root.

This is the single public import root for Material apps. It re-exports every core symbol from :mod:nuiitivet and adds the Material widgets/styles, so a single import gives access to everything::

import nuiitivet.material as nv

nv.Column(...)   # core symbol
nv.Button(...)   # material symbol

Deep imports (nuiitivet.material.buttons, nuiitivet.material.styles.*, ...) are internal and unsupported.

ButtonSize module-attribute

ButtonSize = Literal['xs', 's', 'm', 'l', 'xl']

M3 button size preset.

FabSize module-attribute

FabSize = Literal['s', 'm', 'l']

Framework-unified FAB size preset.

The literal values follow the project-wide s/m/l convention rather than the MD3 spec wording (FAB / Medium FAB / Large FAB):

  • "s" corresponds to the baseline 56dp FAB (MD3 "FAB").
  • "m" corresponds to the 80dp Medium FAB.
  • "l" corresponds to the 96dp Large FAB.

The deprecated 40dp Small FAB is intentionally not represented.

ButtonGroupPosition module-attribute

ButtonGroupPosition = Literal['start', 'middle', 'end', 'only']

Position of a segment within a ButtonGroup.

LargeBadge

LargeBadge(text: str, *, padding: Union[int, Tuple[int, int], Tuple[int, int, int, int], None] = None, style: Optional[LargeBadgeStyle] = None, key: Optional[str] = None)

Bases: Box

Large text badge widget.

Initialize LargeBadge.

The height is MD3-fixed (spec) and the width is content-driven, so neither is a constructor parameter; customize the height via style (SIZE_POLICY: MD3 fixes the axis -> style only).

Parameters:

Name Type Description Default
text str

Badge text to display. Must be non-empty.

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

External badge padding. Defaults to style padding.

None
style Optional[LargeBadgeStyle]

Optional style override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/badge.py
def __init__(
    self,
    text: str,
    *,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int], None] = None,
    style: Optional[LargeBadgeStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize LargeBadge.

    The height is MD3-fixed (spec) and the width is content-driven, so
    neither is a constructor parameter; customize the height via ``style``
    (SIZE_POLICY: MD3 fixes the axis -> style only).

    Args:
        text: Badge text to display. Must be non-empty.
        padding: External badge padding. Defaults to style padding.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    if not text:
        raise ValueError("text must be non-empty")

    self.text = text

    effective_style = style or LargeBadgeStyle()
    resolved_height = Sizing.fixed(effective_style.height)
    resolved_padding = effective_style.padding if padding is None else padding

    label = Text(
        text,
        style=TextStyle(color=effective_style.content_color),
        type_scale=TypeScaleToken.from_size(effective_style.font_size),
        alignment="center",
        max_lines=1,
        overflow="clip",
    )

    super().__init__(
        child=label,
        height=resolved_height,
        padding=resolved_padding,
        background_color=effective_style.background_color,
        corner_radius=effective_style.corner_radius,
        alignment="center",
        key=key,
    )

stick_modifier

stick_modifier(*, badge: Optional[Widget] = None) -> StickModifier

Create a spec-aligned stick modifier for attaching this large badge.

Parameters:

Name Type Description Default
badge Optional[Widget]

Optional badge widget to place. Defaults to this badge instance.

None

Returns:

Type Description
StickModifier

Stick modifier configured for MD3-like large badge placement.

Source code in src/nuiitivet/material/badge.py
def stick_modifier(self, *, badge: Optional[Widget] = None) -> StickModifier:
    """Create a spec-aligned stick modifier for attaching this large badge.

    Args:
        badge: Optional badge widget to place. Defaults to this badge instance.

    Returns:
        Stick modifier configured for MD3-like large badge placement.
    """
    target_badge = badge if badge is not None else self
    return stick(
        target_badge,
        target_anchor="top-right",
        content_anchor="bottom-left",
        offset=(-12.0, 14.0),
    )

SmallBadge

SmallBadge(*, padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0, style: Optional[SmallBadgeStyle] = None, key: Optional[str] = None)

Bases: Box

Small dot badge widget.

Initialize SmallBadge.

The dot dimensions are MD3-fixed (spec size tokens), so they are not constructor parameters; customize them via style instead (SIZE_POLICY: MD3 fixes the axis -> style only).

Parameters:

Name Type Description Default
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

External badge padding.

0
style Optional[SmallBadgeStyle]

Optional style override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/badge.py
def __init__(
    self,
    *,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    style: Optional[SmallBadgeStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize SmallBadge.

    The dot dimensions are MD3-fixed (spec size tokens), so they are not
    constructor parameters; customize them via ``style`` instead
    (SIZE_POLICY: MD3 fixes the axis -> style only).

    Args:
        padding: External badge padding.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    effective_style = style or SmallBadgeStyle()

    super().__init__(
        child=None,
        width=Sizing.fixed(effective_style.width),
        height=Sizing.fixed(effective_style.height),
        padding=padding,
        background_color=effective_style.background_color,
        corner_radius=effective_style.corner_radius,
        key=key,
    )

stick_modifier

stick_modifier(*, badge: Optional[Widget] = None) -> StickModifier

Create a spec-aligned stick modifier for attaching this small badge.

Parameters:

Name Type Description Default
badge Optional[Widget]

Optional badge widget to place. Defaults to this badge instance.

None

Returns:

Type Description
StickModifier

Stick modifier configured for MD3-like small badge placement.

Source code in src/nuiitivet/material/badge.py
def stick_modifier(self, *, badge: Optional[Widget] = None) -> StickModifier:
    """Create a spec-aligned stick modifier for attaching this small badge.

    Args:
        badge: Optional badge widget to place. Defaults to this badge instance.

    Returns:
        Stick modifier configured for MD3-like small badge placement.
    """
    target_badge = badge if badge is not None else self
    return stick(
        target_badge,
        target_anchor="top-right",
        content_anchor="bottom-left",
        offset=(-6.0, 6.0),
    )

App

App(window: Window, *, theme: Optional[Any] = None, exit_policy: ExitPolicy = LAST_WINDOW_CLOSED, tray: Optional[TrayIcon] = None)

Bases: App

Material Design application runner.

Takes its main window as the first argument and supplies the Material default theme. Through the public surface the window is a :class:~nuiitivet.material.window.MaterialWindow: nv.App(nv.Window(content=...)). Secondary windows are constructed with nv.Window(...) and shown with window.open().

Initialize a MaterialApp.

Parameters:

Name Type Description Default
window Window

The main window, typically a :class:~nuiitivet.material.window.MaterialWindow.

required
theme Optional[Any]

The MaterialThemeFactory to use. Defaults to Light theme.

None
exit_policy ExitPolicy

When :meth:run returns; see :class:~nuiitivet.runtime.app.ExitPolicy.

LAST_WINDOW_CLOSED
tray Optional[TrayIcon]

A :class:~nuiitivet.platform.tray.TrayIcon to show while the app runs; see :attr:TrayIcon.installed.

None
Source code in src/nuiitivet/material/app.py
def __init__(
    self,
    window: Window,
    *,
    theme: Optional[Any] = None,
    exit_policy: ExitPolicy = ExitPolicy.LAST_WINDOW_CLOSED,
    tray: Optional[TrayIcon] = None,
) -> None:
    """Initialize a MaterialApp.

    Args:
        window: The main window, typically a
            :class:`~nuiitivet.material.window.MaterialWindow`.
        theme: The MaterialThemeFactory to use. Defaults to Light theme.
        exit_policy: When :meth:`run` returns; see
            :class:`~nuiitivet.runtime.app.ExitPolicy`.
        tray: A :class:`~nuiitivet.platform.tray.TrayIcon` to show while
            the app runs; see :attr:`TrayIcon.installed`.
    """
    if theme is None:
        theme = MaterialThemeFactory.light("#6750A4")

    super().__init__(window, theme=theme, exit_policy=exit_policy, tray=tray)

HorizontalDivider

HorizontalDivider(*, width: SizingLike = None, padding: PaddingLike = 0, style: Optional[DividerStyle] = None, key: Optional[str] = None)

Bases: _DividerBase

Material Design 3 horizontal divider.

Draws a full-width line to separate content. Only width is exposed; the height (thickness) is derived from the style.

Initialize HorizontalDivider.

Parameters:

Name Type Description Default
width SizingLike

Width sizing override. Defaults to Sizing.weight().

None
padding PaddingLike

Padding around the divider line.

0
style Optional[DividerStyle]

Optional :class:~nuiitivet.material.styles.divider_style.DividerStyle override. Falls back to the default DividerStyle when None.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/divider.py
def __init__(
    self,
    *,
    width: SizingLike = None,
    padding: PaddingLike = 0,
    style: Optional[DividerStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize HorizontalDivider.

    Args:
        width: Width sizing override. Defaults to ``Sizing.weight()``.
        padding: Padding around the divider line.
        style: Optional :class:`~nuiitivet.material.styles.divider_style.DividerStyle`
            override. Falls back to the default ``DividerStyle`` when ``None``.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    _pad_l, pad_t, _pad_r, pad_b = parse_padding(padding)
    resolved_width: SizingLike = Sizing.weight() if width is None else width
    super().__init__(
        orientation="horizontal",
        width=resolved_width,
        height=_cross_axis_thickness(style, pad_t, pad_b),
        padding=padding,
        style=style,
        key=key,
    )

VerticalDivider

VerticalDivider(*, height: SizingLike = None, padding: PaddingLike = 0, style: Optional[DividerStyle] = None, key: Optional[str] = None)

Bases: _DividerBase

Material Design 3 vertical divider.

Draws a full-height line to separate content. Only height is exposed; the width (thickness) is derived from the style.

Initialize VerticalDivider.

Parameters:

Name Type Description Default
height SizingLike

Height sizing override. Defaults to Sizing.weight().

None
padding PaddingLike

Padding around the divider line.

0
style Optional[DividerStyle]

Optional :class:~nuiitivet.material.styles.divider_style.DividerStyle override. Falls back to the default DividerStyle when None.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/divider.py
def __init__(
    self,
    *,
    height: SizingLike = None,
    padding: PaddingLike = 0,
    style: Optional[DividerStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize VerticalDivider.

    Args:
        height: Height sizing override. Defaults to ``Sizing.weight()``.
        padding: Padding around the divider line.
        style: Optional :class:`~nuiitivet.material.styles.divider_style.DividerStyle`
            override. Falls back to the default ``DividerStyle`` when ``None``.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    pad_l, _pad_t, pad_r, _pad_b = parse_padding(padding)
    resolved_height: SizingLike = Sizing.weight() if height is None else height
    super().__init__(
        orientation="vertical",
        width=_cross_axis_thickness(style, pad_l, pad_r),
        height=resolved_height,
        padding=padding,
        style=style,
        key=key,
    )

Button

Button(label: str | ReadOnlyObservableProtocol[str] | None = None, icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None = None, *, on_click: Optional[VoidCallback] = None, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = None, padding: Optional[Union[int, Tuple[int, int, int, int]]] = None, style: Optional[ButtonStyle] = None, key: Optional[str] = None)

Bases: MaterialButtonBase

Unified Material Design 3 button.

The visual variant (filled, outlined, text, elevated, tonal) and the M3 size preset ("xs".."xl") are both expressed through the style argument, which accepts any :class:ButtonStyle instance. Use the :class:ButtonStyle factory methods to obtain variant presets: ButtonStyle.filled("s"), ButtonStyle.outlined("m") and so on.

When style is not provided, :meth:ButtonStyle.filled with size "s" is used as the default.

Initialize Button.

The height is MD3-fixed by the style's size variant, so it is not a constructor parameter; select it through style (e.g. ButtonStyle.filled("m")) — SIZE_POLICY: MD3 fixes the axis -> style only.

Parameters:

Name Type Description Default
label str | ReadOnlyObservableProtocol[str] | None

Text label for the button.

None
icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None

Icon glyph for the button (Symbol, string, or observable).

None
on_click Optional[VoidCallback]

Callback invoked when the button is clicked.

None
disabled bool | ObservableProtocol[bool]

Whether the button is disabled.

False
width SizingLike

Width specification. Defaults to auto.

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

Padding override; None delegates to style.padding.

None
style Optional[ButtonStyle]

Visual style preset. Defaults to ButtonStyle.filled("s").

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/buttons.py
def __init__(
    self,
    label: str | ReadOnlyObservableProtocol[str] | None = None,
    icon: "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str] | None = None,
    *,
    on_click: Optional[VoidCallback] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = None,
    padding: Optional[Union[int, Tuple[int, int, int, int]]] = None,
    style: Optional[ButtonStyle] = None,
    key: Optional[str] = None,
):
    """Initialize Button.

    The height is MD3-fixed by the style's size variant, so it is not a
    constructor parameter; select it through ``style`` (e.g.
    ``ButtonStyle.filled("m")``) — SIZE_POLICY: MD3 fixes the axis -> style
    only.

    Args:
        label: Text label for the button.
        icon: Icon glyph for the button (Symbol, string, or observable).
        on_click: Callback invoked when the button is clicked.
        disabled: Whether the button is disabled.
        width: Width specification. Defaults to auto.
        padding: Padding override; ``None`` delegates to ``style.padding``.
        style: Visual style preset. Defaults to ``ButtonStyle.filled("s")``.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    effective_style = style if style is not None else ButtonStyle.filled("s")
    self._user_style = effective_style
    self._user_padding = padding
    self._user_height = None

    text_color = effective_style.foreground if effective_style else ColorRole.ON_PRIMARY

    child_widget = build_button_child(
        label=label,
        icon=icon,
        foreground=text_color,
        button_height=None,
        style=effective_style,
    )

    params = resolve_button_style_params(effective_style, padding, None, disabled)

    super().__init__(
        child=child_widget,
        on_click=on_click,
        width=width,
        disabled=disabled,
        key=key,
        **params,
    )

ExtendedFab

ExtendedFab(label: str | ReadOnlyObservableProtocol[str], *, icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None = None, on_click: Optional[VoidCallback] = None, expanded: bool | ObservableProtocol[bool] = True, disabled: bool | ObservableProtocol[bool] = False, style: Optional[FabStyle] = None, key: Optional[str] = None)

Bases: _FabBase

Material Design 3 Extended FAB with a collapse/expand state.

A pill-shaped FAB carrying a required label and an optional leading icon. The expanded observable morphs the button between the extended pill (icon + label) and a collapsed circular FAB (icon only). The width is content-driven when expanded and animates down to the circular container footprint when collapsed.

When icon is omitted the collapse is a no-op: the button stays a label pill because there is nothing to show in the circular footprint.

Initialize ExtendedFab.

Parameters:

Name Type Description Default
label str | ReadOnlyObservableProtocol[str]

Required text label (string or observable).

required
icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None

Optional leading icon. When present, the collapsed state shows it as a circular FAB; when absent, collapse is a no-op.

None
on_click Optional[VoidCallback]

Callback invoked when the button is clicked.

None
expanded bool | ObservableProtocol[bool]

Initial expanded state or external observable. True (default) shows the icon + label pill; False collapses to the circular FAB footprint.

True
disabled bool | ObservableProtocol[bool]

Whether the button is disabled.

False
style Optional[FabStyle]

FAB style preset selecting the colour mapping and size. Defaults to the theme's FAB style, which itself falls back to :meth:FabStyle.primary (size "s", 56dp).

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/buttons.py
def __init__(
    self,
    label: str | ReadOnlyObservableProtocol[str],
    *,
    icon: "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str] | None = None,
    on_click: Optional[VoidCallback] = None,
    expanded: bool | ObservableProtocol[bool] = True,
    disabled: bool | ObservableProtocol[bool] = False,
    style: Optional[FabStyle] = None,
    key: Optional[str] = None,
):
    """Initialize ExtendedFab.

    Args:
        label: Required text label (string or observable).
        icon: Optional leading icon.  When present, the collapsed state
            shows it as a circular FAB; when absent, collapse is a no-op.
        on_click: Callback invoked when the button is clicked.
        expanded: Initial expanded state or external observable.  ``True``
            (default) shows the icon + label pill; ``False`` collapses to
            the circular FAB footprint.
        disabled: Whether the button is disabled.
        style: FAB style preset selecting the colour mapping and size.
            Defaults to the theme's FAB style, which itself falls back to
            :meth:`FabStyle.primary` (size ``"s"``, 56dp).
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    # See ``Fab.__init__``: the preset stands in until the first measure can
    # reach the theme.
    base_style = style if style is not None else FabStyle.preset()
    effective_style = self._adapt_style(base_style)
    self._container_height = effective_style.container_height
    ext = EXTENDED_FAB_SIZE_TOKENS[_fab_size_from_height(base_style.container_height)]

    self._user_style = style
    self._user_padding = None
    self._user_height = self._container_height
    self._has_icon = icon is not None

    self._expanded_external: ObservableProtocol[bool] | None = None
    if hasattr(expanded, "subscribe") and hasattr(expanded, "value"):
        self._expanded_external = cast("ObservableProtocol[bool]", expanded)
        self._expanded_internal = bool(self._expanded_external.value)
    else:
        self._expanded_internal = bool(expanded)

    text_color = effective_style.foreground if effective_style else ColorRole.ON_PRIMARY_CONTAINER

    child_widget = build_button_child(
        label,
        icon,
        foreground=text_color,
        button_height=self._container_height,
        icon_position="leading",
        spacing=ext["icon_label_space"],
        style=effective_style,
    )

    params = resolve_button_style_params(effective_style, None, self._container_height, disabled)

    super().__init__(
        child=child_widget,
        on_click=on_click,
        width=None,
        disabled=disabled,
        key=key,
        **params,
    )
    # Clip the label as the container shrinks toward the circular footprint.
    self.clip_content = True
    self._sync_state_tokens(effective_style)
    self._setup_press_scale()

    from nuiitivet.widgets.text import TextBase

    self._label_widget: Optional[Widget] = next(
        (w for w in self._foreground_targets if isinstance(w, TextBase)), None
    )
    self._label_base_rgba: Optional[Tuple[int, int, int, int]] = None

    initial_t = 1.0 if self._effective_expanded() else 0.0
    self._morph_anim: Animatable[float] = Animatable(initial_t, motion=EXPRESSIVE_DEFAULT_SPATIAL)
    self.bind(self._morph_anim.subscribe(lambda _: self._on_morph_tick()))
    self._apply_morph_label_alpha()

expanded property writable

expanded: bool

Return whether the button is currently expanded (pill) state.

Fab

Fab(icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str], *, on_click: Optional[VoidCallback] = None, disabled: bool | ObservableProtocol[bool] = False, padding: Optional[Union[int, Tuple[int, int, int, int]]] = None, style: Optional[FabStyle] = None, key: Optional[str] = None)

Bases: _FabBase

Material Design 3 Floating Action Button (FAB).

Initialize Fab.

Parameters:

Name Type Description Default
icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str]

Icon for the button.

required
on_click Optional[VoidCallback]

Callback to be invoked when the button is clicked.

None
disabled bool | ObservableProtocol[bool]

Whether the button is disabled.

False
padding Optional[Union[int, Tuple[int, int, int, int]]]

Padding specification. When None, style.padding is used.

None
style Optional[FabStyle]

FAB style preset. Defaults to the theme's FAB style, which itself falls back to :meth:FabStyle.primary (size "s", 56dp). Use FabStyle.primary("m") / FabStyle.primary("l") for the 80dp / 96dp variants, or FabStyle.secondary / FabStyle.tertiary for alternative tonal colour sets.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/buttons.py
def __init__(
    self,
    icon: "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str],
    *,
    on_click: Optional[VoidCallback] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    padding: Optional[Union[int, Tuple[int, int, int, int]]] = None,
    style: Optional[FabStyle] = None,
    key: Optional[str] = None,
):
    """Initialize Fab.

    Args:
        icon: Icon for the button.
        on_click: Callback to be invoked when the button is clicked.
        disabled: Whether the button is disabled.
        padding: Padding specification.  When ``None``, ``style.padding``
            is used.
        style: FAB style preset.  Defaults to the theme's FAB style, which
            itself falls back to :meth:`FabStyle.primary` (size ``"s"``,
            56dp).  Use ``FabStyle.primary("m")`` / ``FabStyle.primary("l")``
            for the 80dp / 96dp variants, or ``FabStyle.secondary`` /
            ``FabStyle.tertiary`` for alternative tonal colour sets.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    # Held in a local for everything below ``super().__init__()``: reads that
    # run before the widget is attached must not go through ``self.style``,
    # which cannot reach the theme yet. The preset is what
    # ``FabStyle.from_theme`` falls back to, so an unthemed app sees no
    # change; a themed one adopts its style on the first measure.
    effective_style: FabStyle = style if style is not None else FabStyle.preset()
    self._user_style = style
    self._user_padding = padding
    size = effective_style.container_height
    self._user_height = size

    text_color = effective_style.foreground if effective_style else ColorRole.ON_PRIMARY_CONTAINER

    child_widget = build_button_child(
        None,
        icon,
        foreground=text_color,
        button_height=size,
        style=effective_style,
    )

    params = resolve_button_style_params(effective_style, padding, size, disabled)

    super().__init__(
        child=child_widget,
        on_click=on_click,
        width=size,
        disabled=disabled,
        key=key,
        **params,
    )
    self._sync_state_tokens(effective_style)
    self._setup_press_scale()

IconButton

IconButton(icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str], *, on_click: Optional[VoidCallback] = None, disabled: bool | ObservableProtocol[bool] = False, style: Optional[ButtonStyle] = None, key: Optional[str] = None)

Bases: MaterialButtonBase

Material icon-only action button driven by style presets.

Initialize IconButton.

Parameters:

Name Type Description Default
icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str]

Icon glyph source.

required
on_click Optional[VoidCallback]

Callback invoked when the button is clicked.

None
disabled bool | ObservableProtocol[bool]

Whether the button is disabled.

False
style Optional[ButtonStyle]

Icon button style preset or custom style. Defaults to :meth:IconButtonStyle.standard (size "s", 40dp). Use size-aware factories such as IconButtonStyle.filled("m") to control container/icon sizing.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/buttons.py
def __init__(
    self,
    icon: "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str],
    *,
    on_click: Optional[VoidCallback] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    style: Optional[ButtonStyle] = None,
    key: Optional[str] = None,
):
    """Initialize IconButton.

    Args:
        icon: Icon glyph source.
        on_click: Callback invoked when the button is clicked.
        disabled: Whether the button is disabled.
        style: Icon button style preset or custom style.  Defaults to
            :meth:`IconButtonStyle.standard` (size ``"s"``, 40dp).  Use
            size-aware factories such as ``IconButtonStyle.filled("m")``
            to control container/icon sizing.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    effective_style = style if style is not None else IconButtonStyle.standard()

    self._user_style = effective_style
    self._user_padding = None
    self._user_height = effective_style.container_height

    child_widget = build_button_child(
        label=None,
        icon=icon,
        foreground=effective_style.foreground if effective_style else ColorRole.ON_SURFACE,
        button_height=effective_style.container_height,
        style=effective_style,
    )

    params = resolve_button_style_params(effective_style, None, effective_style.container_height, disabled)
    super().__init__(
        child=child_widget,
        on_click=on_click,
        width=effective_style.container_height,
        disabled=disabled,
        key=key,
        **params,
    )

IconToggleButton

IconToggleButton(icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str], *, selected: bool | ObservableProtocol[bool] = False, on_change: Optional[BoolCallback] = None, disabled: bool | ObservableProtocol[bool] = False, style: Optional[IconToggleButtonStyle] = None, key: Optional[str] = None)

Bases: ToggleButtonBase

Material icon-only toggle button driven by state-paired styles.

Initialize IconToggleButton.

Parameters:

Name Type Description Default
icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str]

Icon glyph source.

required
selected bool | ObservableProtocol[bool]

Selected state value or observable.

False
on_change Optional[BoolCallback]

Callback invoked with the new selected state.

None
disabled bool | ObservableProtocol[bool]

Whether the button is disabled.

False
style Optional[IconToggleButtonStyle]

Toggle style pair for selected and unselected states. Defaults to the theme's icon toggle button style, which itself falls back to :meth:IconToggleButtonStyle.standard (size "s"). Use size-aware factories such as IconToggleButtonStyle.filled("m") to control sizing.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/buttons.py
def __init__(
    self,
    icon: "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str],
    *,
    selected: bool | ObservableProtocol[bool] = False,
    on_change: Optional[BoolCallback] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    style: Optional[IconToggleButtonStyle] = None,
    key: Optional[str] = None,
):
    """Initialize IconToggleButton.

    Args:
        icon: Icon glyph source.
        selected: Selected state value or observable.
        on_change: Callback invoked with the new selected state.
        disabled: Whether the button is disabled.
        style: Toggle style pair for selected and unselected states.
            Defaults to the theme's icon toggle button style, which itself
            falls back to :meth:`IconToggleButtonStyle.standard` (size
            ``"s"``).  Use size-aware factories such as
            ``IconToggleButtonStyle.filled("m")`` to control sizing.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    #: The style the caller passed, or ``None`` to follow the theme.
    self._user_toggle_style: Optional[IconToggleButtonStyle] = style
    preset = style if style is not None else IconToggleButtonStyle.preset()
    # Both selected/unselected share container_height per factory contract.
    # The container is sized once, from the style available at construction:
    # a theme arriving later restyles the button but does not resize it.
    self._icon_size = preset.unselected.container_height

    super().__init__(
        label=None,
        icon=icon,
        selected=selected,
        on_change=on_change,
        disabled=disabled,
        width=self._icon_size,
        height=self._icon_size,
        padding=0,
        key=key,
    )

ToggleButton

ToggleButton(label: str | ReadOnlyObservableProtocol[str] | None = None, icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None = None, *, selected: bool | ObservableProtocol[bool] = False, on_change: Optional[BoolCallback] = None, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = None, padding: Optional[Union[int, Tuple[int, int, int, int]]] = None, style: Optional[ToggleButtonStyle] = None, key: Optional[str] = None)

Bases: ToggleButtonBase

Unified Material Design 3 toggle button.

Visual variant and size are encoded in a :class:ToggleButtonStyle which carries both unselected- and selected-state colours. Use :meth:ToggleButtonStyle.filled, .outlined, .elevated or .tonal to obtain presets. When style is None, the style defaults to :meth:ToggleButtonStyle.filled at size "s".

Initialize ToggleButton.

The height is MD3-fixed by the style's size variant, so it is not a constructor parameter; select it through style (e.g. ToggleButtonStyle.filled("m")) — SIZE_POLICY: MD3 fixes the axis -> style only.

Parameters:

Name Type Description Default
label str | ReadOnlyObservableProtocol[str] | None

Text label for the button.

None
icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None

Icon glyph for the button.

None
selected bool | ObservableProtocol[bool]

Initial selected state or external observable.

False
on_change Optional[BoolCallback]

Callback invoked with the new selected value.

None
disabled bool | ObservableProtocol[bool]

Whether the button is disabled.

False
width SizingLike

Width specification.

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

Padding override; None uses style.padding.

None
style Optional[ToggleButtonStyle]

Toggle style preset. Defaults to the theme's toggle button style, which itself falls back to ToggleButtonStyle.filled("s").

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/buttons.py
def __init__(
    self,
    label: str | ReadOnlyObservableProtocol[str] | None = None,
    icon: "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str] | None = None,
    *,
    selected: bool | ObservableProtocol[bool] = False,
    on_change: Optional[BoolCallback] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = None,
    padding: Optional[Union[int, Tuple[int, int, int, int]]] = None,
    style: Optional[ToggleButtonStyle] = None,
    key: Optional[str] = None,
):
    """Initialize ToggleButton.

    The height is MD3-fixed by the style's size variant, so it is not a
    constructor parameter; select it through ``style`` (e.g.
    ``ToggleButtonStyle.filled("m")``) — SIZE_POLICY: MD3 fixes the axis ->
    style only.

    Args:
        label: Text label for the button.
        icon: Icon glyph for the button.
        selected: Initial selected state or external observable.
        on_change: Callback invoked with the new selected value.
        disabled: Whether the button is disabled.
        width: Width specification.
        padding: Padding override; ``None`` uses ``style.padding``.
        style: Toggle style preset. Defaults to the theme's toggle button
            style, which itself falls back to ``ToggleButtonStyle.filled("s")``.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    #: The style the caller passed, or ``None`` to follow the theme.
    self._user_toggle_style: Optional[ToggleButtonStyle] = style

    super().__init__(
        label=label,
        icon=icon,
        selected=selected,
        on_change=on_change,
        disabled=disabled,
        width=width,
        padding=padding,
        key=key,
    )

ButtonStyle dataclass

ButtonStyle(background: Optional[ColorSpec] = None, foreground: Optional[ColorSpec] = None, border_color: Optional[ColorSpec] = None, border_width: float = 0.0, corner_radius: int = 20, container_height: int = 40, padding: PaddingLike = (16, 0, 16, 0), spacing: int = 8, min_width: int = 64, min_height: int = 48, label_font_size: int = 14, icon_size: int = 20, elevation: int = 0, overlay_color: Optional[ColorSpec] = None, overlay_alpha: float = 0.0)

Immutable style for the :class:Button widget (M3-compliant).

Use the filled / outlined / text / elevated / tonal factory classmethods (each accepting a :data:ButtonSize) rather than constructing directly where possible.

copy_with

copy_with(**changes) -> _ButtonStyleT

Create a new style instance with specified fields changed.

Returns the caller's own type, so a :class:FabStyle copy is still a :class:FabStyle.

Source code in src/nuiitivet/material/styles/button_style.py
def copy_with(self: _ButtonStyleT, **changes) -> _ButtonStyleT:
    """Create a new style instance with specified fields changed.

    Returns the caller's own type, so a :class:`FabStyle` copy is still a
    :class:`FabStyle`.
    """
    return replace(self, **changes)

resolve_colors

resolve_colors(theme: Theme | None = None) -> dict

Resolve :class:ColorRole entries to concrete RGBA values.

Source code in src/nuiitivet/material/styles/button_style.py
def resolve_colors(self, theme: "Theme | None" = None) -> dict:
    """Resolve :class:`ColorRole` entries to concrete RGBA values."""
    from ...theme.resolver import resolve_color_to_rgba

    return {
        "background": resolve_color_to_rgba(self.background, theme=theme) if self.background else None,
        "foreground": resolve_color_to_rgba(self.foreground, theme=theme) if self.foreground else None,
        "border_color": resolve_color_to_rgba(self.border_color, theme=theme) if self.border_color else None,
        "overlay_color": resolve_color_to_rgba(self.overlay_color, theme=theme) if self.overlay_color else None,
    }

resolve

resolve(theme: Theme | None = None) -> dict

Compatibility resolver returning a dict shaped like the legacy style.

Source code in src/nuiitivet/material/styles/button_style.py
def resolve(self, theme: "Theme | None" = None) -> dict:
    """Compatibility resolver returning a dict shaped like the legacy style."""
    colors = self.resolve_colors(theme=theme)
    resolved = {
        "background": colors.get("background"),
        "foreground": colors.get("foreground"),
        "border_color": colors.get("border_color"),
        "corner_radius": self.corner_radius,
        "padding": self.padding,
        "spacing": getattr(self, "spacing", 8),
        "min_size": (self.min_width, self.min_height),
        "text_style": None,
    }

    if self.overlay_color is not None:
        try:
            resolved["overlay"] = (self.overlay_color, float(self.overlay_alpha or 0.0))
        except Exception:
            resolved["overlay"] = None
    else:
        resolved["overlay"] = None

    return resolved

filled classmethod

filled(size: ButtonSize = 's') -> ButtonStyle

Return the filled-variant style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def filled(cls, size: ButtonSize = "s") -> "ButtonStyle":
    """Return the filled-variant style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.PRIMARY,
        foreground=ColorRole.ON_PRIMARY,
        border_width=0.0,
        corner_radius=t["corner_radius"],
        container_height=t["container_height"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=_size_min_width(size),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        elevation=0,
        overlay_color=ColorRole.ON_PRIMARY,
        overlay_alpha=0.08,
    )

outlined classmethod

outlined(size: ButtonSize = 's') -> ButtonStyle

Return the outlined-variant style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def outlined(cls, size: ButtonSize = "s") -> "ButtonStyle":
    """Return the outlined-variant style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        background=None,
        foreground=ColorRole.ON_SURFACE_VARIANT,
        border_color=ColorRole.OUTLINE_VARIANT,
        border_width=t["outline_width"],
        corner_radius=t["corner_radius"],
        container_height=t["container_height"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=_size_min_width(size),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        elevation=0,
        overlay_color=ColorRole.ON_SURFACE_VARIANT,
        overlay_alpha=0.08,
    )

text classmethod

text(size: ButtonSize = 's') -> ButtonStyle

Return the text-variant style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def text(cls, size: ButtonSize = "s") -> "ButtonStyle":
    """Return the text-variant style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        background=None,
        foreground=ColorRole.PRIMARY,
        border_width=0.0,
        corner_radius=t["corner_radius"],
        container_height=t["container_height"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=max(48, t["container_height"]),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        elevation=0,
        overlay_color=ColorRole.PRIMARY,
        overlay_alpha=0.08,
    )

elevated classmethod

elevated(size: ButtonSize = 's') -> ButtonStyle

Return the elevated-variant style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def elevated(cls, size: ButtonSize = "s") -> "ButtonStyle":
    """Return the elevated-variant style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.SURFACE_CONTAINER_LOW,
        foreground=ColorRole.PRIMARY,
        border_width=0.0,
        corner_radius=t["corner_radius"],
        container_height=t["container_height"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=_size_min_width(size),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        elevation=1,
        overlay_color=ColorRole.PRIMARY,
        overlay_alpha=0.08,
    )

tonal classmethod

tonal(size: ButtonSize = 's') -> ButtonStyle

Return the tonal-variant style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def tonal(cls, size: ButtonSize = "s") -> "ButtonStyle":
    """Return the tonal-variant style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.SECONDARY_CONTAINER,
        foreground=ColorRole.ON_SECONDARY_CONTAINER,
        border_width=0.0,
        corner_radius=t["corner_radius"],
        container_height=t["container_height"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=_size_min_width(size),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        elevation=0,
        overlay_color=ColorRole.ON_SECONDARY_CONTAINER,
        overlay_alpha=0.08,
    )

IconButtonStyle

Preset factories for icon-only button styles.

Each factory accepts a :data:ButtonSize argument that drives container size, icon size, corner radius, and outline width from :data:ICON_BUTTON_SIZE_TOKENS. Defaults to "s" (40dp).

standard classmethod

standard(size: ButtonSize = 's') -> ButtonStyle

Return the standard icon-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def standard(cls, size: ButtonSize = "s") -> ButtonStyle:
    """Return the standard icon-button style at the given M3 size."""
    return ButtonStyle(
        background=None,
        foreground=ColorRole.ON_SURFACE_VARIANT,
        border_width=0.0,
        elevation=0,
        overlay_color=ColorRole.ON_SURFACE,
        overlay_alpha=0.12,
        **cls._base(size),
    )

filled classmethod

filled(size: ButtonSize = 's') -> ButtonStyle

Return the filled icon-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def filled(cls, size: ButtonSize = "s") -> ButtonStyle:
    """Return the filled icon-button style at the given M3 size."""
    return ButtonStyle(
        background=ColorRole.PRIMARY,
        foreground=ColorRole.ON_PRIMARY,
        border_width=0.0,
        elevation=0,
        overlay_color=ColorRole.ON_PRIMARY,
        overlay_alpha=0.12,
        **cls._base(size),
    )

outlined classmethod

outlined(size: ButtonSize = 's') -> ButtonStyle

Return the outlined icon-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def outlined(cls, size: ButtonSize = "s") -> ButtonStyle:
    """Return the outlined icon-button style at the given M3 size."""
    t = ICON_BUTTON_SIZE_TOKENS[size]
    return ButtonStyle(
        background=None,
        foreground=ColorRole.ON_SURFACE_VARIANT,
        border_color=ColorRole.OUTLINE,
        border_width=t["outline_width"],
        elevation=0,
        overlay_color=ColorRole.ON_SURFACE,
        overlay_alpha=0.12,
        **cls._base(size),
    )

tonal classmethod

tonal(size: ButtonSize = 's') -> ButtonStyle

Return the tonal icon-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def tonal(cls, size: ButtonSize = "s") -> ButtonStyle:
    """Return the tonal icon-button style at the given M3 size."""
    return ButtonStyle(
        background=ColorRole.SECONDARY_CONTAINER,
        foreground=ColorRole.ON_SECONDARY_CONTAINER,
        border_width=0.0,
        elevation=0,
        overlay_color=ColorRole.ON_SECONDARY_CONTAINER,
        overlay_alpha=0.12,
        **cls._base(size),
    )

vibrant classmethod

vibrant(size: ButtonSize = 's') -> ButtonStyle

Return the vibrant icon-button style at the given M3 size.

Intended for use on vibrant containers such as a vibrant toolbar.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def vibrant(cls, size: ButtonSize = "s") -> ButtonStyle:
    """Return the vibrant icon-button style at the given M3 size.

    Intended for use on vibrant containers such as a vibrant toolbar.
    """
    return ButtonStyle(
        background=None,
        foreground=ColorRole.ON_PRIMARY_CONTAINER,
        border_width=0.0,
        elevation=0,
        overlay_color=ColorRole.ON_PRIMARY_CONTAINER,
        overlay_alpha=0.12,
        **cls._base(size),
    )

filled_vibrant classmethod

filled_vibrant(size: ButtonSize = 's') -> ButtonStyle

Return the filled vibrant icon-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def filled_vibrant(cls, size: ButtonSize = "s") -> ButtonStyle:
    """Return the filled vibrant icon-button style at the given M3 size."""
    return ButtonStyle(
        background=ColorRole.PRIMARY,
        foreground=ColorRole.ON_PRIMARY,
        border_width=0.0,
        elevation=0,
        overlay_color=ColorRole.ON_PRIMARY,
        overlay_alpha=0.12,
        **cls._base(size),
    )

outlined_vibrant classmethod

outlined_vibrant(size: ButtonSize = 's') -> ButtonStyle

Return the outlined vibrant icon-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def outlined_vibrant(cls, size: ButtonSize = "s") -> ButtonStyle:
    """Return the outlined vibrant icon-button style at the given M3 size."""
    t = ICON_BUTTON_SIZE_TOKENS[size]
    return ButtonStyle(
        background=None,
        foreground=ColorRole.ON_PRIMARY_CONTAINER,
        border_color=ColorRole.ON_PRIMARY_CONTAINER,
        border_width=t["outline_width"],
        elevation=0,
        overlay_color=ColorRole.ON_PRIMARY_CONTAINER,
        overlay_alpha=0.12,
        **cls._base(size),
    )

tonal_vibrant classmethod

tonal_vibrant(size: ButtonSize = 's') -> ButtonStyle

Return the tonal vibrant icon-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def tonal_vibrant(cls, size: ButtonSize = "s") -> ButtonStyle:
    """Return the tonal vibrant icon-button style at the given M3 size."""
    return ButtonStyle(
        background=ColorRole.SURFACE_CONTAINER_HIGHEST,
        foreground=ColorRole.ON_SURFACE,
        border_width=0.0,
        elevation=0,
        overlay_color=ColorRole.ON_SURFACE,
        overlay_alpha=0.12,
        **cls._base(size),
    )

IconToggleButtonStyle dataclass

IconToggleButtonStyle(selected: ButtonStyle, unselected: ButtonStyle)

State-paired style for icon toggle button widgets.

standard classmethod

standard(size: ButtonSize = 's') -> IconToggleButtonStyle

Return styles for the standard icon-toggle button variant.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def standard(cls, size: ButtonSize = "s") -> "IconToggleButtonStyle":
    """Return styles for the standard icon-toggle button variant."""
    base = IconButtonStyle._base(size)
    return cls(
        selected=ButtonStyle(
            background=ColorRole.SECONDARY_CONTAINER,
            foreground=ColorRole.ON_SECONDARY_CONTAINER,
            border_width=0.0,
            elevation=0,
            overlay_color=ColorRole.ON_SECONDARY_CONTAINER,
            overlay_alpha=0.12,
            **base,
        ),
        unselected=IconButtonStyle.standard(size),
    )

filled classmethod

filled(size: ButtonSize = 's') -> IconToggleButtonStyle

Return styles for the filled icon-toggle button variant.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def filled(cls, size: ButtonSize = "s") -> "IconToggleButtonStyle":
    """Return styles for the filled icon-toggle button variant."""
    base = IconButtonStyle._base(size)
    return cls(
        selected=IconButtonStyle.filled(size),
        unselected=ButtonStyle(
            background=ColorRole.SURFACE_CONTAINER_HIGHEST,
            foreground=ColorRole.ON_SURFACE_VARIANT,
            border_width=0.0,
            elevation=0,
            overlay_color=ColorRole.ON_SURFACE,
            overlay_alpha=0.12,
            **base,
        ),
    )

outlined classmethod

outlined(size: ButtonSize = 's') -> IconToggleButtonStyle

Return styles for the outlined icon-toggle button variant.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def outlined(cls, size: ButtonSize = "s") -> "IconToggleButtonStyle":
    """Return styles for the outlined icon-toggle button variant."""
    t = ICON_BUTTON_SIZE_TOKENS[size]
    base = IconButtonStyle._base(size)
    return cls(
        selected=ButtonStyle(
            background=ColorRole.INVERSE_SURFACE,
            foreground=ColorRole.INVERSE_ON_SURFACE,
            border_color=ColorRole.INVERSE_SURFACE,
            border_width=t["outline_width"],
            elevation=0,
            overlay_color=ColorRole.INVERSE_ON_SURFACE,
            overlay_alpha=0.12,
            **base,
        ),
        unselected=IconButtonStyle.outlined(size),
    )

tonal classmethod

tonal(size: ButtonSize = 's') -> IconToggleButtonStyle

Return styles for the tonal icon-toggle button variant.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def tonal(cls, size: ButtonSize = "s") -> "IconToggleButtonStyle":
    """Return styles for the tonal icon-toggle button variant."""
    base = IconButtonStyle._base(size)
    return cls(
        selected=ButtonStyle(
            background=ColorRole.TERTIARY_CONTAINER,
            foreground=ColorRole.ON_TERTIARY_CONTAINER,
            border_width=0.0,
            elevation=0,
            overlay_color=ColorRole.ON_TERTIARY_CONTAINER,
            overlay_alpha=0.12,
            **base,
        ),
        unselected=ButtonStyle(
            background=ColorRole.SECONDARY_CONTAINER,
            foreground=ColorRole.ON_SECONDARY_CONTAINER,
            border_width=0.0,
            elevation=0,
            overlay_color=ColorRole.ON_SECONDARY_CONTAINER,
            overlay_alpha=0.12,
            **base,
        ),
    )

preset classmethod

Return the framework preset, ignoring any theme.

This is what an icon toggle button renders with before it is mounted, and what :meth:from_theme falls back to when no Material theme is installed.

Returns:

Type Description
IconToggleButtonStyle

The standard icon-toggle style at size "s".

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def preset(cls) -> "IconToggleButtonStyle":
    """Return the framework preset, ignoring any theme.

    This is what an icon toggle button renders with before it is mounted,
    and what :meth:`from_theme` falls back to when no Material theme is
    installed.

    Returns:
        The standard icon-toggle style at size ``"s"``.
    """
    return cls.standard()

from_theme classmethod

from_theme(theme: Theme) -> IconToggleButtonStyle

Resolve the icon-toggle style from theme.

Parameters:

Name Type Description Default
theme Theme

Theme instance.

required

Returns:

Type Description
IconToggleButtonStyle

Resolved icon-toggle style.

Source code in src/nuiitivet/material/styles/button_style.py
@classmethod
def from_theme(cls, theme: "Theme") -> "IconToggleButtonStyle":
    """Resolve the icon-toggle style from ``theme``.

    Args:
        theme: Theme instance.

    Returns:
        Resolved icon-toggle style.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    theme_data = theme.extension(MaterialThemeData)
    if theme_data is not None:
        return theme_data.icon_toggle_button_style
    return cls.preset()

FabStyle dataclass

FabStyle(background: Optional[ColorSpec] = None, foreground: Optional[ColorSpec] = None, border_color: Optional[ColorSpec] = None, border_width: float = 0.0, corner_radius: int = 20, container_height: int = 40, padding: PaddingLike = (16, 0, 16, 0), spacing: int = 8, min_width: int = 64, min_height: int = 48, label_font_size: int = 14, icon_size: int = 20, elevation: int = 0, overlay_color: Optional[ColorSpec] = None, overlay_alpha: float = 0.0, focus_opacity: float = 0.1, hover_opacity: float = 0.08, pressed_opacity: float = 0.1, focused_elevation: int = 3, hovered_elevation: int = 4, pressed_elevation: int = 3)

Bases: ButtonStyle

Style preset used by the :class:Fab widget.

Inherits the field set of :class:ButtonStyle so that Fab can reuse the shared resolve_button_style_params machinery without changes.

primary classmethod

primary(size: FabSize = 's') -> FabStyle

Return the tonal-primary FAB style at the given size.

Source code in src/nuiitivet/material/styles/fab_style.py
@classmethod
def primary(cls, size: FabSize = "s") -> "FabStyle":
    """Return the tonal-primary FAB style at the given size."""
    return cls(
        background=ColorRole.PRIMARY_CONTAINER,
        foreground=ColorRole.ON_PRIMARY_CONTAINER,
        overlay_color=ColorRole.ON_PRIMARY_CONTAINER,
        **cls._base(size),
    )

secondary classmethod

secondary(size: FabSize = 's') -> FabStyle

Return the tonal-secondary FAB style at the given size.

Source code in src/nuiitivet/material/styles/fab_style.py
@classmethod
def secondary(cls, size: FabSize = "s") -> "FabStyle":
    """Return the tonal-secondary FAB style at the given size."""
    return cls(
        background=ColorRole.SECONDARY_CONTAINER,
        foreground=ColorRole.ON_SECONDARY_CONTAINER,
        overlay_color=ColorRole.ON_SECONDARY_CONTAINER,
        **cls._base(size),
    )

tertiary classmethod

tertiary(size: FabSize = 's') -> FabStyle

Return the tonal-tertiary FAB style at the given size.

Source code in src/nuiitivet/material/styles/fab_style.py
@classmethod
def tertiary(cls, size: FabSize = "s") -> "FabStyle":
    """Return the tonal-tertiary FAB style at the given size."""
    return cls(
        background=ColorRole.TERTIARY_CONTAINER,
        foreground=ColorRole.ON_TERTIARY_CONTAINER,
        overlay_color=ColorRole.ON_TERTIARY_CONTAINER,
        **cls._base(size),
    )

primary_solid classmethod

primary_solid(size: FabSize = 's') -> FabStyle

Return the solid-primary FAB style at the given size.

Source code in src/nuiitivet/material/styles/fab_style.py
@classmethod
def primary_solid(cls, size: FabSize = "s") -> "FabStyle":
    """Return the solid-primary FAB style at the given size."""
    return cls(
        background=ColorRole.PRIMARY,
        foreground=ColorRole.ON_PRIMARY,
        overlay_color=ColorRole.ON_PRIMARY,
        **cls._base(size),
    )

secondary_solid classmethod

secondary_solid(size: FabSize = 's') -> FabStyle

Return the solid-secondary FAB style at the given size.

Source code in src/nuiitivet/material/styles/fab_style.py
@classmethod
def secondary_solid(cls, size: FabSize = "s") -> "FabStyle":
    """Return the solid-secondary FAB style at the given size."""
    return cls(
        background=ColorRole.SECONDARY,
        foreground=ColorRole.ON_SECONDARY,
        overlay_color=ColorRole.ON_SECONDARY,
        **cls._base(size),
    )

tertiary_solid classmethod

tertiary_solid(size: FabSize = 's') -> FabStyle

Return the solid-tertiary FAB style at the given size.

Source code in src/nuiitivet/material/styles/fab_style.py
@classmethod
def tertiary_solid(cls, size: FabSize = "s") -> "FabStyle":
    """Return the solid-tertiary FAB style at the given size."""
    return cls(
        background=ColorRole.TERTIARY,
        foreground=ColorRole.ON_TERTIARY,
        overlay_color=ColorRole.ON_TERTIARY,
        **cls._base(size),
    )

preset classmethod

preset() -> FabStyle

Return the framework preset, ignoring any theme.

This is what a FAB renders with before it is mounted, and what :meth:from_theme falls back to when no Material theme is installed.

Returns:

Type Description
FabStyle

The tonal-primary FAB style at size "s".

Source code in src/nuiitivet/material/styles/fab_style.py
@classmethod
def preset(cls) -> "FabStyle":
    """Return the framework preset, ignoring any theme.

    This is what a FAB renders with before it is mounted, and what
    :meth:`from_theme` falls back to when no Material theme is installed.

    Returns:
        The tonal-primary FAB style at size ``"s"``.
    """
    return cls.primary()

from_theme classmethod

from_theme(theme: Theme) -> FabStyle

Resolve the FAB style from theme.

Parameters:

Name Type Description Default
theme Theme

Theme instance.

required

Returns:

Type Description
FabStyle

Resolved FAB style.

Source code in src/nuiitivet/material/styles/fab_style.py
@classmethod
def from_theme(cls, theme: "Theme") -> "FabStyle":
    """Resolve the FAB style from ``theme``.

    Args:
        theme: Theme instance.

    Returns:
        Resolved FAB style.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    theme_data = theme.extension(MaterialThemeData)
    if theme_data is not None:
        return theme_data.fab_style
    return cls.preset()

ToggleButtonStyle dataclass

ToggleButtonStyle(container_height: int = 40, corner_radius: int = 20, padding: PaddingLike = (16, 0, 16, 0), spacing: int = 8, min_width: int = 64, min_height: int = 48, label_font_size: int = 14, icon_size: int = 20, border_width: float = 0.0, elevation: int = 0, unselected_background: Optional[ColorSpec] = None, unselected_foreground: Optional[ColorSpec] = None, unselected_border_color: Optional[ColorSpec] = None, unselected_overlay_color: Optional[ColorSpec] = None, unselected_overlay_alpha: float = 0.08, selected_background: Optional[ColorSpec] = None, selected_foreground: Optional[ColorSpec] = None, selected_border_color: Optional[ColorSpec] = None, selected_overlay_color: Optional[ColorSpec] = None, selected_overlay_alpha: float = 0.08)

Immutable style for :class:ToggleButton (M3-compliant).

Stores a single flat set of shape/size tokens plus two paired colour groups: unselected_* and selected_*. The :meth:for_selected helper projects the style into a :class:ButtonStyle for the active state, so the widget internals can reuse the normal Button machinery.

copy_with

copy_with(**changes) -> 'ToggleButtonStyle'

Create a new style instance with specified fields changed.

Source code in src/nuiitivet/material/styles/toggle_button_style.py
def copy_with(self, **changes) -> "ToggleButtonStyle":
    """Create a new style instance with specified fields changed."""
    return replace(self, **changes)

for_selected

for_selected(selected: bool) -> ButtonStyle

Project this style into a :class:ButtonStyle for the given state.

Parameters:

Name Type Description Default
selected bool

When True, project the selected-state colours; otherwise use unselected-state colours.

required
Source code in src/nuiitivet/material/styles/toggle_button_style.py
def for_selected(self, selected: bool) -> ButtonStyle:
    """Project this style into a :class:`ButtonStyle` for the given state.

    Args:
        selected: When ``True``, project the selected-state colours;
            otherwise use unselected-state colours.
    """
    if selected:
        return ButtonStyle(
            background=self.selected_background,
            foreground=self.selected_foreground,
            border_color=self.selected_border_color,
            border_width=self.border_width,
            corner_radius=self.corner_radius,
            container_height=self.container_height,
            padding=self.padding,
            spacing=self.spacing,
            min_width=self.min_width,
            min_height=self.min_height,
            label_font_size=self.label_font_size,
            icon_size=self.icon_size,
            elevation=self.elevation,
            overlay_color=self.selected_overlay_color,
            overlay_alpha=self.selected_overlay_alpha,
        )
    return ButtonStyle(
        background=self.unselected_background,
        foreground=self.unselected_foreground,
        border_color=self.unselected_border_color,
        border_width=self.border_width,
        corner_radius=self.corner_radius,
        container_height=self.container_height,
        padding=self.padding,
        spacing=self.spacing,
        min_width=self.min_width,
        min_height=self.min_height,
        label_font_size=self.label_font_size,
        icon_size=self.icon_size,
        elevation=self.elevation,
        overlay_color=self.unselected_overlay_color,
        overlay_alpha=self.unselected_overlay_alpha,
    )

filled classmethod

filled(size: ButtonSize = 's') -> 'ToggleButtonStyle'

Return the filled toggle-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/toggle_button_style.py
@classmethod
def filled(cls, size: ButtonSize = "s") -> "ToggleButtonStyle":
    """Return the filled toggle-button style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        container_height=t["container_height"],
        corner_radius=t["corner_radius"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=_size_min_width(size),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        border_width=0.0,
        elevation=0,
        unselected_background=ColorRole.SURFACE_CONTAINER_HIGHEST,
        unselected_foreground=ColorRole.ON_SURFACE_VARIANT,
        unselected_border_color=None,
        unselected_overlay_color=ColorRole.ON_SURFACE_VARIANT,
        unselected_overlay_alpha=0.08,
        selected_background=ColorRole.PRIMARY,
        selected_foreground=ColorRole.ON_PRIMARY,
        selected_border_color=None,
        selected_overlay_color=ColorRole.ON_PRIMARY,
        selected_overlay_alpha=0.08,
    )

outlined classmethod

outlined(size: ButtonSize = 's') -> 'ToggleButtonStyle'

Return the outlined toggle-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/toggle_button_style.py
@classmethod
def outlined(cls, size: ButtonSize = "s") -> "ToggleButtonStyle":
    """Return the outlined toggle-button style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        container_height=t["container_height"],
        corner_radius=t["corner_radius"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=_size_min_width(size),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        border_width=t["outline_width"],
        elevation=0,
        unselected_background=None,
        unselected_foreground=ColorRole.ON_SURFACE_VARIANT,
        unselected_border_color=ColorRole.OUTLINE_VARIANT,
        unselected_overlay_color=ColorRole.ON_SURFACE_VARIANT,
        unselected_overlay_alpha=0.08,
        selected_background=ColorRole.INVERSE_SURFACE,
        selected_foreground=ColorRole.INVERSE_ON_SURFACE,
        selected_border_color=ColorRole.INVERSE_SURFACE,
        selected_overlay_color=ColorRole.INVERSE_ON_SURFACE,
        selected_overlay_alpha=0.08,
    )

elevated classmethod

elevated(size: ButtonSize = 's') -> 'ToggleButtonStyle'

Return the elevated toggle-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/toggle_button_style.py
@classmethod
def elevated(cls, size: ButtonSize = "s") -> "ToggleButtonStyle":
    """Return the elevated toggle-button style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        container_height=t["container_height"],
        corner_radius=t["corner_radius"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=_size_min_width(size),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        border_width=0.0,
        elevation=1,
        unselected_background=ColorRole.SURFACE_CONTAINER_LOW,
        unselected_foreground=ColorRole.PRIMARY,
        unselected_border_color=None,
        unselected_overlay_color=ColorRole.PRIMARY,
        unselected_overlay_alpha=0.08,
        selected_background=ColorRole.PRIMARY,
        selected_foreground=ColorRole.ON_PRIMARY,
        selected_border_color=None,
        selected_overlay_color=ColorRole.ON_PRIMARY,
        selected_overlay_alpha=0.08,
    )

tonal classmethod

tonal(size: ButtonSize = 's') -> 'ToggleButtonStyle'

Return the tonal toggle-button style at the given M3 size.

Source code in src/nuiitivet/material/styles/toggle_button_style.py
@classmethod
def tonal(cls, size: ButtonSize = "s") -> "ToggleButtonStyle":
    """Return the tonal toggle-button style at the given M3 size."""
    t = BUTTON_SIZE_TOKENS[size]
    return cls(
        container_height=t["container_height"],
        corner_radius=t["corner_radius"],
        padding=_size_padding(size),
        spacing=t["icon_label_space"],
        min_width=_size_min_width(size),
        min_height=_size_min_height(size),
        label_font_size=t["label_font_size"],
        icon_size=t["icon_size"],
        border_width=0.0,
        elevation=0,
        unselected_background=ColorRole.SECONDARY_CONTAINER,
        unselected_foreground=ColorRole.ON_SECONDARY_CONTAINER,
        unselected_border_color=None,
        unselected_overlay_color=ColorRole.ON_SECONDARY_CONTAINER,
        unselected_overlay_alpha=0.08,
        selected_background=ColorRole.SECONDARY,
        selected_foreground=ColorRole.ON_SECONDARY,
        selected_border_color=None,
        selected_overlay_color=ColorRole.ON_SECONDARY,
        selected_overlay_alpha=0.08,
    )

preset classmethod

preset() -> 'ToggleButtonStyle'

Return the framework preset, ignoring any theme.

This is what a toggle button renders with before it is mounted, and what :meth:from_theme falls back to when no Material theme is installed.

Returns:

Type Description
'ToggleButtonStyle'

The filled toggle-button style at size "s".

Source code in src/nuiitivet/material/styles/toggle_button_style.py
@classmethod
def preset(cls) -> "ToggleButtonStyle":
    """Return the framework preset, ignoring any theme.

    This is what a toggle button renders with before it is mounted, and
    what :meth:`from_theme` falls back to when no Material theme is
    installed.

    Returns:
        The filled toggle-button style at size ``"s"``.
    """
    return cls.filled("s")

from_theme classmethod

from_theme(theme: 'Theme') -> 'ToggleButtonStyle'

Resolve the toggle-button style from theme.

Parameters:

Name Type Description Default
theme 'Theme'

Theme instance.

required

Returns:

Type Description
'ToggleButtonStyle'

Resolved toggle-button style.

Source code in src/nuiitivet/material/styles/toggle_button_style.py
@classmethod
def from_theme(cls, theme: "Theme") -> "ToggleButtonStyle":
    """Resolve the toggle-button style from ``theme``.

    Args:
        theme: Theme instance.

    Returns:
        Resolved toggle-button style.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    theme_data = theme.extension(MaterialThemeData)
    if theme_data is not None:
        return theme_data.toggle_button_style
    return cls.preset()

Card

Card(child: ChildSpec, *, width: SizingLike = None, height: SizingLike = None, padding: PaddingLike = 0, alignment: AlignmentLike = 'start', style: Optional[CardStyle] = None, key: Optional[str] = None)

Bases: ComposableWidget, Box

Unified Material Design 3 card.

The visual variant (filled, outlined, elevated) is expressed entirely through the style argument, which accepts any :class:CardStyle instance. Use the :class:CardStyle factory methods to obtain variant presets: CardStyle.filled(), CardStyle.outlined(), CardStyle.elevated().

When style is not provided, the theme's filled card style is used as the default.

Initialize Card.

Parameters:

Name Type Description Default
child ChildSpec

The child widget or factory.

required
width SizingLike

Width specification.

None
height SizingLike

Height specification.

None
padding PaddingLike

Padding around the content.

0
alignment AlignmentLike

Alignment of the content.

'start'
style Optional[CardStyle]

Visual style preset. Defaults to the theme's filled card style. Use :meth:CardStyle.filled, :meth:CardStyle.outlined, or :meth:CardStyle.elevated for the standard M3 variants.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/card.py
def __init__(
    self,
    child: ChildSpec,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: PaddingLike = 0,
    alignment: AlignmentLike = "start",
    style: Optional[CardStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize Card.

    Args:
        child: The child widget or factory.
        width: Width specification.
        height: Height specification.
        padding: Padding around the content.
        alignment: Alignment of the content.
        style: Visual style preset. Defaults to the theme's filled card
            style. Use :meth:`CardStyle.filled`, :meth:`CardStyle.outlined`,
            or :meth:`CardStyle.elevated` for the standard M3 variants.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._child_spec: ChildSpec = child
    self._user_style: Optional[CardStyle] = style

    # The theme is unreachable here: the widget has no parent link until it
    # is attached, so ``Theme.of`` would return the light default and freeze
    # it forever. Start from the framework preset -- the same value
    # ``CardStyle.from_theme`` falls back to when no Material theme is
    # installed -- and re-apply the theme's style once mounted.
    initial_style = style if style is not None else CardStyle.filled()
    self._effective_style: CardStyle = initial_style
    _shadows = elevation_shadows(initial_style.elevation)

    # Pass raw colors to Box; it will resolve them lazily via BackgroundRenderer
    super().__init__(
        child=None,
        width=width,
        height=height,
        padding=padding,
        background_color=initial_style.background,
        border_width=initial_style.border_width,
        border_color=initial_style.border_color,
        corner_radius=initial_style.border_radius,
        shadows=_shadows,
        alignment=alignment,
        key=key,
    )

    self._content_scope_id: Optional[str] = None

    if isinstance(child, Widget):
        super().add_child(child)

style property

style: CardStyle

Return the style currently in effect.

This is the explicit style when one was given, otherwise the theme's filled card style — pushed in by :meth:on_mount and kept current by the theme subscription. It is not pulled from Theme.of, which cannot answer before the card is attached.

CardStyle dataclass

CardStyle(background: Optional[ColorSpec] = None, border_color: Optional[ColorSpec] = None, border_width: float = 0.0, border_radius: Union[float, tuple[float, float, float, float]] = 12.0, elevation: int = 0)

Immutable style for Card widgets (M3-compliant).

copy_with

copy_with(**changes) -> CardStyle

Create a new style instance with specified fields changed.

Source code in src/nuiitivet/material/styles/card_style.py
def copy_with(self, **changes) -> "CardStyle":
    """Create a new style instance with specified fields changed."""
    return replace(self, **changes)

resolve_colors

resolve_colors(theme: Theme | None = None) -> dict

Resolve ColorRole to concrete color values.

Source code in src/nuiitivet/material/styles/card_style.py
def resolve_colors(self, theme: "Theme | None" = None) -> dict:
    """Resolve ColorRole to concrete color values."""
    from ...theme.resolver import resolve_color_to_rgba

    return {
        "background": resolve_color_to_rgba(self.background, theme=theme) if self.background else None,
        "border_color": resolve_color_to_rgba(self.border_color, theme=theme) if self.border_color else None,
    }

elevated classmethod

elevated() -> CardStyle

Create a default style for an elevated card.

Source code in src/nuiitivet/material/styles/card_style.py
@classmethod
def elevated(cls) -> "CardStyle":
    """Create a default style for an elevated card."""
    return cls(
        background=ColorRole.SURFACE_CONTAINER_LOW,
        elevation=1,  # MD3 level 1 = 1 dp
        border_radius=12.0,
    )

filled classmethod

filled() -> CardStyle

Create a default style for a filled card.

Source code in src/nuiitivet/material/styles/card_style.py
@classmethod
def filled(cls) -> "CardStyle":
    """Create a default style for a filled card."""
    return cls(
        background=ColorRole.SURFACE_CONTAINER_HIGHEST,
        elevation=0,
        border_radius=12.0,
    )

outlined classmethod

outlined() -> CardStyle

Create a default style for an outlined card.

Source code in src/nuiitivet/material/styles/card_style.py
@classmethod
def outlined(cls) -> "CardStyle":
    """Create a default style for an outlined card."""
    return cls(
        background=ColorRole.SURFACE,
        elevation=0,
        border_width=1.0,
        border_color=ColorRole.OUTLINE,
        border_radius=12.0,
    )

from_theme classmethod

from_theme(theme: Theme) -> CardStyle

Resolve the default :class:CardStyle for the given theme.

Returns the theme's filled card style if a Material theme extension is present, otherwise a fresh :meth:filled preset.

Source code in src/nuiitivet/material/styles/card_style.py
@classmethod
def from_theme(cls, theme: "Theme") -> "CardStyle":
    """Resolve the default :class:`CardStyle` for the given theme.

    Returns the theme's filled card style if a Material theme extension
    is present, otherwise a fresh :meth:`filled` preset.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    theme_data = theme.extension(MaterialThemeData)
    if theme_data:
        return theme_data.filled_card_style
    return cls.filled()

AssistChip

AssistChip(label: str | ReadOnlyObservableProtocol[str], *, leading_icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None = None, on_click: Optional[Callable[[], None]] = None, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = None, padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None, style: Optional['ChipStyle'] = None, key: Optional[str] = None)

Bases: MaterialChipBase

Material Design 3 Assist Chip widget.

Initialize AssistChip.

Parameters:

Name Type Description Default
label str | ReadOnlyObservableProtocol[str]

Chip label.

required
leading_icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None

Optional leading icon.

None
on_click Optional[Callable[[], None]]

Click callback.

None
disabled bool | ObservableProtocol[bool]

Disabled flag.

False
width SizingLike

Width sizing.

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

External insets around chip widget.

None
style Optional['ChipStyle']

Optional chip style.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/chip.py
def __init__(
    self,
    label: str | ReadOnlyObservableProtocol[str],
    *,
    leading_icon: (
        "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str] | None
    ) = None,
    on_click: Optional[Callable[[], None]] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = None,
    padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None,
    style: Optional["ChipStyle"] = None,
    key: Optional[str] = None,
):
    """Initialize AssistChip.

    Args:
        label: Chip label.
        leading_icon: Optional leading icon.
        on_click: Click callback.
        disabled: Disabled flag.
        width: Width sizing.
        padding: External insets around chip widget.
        style: Optional chip style.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._label = label
    self._leading_icon = leading_icon

    super().__init__(
        on_click=on_click,
        disabled=disabled,
        width=width,
        padding=padding,
        style=style,
        key=key,
    )

FilterChip

FilterChip(label: str | ReadOnlyObservableProtocol[str], *, selected: bool | ObservableProtocol[bool] = False, on_selected_change: Optional[Callable[[bool], None]] = None, leading_icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None = None, on_click: Optional[Callable[[], None]] = None, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = None, padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None, style: Optional['ChipStyle'] = None, key: Optional[str] = None)

Bases: MaterialChipBase

Material Design 3 Filter Chip widget.

Initialize FilterChip.

Parameters:

Name Type Description Default
label str | ReadOnlyObservableProtocol[str]

Chip label.

required
selected bool | ObservableProtocol[bool]

Selected state source.

False
on_selected_change Optional[Callable[[bool], None]]

Callback when selected state changes.

None
leading_icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None

Optional leading icon in unselected state.

None
on_click Optional[Callable[[], None]]

Additional click callback.

None
disabled bool | ObservableProtocol[bool]

Disabled flag.

False
width SizingLike

Width sizing.

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

External insets around chip widget.

None
style Optional['ChipStyle']

Optional chip style.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/chip.py
def __init__(
    self,
    label: str | ReadOnlyObservableProtocol[str],
    *,
    selected: bool | ObservableProtocol[bool] = False,
    on_selected_change: Optional[Callable[[bool], None]] = None,
    leading_icon: (
        "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str] | None
    ) = None,
    on_click: Optional[Callable[[], None]] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = None,
    padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None,
    style: Optional["ChipStyle"] = None,
    key: Optional[str] = None,
):
    """Initialize FilterChip.

    Args:
        label: Chip label.
        selected: Selected state source.
        on_selected_change: Callback when selected state changes.
        leading_icon: Optional leading icon in unselected state.
        on_click: Additional click callback.
        disabled: Disabled flag.
        width: Width sizing.
        padding: External insets around chip widget.
        style: Optional chip style.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._selected_external: ObservableProtocol[bool] | None = None
    self._selected = bool(selected)
    self._label = label
    if hasattr(selected, "subscribe") and hasattr(selected, "value"):
        self._selected_external = cast("ObservableProtocol[bool]", selected)
        self._selected = bool(self._selected_external.value)

    self._base_on_click = on_click
    self._on_selected_change = on_selected_change
    self._leading_icon = leading_icon

    super().__init__(
        on_click=self._handle_click,
        disabled=disabled,
        width=width,
        padding=padding,
        style=style,
        key=key,
    )

selected property

selected: bool

Return current selected state.

InputChip

InputChip(label: str | ReadOnlyObservableProtocol[str], *, trailing_icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str], leading_icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None = None, on_trailing_icon_click: Optional[Callable[[], None]] = None, on_click: Optional[Callable[[], None]] = None, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = None, padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None, style: Optional['ChipStyle'] = None, key: Optional[str] = None)

Bases: MaterialChipBase

Material Design 3 Input Chip widget.

Initialize InputChip.

Parameters:

Name Type Description Default
label str | ReadOnlyObservableProtocol[str]

Chip label.

required
trailing_icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str]

Required trailing icon.

required
leading_icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None

Optional leading icon.

None
on_trailing_icon_click Optional[Callable[[], None]]

Callback invoked when trailing icon is pressed.

None
on_click Optional[Callable[[], None]]

Click callback.

None
disabled bool | ObservableProtocol[bool]

Disabled flag.

False
width SizingLike

Width sizing.

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

External insets around chip widget.

None
style Optional['ChipStyle']

Optional chip style.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/chip.py
def __init__(
    self,
    label: str | ReadOnlyObservableProtocol[str],
    *,
    trailing_icon: "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str],
    leading_icon: (
        "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str] | None
    ) = None,
    on_trailing_icon_click: Optional[Callable[[], None]] = None,
    on_click: Optional[Callable[[], None]] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = None,
    padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None,
    style: Optional["ChipStyle"] = None,
    key: Optional[str] = None,
):
    """Initialize InputChip.

    Args:
        label: Chip label.
        trailing_icon: Required trailing icon.
        leading_icon: Optional leading icon.
        on_trailing_icon_click: Callback invoked when trailing icon is pressed.
        on_click: Click callback.
        disabled: Disabled flag.
        width: Width sizing.
        padding: External insets around chip widget.
        style: Optional chip style.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._label = label
    self._leading_icon = leading_icon
    self._trailing_icon = trailing_icon
    self._on_trailing_icon_click = on_trailing_icon_click
    self._trailing_icon_widget: Optional[Icon] = None
    self._trailing_icon_tap_target: Optional[Widget] = None

    super().__init__(
        on_click=on_click,
        disabled=disabled,
        width=width,
        padding=padding,
        style=style,
        key=key,
    )

SuggestionChip

SuggestionChip(label: str | ReadOnlyObservableProtocol[str], *, leading_icon: 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None = None, on_click: Optional[Callable[[], None]] = None, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = None, padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None, style: Optional['ChipStyle'] = None, key: Optional[str] = None)

Bases: MaterialChipBase

Material Design 3 Suggestion Chip widget.

Initialize SuggestionChip.

Parameters:

Name Type Description Default
label str | ReadOnlyObservableProtocol[str]

Chip label.

required
leading_icon 'Symbol' | str | ReadOnlyObservableProtocol['Symbol'] | ReadOnlyObservableProtocol[str] | None

Optional leading icon.

None
on_click Optional[Callable[[], None]]

Click callback.

None
disabled bool | ObservableProtocol[bool]

Disabled flag.

False
width SizingLike

Width sizing.

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

External insets around chip widget.

None
style Optional['ChipStyle']

Optional chip style.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/chip.py
def __init__(
    self,
    label: str | ReadOnlyObservableProtocol[str],
    *,
    leading_icon: (
        "Symbol" | str | ReadOnlyObservableProtocol["Symbol"] | ReadOnlyObservableProtocol[str] | None
    ) = None,
    on_click: Optional[Callable[[], None]] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = None,
    padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None,
    style: Optional["ChipStyle"] = None,
    key: Optional[str] = None,
):
    """Initialize SuggestionChip.

    Args:
        label: Chip label.
        leading_icon: Optional leading icon.
        on_click: Click callback.
        disabled: Disabled flag.
        width: Width sizing.
        padding: External insets around chip widget.
        style: Optional chip style.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._label = label
    self._leading_icon = leading_icon

    super().__init__(
        on_click=on_click,
        disabled=disabled,
        width=width,
        padding=padding,
        style=style,
        key=key,
    )

BasicDialog

BasicDialog(title: Optional[Union[str, ReadOnlyObservableProtocol[str]]] = None, message: Optional[Union[str, ReadOnlyObservableProtocol[str]]] = None, *, icon: Optional[Union[str, 'Symbol', ReadOnlyObservableProtocol[str], ReadOnlyObservableProtocol['Symbol']]] = None, actions: Optional[List[Widget]] = None, style: Optional[DialogStyle] = None, width: float = 280.0, key: Optional[str] = None)

Bases: ComposableWidget

Material dialog widget (Basic Dialog).

Displays a modal dialog with optional icon, title, content, and action buttons. Follows Material Design 3 dialog guidelines.

Parameters:

Name Type Description Default
title Optional[Union[str, ReadOnlyObservableProtocol[str]]]

Optional title text (str or Observable).

None
message Optional[Union[str, ReadOnlyObservableProtocol[str]]]

Optional message text (str or Observable).

None
icon Optional[Union[str, 'Symbol', ReadOnlyObservableProtocol[str], ReadOnlyObservableProtocol['Symbol']]]

Optional icon (str, Symbol, or Observable).

None
actions Optional[List[Widget]]

list of action widgets (typically TextButtons).

None
style Optional[DialogStyle]

Optional DialogStyle. If None, uses theme default.

None
width float

Container width in dp. Per MD3 the basic dialog width is between min_width (280) and max_width (560). Defaults to 280 (MD3 minimum). Note: viewport-aware dynamic sizing is tracked as a separate enhancement.

280.0

Initialize BasicDialog.

Parameters:

Name Type Description Default
title Optional[Union[str, ReadOnlyObservableProtocol[str]]]

Optional title text source.

None
message Optional[Union[str, ReadOnlyObservableProtocol[str]]]

Optional message text source.

None
icon Optional[Union[str, 'Symbol', ReadOnlyObservableProtocol[str], ReadOnlyObservableProtocol['Symbol']]]

Optional icon source.

None
actions Optional[List[Widget]]

Optional action widgets (typically buttons).

None
style Optional[DialogStyle]

Optional dialog style override.

None
width float

Container width in dp (MD3 range: 280-560). Defaults to 280.

280.0
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/dialogs.py
def __init__(
    self,
    title: Optional[Union[str, ReadOnlyObservableProtocol[str]]] = None,
    message: Optional[Union[str, ReadOnlyObservableProtocol[str]]] = None,
    *,
    icon: Optional[
        Union[
            str,
            "Symbol",
            ReadOnlyObservableProtocol[str],
            ReadOnlyObservableProtocol["Symbol"],
        ]
    ] = None,
    actions: Optional[List[Widget]] = None,
    style: Optional[DialogStyle] = None,
    width: float = 280.0,
    key: Optional[str] = None,
):
    """Initialize BasicDialog.

    Args:
        title: Optional title text source.
        message: Optional message text source.
        icon: Optional icon source.
        actions: Optional action widgets (typically buttons).
        style: Optional dialog style override.
        width: Container width in dp (MD3 range: 280-560). Defaults to 280.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(key=key)
    self.title = title
    self.message = message
    self.icon = icon
    self.actions = actions or []
    self._user_style = style
    self.width = float(width)

style property

style: DialogStyle

Get the resolved dialog style.

LoadingIndicator

LoadingIndicator(*, size: int = 48, padding: Optional[Tuple[int, int, int, int] | Tuple[int, int] | int] = 0, style: Optional[LoadingIndicatorStyle] = None, key: Optional[str] = None)

Bases: Widget

M3 Expressive loading indicator.

This widget is intended for short, indeterminate waits.

Parameters:

Name Type Description Default
size int

Outer size of the indicator (default 48). Sets both width and height.

48
style Optional[LoadingIndicatorStyle]

Style configuration for appearance and animation.

None
padding Optional[Tuple[int, int, int, int] | Tuple[int, int] | int]

Padding around the indicator.

0

Initialize the LoadingIndicator.

Parameters:

Name Type Description Default
size int

Outer size of the indicator (default 48).

48
padding Optional[Tuple[int, int, int, int] | Tuple[int, int] | int]

Padding around the indicator.

0
style Optional[LoadingIndicatorStyle]

Style configuration for appearance and animation.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/loading_indicator.py
def __init__(
    self,
    *,
    size: int = 48,
    padding: Optional[Tuple[int, int, int, int] | Tuple[int, int] | int] = 0,
    style: Optional[LoadingIndicatorStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize the LoadingIndicator.

    Args:
        size: Outer size of the indicator (default 48).
        padding: Padding around the indicator.
        style: Style configuration for appearance and animation.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(width=int(size), height=int(size), padding=padding, key=key)
    self._size = int(size)
    self._user_style = style

    self._phase_anim: Animatable[float] | None = None
    self._anim_sub: Any = None
    self._loop_timer: Any = None
    self._path: Any = None

style property

style: LoadingIndicatorStyle

Get effective style (user style or theme default).

CircularProgressIndicator

CircularProgressIndicator(value: float | ObservableProtocol[float] = 0.0, *, disabled: bool | ObservableProtocol[bool] = False, size: int | None = None, padding: PaddingArg = 0, style: CircularProgressIndicatorStyle | None = None, key: str | None = None)

Bases: _DeterminateProgressBase

Material Design 3 determinate circular progress indicator.

Initialize CircularProgressIndicator.

Parameters:

Name Type Description Default
value float | ObservableProtocol[float]

Progress value in range [0.0, 1.0]. Values are clamped.

0.0
disabled bool | ObservableProtocol[bool]

Disabled state.

False
size int | None

Outer indicator size in dp. Uses style default when omitted.

None
padding PaddingArg

Padding around the indicator.

0
style CircularProgressIndicatorStyle | None

Optional style override.

None
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/progress_indicators.py
def __init__(
    self,
    value: float | ObservableProtocol[float] = 0.0,
    *,
    disabled: bool | ObservableProtocol[bool] = False,
    size: int | None = None,
    padding: PaddingArg = 0,
    style: CircularProgressIndicatorStyle | None = None,
    key: str | None = None,
) -> None:
    """Initialize CircularProgressIndicator.

    Args:
        value: Progress value in range ``[0.0, 1.0]``. Values are clamped.
        disabled: Disabled state.
        size: Outer indicator size in dp. Uses style default when omitted.
        padding: Padding around the indicator.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._style = style
    style_for_layout = style or CircularProgressIndicatorStyle.default()
    self._size = int(size) if size is not None else max(1, int(round(style_for_layout.size)))
    pad_l, pad_t, pad_r, pad_b = parse_padding(padding)
    super().__init__(
        value=value,
        disabled=disabled,
        width=self._size + pad_l + pad_r,
        height=self._size + pad_t + pad_b,
        padding=(pad_l, pad_t, pad_r, pad_b),
        key=key,
    )

style property

Return effective circular progress indicator style.

IndeterminateCircularProgressIndicator

IndeterminateCircularProgressIndicator(*, disabled: bool | ObservableProtocol[bool] = False, size: int | None = None, padding: PaddingArg = 0, style: CircularProgressIndicatorStyle | None = None, key: str | None = None)

Bases: _IndeterminateProgressBase

Material Design 3 indeterminate circular progress indicator.

Initialize IndeterminateCircularProgressIndicator.

Parameters:

Name Type Description Default
disabled bool | ObservableProtocol[bool]

Disabled state.

False
size int | None

Outer indicator size in dp. Uses style default when omitted.

None
padding PaddingArg

Padding around the indicator.

0
style CircularProgressIndicatorStyle | None

Optional style override.

None
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/progress_indicators.py
def __init__(
    self,
    *,
    disabled: bool | ObservableProtocol[bool] = False,
    size: int | None = None,
    padding: PaddingArg = 0,
    style: CircularProgressIndicatorStyle | None = None,
    key: str | None = None,
) -> None:
    """Initialize IndeterminateCircularProgressIndicator.

    Args:
        disabled: Disabled state.
        size: Outer indicator size in dp. Uses style default when omitted.
        padding: Padding around the indicator.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._style = style
    style_for_motion = style or CircularProgressIndicatorStyle.default()
    self._size = int(size) if size is not None else max(1, int(round(style_for_motion.size)))
    self._animation_motion = LinearMotion(self._animation_motion_duration())
    pad_l, pad_t, pad_r, pad_b = parse_padding(padding)
    super().__init__(
        disabled=disabled,
        width=self._size + pad_l + pad_r,
        height=self._size + pad_t + pad_b,
        padding=(pad_l, pad_t, pad_r, pad_b),
        key=key,
    )

style property

Return effective circular progress indicator style.

IndeterminateLinearProgressIndicator

IndeterminateLinearProgressIndicator(*, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = 'wt', padding: PaddingArg = 0, style: LinearProgressIndicatorStyle | None = None, key: str | None = None)

Bases: _IndeterminateProgressBase

Material Design 3 indeterminate linear progress indicator.

Initialize IndeterminateLinearProgressIndicator.

Parameters:

Name Type Description Default
disabled bool | ObservableProtocol[bool]

Disabled state.

False
width SizingLike

Width sizing.

'wt'
padding PaddingArg

Padding around the indicator.

0
style LinearProgressIndicatorStyle | None

Optional style override.

None
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/progress_indicators.py
def __init__(
    self,
    *,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = "wt",
    padding: PaddingArg = 0,
    style: LinearProgressIndicatorStyle | None = None,
    key: str | None = None,
) -> None:
    """Initialize IndeterminateLinearProgressIndicator.

    Args:
        disabled: Disabled state.
        width: Width sizing.
        padding: Padding around the indicator.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._style = style
    style_for_layout = style or LinearProgressIndicatorStyle.default()
    self._animation_motion = LinearMotion(self._animation_motion_duration())
    track_h = max(1, int(round(style_for_layout.track_thickness)))
    pad_l, pad_t, pad_r, pad_b = parse_padding(padding)
    super().__init__(
        disabled=disabled,
        width=width,
        height=track_h + pad_t + pad_b,
        padding=(pad_l, pad_t, pad_r, pad_b),
        key=key,
    )

style property

Return effective linear progress indicator style.

LinearProgressIndicator

LinearProgressIndicator(value: float | ObservableProtocol[float] = 0.0, *, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = 'wt', padding: PaddingArg = 0, style: LinearProgressIndicatorStyle | None = None, key: str | None = None)

Bases: _DeterminateProgressBase

Material Design 3 determinate linear progress indicator.

Initialize LinearProgressIndicator.

Parameters:

Name Type Description Default
value float | ObservableProtocol[float]

Progress value in range [0.0, 1.0]. Values are clamped.

0.0
disabled bool | ObservableProtocol[bool]

Disabled state.

False
width SizingLike

Width sizing.

'wt'
padding PaddingArg

Padding around the indicator.

0
style LinearProgressIndicatorStyle | None

Optional style override.

None
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/progress_indicators.py
def __init__(
    self,
    value: float | ObservableProtocol[float] = 0.0,
    *,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = "wt",
    padding: PaddingArg = 0,
    style: LinearProgressIndicatorStyle | None = None,
    key: str | None = None,
) -> None:
    """Initialize LinearProgressIndicator.

    Args:
        value: Progress value in range ``[0.0, 1.0]``. Values are clamped.
        disabled: Disabled state.
        width: Width sizing.
        padding: Padding around the indicator.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._style = style
    style_for_layout = style or LinearProgressIndicatorStyle.default()
    track_h = max(1, int(round(style_for_layout.track_thickness)))
    pad_l, pad_t, pad_r, pad_b = parse_padding(padding)
    super().__init__(
        value=value,
        disabled=disabled,
        width=width,
        height=track_h + pad_t + pad_b,
        padding=(pad_l, pad_t, pad_r, pad_b),
        key=key,
    )

style property

Return effective linear progress indicator style.

Menu

Menu(items: list[MenuItem | SubMenuItem | MenuDivider], *, on_dismiss: Callable[[], None] | None = None, style: MenuStyle | None = None, autofocus: bool = True, parent_item: 'SubMenuItem | None' = None, key: str | None = None)

Bases: InteractiveWidget

Material Design 3 vertical menu popup surface.

The menu is a focus traversal group. Opening a popup moves the focus onto the menu surface, with no item current — nothing is highlighted, as in a desktop menu — unless it was opened from the keyboard, which focuses the first enabled item. Up/Down rove the items (wrapping), Tab/Shift+Tab rove them too (without wrapping) and dismiss the popup once they step past the end, Right/Left walk into and out of a submenu, and Escape dismisses.

An inline menu (one placed in the page rather than shown as an overlay) is a single Tab stop instead: Tab enters it, roves it, and leaves it for the next widget rather than dismissing anything.

Initialize Menu.

Parameters:

Name Type Description Default
items list[MenuItem | SubMenuItem | MenuDivider]

Flat menu entries list.

required
on_dismiss Callable[[], None] | None

Called when menu is dismissed by keyboard.

None
style MenuStyle | None

Optional menu style override.

None
autofocus bool

Whether opening the menu focuses its first enabled item.

True
parent_item 'SubMenuItem | None'

The SubMenuItem this menu expands from, if it is a submenu.

None
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/menu.py
def __init__(
    self,
    items: list[MenuItem | SubMenuItem | MenuDivider],
    *,
    on_dismiss: Callable[[], None] | None = None,
    style: MenuStyle | None = None,
    autofocus: bool = True,
    parent_item: "SubMenuItem | None" = None,
    key: str | None = None,
) -> None:
    """Initialize Menu.

    Args:
        items: Flat menu entries list.
        on_dismiss: Called when menu is dismissed by keyboard.
        style: Optional menu style override.
        autofocus: Whether opening the menu focuses its first enabled item.
        parent_item: The SubMenuItem this menu expands from, if it is a submenu.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self.items = list(items)
    self.on_dismiss = on_dismiss
    # Held in a local for everything below ``super().__init__()``: reads that
    # run before the widget is attached must not go through an accessor that
    # could reach for the theme, which is not resolvable yet. The preset is
    # what ``MenuStyle.from_theme`` falls back to, so an unthemed app sees
    # no change; a themed one adopts its style on the first measure.
    effective_style = style or MenuStyle.preset()
    #: The style the caller passed, or ``None`` to follow the theme.
    self._user_style: MenuStyle | None = style
    self._applied_style: MenuStyle = effective_style
    self._autofocus = bool(autofocus)
    self._autofocus_pending = False
    self._parent_item = parent_item
    self._focus_index = -1
    self._focusable_items: list[MenuItem] = []

    _shadows = elevation_shadows(effective_style.elevation)

    children = self._materialize_children(effective_style)
    self._column = Column(children=children, width=Sizing.weight(), gap=0, cross_alignment="start")

    super().__init__(
        child=self._column,
        on_click=None,
        state_layer_color=effective_style.state_layer_color,
        padding=(
            0,
            effective_style.container_vertical_padding,
            0,
            effective_style.container_vertical_padding,
        ),
        background_color=effective_style.background,
        corner_radius=effective_style.corner_radius,
        shadows=_shadows,
        # The menu is one group, not a row of stops: Tab neither lands on the
        # surface nor on the items, it leaves the menu (see the scope below).
        traversable=False,
        key=key,
    )

    # Tab roves the items like everywhere else in the framework: it is the key
    # the user presses to be given the focus, so the first Tab must land on an
    # item, not close the menu. Only stepping past the last item is a boundary
    # (see _MenuTraversalPolicy.on_boundary). The arrow keys wrap; Tab does not.
    self._focus_scope = FocusScope(_MenuTraversalPolicy(self))
    self.add_node(self._focus_scope)

    # Menu surface itself should not paint state layers.
    self._HOVER_OPACITY = 0.0
    self._PRESS_OPACITY = 0.0
    self._FOCUS_OPACITY = 0.0

should_show_focus_ring property

should_show_focus_ring: bool

Never ring the surface: it holds the focus, but the items show where it is.

style property

style: MenuStyle

Return the menu style currently in effect, pulled from the theme.

A menu has no build(), so it reads the theme where the style is consumed -- :meth:preferred_size. The read registers a dependency, so a theme change re-measures the menu and lands back here with the new value. The container visuals and the items' styles are derived from it rather than re-derived on every read, so they are re-applied whenever the resolved style has moved.

focus_first_item

focus_first_item() -> bool

Focus the first enabled item.

Returns False if the menu has no enabled item, or if it is not mounted yet: a widget's on_mount runs before its children's, so in that case the request is latched and :meth:_item_mounted completes it.

Source code in src/nuiitivet/material/menu.py
def focus_first_item(self) -> bool:
    """Focus the first enabled item.

    Returns False if the menu has no enabled item, or if it is not mounted
    yet: a widget's ``on_mount`` runs before its children's, so in that case
    the request is latched and :meth:`_item_mounted` completes it.
    """
    item = self._first_enabled_item()
    if item is None:
        return False
    if getattr(item, "_app", None) is None:
        self._autofocus_pending = True
        return False

    self._autofocus_pending = False
    self._focus_item(item)
    return True

MenuDivider

Sentinel that renders a horizontal divider inside a Menu.

MenuItem

MenuItem(label: str, *, on_click: Callable[[], None] | None = None, disabled: bool = False, leading_icon: Symbol | str | None = None, trailing: Symbol | str | None = None, key: str | None = None)

Bases: InteractiveWidget

Material Design 3 menu item widget.

Initialize MenuItem.

The item height is MD3-fixed (list-item token), so it is not a constructor parameter; customize it via MenuStyle.item_height (SIZE_POLICY: MD3 fixes the axis -> style only).

Parameters:

Name Type Description Default
label str

Item label.

required
on_click Callable[[], None] | None

Click callback.

None
disabled bool

Whether this item is disabled.

False
leading_icon Symbol | str | None

Optional leading icon.

None
trailing Symbol | str | None

Optional trailing icon (Symbol) or trailing text (str).

None
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/menu.py
def __init__(
    self,
    label: str,
    *,
    on_click: Callable[[], None] | None = None,
    disabled: bool = False,
    leading_icon: Symbol | str | None = None,
    trailing: Symbol | str | None = None,
    key: str | None = None,
) -> None:
    """Initialize MenuItem.

    The item height is MD3-fixed (list-item token), so it is not a
    constructor parameter; customize it via ``MenuStyle.item_height``
    (SIZE_POLICY: MD3 fixes the axis -> style only).

    Args:
        label: Item label.
        on_click: Click callback.
        disabled: Whether this item is disabled.
        leading_icon: Optional leading icon.
        trailing: Optional trailing icon (Symbol) or trailing text (str).
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self.label = label
    self.leading_icon = leading_icon
    self.trailing = trailing
    self._menu_style: MenuStyle = MenuStyle.standard()
    self._owner_menu: Menu | None = None
    self._selected = False
    self._leading_icon_widget: Icon | None = None
    self._label_widget: Text | None = None
    self._trailing_text_widget: Text | None = None
    self._trailing_icon_widget: Icon | None = None
    self._content_row: Row | None = None
    self._content_container: Container | None = None
    self._content_icon_size: int | None = None

    resolved_height = Sizing.fixed(self._menu_style.item_height)

    super().__init__(
        child=Text(label),
        on_click=on_click,
        on_hover=self._handle_hover_change,
        on_press=self._handle_press,
        on_release=self._handle_release,
        disabled=disabled,
        state_layer_color=self._menu_style.state_layer_color,
        width=Sizing.weight(),
        height=resolved_height,
        background_color=None,
        padding=0,
        corner_radius=0,
        # The enclosing Menu's FocusScope moves focus between the items; the
        # global Tab sequence must not stop on them (WAI-ARIA menu pattern).
        traversable=False,
        key=key,
    )
    self._build_content(self._menu_style)
    self._apply_style(self._menu_style)

SubMenuItem

SubMenuItem(label: str, items: list[MenuItem | 'SubMenuItem' | MenuDivider], *, leading_icon: Symbol | str | None = None, disabled: bool = False, key: str | None = None)

Bases: MenuItem

Material Design 3 submenu item that expands a nested menu.

Initialize SubMenuItem.

Parameters:

Name Type Description Default
label str

Item label.

required
items list[MenuItem | 'SubMenuItem' | MenuDivider]

Submenu entries.

required
leading_icon Symbol | str | None

Optional leading icon.

None
disabled bool

Whether this item is disabled.

False
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/menu.py
def __init__(
    self,
    label: str,
    items: list[MenuItem | "SubMenuItem" | MenuDivider],
    *,
    leading_icon: Symbol | str | None = None,
    disabled: bool = False,
    key: str | None = None,
) -> None:
    """Initialize SubMenuItem.

    Args:
        label: Item label.
        items: Submenu entries.
        leading_icon: Optional leading icon.
        disabled: Whether this item is disabled.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._submenu_items = list(items)
    self._submenu_handle: OverlayHandle[object] | None = None
    self._submenu_tick: Callable[[float], None] | None = None
    self._submenu: Menu | None = None
    self._parent_dismiss: Callable[[], None] | None = None
    self._submenu_pinned = False
    self._suppress_reopen = False

    super().__init__(
        label,
        on_click=self._on_self_click,
        disabled=disabled,
        leading_icon=leading_icon,
        trailing=Symbols.chevron_right,
        key=key,
    )

FabMenu

FabMenu(icon: IconLike, items: List[FabMenuItem], *, is_open: Optional[Observable[bool]] = None, auto_close: bool = True, close_icon: Union['Symbol', str] = close, style: Optional[FabStyle] = None, key: Optional[str] = None)

Bases: Widget

Material Design 3 Expressive FAB Menu.

A Floating Action Button that expands into a vertical list of labelled actions. A single is_open observable is the source of truth. On open the FAB morphs into the MD3 close button: its icon changes (icon -> close_icon) and it shrinks from its closed size to a fixed 56dp (size "s") fully-rounded circle, regardless of the configured size. The overlay -- scrim, outside-tap dismissal, and anchored positioning -- is driven through the same observable via :func:~nuiitivet.modifiers.popup.popup.

The closed-size footprint is reserved for layout stability; the shrinking close button aligns to its top-trailing corner, so larger closed FAB sizes place the menu higher with a larger margin underneath (40dp for medium, 56dp for large per MD3). The menu expands upward from that top-trailing edge with a 4dp gap and a staggered item reveal. Selecting an item invokes its on_click and, by default, closes the menu.

Initialize a FabMenu.

Parameters:

Name Type Description Default
icon IconLike

FAB icon shown while the menu is closed.

required
items List[FabMenuItem]

The actions to display when the menu is open.

required
is_open Optional[Observable[bool]]

Optional external Observable[bool] controlling the open/close state. When None an internal one is created and exposed via :attr:is_open.

None
auto_close bool

When True (default), selecting an item closes the menu after invoking its on_click.

True
close_icon Union['Symbol', str]

Icon the FAB morphs to while the menu is open. Defaults to Symbols.close.

close
style Optional[FabStyle]

FAB style preset selecting the colour family and size. Defaults to :meth:FabStyle.primary. List items use the matching *-container colour set.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/fab_menu.py
def __init__(
    self,
    icon: IconLike,
    items: List[FabMenuItem],
    *,
    is_open: Optional[Observable[bool]] = None,
    auto_close: bool = True,
    close_icon: Union["Symbol", str] = Symbols.close,
    style: Optional[FabStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize a FabMenu.

    Args:
        icon: FAB icon shown while the menu is closed.
        items: The actions to display when the menu is open.
        is_open: Optional external ``Observable[bool]`` controlling the
            open/close state.  When ``None`` an internal one is created and
            exposed via :attr:`is_open`.
        auto_close: When ``True`` (default), selecting an item closes the
            menu after invoking its ``on_click``.
        close_icon: Icon the FAB morphs to while the menu is open.
            Defaults to ``Symbols.close``.
        style: FAB style preset selecting the colour family and size.
            Defaults to :meth:`FabStyle.primary`.  List items use the
            matching ``*-container`` colour set.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(key=key)

    base_style = style if style is not None else FabStyle.primary()
    self._is_open: Observable[bool] = is_open if is_open is not None else Observable(False)
    self._auto_close = bool(auto_close)

    # FAB icon is bound to is_open: there is no separate toggle state.
    def _resolve_icon(is_open_value: bool) -> Union["Symbol", str, IconLike]:
        return close_icon if is_open_value else icon

    icon_source = self._is_open.map(_resolve_icon)

    # The FAB keeps its normal size/shape while closed and morphs into the
    # fixed 56dp circular "close button" while open.  The morph is owned by
    # the FAB and bound to is_open.  The closed footprint is reserved so the
    # surrounding layout stays stable; the shrinking close button aligns to
    # the footprint's top-trailing corner, growing the bottom margin.
    # The close button uses the family's *solid* colour (distinct from the
    # tonal list items) per MD3.
    fab_style = _close_button_style(base_style)
    self._closed_size = float(base_style.container_height)
    morph_fab_cls = _build_morph_fab_class()
    self._fab = morph_fab_cls(
        icon_source,
        on_click=self._toggle,
        style=fab_style,
        is_open=self._is_open,
        closed_size=self._closed_size,
        open_size=_OPEN_FAB_SIZE,
        closed_corner=_scalar_corner(fab_style.corner_radius),
        open_corner=_OPEN_FAB_CORNER,
    )

    self._list = _FabMenuList(
        items,
        item_style=_list_item_style(base_style),
        on_select=self._on_item_selected,
    )

    # Reuse the existing popup overlay for blocking + outside-tap close.
    self._inner = self._fab.modifier(
        popup(
            self._list,
            is_open=self._is_open,
            target_anchor="top-right",
            content_anchor="bottom-right",
            offset=(0.0, -_CLOSE_BUTTON_BETWEEN_SPACE),
        )
    )
    self.add_child(self._inner)

is_open property

is_open: Observable[bool]

Observable that controls (and reflects) the menu's open state.

FabMenuItem dataclass

FabMenuItem(icon: IconLike, label: LabelLike, on_click: Optional[VoidCallback] = None, disabled: Union[bool, ObservableProtocol[bool]] = False)

Declarative spec for a single action inside a :class:FabMenu.

Parameters:

Name Type Description Default
icon IconLike

Leading icon shown in the menu-item pill.

required
label LabelLike

Text label rendered next to the icon.

required
on_click Optional[VoidCallback]

Optional callback invoked when the item is selected.

None
disabled Union[bool, ObservableProtocol[bool]]

Whether the item is disabled (non-interactive).

False

LoadingIntent dataclass

LoadingIntent()

Intent for showing a loading indicator via MaterialOverlay.

This is a marker intent with no parameters. Visual properties should be configured via overlay_routes in MaterialApp.

Icon

Icon(name: Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str], *, size: SizingLike = 24, padding: Optional[Tuple[int, int, int, int] | Tuple[int, int] | int] = None, style: Optional['IconStyle'] = None, key: Optional[str] = None)

Bases: IconBase

Material Symbols icon widget (M3準拠).

Parameters: - name: Ligature name (e.g. "home", "menu") or Symbol - size: Icon visual size in pixels (default 24dp) - padding: Space around icon (M3: "space between UI elements") - style: IconStyle for customization (defaults to theme style)

Create a Material-like icon by ligature name.

Parameters:

Name Type Description Default
name Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str]

Ligature name such as "home", "menu", "search", a Symbol, or an Observable that yields either.

required
size SizingLike

Logical pixel size of the icon (font size used for the glyph).

24
padding Optional[Tuple[int, int, int, int] | Tuple[int, int] | int]

Space around the icon (M3: "space between UI elements").

None
style Optional['IconStyle']

IconStyle for customization (defaults to theme style).

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/icon.py
def __init__(
    self,
    name: Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str],
    *,
    size: SizingLike = 24,
    padding: Optional[Tuple[int, int, int, int] | Tuple[int, int] | int] = None,
    style: Optional["IconStyle"] = None,
    key: Optional[str] = None,
):
    """Create a Material-like icon by ligature name.

    Args:
        name: Ligature name such as "home", "menu", "search", a Symbol,
              or an Observable that yields either.
        size: Logical pixel size of the icon (font size used for the glyph).
        padding: Space around the icon (M3: "space between UI elements").
        style: IconStyle for customization (defaults to theme style).
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    # Store style (use provided or get from theme lazily)
    self._style = style

    # Resolve padding
    final_padding = padding
    if final_padding is None:
        if style is not None:
            final_padding = style.padding
        else:
            final_padding = 0

    # Treat `size` as a SizingLike and use it for layout via the
    # base Widget's width/height. Also compute a pixel fallback stored
    # in self._size for paint-time operations.
    super().__init__(size=size, padding=final_padding, key=key)

    self._user_padding = padding

    self._name_source: Any = name
    self._symbol: Optional["Symbol"] = None
    self._symbol_codepoint: Optional[str] = None

    resolved_name: Symbol | str
    if hasattr(name, "value"):
        try:
            resolved_name = name.value  # type: ignore[assignment]
        except Exception:
            exception_once(logger, "icon_name_value_exc", "Failed to read icon name.value")
            resolved_name = str(name)
    else:
        resolved_name = name  # type: ignore[assignment]

    self._apply_name(resolved_name)

    self._size = _pixel_size_from_sizing(size)
    self._font_file_candidates: Tuple[str, ...] = ("MaterialIcons-Regular.ttf",)
    # Cache typeface to avoid repeated _load_typeface calls on every paint
    self._cached_typeface: Optional[object] = None

family property

family: str

Return the style-driven icon family.

The family is sourced from IconStyle.family (explicit style= or theme-resolved style). If style resolution fails, this falls back to "outlined".

preferred_size

preferred_size(max_width: Optional[int] = None, max_height: Optional[int] = None) -> tuple[int, int]

Return preferred size including padding (M3準拠).

Source code in src/nuiitivet/material/icon.py
def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> tuple[int, int]:
    """Return preferred size including padding (M3準拠)."""
    # Respect explicit Sizing overrides when provided on the widget.
    # Icons are square by default (size x size) when no fixed Sizing
    # is provided.
    w_dim = self.width_sizing
    h_dim = self.height_sizing

    if w_dim.kind == "fixed":
        width = int(w_dim.value)
    else:
        width = self._size

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

    # 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 icon with padding support (M3準拠).

Source code in src/nuiitivet/material/icon.py
def paint(self, canvas, x: int, y: int, width: int, height: int):
    """Paint icon 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)

    # Determine size to draw (contain behavior).
    # Use the smaller dimension of the content area to maintain aspect ratio.
    draw_size = min(cw, ch)
    if draw_size <= 0:
        return

    # Use cached typeface if available, otherwise load and cache it
    tf = self._cached_typeface
    if tf is None:
        tf = self._load_typeface()
        self._cached_typeface = tf
    # Prefer the resolved typeface. If none was found, try to load a
    # packaged legacy MaterialIcons font before falling back to a
    # Font constructed without an explicit typeface.
    font = None
    if tf is not None:
        try:
            font = make_font(tf, draw_size)
        except Exception:
            exception_once(logger, "icon_make_font_exc", "make_font failed for resolved typeface")
            font = None

    if font is None:
        try:
            legacy_tf = None
            fallback_names = getattr(self, "_font_file_candidates", ("MaterialIcons-Regular.ttf",))
            for fallback_name in fallback_names:
                legacy_fp = self._first_available_font_file(fallback_name)
                if legacy_fp and os.path.isfile(legacy_fp):
                    try:
                        legacy_tf = typeface_from_file(legacy_fp)
                    except Exception:
                        exception_once(
                            logger,
                            "icon_typeface_from_file_primary_exc",
                            "typeface_from_file failed (file=%s)",
                            os.path.basename(legacy_fp),
                        )
                        legacy_tf = None
                if legacy_tf is not None:
                    break
            if legacy_tf is not None:
                font = make_font(legacy_tf, draw_size)
            else:
                font = make_font(None, draw_size)
        except Exception:
            try:
                font = make_font(None, draw_size)
            except Exception:
                exception_once(logger, "icon_make_font_fallback_exc", "make_font(None) failed")
                return

    # Use ligature name as text; Material fonts may convert to glyphs.
    try:
        blob = make_text_blob(self.name, font)
    except Exception:
        exception_once(logger, "icon_make_text_blob_exc", "make_text_blob failed for icon ligature")
        blob = None

    # If we have a mapping for this name, prefer rendering the mapped
    # PUA codepoint with the packaged legacy MaterialIcons font when
    # possible. This is a pragmatic default that makes common icons
    # reliably visible across different Material font variants.
    cp = self._symbol_codepoint
    if cp:
        try:
            # Try current typeface first
            try:
                cp_blob = make_text_blob(cp, font)
            except Exception:
                exception_once(logger, "icon_make_text_blob_codepoint_exc", "make_text_blob failed for codepoint")
                cp_blob = None

            def blob_has_height(b):
                try:
                    return b is not None and b.bounds().height() > 0.5
                except Exception:
                    exception_once(logger, "icon_blob_bounds_exc", "Failed to read text blob bounds")
                    return False

            if blob_has_height(cp_blob):
                blob = cp_blob
            else:
                # Try packaged legacy font
                try:
                    fallback_names = getattr(self, "_font_file_candidates", ("MaterialIcons-Regular.ttf",))
                    legacy_tf = None
                    for fallback_name in fallback_names:
                        legacy_fp = self._first_available_font_file(fallback_name)
                        if legacy_fp and os.path.isfile(legacy_fp):
                            try:
                                legacy_tf = typeface_from_file(legacy_fp)
                            except Exception:
                                exception_once(
                                    logger,
                                    "icon_typeface_from_file_exc",
                                    "typeface_from_file failed",
                                )
                                legacy_tf = None
                        if legacy_tf is not None:
                            break
                    if legacy_tf is not None:
                        legacy_font = make_font(legacy_tf, draw_size)
                        try:
                            legacy_blob = make_text_blob(cp, legacy_font)
                            if blob_has_height(legacy_blob):
                                blob = legacy_blob
                        except Exception:
                            exception_once(
                                logger,
                                "icon_make_text_blob_legacy_exc",
                                "make_text_blob failed for legacy font",
                            )
                except Exception:
                    exception_once(
                        logger,
                        "icon_legacy_font_fallback_exc",
                        "Icon legacy font fallback failed",
                    )
        except Exception:
            # if still no blob, leave as-is (no-op)
            exception_once(logger, "icon_paint_codepoint_exc", "Icon paint failed")
    if blob is None:
        return

    # Use IconBase to draw the blob
    from nuiitivet.theme.theme import Theme

    color = self.style.color
    resolved_color = resolve_color_to_rgba(color, theme=Theme.of(self))
    self.draw_blob(canvas, blob, resolved_color, x, y, width, height)

NavigationRail

NavigationRail(children: Sequence[RailItem], *, index: Union[int, MutableObservableBase[int]] = 0, on_select: Optional[Callable[[int], None]] = None, expanded: Union[bool, MutableObservableBase[bool]] = False, show_menu_button: bool = True, width: Union[SizingLike, ReadOnlyObservableProtocol] = None, height: Union[SizingLike, ReadOnlyObservableProtocol] = None, padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0, style: Optional[NavigationRailStyle] = None, key: Optional[str] = None)

Bases: InteractionHostMixin, Widget

Vertical navigation bar for desktop applications.

Material Design 3 component for persistent side navigation. Replaces NavigationDrawer for desktop/tablet layouts.

Display modes: - Collapsed (expanded=False): Icon above label (vertical), 96px wide - Expanded (expanded=True): Icon + label (horizontal), 220-360px wide (the expanded width is set via the width argument; see __init__)

Both modes show labels. The active indicator (selection background) wraps: - Collapsed: Only the icon (56×32dp) - Expanded: Both icon and label

Users can toggle between modes with optional menu button.

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

Initialize NavigationRail.

Parameters:

Name Type Description Default
children Sequence[RailItem]

The rail items to display.

required
index Union[int, MutableObservableBase[int]]

The currently selected index. int or a mutable Observable[int] — the rail writes the selection back, so a read-only/computed observable is rejected.

0
on_select Optional[Callable[[int], None]]

Callback when an item is selected.

None
expanded Union[bool, MutableObservableBase[bool]]

Whether the rail is expanded. bool or a mutable Observable[bool] — the menu button writes it back, so a read-only/computed observable is rejected. To drive expansion from derived state (e.g. window size), mirror it into an Observable and update that.

False
show_menu_button bool

Whether to show the menu toggle button.

True
width Union[SizingLike, ReadOnlyObservableProtocol]

Expanded rail width. A fixed value (e.g. 280 or Sizing.fixed(280)) sets the expanded width, clamped into the MD3 range [220, 360] (see NavigationRailStyle). The rail always animates between the collapsed width and this expanded width; the collapsed width is never overridden here. Any non-fixed value (or None) uses the minimum expanded width; non-fixed values also emit a warning.

None
height Union[SizingLike, ReadOnlyObservableProtocol]

Height specification.

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

Padding specification.

0
style Optional[NavigationRailStyle]

Custom NavigationRailStyle.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/navigation_rail.py
def __init__(
    self,
    children: Sequence[RailItem],
    *,
    index: Union[int, MutableObservableBase[int]] = 0,
    on_select: Optional[Callable[[int], None]] = None,
    expanded: Union[bool, MutableObservableBase[bool]] = False,
    show_menu_button: bool = True,
    width: Union[SizingLike, ReadOnlyObservableProtocol] = None,
    height: Union[SizingLike, ReadOnlyObservableProtocol] = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    style: Optional[NavigationRailStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize NavigationRail.

    Args:
        children: The rail items to display.
        index: The currently selected index. ``int`` or a **mutable**
            ``Observable[int]`` — the rail writes the selection back, so a
            read-only/computed observable is rejected.
        on_select: Callback when an item is selected.
        expanded: Whether the rail is expanded. ``bool`` or a **mutable**
            ``Observable[bool]`` — the menu button writes it back, so a
            read-only/computed observable is rejected. To drive expansion
            from derived state (e.g. window size), mirror it into an
            ``Observable`` and update that.
        show_menu_button: Whether to show the menu toggle button.
        width: Expanded rail width. A *fixed* value (e.g. ``280`` or
            ``Sizing.fixed(280)``) sets the expanded width, clamped into the
            MD3 range ``[220, 360]`` (see ``NavigationRailStyle``). The rail
            always animates between the collapsed width and this expanded
            width; the collapsed width is never overridden here. Any
            non-fixed value (or ``None``) uses the minimum expanded width;
            non-fixed values also emit a warning.
        height: Height specification.
        padding: Padding specification.
        style: Custom NavigationRailStyle.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    _reject_readonly_observable(index, "index")
    _reject_readonly_observable(expanded, "expanded")

    self._is_expanded = expanded.value if isinstance(expanded, MutableObservableBase) else bool(expanded)
    self._style = style
    self._menu_icon_name: Optional[_ObservableValue[str]] = None
    eff_style = style or NavigationRailStyle()

    # Animation setup
    initial_expanded_value = 1.0 if self._is_expanded else 0.0
    self._expand_motion = EXPRESSIVE_DEFAULT_SPATIAL
    self._expand_animation: Animatable[float] = Animatable(initial_expanded_value, motion=self._expand_motion)
    self._label_animation: Animatable[float] = Animatable(initial_expanded_value, motion=EXPRESSIVE_DEFAULT_EFFECTS)
    self._menu_rotation_anim: Animatable[float] = Animatable(
        initial_expanded_value,
        motion=EXPRESSIVE_DEFAULT_EFFECTS,
    )
    self._menu_rotation = self._menu_rotation_anim.map(lambda progress: lerp(180.0, 360.0, progress))
    self._log_instance_id = id(self)
    logger.debug("NavigationRail init id=%s", self._log_instance_id)

    # Resolve the expanded width from `width` (only a fixed value sets it),
    # then always drive the outer width via the collapse animation so the
    # rail animates 96dp <-> expanded width regardless of what was passed.
    self._expanded_width, width_warning = _resolve_expanded_width(width, eff_style)
    collapsed_width = float(eff_style.container_width_collapsed)
    expanded_width = self._expanded_width
    animated_width = self._expand_animation.map(
        lambda progress: Sizing.fixed(
            int(lerp(collapsed_width, expanded_width, progress))
        )
    )

    super().__init__(width=animated_width, height=height, padding=padding, key=key)

    if width_warning is not None:
        warning_once(logger, width_warning[0], width_warning[1])

    # The items are one focus traversal group (WAI-ARIA tabs, manual
    # activation): a single Tab stop entered at the selected item, with
    # Up/Down roving the focus between the items — wrapping at the ends —
    # and Enter/Space selecting the focused one. Roving deliberately does
    # not move the selection: selecting a destination navigates, which is
    # too heavy an action to fire on every arrow press. The nodes are
    # created here but attached to the item-group widget each rebuild (see
    # ``_RailItemGroup``); the menu button is a separate ordinary Tab stop.
    self._focus_node = FocusNode(on_key=self.on_key_event)
    self._focus_scope = FocusScope(_RailTraversalPolicy(self), tab_roves=False)

    self._item_buttons: list[_RailItemButton] = []
    self._item_group: Optional[_RailItemGroup] = None
    self._menu_button: Optional[_RailMenuButton] = None

    self._rail_items: Sequence[RailItem] = list(children)
    self.on_select = on_select
    self.show_menu_button = show_menu_button

    # Handle index.
    self._index_observable: Optional[MutableObservableBase[int]] = None
    self._index_subscription = None
    if isinstance(index, MutableObservableBase):
        self._index_observable = index
        self._current_index = self._validate_index(index.value)
        self._index_subscription = index.subscribe(self._on_index_changed)
    else:
        self._current_index = self._validate_index(int(index))

    # Handle expanded state.
    self._expanded_observable: Optional[MutableObservableBase[bool]] = None
    self._expanded_subscription = None
    if isinstance(expanded, MutableObservableBase):
        self._expanded_observable = expanded
        # _is_expanded already set above
        self._expanded_subscription = expanded.subscribe(self._on_expanded_changed)
    # else: _is_expanded already set above

    # Ensure subscriptions are released when removed from the tree.
    self.on_dispose(self.dispose)

    # Build UI.
    self._rebuild_ui()

style property

style: Optional[NavigationRailStyle]

Get the navigation rail style.

current_index property

current_index: int

Get the currently selected item index.

is_expanded property

is_expanded: bool

Get the current expanded state.

on_key_event

on_key_event(key: str, modifier_keys: int = 0) -> bool

Rove the items with Up/Down, wrapping; Enter/Space acts on the focused item.

Only the vertical axis roves — a rail is always a column. Enter/Space are handled by the focused item itself, so they never reach here.

Source code in src/nuiitivet/material/navigation_rail.py
def on_key_event(self, key: str, modifier_keys: int = 0) -> bool:
    """Rove the items with Up/Down, wrapping; Enter/Space acts on the focused item.

    Only the vertical axis roves — a rail is always a column. Enter/Space
    are handled by the focused item itself, so they never reach here.
    """
    key_name = str(key).lower()

    if key_name == "down":
        return self._focus_scope.move(1, wrap=True)

    if key_name == "up":
        return self._focus_scope.move(-1, wrap=True)

    return False

preferred_size

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

Calculate preferred size for the navigation rail.

Source code in src/nuiitivet/material/navigation_rail.py
def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
    """Calculate preferred size for the navigation rail."""
    rail_width = self._calculate_width()

    # Get height from child if present.
    children = self.children_snapshot()
    if children:
        child = children[0]
        child_w, child_height = child.preferred_size(max_width=rail_width, max_height=max_height)
        preferred_w = max(int(rail_width), int(child_w))
        preferred_h = int(child_height)

        if max_width is not None:
            preferred_w = min(int(preferred_w), int(max_width))
        if max_height is not None:
            preferred_h = min(int(preferred_h), int(max_height))

        return (int(preferred_w), int(preferred_h))

    # Default minimum height.
    preferred_w = int(rail_width)
    preferred_h = 400
    if max_width is not None:
        preferred_w = min(int(preferred_w), int(max_width))
    if max_height is not None:
        preferred_h = min(int(preferred_h), int(max_height))
    return (int(preferred_w), int(preferred_h))

layout

layout(width: int, height: int) -> None

Layout the navigation rail and its child.

Source code in src/nuiitivet/material/navigation_rail.py
def layout(self, width: int, height: int) -> None:
    """Layout the navigation rail and its child."""
    super().layout(width, height)

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

    # Layout the single child (Box containing Column).
    child = children[0]
    # Use provided dimensions minus padding.
    l, t, r, b = self.padding
    cw = max(0, width - l - r)
    ch = max(0, height - t - b)

    child.layout(cw, ch)
    child.set_layout_rect(l, t, cw, ch)

paint

paint(canvas, x: int, y: int, width: int, height: int) -> None

Paint the NavigationRail.

Source code in src/nuiitivet/material/navigation_rail.py
def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
    """Paint the NavigationRail."""
    children = self.children_snapshot()
    if not children:
        return

    # Layout not yet complete; skip this frame.
    if any(c.layout_rect is None for c in children):
        return

    # Paint the child.
    child = children[0]
    rect = 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

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

dispose

dispose() -> None

Clean up subscriptions.

Source code in src/nuiitivet/material/navigation_rail.py
def dispose(self) -> None:
    """Clean up subscriptions."""
    logger.debug("NavigationRail dispose id=%s", self._log_instance_id)
    if self._index_subscription is not None:
        self._index_subscription.dispose()
        self._index_subscription = None
    if self._expanded_subscription is not None:
        self._expanded_subscription.dispose()
        self._expanded_subscription = None
    self._expand_animation.stop()  # Ensure ticker stopped
    self._label_animation.stop()
    self._menu_rotation_anim.stop()

RailItem

RailItem(icon: IconLike, label: LabelLike, *, small_badge: Optional[ReadOnlyObservableProtocol[bool]] = None, large_badge: Optional[ReadOnlyObservableProtocol[Optional[str]]] = None, style: Optional[NavigationRailStyle] = None, key: Optional[str] = None)

Bases: Widget

Navigation rail destination item.

A widget representing a single destination in NavigationRail. Displays an icon and optional label (when rail is expanded).

Initialize RailItem.

Parameters:

Name Type Description Default
icon IconLike

The icon to display. May be a :class:Symbol, a ligature string, or an observable of either (mirroring IconButton).

required
label LabelLike

The label to display. May be a string or an observable string.

required
small_badge Optional[ReadOnlyObservableProtocol[bool]]

Optional Observable controlling small dot badge visibility.

None
large_badge Optional[ReadOnlyObservableProtocol[Optional[str]]]

Optional Observable with badge text. None or "" hides the badge. When both small_badge and large_badge are provided, large_badge takes precedence.

None
style Optional[NavigationRailStyle]

Optional style override for this item.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/navigation_rail.py
def __init__(
    self,
    icon: IconLike,
    label: LabelLike,
    *,
    small_badge: Optional[ReadOnlyObservableProtocol[bool]] = None,
    large_badge: Optional[ReadOnlyObservableProtocol[Optional[str]]] = None,
    style: Optional[NavigationRailStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize RailItem.

    Args:
        icon: The icon to display. May be a :class:`Symbol`, a ligature
            string, or an observable of either (mirroring ``IconButton``).
        label: The label to display. May be a string or an observable string.
        small_badge: Optional Observable controlling small dot badge visibility.
        large_badge: Optional Observable with badge text. ``None`` or ``""`` hides the badge.
            When both ``small_badge`` and ``large_badge`` are provided,
            ``large_badge`` takes precedence.
        style: Optional style override for this item.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(key=key)

    self.icon_spec = icon
    self.label_spec = label
    self._small_badge_observable: Optional[ReadOnlyObservableProtocol[bool]] = small_badge
    self._large_badge_observable: Optional[ReadOnlyObservableProtocol[Optional[str]]] = large_badge
    self._style = style

    self._icon_widget: Widget
    self._label_widget: Widget

    eff_style = style or NavigationRailStyle()
    icon_color = eff_style.icon_color or ColorRole.ON_SURFACE
    icon_size = eff_style.icon_size
    self._icon_widget = Icon(icon, size=icon_size, style=IconStyle(color=icon_color))

    label_color = eff_style.label_color or ColorRole.ON_SURFACE_VARIANT
    if eff_style.label_text_style is not None:
        text_style = eff_style.label_text_style.copy_with(color=label_color)
    else:
        text_style = TextStyle(color=label_color)

    self._label_widget = Text(
        label,
        style=text_style,
        type_scale=TypeScale.LABEL_MEDIUM,
        alignment="center",
        width=Sizing.fixed(eff_style.container_width_collapsed),
        max_lines=1,
        overflow="ellipsis",
        soft_wrap=False,
    )

style property

style: Optional[NavigationRailStyle]

Get the style override.

icon_widget property

icon_widget: Widget

Get the icon widget.

label_widget property

label_widget: Widget

Get the label widget.

small_badge_observable property

small_badge_observable: Optional[ReadOnlyObservableProtocol[bool]]

Get the optional small badge observable.

large_badge_observable property

large_badge_observable: Optional[ReadOnlyObservableProtocol[Optional[str]]]

Get the optional large badge observable.

Checkbox

Checkbox(checked: bool | ObservableProtocol[bool] | ObservableProtocol[Optional[bool]] = False, *, on_toggle: Optional[Callable[[Optional[bool]], None]] = None, indeterminate: bool | ObservableProtocol[bool] = False, disabled: bool | ObservableProtocol[bool] = False, padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None, style: Optional['CheckboxStyle'] = None, key: Optional[str] = None)

Bases: Toggleable, InteractiveWidget

A minimal Material-like Checkbox widget (M3).

Parameters: - checked: Checked state source (bool / Observable[bool] / Observable[Optional[bool]]) - on_toggle: Callback when toggled - padding: Space around the checkbox (M3: "space between UI elements") - indeterminate: Indeterminate flag (bool / Observable[bool]) - disabled: Disable interaction (bool / Observable[bool]) - style: CheckboxStyle for visual customization (defaults to theme style)

Source code in src/nuiitivet/material/selection_controls.py
def __init__(
    self,
    checked: bool | ObservableProtocol[bool] | ObservableProtocol[Optional[bool]] = False,
    *,
    on_toggle: Optional[Callable[[Optional[bool]], None]] = None,
    indeterminate: bool | ObservableProtocol[bool] = False,
    disabled: bool | ObservableProtocol[bool] = False,
    padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None,
    style: Optional["CheckboxStyle"] = None,
    key: Optional[str] = None,
):
    self._checked_external_tri: ObservableProtocol[Optional[bool]] | None = None
    self._checked_external_bool: ObservableProtocol[bool] | None = None
    self._indeterminate_external: ObservableProtocol[bool] | None = None

    checked_is_obs = hasattr(checked, "subscribe") and hasattr(checked, "value")
    indeterminate_is_obs = hasattr(indeterminate, "subscribe") and hasattr(indeterminate, "value")

    if checked_is_obs and not (indeterminate_is_obs or bool(indeterminate)):
        self._checked_external_tri = cast("ObservableProtocol[Optional[bool]]", checked)
    elif checked_is_obs:
        self._checked_external_bool = cast("ObservableProtocol[bool]", checked)

    if indeterminate_is_obs:
        self._indeterminate_external = cast("ObservableProtocol[bool]", indeterminate)

    # Determine initial value for Toggleable (internal state is the render source-of-truth)
    value: Optional[bool]
    if self._checked_external_tri is not None:
        value = self._checked_external_tri.value
    else:
        if self._checked_external_bool is not None:
            base_checked = bool(self._checked_external_bool.value)
        else:
            base_checked = bool(checked)

        if self._indeterminate_external is not None:
            is_indeterminate = bool(self._indeterminate_external.value)
        else:
            is_indeterminate = bool(indeterminate)

        value = None if is_indeterminate else base_checked

    # Store style (use provided or get from theme lazily)
    self._style = style

    # Touch-target size is style-driven, not a constructor parameter: MD3
    # fixes the selection-control target at 48dp (SIZE_POLICY: MD3 fixes the
    # axis -> style only). Sourced from the resolved style's
    # ``default_touch_target``; the ``width_sizing``/``height_sizing``
    # escape hatch on the base kernel still overrides it.
    # Read from the argument, not ``self.style``: the theme is unreachable
    # until the widget is attached.
    touch_target = int(style.default_touch_target) if style is not None else 48

    # Resolve padding
    final_padding = padding
    if final_padding is None:
        if style is not None:
            final_padding = style.padding
        else:
            final_padding = 0

    # Initialize Toggleable
    super().__init__(
        value=value,
        on_change=on_toggle,
        tristate=False,  # Checkbox does not cycle to indeterminate
        disabled=disabled,
        width=touch_target,
        height=touch_target,
        padding=final_padding,
        key=key,
    )

    # If padding was None and style was None, we might need to update padding from theme later.
    # We can do this in on_mount or similar if we want full theme support for padding.
    self._user_padding = padding

    self._touch_target_size = touch_target

    initial_selection = 1.0 if self.value is True or self.value is None else 0.0
    self._state_layer_anim: Animatable[float] = Animatable(0.0, motion=EXPRESSIVE_DEFAULT_EFFECTS)
    self.bind(self._state_layer_anim.subscribe(lambda _: self.invalidate()))
    self._selection_anim: Animatable[float] = Animatable(initial_selection, motion=EXPRESSIVE_DEFAULT_SPATIAL)
    self.bind(self._selection_anim.subscribe(lambda _: self.invalidate()))

preferred_size

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

Return preferred size including padding (M3準拠).

Source code in src/nuiitivet/material/selection_controls.py
def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
    """Return preferred size including padding (M3準拠)."""
    w_dim = self.width_sizing
    h_dim = self.height_sizing

    if w_dim.kind == "fixed":
        width = int(w_dim.value)
    else:
        width = self._touch_target_size

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

    l, t, r, b = self.padding
    total_w = width + l + r
    total_h = height + t + 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 checkbox with padding support (M3準拠).

Source code in src/nuiitivet/material/selection_controls.py
def paint(self, canvas, x: int, y: int, width: int, height: int):
    """Paint checkbox with padding support (M3準拠)."""
    try:
        from nuiitivet.rendering.skia import (
            draw_oval,
            draw_round_rect,
            make_paint,
            make_path,
            make_rect,
            path_line_to,
            path_move_to,
            rgba_to_skia_color,
            skcolor,
        )

        content_x, content_y, content_w, content_h = self.content_rect(x, y, width, height)
        touch_sz = min(content_w, content_h)
        if touch_sz <= 0:
            return

        cx = content_x + (content_w - touch_sz) // 2
        cy = content_y + (content_h - touch_sz) // 2

        self.set_last_rect(x, y, width, height)

        sizes = self.style.compute_sizes(touch_sz)
        icon_sz = sizes["icon_size"]
        corner = sizes["corner_radius"]
        stroke_w = sizes["stroke_width"]
        state_diam = sizes["state_layer_size"]

        icon_x = cx + (touch_sz - icon_sz) // 2
        icon_y = cy + (touch_sz - icon_sz) // 2

        from nuiitivet.theme.theme import Theme
        from nuiitivet.material.theme.color_role import ColorRole
        from nuiitivet.material.theme.theme_data import MaterialThemeData

        theme = Theme.of(self)
        mat = theme.extension(MaterialThemeData)
        roles = mat.roles if mat is not None else {}

        outline_color, container_color, mark_color = self._resolve_box_colors(theme)

        stroke_p = make_paint(
            color=rgba_to_skia_color(outline_color), style="stroke", stroke_width=stroke_w, aa=True
        )
        rect = make_rect(icon_x, icon_y, icon_sz, icon_sz)

        # Check for keyboard focus (Ring visible)
        is_keyboard_focus = self.should_show_focus_ring

        # Determine State Layer opacity (a disabled checkbox has no state layer per M3)
        overlay_alpha = 0.0 if self.disabled else self._get_active_state_layer_opacity()

        if overlay_alpha > 0.0:
            cx_center = float(cx + touch_sz / 2.0)
            cy_center = float(cy + touch_sz / 2.0)
            r = float(state_diam / 2.0)

            # State Layer color (Checked=Primary, Unchecked=OnSurface)
            is_checked = self.value is True or self.value is None
            base_color_role = ColorRole.PRIMARY if is_checked else ColorRole.ON_SURFACE
            base_color = roles.get(base_color_role, "#000000")

            ov = skcolor(base_color, overlay_alpha)
            p_ov = make_paint(color=ov, style="fill", aa=True)
            try:
                canvas.drawCircle(cx_center, cy_center, r, p_ov)
            except Exception:
                draw_oval(canvas, make_rect(cx_center - r, cy_center - r, state_diam, state_diam), p_ov)

        if rect is not None and stroke_p is not None:
            draw_round_rect(canvas, rect, corner, stroke_p)

        if not self.disabled and is_keyboard_focus:
            self.draw_focus_indicator(canvas, x, y, width, height)

        val = self.value
        selection_progress = self._get_selection_progress()
        if selection_progress > 1e-6:
            fill_p = make_paint(
                color=rgba_to_skia_color(_scale_alpha(container_color, selection_progress)),
                style="fill",
                aa=True,
            )
            if rect is not None and fill_p is not None:
                draw_round_rect(canvas, rect, corner, fill_p)

        # Secondary overlay check (legacy or box-specific?)
        # We use the same opacity logic
        overlay_alpha_box = overlay_alpha

        if overlay_alpha_box and overlay_alpha_box > 0.0:
            base = "#000000" if self.state.pressed else "#FFFFFF"
            ov = skcolor(base, overlay_alpha_box)
            p_ov = make_paint(color=ov, style="fill", aa=True)
            if rect is not None and p_ov is not None:
                draw_round_rect(canvas, rect, corner, p_ov)

        if (val is True or val is None) and selection_progress > 1e-6:
            mark_is_none = val is None
            mark_style = "stroke" if not mark_is_none else "fill"
            mark_p = make_paint(
                color=rgba_to_skia_color(_scale_alpha(mark_color, selection_progress)),
                style=mark_style,
                stroke_width=max(1.0, icon_sz * 0.12),
                aa=True,
            )
            if mark_p is None:
                return

            if mark_is_none:
                bar_w = icon_sz * 0.5
                bar_h = max(1.0, icon_sz * 0.12)
                bx = icon_x + (icon_sz - bar_w) / 2.0
                by = icon_y + (icon_sz - bar_h) / 2.0
                r_bar = make_rect(bx, by, bar_w, bar_h)
                if r_bar is not None:
                    canvas.drawRect(r_bar, mark_p)
            else:
                x1 = icon_x + icon_sz * 0.18
                y1 = icon_y + icon_sz * 0.52
                x2 = icon_x + icon_sz * 0.42
                y2 = icon_y + icon_sz * 0.72
                x3 = icon_x + icon_sz * 0.78
                y3 = icon_y + icon_sz * 0.30
                try:
                    canvas.drawLine(x1, y1, x2, y2, mark_p)
                    canvas.drawLine(x2, y2, x3, y3, mark_p)
                except Exception:
                    path = make_path()
                    if path_move_to(path, x1, y1) and path_line_to(path, x2, y2) and path_line_to(path, x3, y3):
                        canvas.drawPath(path, mark_p)
    except Exception:
        exception_once(_logger, "checkbox_paint_exc", "Checkbox paint raised")
        return

draw_focus_indicator

draw_focus_indicator(canvas, x: int, y: int, width: int, height: int) -> None

Draw the standard focus ring around the state-layer circle.

Source code in src/nuiitivet/material/selection_controls.py
def draw_focus_indicator(self, canvas, x: int, y: int, width: int, height: int) -> None:
    """Draw the standard focus ring around the state-layer circle."""
    content_x, content_y, content_w, content_h = self.content_rect(x, y, width, height)
    touch_sz = min(content_w, content_h)
    if touch_sz <= 0:
        return
    cx = content_x + (content_w - touch_sz) // 2
    cy = content_y + (content_h - touch_sz) // 2
    diameter = float(cast(float, self.style.compute_sizes(touch_sz)["state_layer_size"]))
    ring_x = cx + (touch_sz - diameter) / 2.0
    ring_y = cy + (touch_sz - diameter) / 2.0
    self.draw_focus_ring(canvas, ring_x, ring_y, diameter, diameter, [diameter / 2.0] * 4)

RadioButton

RadioButton(value: object | None, *, disabled: bool | ObservableProtocol[bool] = False, padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None, style: Optional['RadioButtonStyle'] = None, key: Optional[str] = None)

Bases: Toggleable, InteractiveWidget

Material Design 3 RadioButton controlled by nearest RadioGroup.

Initialize RadioButton.

Parameters:

Name Type Description Default
value object | None

Option value represented by this radio button.

required
disabled bool | ObservableProtocol[bool]

Disable interaction when True.

False
padding Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]]

Space around the touch target.

None
style Optional['RadioButtonStyle']

Style override. Uses theme style when omitted.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/selection_controls.py
def __init__(
    self,
    value: object | None,
    *,
    disabled: bool | ObservableProtocol[bool] = False,
    padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None,
    style: Optional["RadioButtonStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize RadioButton.

    Args:
        value: Option value represented by this radio button.
        disabled: Disable interaction when True.
        padding: Space around the touch target.
        style: Style override. Uses theme style when omitted.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self.option_value = value
    self._style = style

    # Touch-target size is style-driven (MD3 fixes the axis -> style only).
    # Read from the argument, not ``self.style``: the theme is unreachable
    # until the widget is attached.
    touch_target = int(style.default_touch_target) if style is not None else 48

    final_padding = padding if padding is not None else (style.padding if style is not None else 0)
    self._user_padding = padding

    super().__init__(
        value=False,
        on_change=None,
        tristate=False,
        disabled=disabled,
        width=touch_target,
        height=touch_target,
        padding=final_padding,
        key=key,
    )

    self._touch_target_size = touch_target

    self._state_layer_anim: Animatable[float] = Animatable(0.0, motion=EXPRESSIVE_DEFAULT_EFFECTS)
    self.bind(self._state_layer_anim.subscribe(lambda _: self.invalidate()))

    self._selection_anim: Animatable[float] = Animatable(0.0, motion=EXPRESSIVE_DEFAULT_SPATIAL)
    self.bind(self._selection_anim.subscribe(lambda _: self.invalidate()))

style property

style: 'RadioButtonStyle'

Resolved style for this RadioButton.

preferred_size

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

Return preferred size including padding.

Source code in src/nuiitivet/material/selection_controls.py
def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
    """Return preferred size including padding."""
    w_dim = self.width_sizing
    h_dim = self.height_sizing

    width = int(w_dim.value) if w_dim.kind == "fixed" else self._touch_target_size
    height = int(h_dim.value) if h_dim.kind == "fixed" else self._touch_target_size

    l, t, r, b = self.padding
    total_w = width + l + r
    total_h = height + t + 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) -> None

Paint radio button with MD3-like visuals.

Source code in src/nuiitivet/material/selection_controls.py
def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
    """Paint radio button with MD3-like visuals."""
    try:
        from nuiitivet.rendering.skia import draw_oval, make_paint, make_rect, skcolor
        from nuiitivet.material.theme.color_role import ColorRole
        from nuiitivet.material.theme.theme_data import MaterialThemeData
        from nuiitivet.theme.theme import Theme

        content_x, content_y, content_w, content_h = self.content_rect(x, y, width, height)
        touch_sz = min(content_w, content_h)
        if touch_sz <= 0:
            return

        cx = content_x + (content_w - touch_sz) // 2
        cy = content_y + (content_h - touch_sz) // 2

        self.set_last_rect(x, y, width, height)

        sizes = self.style.compute_sizes(touch_sz)
        icon_diameter = float(cast(float, sizes["icon_diameter"]))
        inner_dot = float(cast(float, sizes["inner_dot"]))
        stroke_width = float(cast(float, sizes["stroke_width"]))
        state_layer_size = float(cast(float, sizes["state_layer_size"]))

        icon_x = cx + (touch_sz - icon_diameter) / 2.0
        icon_y = cy + (touch_sz - icon_diameter) / 2.0

        mat = Theme.of(self).extension(MaterialThemeData)
        roles = mat.roles if mat is not None else {}

        selected = bool(self.value)
        if self.disabled:
            stroke_hex = roles.get(ColorRole.ON_SURFACE, "#000000")
            stroke_alpha = self.style.disabled_alpha
        else:
            stroke_hex = roles.get(
                ColorRole.PRIMARY if selected else ColorRole.ON_SURFACE_VARIANT,
                "#000000",
            )
            stroke_alpha = 1.0

        overlay_alpha = self._get_active_state_layer_opacity()
        if overlay_alpha > 0.0:
            base = roles.get(ColorRole.PRIMARY if selected else ColorRole.ON_SURFACE, "#000000")
            layer_paint = make_paint(color=skcolor(base, overlay_alpha), style="fill", aa=True)
            layer_rect = make_rect(
                cx + (touch_sz - state_layer_size) / 2.0,
                cy + (touch_sz - state_layer_size) / 2.0,
                state_layer_size,
                state_layer_size,
            )
            if layer_rect is not None and layer_paint is not None:
                draw_oval(canvas, layer_rect, layer_paint)

        ring_paint = make_paint(
            color=skcolor(stroke_hex, stroke_alpha),
            style="stroke",
            stroke_width=stroke_width,
            aa=True,
        )
        ring_rect = make_rect(icon_x, icon_y, icon_diameter, icon_diameter)
        if ring_rect is not None and ring_paint is not None:
            draw_oval(canvas, ring_rect, ring_paint)

        progress = self._get_selection_progress()
        if progress > 1e-6:
            if self.disabled:
                dot_color = roles.get(ColorRole.ON_SURFACE, "#000000")
                dot_alpha = progress * self.style.disabled_alpha
            else:
                dot_color = roles.get(ColorRole.PRIMARY, "#000000")
                dot_alpha = progress
            dot_paint = make_paint(
                color=skcolor(dot_color, dot_alpha),
                style="fill",
                aa=True,
            )
            dot_size = inner_dot * progress
            dot_rect = make_rect(
                cx + (touch_sz - dot_size) / 2.0,
                cy + (touch_sz - dot_size) / 2.0,
                dot_size,
                dot_size,
            )
            if dot_rect is not None and dot_paint is not None:
                draw_oval(canvas, dot_rect, dot_paint)

        if not self.disabled and self.should_show_focus_ring:
            self.draw_focus_indicator(canvas, x, y, width, height)
    except Exception:
        exception_once(_logger, "radio_button_paint_exc", "RadioButton paint raised")

draw_focus_indicator

draw_focus_indicator(canvas, x: int, y: int, width: int, height: int) -> None

Draw the standard focus ring around the state-layer circle.

Source code in src/nuiitivet/material/selection_controls.py
def draw_focus_indicator(self, canvas, x: int, y: int, width: int, height: int) -> None:
    """Draw the standard focus ring around the state-layer circle."""
    content_x, content_y, content_w, content_h = self.content_rect(x, y, width, height)
    touch_sz = min(content_w, content_h)
    if touch_sz <= 0:
        return
    cx = content_x + (content_w - touch_sz) // 2
    cy = content_y + (content_h - touch_sz) // 2
    diameter = float(cast(float, self.style.compute_sizes(touch_sz)["state_layer_size"]))
    ring_x = cx + (touch_sz - diameter) / 2.0
    ring_y = cy + (touch_sz - diameter) / 2.0
    self.draw_focus_ring(canvas, ring_x, ring_y, diameter, diameter, [diameter / 2.0] * 4)

RadioGroup

RadioGroup(child: Widget, *, value: object | ObservableProtocol[object | None] | None = None, on_change: Optional[Callable[[object | None], None]] = None, key: Optional[str] = None)

Bases: InteractionHostMixin, Container

Container that manages a single selected value for descendant RadioButtons.

The group is one focus traversal group (WAI-ARIA): a single Tab stop, entered at the selected radio, with the arrow keys roving between the radios. Roving also moves the selection ("selection follows focus"), so the arrows are how the keyboard picks an option; Space and Enter select the current one as well. The arrows wrap at the ends, and either axis roves — a radio group may be laid out as a Row or a Column, and the keys must work whichever it is.

Initialize RadioGroup.

Parameters:

Name Type Description Default
child Widget

Root child subtree that contains radio options.

required
value object | ObservableProtocol[object | None] | None

Selected value or external observable selected value.

None
on_change Optional[Callable[[object | None], None]]

Callback invoked when selection changes.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/selection_controls.py
def __init__(
    self,
    child: Widget,
    *,
    value: object | ObservableProtocol[object | None] | None = None,
    on_change: Optional[Callable[[object | None], None]] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize RadioGroup.

    Args:
        child: Root child subtree that contains radio options.
        value: Selected value or external observable selected value.
        on_change: Callback invoked when selection changes.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    if not isinstance(child, Widget):
        raise TypeError(f"child must be Widget, got {type(child)}")
    super().__init__(child=child, key=key)

    self._value_external: ObservableProtocol[object | None] | None = None
    if hasattr(value, "subscribe") and hasattr(value, "value"):
        self._value_external = cast("ObservableProtocol[object | None]", value)
        initial_value = self._value_external.value
    else:
        initial_value = value

    self._value_internal: Observable[object | None] = Observable(initial_value)
    self._on_change = on_change

    # The group is the Tab stop; the radios inside it are not (see
    # RadioButton.on_mount). Tab lands here, the scope hands the focus to the
    # selected radio, and the arrow keys take over from there.
    self._focus_node = FocusNode(on_key=self.on_key_event)
    self.add_node(self._focus_node)
    self._focus_scope = FocusScope(_RadioTraversalPolicy(self), tab_roves=False)
    self.add_node(self._focus_scope)

value property writable

value: object | None

Current selected value.

radios

radios() -> list['RadioButton']

Return the radios the keyboard can rove, in tree order.

Disabled radios are left out: they are not selectable, so the arrow keys skip over them rather than roving onto a dead option. A disabled radio has no FocusNode either (see :class:~nuiitivet.widgets.clickable.Clickable), which keeps this list and :meth:_radio_focus_nodes index-aligned.

Source code in src/nuiitivet/material/selection_controls.py
def radios(self) -> list["RadioButton"]:
    """Return the radios the keyboard can rove, in tree order.

    Disabled radios are left out: they are not selectable, so the arrow keys
    skip over them rather than roving onto a dead option. A disabled radio has
    no FocusNode either (see :class:`~nuiitivet.widgets.clickable.Clickable`),
    which keeps this list and :meth:`_radio_focus_nodes` index-aligned.
    """
    found: list[RadioButton] = []

    def _walk(node: Widget) -> None:
        for child in node.children_snapshot():
            if not isinstance(child, Widget):
                continue
            if isinstance(child, RadioGroup):
                continue
            if isinstance(child, RadioButton):
                if isinstance(child.get_node(FocusNode), FocusNode):
                    found.append(child)
            _walk(child)

    _walk(self)
    return found

on_key_event

on_key_event(key: str, modifier_keys: int = 0) -> bool

Rove the radios with the arrow keys, moving the selection with the focus.

Source code in src/nuiitivet/material/selection_controls.py
def on_key_event(self, key: str, modifier_keys: int = 0) -> bool:
    """Rove the radios with the arrow keys, moving the selection with the focus."""
    key_name = str(key).lower()

    if key_name in ("down", "right"):
        return self._move_focus(1)

    if key_name in ("up", "left"):
        return self._move_focus(-1)

    return False

select

select(new_value: object | None) -> None

Select a new value and notify listeners.

Source code in src/nuiitivet/material/selection_controls.py
def select(self, new_value: object | None) -> None:
    """Select a new value and notify listeners."""
    self._set_value(new_value, emit=True)

Switch

Switch(checked: bool | ObservableProtocol[bool] = False, *, on_change: Optional[Callable[[bool], None]] = None, disabled: bool | ObservableProtocol[bool] = False, padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None, style: Optional['SwitchStyle'] = None, key: Optional[str] = None)

Bases: Toggleable, InteractiveWidget

Material Design 3 Switch widget.

Initialize Switch.

Parameters:

Name Type Description Default
checked bool | ObservableProtocol[bool]

Checked state source (bool or observable bool).

False
on_change Optional[Callable[[bool], None]]

Callback invoked when checked state changes.

None
disabled bool | ObservableProtocol[bool]

Disable interaction when True.

False
padding Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]]

Space around the switch.

None
style Optional['SwitchStyle']

Style override. Uses theme style when omitted.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/selection_controls.py
def __init__(
    self,
    checked: bool | ObservableProtocol[bool] = False,
    *,
    on_change: Optional[Callable[[bool], None]] = None,
    disabled: bool | ObservableProtocol[bool] = False,
    padding: Optional[Union[int, Tuple[int, int], Tuple[int, int, int, int]]] = None,
    style: Optional["SwitchStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize Switch.

    Args:
        checked: Checked state source (bool or observable bool).
        on_change: Callback invoked when checked state changes.
        disabled: Disable interaction when True.
        padding: Space around the switch.
        style: Style override. Uses theme style when omitted.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._style = style
    self._user_padding = padding
    self._on_change_bool = on_change

    # Touch-target size is style-driven (MD3 fixes the axis -> style only).
    # Read from the argument, not ``self.style``: the theme is unreachable
    # until the widget is attached.
    touch_target = int(style.default_touch_target) if style is not None else 48

    final_padding = padding if padding is not None else (style.padding if style is not None else 0)

    def _on_toggle(next_val: Optional[bool]) -> None:
        if self._on_change_bool is not None:
            self._on_change_bool(bool(next_val))

    toggleable_value = cast("bool | ObservableProtocol[Optional[bool]]", checked)

    super().__init__(
        value=toggleable_value,
        on_change=_on_toggle,
        tristate=False,
        disabled=disabled,
        width=touch_target,
        height=touch_target,
        padding=final_padding,
        key=key,
    )

    self._touch_target_size = touch_target

    self._state_layer_anim: Animatable[float] = Animatable(0.0, motion=EXPRESSIVE_DEFAULT_EFFECTS)
    self.bind(self._state_layer_anim.subscribe(lambda _: self.invalidate()))
    initial_selection = 1.0 if bool(self.value) else 0.0
    self._selection_anim: Animatable[float] = Animatable(initial_selection, motion=EXPRESSIVE_DEFAULT_SPATIAL)
    self.bind(self._selection_anim.subscribe(lambda _: self.invalidate()))

style property

style: 'SwitchStyle'

Resolved style for this Switch.

preferred_size

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

Return preferred size including padding.

Source code in src/nuiitivet/material/selection_controls.py
def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
    """Return preferred size including padding."""
    w_dim = self.width_sizing
    h_dim = self.height_sizing

    width = int(w_dim.value) if w_dim.kind == "fixed" else self._touch_target_size
    height = int(h_dim.value) if h_dim.kind == "fixed" else self._touch_target_size

    l, t, r, b = self.padding
    total_w = width + l + r
    total_h = height + t + 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_outsets

paint_outsets() -> Tuple[int, int, int, int]

Extend the overflow allowance for the track's sideways overhang.

The track is wider than the touch target and the focus ring sits outside the track, so the base ring-only allowance would clip the ring's left and right edges.

Source code in src/nuiitivet/material/selection_controls.py
def paint_outsets(self) -> Tuple[int, int, int, int]:
    """Extend the overflow allowance for the track's sideways overhang.

    The track is wider than the touch target and the focus ring sits
    outside the track, so the base ring-only allowance would clip the
    ring's left and right edges.
    """
    import math

    base = super().paint_outsets()
    try:
        sizes = self.style.compute_sizes(self._touch_target_size)
        track_overflow = (float(cast(float, sizes["track_width"])) - float(self._touch_target_size)) / 2.0
    except Exception:
        track_overflow = 0.0
    if track_overflow <= 0:
        return base
    extra = int(math.ceil(track_overflow))
    return (base[0] + extra, base[1], base[2] + extra, base[3])

paint

paint(canvas, x: int, y: int, width: int, height: int) -> None

Paint switch with animated thumb and track.

Source code in src/nuiitivet/material/selection_controls.py
def paint(self, canvas, x: int, y: int, width: int, height: int) -> None:
    """Paint switch with animated thumb and track."""
    try:
        from nuiitivet.material.theme.color_role import ColorRole
        from nuiitivet.material.theme.theme_data import MaterialThemeData
        from nuiitivet.rendering.skia import draw_oval, draw_round_rect, make_paint, make_rect, skcolor
        from nuiitivet.theme.theme import Theme

        content_x, content_y, content_w, content_h = self.content_rect(x, y, width, height)
        touch_sz = min(content_w, content_h)
        if touch_sz <= 0:
            return

        cx = content_x + (content_w - touch_sz) // 2
        cy = content_y + (content_h - touch_sz) // 2
        self.set_last_rect(x, y, width, height)

        sizes = self.style.compute_sizes(touch_sz)
        track_w = float(cast(float, sizes["track_width"]))
        track_h = float(cast(float, sizes["track_height"]))
        thumb_unselected_d = float(cast(float, sizes["thumb_diameter_unselected"]))
        thumb_selected_d = float(cast(float, sizes["thumb_diameter_selected"]))
        thumb_pressed_d = float(cast(float, sizes["thumb_diameter_pressed"]))
        track_outline_w = float(cast(float, sizes["track_outline_width"]))
        state_layer_size = float(cast(float, sizes["state_layer_size"]))
        track_radius = track_h / 2.0

        track_x = cx + (touch_sz - track_w) / 2.0
        track_y = cy + (touch_sz - track_h) / 2.0

        mat = Theme.of(self).extension(MaterialThemeData)
        roles = mat.roles if mat is not None else {}

        progress = self._get_selection_progress()
        checked = bool(self.value)
        pressed = bool(self.state.pressed or self.state.dragging)

        if pressed:
            thumb_d = thumb_pressed_d
        else:
            thumb_d = thumb_selected_d if checked else thumb_unselected_d

        unchecked_track_hex = roles.get(ColorRole.SURFACE_CONTAINER_HIGHEST, "#9E9E9E")
        checked_track_hex = roles.get(ColorRole.PRIMARY, "#000000")
        unchecked_outline_hex = roles.get(ColorRole.OUTLINE, "#616161")
        unchecked_thumb_hex = roles.get(ColorRole.OUTLINE, "#616161")
        checked_thumb_hex = roles.get(ColorRole.ON_PRIMARY, "#FFFFFF")

        disabled_checked_track_hex = roles.get(ColorRole.ON_SURFACE, "#000000")
        disabled_checked_thumb_hex = roles.get(ColorRole.SURFACE, "#FFFFFF")
        disabled_unchecked_track_hex = roles.get(ColorRole.SURFACE_CONTAINER_HIGHEST, "#9E9E9E")
        disabled_unchecked_outline_hex = roles.get(ColorRole.ON_SURFACE, "#000000")
        disabled_unchecked_thumb_hex = roles.get(ColorRole.ON_SURFACE, "#000000")

        if self.disabled:
            if checked:
                track_hex = disabled_checked_track_hex
                track_alpha = self.style.disabled_checked_track_alpha
                thumb_hex = disabled_checked_thumb_hex
                thumb_alpha = self.style.disabled_checked_thumb_alpha
                outline_hex = None
                outline_alpha = 0.0
            else:
                track_hex = disabled_unchecked_track_hex
                track_alpha = self.style.disabled_unchecked_track_alpha
                thumb_hex = disabled_unchecked_thumb_hex
                thumb_alpha = self.style.disabled_unchecked_thumb_alpha
                outline_hex = disabled_unchecked_outline_hex
                outline_alpha = self.style.disabled_unchecked_track_outline_alpha
        else:
            track_hex = checked_track_hex if checked else unchecked_track_hex
            track_alpha = 1.0
            thumb_hex = checked_thumb_hex if checked else unchecked_thumb_hex
            thumb_alpha = 1.0
            outline_hex = None if checked else unchecked_outline_hex
            outline_alpha = 1.0

        track_paint = make_paint(color=skcolor(track_hex, track_alpha), style="fill", aa=True)
        track_rect = make_rect(track_x, track_y, track_w, track_h)
        if track_rect is not None and track_paint is not None:
            draw_round_rect(canvas, track_rect, track_radius, track_paint)

        if outline_hex is not None:
            outline_paint = make_paint(
                color=skcolor(outline_hex, outline_alpha),
                style="stroke",
                stroke_width=track_outline_w,
                aa=True,
            )
            if track_rect is not None and outline_paint is not None:
                draw_round_rect(canvas, track_rect, track_radius, outline_paint)

        thumb_center_start = track_x + (track_h / 2.0)
        thumb_center_end = track_x + track_w - (track_h / 2.0)
        thumb_center_x = thumb_center_start + (thumb_center_end - thumb_center_start) * progress
        thumb_x = thumb_center_x - (thumb_d / 2.0)
        thumb_y = track_y + (track_h - thumb_d) / 2.0

        overlay_alpha = self._get_active_state_layer_opacity()
        if overlay_alpha > 0.0:
            overlay_rect = make_rect(
                thumb_x + (thumb_d - state_layer_size) / 2.0,
                thumb_y + (thumb_d - state_layer_size) / 2.0,
                state_layer_size,
                state_layer_size,
            )
            overlay_base_role = ColorRole.PRIMARY if checked else ColorRole.ON_SURFACE
            overlay_color = roles.get(overlay_base_role, "#000000")
            overlay_paint = make_paint(color=skcolor(overlay_color, overlay_alpha), style="fill", aa=True)
            if overlay_rect is not None and overlay_paint is not None:
                draw_oval(canvas, overlay_rect, overlay_paint)

        thumb_paint = make_paint(color=skcolor(thumb_hex, thumb_alpha), style="fill", aa=True)
        thumb_rect = make_rect(thumb_x, thumb_y, thumb_d, thumb_d)
        if thumb_rect is not None and thumb_paint is not None:
            draw_oval(canvas, thumb_rect, thumb_paint)

        if not self.disabled and self.should_show_focus_ring:
            self.draw_focus_indicator(canvas, x, y, width, height)
    except Exception:
        exception_once(_logger, "switch_paint_exc", "Switch paint raised")

draw_focus_indicator

draw_focus_indicator(canvas, x: int, y: int, width: int, height: int) -> None

Draw the standard focus ring around the track.

Unlike Checkbox/RadioButton, the MD3 switch ring hugs the track outline rather than the thumb's state-layer circle, so it is a pill shape that stays put as the thumb moves.

Source code in src/nuiitivet/material/selection_controls.py
def draw_focus_indicator(self, canvas, x: int, y: int, width: int, height: int) -> None:
    """Draw the standard focus ring around the track.

    Unlike Checkbox/RadioButton, the MD3 switch ring hugs the track
    outline rather than the thumb's state-layer circle, so it is a pill
    shape that stays put as the thumb moves.
    """
    content_x, content_y, content_w, content_h = self.content_rect(x, y, width, height)
    touch_sz = min(content_w, content_h)
    if touch_sz <= 0:
        return
    cx = content_x + (content_w - touch_sz) // 2
    cy = content_y + (content_h - touch_sz) // 2

    sizes = self.style.compute_sizes(touch_sz)
    track_w = float(cast(float, sizes["track_width"]))
    track_h = float(cast(float, sizes["track_height"]))

    track_x = cx + (touch_sz - track_w) / 2.0
    track_y = cy + (touch_sz - track_h) / 2.0

    self.draw_focus_ring(canvas, track_x, track_y, track_w, track_h, [track_h / 2.0] * 4)

HorizontalCenteredSlider

HorizontalCenteredSlider(value: float | ObservableProtocol[float] = 0.0, *, on_change: Optional[Callable[[float], None]] = None, min_value: float = -1.0, max_value: float = 1.0, stops: Optional[int] = None, show_value_indicator: bool = False, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = 'wt', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = None, key: Optional[str] = None)

Bases: _CenteredSlider

Material Design 3 horizontal centered slider. Sized with width.

Initialize HorizontalCenteredSlider.

Parameters:

Name Type Description Default
value float | ObservableProtocol[float]

Current slider value or observable value.

0.0
on_change Optional[Callable[[float], None]]

Callback invoked when value changes.

None
min_value float

Minimum value (default: -1.0).

-1.0
max_value float

Maximum value (default: 1.0).

1.0
stops Optional[int]

Discrete stop count. None means continuous.

None
show_value_indicator bool

Whether to show value indicator during drag.

False
disabled bool | ObservableProtocol[bool]

Disabled state.

False
width SizingLike

Main-axis (width) sizing.

'wt'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/slider.py
def __init__(
    self,
    value: float | ObservableProtocol[float] = 0.0,
    *,
    on_change: Optional[Callable[[float], None]] = None,
    min_value: float = -1.0,
    max_value: float = 1.0,
    stops: Optional[int] = None,
    show_value_indicator: bool = False,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = "wt",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize HorizontalCenteredSlider.

    Args:
        value: Current slider value or observable value.
        on_change: Callback invoked when value changes.
        min_value: Minimum value (default: -1.0).
        max_value: Maximum value (default: 1.0).
        stops: Discrete stop count. ``None`` means continuous.
        show_value_indicator: Whether to show value indicator during drag.
        disabled: Disabled state.
        width: Main-axis (width) sizing.
        padding: Slider padding.
        style: Optional SliderStyle override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(
        value=value,
        on_change=on_change,
        min_value=min_value,
        max_value=max_value,
        stops=stops,
        show_value_indicator=show_value_indicator,
        disabled=disabled,
        orientation=Orientation.HORIZONTAL,
        length=width,
        padding=padding,
        style=style,
        key=key,
    )

HorizontalRangeSlider

HorizontalRangeSlider(value_start: float | ObservableProtocol[float] = 0.0, value_end: float | ObservableProtocol[float] = 1.0, *, on_change: Optional[Callable[[Tuple[float, float]], None]] = None, min_value: float = 0.0, max_value: float = 1.0, stops: Optional[int] = None, show_value_indicator: bool = False, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = 'wt', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = None, key: Optional[str] = None)

Bases: _RangeSlider

Material Design 3 horizontal range slider. Sized with width.

Initialize HorizontalRangeSlider.

Parameters:

Name Type Description Default
value_start float | ObservableProtocol[float]

Start value or observable value.

0.0
value_end float | ObservableProtocol[float]

End value or observable value.

1.0
on_change Optional[Callable[[Tuple[float, float]], None]]

Callback invoked when range changes.

None
min_value float

Minimum value.

0.0
max_value float

Maximum value.

1.0
stops Optional[int]

Discrete stop count. None means continuous.

None
show_value_indicator bool

Whether to show value indicator during drag.

False
disabled bool | ObservableProtocol[bool]

Disabled state.

False
width SizingLike

Main-axis (width) sizing.

'wt'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/slider.py
def __init__(
    self,
    value_start: float | ObservableProtocol[float] = 0.0,
    value_end: float | ObservableProtocol[float] = 1.0,
    *,
    on_change: Optional[Callable[[Tuple[float, float]], None]] = None,
    min_value: float = 0.0,
    max_value: float = 1.0,
    stops: Optional[int] = None,
    show_value_indicator: bool = False,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = "wt",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize HorizontalRangeSlider.

    Args:
        value_start: Start value or observable value.
        value_end: End value or observable value.
        on_change: Callback invoked when range changes.
        min_value: Minimum value.
        max_value: Maximum value.
        stops: Discrete stop count. ``None`` means continuous.
        show_value_indicator: Whether to show value indicator during drag.
        disabled: Disabled state.
        width: Main-axis (width) sizing.
        padding: Slider padding.
        style: Optional SliderStyle override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(
        value_start,
        value_end,
        on_change=on_change,
        min_value=min_value,
        max_value=max_value,
        stops=stops,
        show_value_indicator=show_value_indicator,
        disabled=disabled,
        orientation=Orientation.HORIZONTAL,
        length=width,
        padding=padding,
        style=style,
        key=key,
    )

HorizontalSlider

HorizontalSlider(value: float | ObservableProtocol[float] = 0.0, *, on_change: Optional[Callable[[float], None]] = None, min_value: float = 0.0, max_value: float = 1.0, stops: Optional[int] = None, show_value_indicator: bool = False, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = 'wt', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = None, key: Optional[str] = None)

Bases: _Slider

Material Design 3 horizontal slider. Sized with width.

Initialize HorizontalSlider.

Parameters:

Name Type Description Default
value float | ObservableProtocol[float]

Current slider value or observable value.

0.0
on_change Optional[Callable[[float], None]]

Callback invoked when value changes.

None
min_value float

Minimum value.

0.0
max_value float

Maximum value.

1.0
stops Optional[int]

Discrete stop count. None means continuous.

None
show_value_indicator bool

Whether to show value indicator during drag.

False
disabled bool | ObservableProtocol[bool]

Disabled state.

False
width SizingLike

Main-axis (width) sizing.

'wt'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/slider.py
def __init__(
    self,
    value: float | ObservableProtocol[float] = 0.0,
    *,
    on_change: Optional[Callable[[float], None]] = None,
    min_value: float = 0.0,
    max_value: float = 1.0,
    stops: Optional[int] = None,
    show_value_indicator: bool = False,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = "wt",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize HorizontalSlider.

    Args:
        value: Current slider value or observable value.
        on_change: Callback invoked when value changes.
        min_value: Minimum value.
        max_value: Maximum value.
        stops: Discrete stop count. ``None`` means continuous.
        show_value_indicator: Whether to show value indicator during drag.
        disabled: Disabled state.
        width: Main-axis (width) sizing.
        padding: Slider padding.
        style: Optional SliderStyle override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(
        value,
        on_change=on_change,
        min_value=min_value,
        max_value=max_value,
        stops=stops,
        show_value_indicator=show_value_indicator,
        disabled=disabled,
        orientation=Orientation.HORIZONTAL,
        length=width,
        padding=padding,
        style=style,
        key=key,
    )

VerticalCenteredSlider

VerticalCenteredSlider(value: float | ObservableProtocol[float] = 0.0, *, on_change: Optional[Callable[[float], None]] = None, min_value: float = -1.0, max_value: float = 1.0, stops: Optional[int] = None, show_value_indicator: bool = False, disabled: bool | ObservableProtocol[bool] = False, height: SizingLike = 'wt', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = None, key: Optional[str] = None)

Bases: _CenteredSlider

Material Design 3 vertical centered slider. Sized with height.

Initialize VerticalCenteredSlider.

Parameters:

Name Type Description Default
value float | ObservableProtocol[float]

Current slider value or observable value.

0.0
on_change Optional[Callable[[float], None]]

Callback invoked when value changes.

None
min_value float

Minimum value (default: -1.0).

-1.0
max_value float

Maximum value (default: 1.0).

1.0
stops Optional[int]

Discrete stop count. None means continuous.

None
show_value_indicator bool

Whether to show value indicator during drag.

False
disabled bool | ObservableProtocol[bool]

Disabled state.

False
height SizingLike

Main-axis (height) sizing.

'wt'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/slider.py
def __init__(
    self,
    value: float | ObservableProtocol[float] = 0.0,
    *,
    on_change: Optional[Callable[[float], None]] = None,
    min_value: float = -1.0,
    max_value: float = 1.0,
    stops: Optional[int] = None,
    show_value_indicator: bool = False,
    disabled: bool | ObservableProtocol[bool] = False,
    height: SizingLike = "wt",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize VerticalCenteredSlider.

    Args:
        value: Current slider value or observable value.
        on_change: Callback invoked when value changes.
        min_value: Minimum value (default: -1.0).
        max_value: Maximum value (default: 1.0).
        stops: Discrete stop count. ``None`` means continuous.
        show_value_indicator: Whether to show value indicator during drag.
        disabled: Disabled state.
        height: Main-axis (height) sizing.
        padding: Slider padding.
        style: Optional SliderStyle override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(
        value=value,
        on_change=on_change,
        min_value=min_value,
        max_value=max_value,
        stops=stops,
        show_value_indicator=show_value_indicator,
        disabled=disabled,
        orientation=Orientation.VERTICAL,
        length=height,
        padding=padding,
        style=style,
        key=key,
    )

VerticalRangeSlider

VerticalRangeSlider(value_start: float | ObservableProtocol[float] = 0.0, value_end: float | ObservableProtocol[float] = 1.0, *, on_change: Optional[Callable[[Tuple[float, float]], None]] = None, min_value: float = 0.0, max_value: float = 1.0, stops: Optional[int] = None, show_value_indicator: bool = False, disabled: bool | ObservableProtocol[bool] = False, height: SizingLike = 'wt', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = None, key: Optional[str] = None)

Bases: _RangeSlider

Material Design 3 vertical range slider. Sized with height.

Initialize VerticalRangeSlider.

Parameters:

Name Type Description Default
value_start float | ObservableProtocol[float]

Start value or observable value.

0.0
value_end float | ObservableProtocol[float]

End value or observable value.

1.0
on_change Optional[Callable[[Tuple[float, float]], None]]

Callback invoked when range changes.

None
min_value float

Minimum value.

0.0
max_value float

Maximum value.

1.0
stops Optional[int]

Discrete stop count. None means continuous.

None
show_value_indicator bool

Whether to show value indicator during drag.

False
disabled bool | ObservableProtocol[bool]

Disabled state.

False
height SizingLike

Main-axis (height) sizing.

'wt'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/slider.py
def __init__(
    self,
    value_start: float | ObservableProtocol[float] = 0.0,
    value_end: float | ObservableProtocol[float] = 1.0,
    *,
    on_change: Optional[Callable[[Tuple[float, float]], None]] = None,
    min_value: float = 0.0,
    max_value: float = 1.0,
    stops: Optional[int] = None,
    show_value_indicator: bool = False,
    disabled: bool | ObservableProtocol[bool] = False,
    height: SizingLike = "wt",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize VerticalRangeSlider.

    Args:
        value_start: Start value or observable value.
        value_end: End value or observable value.
        on_change: Callback invoked when range changes.
        min_value: Minimum value.
        max_value: Maximum value.
        stops: Discrete stop count. ``None`` means continuous.
        show_value_indicator: Whether to show value indicator during drag.
        disabled: Disabled state.
        height: Main-axis (height) sizing.
        padding: Slider padding.
        style: Optional SliderStyle override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(
        value_start,
        value_end,
        on_change=on_change,
        min_value=min_value,
        max_value=max_value,
        stops=stops,
        show_value_indicator=show_value_indicator,
        disabled=disabled,
        orientation=Orientation.VERTICAL,
        length=height,
        padding=padding,
        style=style,
        key=key,
    )

VerticalSlider

VerticalSlider(value: float | ObservableProtocol[float] = 0.0, *, on_change: Optional[Callable[[float], None]] = None, min_value: float = 0.0, max_value: float = 1.0, stops: Optional[int] = None, show_value_indicator: bool = False, disabled: bool | ObservableProtocol[bool] = False, height: SizingLike = 'wt', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = None, key: Optional[str] = None)

Bases: _Slider

Material Design 3 vertical slider. Sized with height.

Initialize VerticalSlider.

Parameters:

Name Type Description Default
value float | ObservableProtocol[float]

Current slider value or observable value.

0.0
on_change Optional[Callable[[float], None]]

Callback invoked when value changes.

None
min_value float

Minimum value.

0.0
max_value float

Maximum value.

1.0
stops Optional[int]

Discrete stop count. None means continuous.

None
show_value_indicator bool

Whether to show value indicator during drag.

False
disabled bool | ObservableProtocol[bool]

Disabled state.

False
height SizingLike

Main-axis (height) sizing.

'wt'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/slider.py
def __init__(
    self,
    value: float | ObservableProtocol[float] = 0.0,
    *,
    on_change: Optional[Callable[[float], None]] = None,
    min_value: float = 0.0,
    max_value: float = 1.0,
    stops: Optional[int] = None,
    show_value_indicator: bool = False,
    disabled: bool | ObservableProtocol[bool] = False,
    height: SizingLike = "wt",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize VerticalSlider.

    Args:
        value: Current slider value or observable value.
        on_change: Callback invoked when value changes.
        min_value: Minimum value.
        max_value: Maximum value.
        stops: Discrete stop count. ``None`` means continuous.
        show_value_indicator: Whether to show value indicator during drag.
        disabled: Disabled state.
        height: Main-axis (height) sizing.
        padding: Slider padding.
        style: Optional SliderStyle override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(
        value,
        on_change=on_change,
        min_value=min_value,
        max_value=max_value,
        stops=stops,
        show_value_indicator=show_value_indicator,
        disabled=disabled,
        orientation=Orientation.VERTICAL,
        length=height,
        padding=padding,
        style=style,
        key=key,
    )

Symbol dataclass

Symbol(name: str, codepoint: str)

Material symbol descriptor.

Symbols

Material Symbols constants (auto-generated).

TextField

TextField(value: Union[str, ReadOnlyObservableProtocol[str]] = '', *, on_change: Optional[Callable[[str], None]] = None, on_submit: Optional[Callable[[str], None]] = None, on_focus_change: Optional[FocusChangeCallback] = None, input_filter: Optional[InputFilterLike] = None, label: str | ReadOnlyObservableProtocol[str] | None = None, leading_icon: Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str] | None = None, on_tap_leading_icon: Optional[Callable[[], None]] = None, trailing_icon: Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str] | None = None, on_tap_trailing_icon: Optional[Callable[[], None]] = None, obscure_text: bool = False, supporting_text: str | ReadOnlyObservableProtocol[str | None] | None = None, is_error: bool | ReadOnlyObservableProtocol[bool] = False, disabled: bool | ReadOnlyObservableProtocol[bool] = False, width: SizingLike = 200, padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0, style: Optional[TextFieldStyle] = None, key: Optional[str] = None)

Bases: InteractiveWidget

A text input widget base class.

Note

An observable passed as value holds the field's value, the same as for every other input widget: it is displayed, and edits are written back to it. A read-only observable (.map(...), a computed value) has nowhere to write, so it displays only -- pair it with disabled=True to make that visible to the user.

Parameters: - value: Initial text (str), or the observable that holds the field's value - on_change: Callback when value changes - on_submit: Callback invoked with the confirmed value when the user presses Enter in the field or moves focus away from it, and only when the text changed since the last commit - input_filter: Rule applied to text as the user types it -- see :mod:nuiitivet.widgets.input_filter. It governs what is typeable; whether a finished value is acceptable belongs in is_error / supporting_text, and reshaping a finished value belongs in on_submit - label: Floating label text (supports Observable) - leading_icon: Icon source (Symbol/str or Observable of them) - on_tap_leading_icon: Callback invoked when the leading icon is tapped. Supplying it upgrades the icon to a standard IconButton with hover / focus / pressed state layers; a decorative icon (no callback) renders as a plain, feedback-free glyph. - trailing_icon: Icon source (Symbol/str or Observable of them) - on_tap_trailing_icon: Callback invoked when the trailing icon is tapped (see on_tap_leading_icon for the interactive-icon behavior) - obscure_text: Whether to mask text display (password-style) - supporting_text: Supporting text to display below the field (supports Observable) - is_error: Whether the field is in error state (supports Observable) - style: Custom style configuration - width: Explicit width sizing - height: Explicit height sizing - padding: Space around the text field - disabled: Disable interaction (supports Observable)

Initialize TextField.

Parameters:

Name Type Description Default
value Union[str, ReadOnlyObservableProtocol[str]]

Initial text, or the observable holding the field's value. Edits are written back to a writable observable.

''
on_change Optional[Callable[[str], None]]

Callback invoked with the text as it changes, for a side effect of the change. The observable bound to value is already updated without it, and carries the same signal: the two are announced together, so neither reports the provisional text of an unconfirmed IME composition.

None
on_submit Optional[Callable[[str], None]]

Callback invoked with the text when the user presses Enter. Fires on every press, including a repeat on an unchanged value, and never on focus loss -- it reports a request to act, not a value settling. To react to the user leaving the field, use on_focus_change.

None
on_focus_change Optional[FocusChangeCallback]

Callback invoked as focus arrives and leaves, with (focused, source) -- the same signature as the focusable() modifier. This is where blur-triggered work belongs: validating once the user has left, saving an inline edit, finishing a half-typed value. It can arrive more than once with focused=True for a single acquisition, because the source is re-announced when the user switches from keyboard to pointer; focused=False arrives once.

None
input_filter Optional[InputFilterLike]

Rule applied to text as the user types it.

None
label str | ReadOnlyObservableProtocol[str] | None

Floating label text.

None
leading_icon Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str] | None

Icon displayed before the text.

None
on_tap_leading_icon Optional[Callable[[], None]]

Callback invoked when the leading icon is tapped.

None
trailing_icon Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str] | None

Icon displayed after the text.

None
on_tap_trailing_icon Optional[Callable[[], None]]

Callback invoked when the trailing icon is tapped.

None
obscure_text bool

Whether to mask text display (password-style).

False
supporting_text str | ReadOnlyObservableProtocol[str | None] | None

Supporting text displayed below the field.

None
is_error bool | ReadOnlyObservableProtocol[bool]

Whether the field is in its error state. This is a visual axis of the whole field -- outline, label, cursor and supporting text all change -- and is independent of what supporting_text says, so a field can be flagged without a message and carry a message without being flagged.

False
disabled bool | ReadOnlyObservableProtocol[bool]

Whether the text field is disabled.

False
width SizingLike

Width specification.

200
padding Union[int, Tuple[int, int], Tuple[int, int, int, int]]

Padding around the text field.

0
style Optional[TextFieldStyle]

Custom style configuration.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/text_fields.py
def __init__(
    self,
    value: Union[str, ReadOnlyObservableProtocol[str]] = "",
    *,
    on_change: Optional[Callable[[str], None]] = None,
    on_submit: Optional[Callable[[str], None]] = None,
    on_focus_change: Optional[FocusChangeCallback] = None,
    input_filter: Optional[InputFilterLike] = None,
    label: str | ReadOnlyObservableProtocol[str] | None = None,
    leading_icon: Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str] | None = None,
    on_tap_leading_icon: Optional[Callable[[], None]] = None,
    trailing_icon: (
        Symbol | str | ReadOnlyObservableProtocol[Symbol] | ReadOnlyObservableProtocol[str] | None
    ) = None,
    on_tap_trailing_icon: Optional[Callable[[], None]] = None,
    obscure_text: bool = False,
    supporting_text: str | ReadOnlyObservableProtocol[str | None] | None = None,
    is_error: bool | ReadOnlyObservableProtocol[bool] = False,
    disabled: bool | ReadOnlyObservableProtocol[bool] = False,
    width: SizingLike = 200,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    style: Optional[TextFieldStyle] = None,
    key: Optional[str] = None,
):
    """Initialize TextField.

    Args:
        value: Initial text, or the observable holding the field's value.
            Edits are written back to a writable observable.
        on_change: Callback invoked with the text as it changes, for a
            side effect of the change. The observable bound to *value* is
            already updated without it, and carries the same signal: the
            two are announced together, so neither reports the provisional
            text of an unconfirmed IME composition.
        on_submit: Callback invoked with the text when the user presses
            Enter. Fires on every press, including a repeat on an unchanged
            value, and never on focus loss -- it reports a request to act,
            not a value settling. To react to the user leaving the field,
            use *on_focus_change*.
        on_focus_change: Callback invoked as focus arrives and leaves,
            with ``(focused, source)`` -- the same signature as the
            ``focusable()`` modifier. This is where blur-triggered work
            belongs: validating once the user has left, saving an inline
            edit, finishing a half-typed value. It can arrive more than
            once with ``focused=True`` for a single acquisition, because
            the *source* is re-announced when the user switches from
            keyboard to pointer; ``focused=False`` arrives once.
        input_filter: Rule applied to text as the user types it.
        label: Floating label text.
        leading_icon: Icon displayed before the text.
        on_tap_leading_icon: Callback invoked when the leading icon is tapped.
        trailing_icon: Icon displayed after the text.
        on_tap_trailing_icon: Callback invoked when the trailing icon is tapped.
        obscure_text: Whether to mask text display (password-style).
        supporting_text: Supporting text displayed below the field.
        is_error: Whether the field is in its error state. This is a
            visual axis of the whole field -- outline, label, cursor and
            supporting text all change -- and is independent of what
            *supporting_text* says, so a field can be flagged without a
            message and carry a message without being flagged.
        disabled: Whether the text field is disabled.
        width: Width specification.
        padding: Padding around the text field.
        style: Custom style configuration.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(
        key=key,
        width=width,
        padding=padding,
        state_layer_color=ColorRole.ON_SURFACE,
        disabled=False,  # Set initial disabled state below
        # The TextField itself does not host a FocusNode. The focus
        # subject is the inner EditableText; mirroring it here would
        # cause focus ping-pong between two FocusNodes during pointer
        # press handling and produce stale ``_focus_from_pointer``
        # state. The visual focus indicator (ring / state layer) reads
        # the editable's focus state directly via
        # ``should_show_focus_ring``. Pointer presses are routed to the
        # editable in ``_handle_press`` below; keyboard Tab traversal
        # collects the editable's FocusNode directly because the
        # TextField has none.
        focusable=False,
    )

    self._label_source: ReadOnlyObservableProtocol[str] | None = None
    self._supporting_text_source: ReadOnlyObservableProtocol[str | None] | None = None
    self._is_error_source: ReadOnlyObservableProtocol[bool] | None = None
    self._disabled_source: ReadOnlyObservableProtocol[bool] | None = None

    label_value: str | None
    if hasattr(label, "subscribe") and hasattr(label, "value"):
        self._label_source = cast("ReadOnlyObservableProtocol[str]", label)
        try:
            label_value = str(self._label_source.value)
        except Exception:
            label_value = None
    else:
        label_value = str(label) if label is not None else None

    supporting_text_value: str | None
    if hasattr(supporting_text, "subscribe") and hasattr(supporting_text, "value"):
        self._supporting_text_source = cast("ReadOnlyObservableProtocol[str | None]", supporting_text)
        try:
            v = self._supporting_text_source.value
            supporting_text_value = str(v) if v is not None else None
        except Exception:
            supporting_text_value = None
    else:
        supporting_text_value = str(supporting_text) if supporting_text is not None else None

    initial_is_error: bool
    if hasattr(is_error, "subscribe") and hasattr(is_error, "value"):
        self._is_error_source = cast("ReadOnlyObservableProtocol[bool]", is_error)
        try:
            initial_is_error = bool(self._is_error_source.value)
        except Exception:
            initial_is_error = False
    else:
        initial_is_error = bool(is_error)

    initial_disabled: bool
    if hasattr(disabled, "subscribe") and hasattr(disabled, "value"):
        self._disabled_source = cast("ReadOnlyObservableProtocol[bool]", disabled)
        try:
            initial_disabled = bool(self._disabled_source.value)
        except Exception:
            initial_disabled = False
    else:
        initial_disabled = bool(disabled)

    self.label = label_value
    self._on_tap_leading_icon = on_tap_leading_icon
    self._on_tap_trailing_icon = on_tap_trailing_icon

    if on_tap_leading_icon is not None and leading_icon is None:
        raise ValueError("on_tap_leading_icon requires leading_icon to be provided")
    if on_tap_trailing_icon is not None and trailing_icon is None:
        raise ValueError("on_tap_trailing_icon requires trailing_icon to be provided")

    # A tap callback upgrades the icon to a standard IconButton (state
    # layers + keyboard focus); a decorative icon stays a plain Icon.
    self.leading_icon = _build_text_field_icon(
        leading_icon, arg_name="leading_icon", on_tap=on_tap_leading_icon
    )
    self.trailing_icon = _build_text_field_icon(
        trailing_icon, arg_name="trailing_icon", on_tap=on_tap_trailing_icon
    )
    self.supporting_text = supporting_text_value
    self.is_error = initial_is_error

    self._user_style = style

    self._on_change = on_change
    self._on_submit = on_submit
    self._on_focus_change = on_focus_change

    # Children
    if self.leading_icon is not None:
        self.add_child(self.leading_icon)
    if self.trailing_icon is not None:
        self.add_child(self.trailing_icon)

    # EditableText
    style = self.style
    self._editable = EditableText(
        value=value,
        on_change=self._handle_editable_change,
        on_focus_change=self._on_editable_focus_change,
        # Only forward a handler when there is something to submit to.
        # EditableText claims Enter iff it has an on_submit, and declines it
        # otherwise so the key can reach a shortcut — a dialog's default
        # action. Wrapping unconditionally would claim Enter and drop it.
        on_submit=self._handle_editable_submit if on_submit is not None else None,
        input_filter=input_filter,
        text_color=style.text_color,
        cursor_color=style.error_cursor_color if self.is_error else style.cursor_color,
        selection_color=style.selection_color,
        font_size=16,  # BodyLarge
        disabled=initial_disabled,
        obscure_text=bool(obscure_text),
    )
    self.add_child(self._editable)

    # Animation state
    has_text = bool(self._editable.value)
    # Each of these is subscribed on mount, not here: an Animatable that is
    # running holds its ticker on the clock, so a bare subscribe left every
    # unmounted TextField reachable and repainting. See on_mount below.
    self._label_progress = Animatable(
        1.0 if has_text else 0.0,
        motion=EXPRESSIVE_DEFAULT_EFFECTS,
    )

    # Indicator Animations
    init_ind_width = style.indicator_width
    self._anim_indicator_width = Animatable(
        float(init_ind_width),
        motion=EXPRESSIVE_DEFAULT_EFFECTS,
    )

    init_ind_color = resolve_color_to_rgba(style.indicator_color, theme=None)
    self._anim_indicator_color = Animatable.vector(
        init_ind_color,
        converter=RgbaTupleConverter(),
        motion=EXPRESSIVE_DEFAULT_EFFECTS,
    )

    init_label_color = resolve_color_to_rgba(style.label_color, theme=None)
    self._anim_label_color = Animatable.vector(
        init_label_color,
        converter=RgbaTupleConverter(),
        motion=EXPRESSIVE_DEFAULT_EFFECTS,
    )

    # Handle initial disabled state
    if initial_disabled:
        self._apply_disabled(True)

    # Initialize label state
    self._update_label_state()

    # Preserve existing click behavior while adding press handling for icon taps.
    self.enable_click(on_press=self._handle_press)

should_show_focus_ring property

should_show_focus_ring: bool

Show the focus ring only when focus arrived via keyboard navigation.

The actual focus subject is self._editable; the host TextField does not own a FocusNode (see focusable=False in init). EditableText exposes is_focus_from_pointer so that, per MD3 spec, the ring is suppressed for clicks.

preferred_size

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

Return the preferred (width, height) for this TextField.

Source code in src/nuiitivet/material/text_fields.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 TextField."""
    w_dim = self.width_sizing
    h_dim = self.height_sizing

    default_width = 200
    default_height = 56  # M3 default height

    font = self._get_font()
    style = self.style
    if not style:
        return (default_width, default_height)

    pl, pt, pr, pb = style.content_padding

    icon_w = 0
    if self.leading_icon:
        lw, _ = self.leading_icon.preferred_size()
        icon_w += lw + 24
    if self.trailing_icon:
        tw, _ = self.trailing_icon.preferred_size()
        icon_w += tw + 24

    if w_dim.kind == "fixed":
        width = int(w_dim.value)
    else:
        if font:
            char_width = font.measureText("M")
            width = int(char_width * 15) + pl + pr + icon_w
        else:
            width = default_width

    if h_dim.kind == "fixed":
        height = int(h_dim.value)
    else:
        height = default_height
        if self.supporting_text and font:
            font.setSize(12)
            metrics = font.getMetrics()
            error_h = -metrics.fAscent + metrics.fDescent
            height += int(error_h + 4)

    l, t, r, b = self.padding
    total_w = width + l + r
    total_h = height + t + 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))

focus

focus() -> None

Programmatically focus the TextField (keyboard-style focus).

Delegates to the inner EditableText. This path does NOT mark the focus as pointer-driven, so the focus ring will be shown — use this for keyboard / API-initiated focus only. Pointer presses go through _handle_press which calls EditableText.request_focus_from_pointer to suppress the ring per MD3 spec.

Source code in src/nuiitivet/material/text_fields.py
def focus(self) -> None:
    """Programmatically focus the TextField (keyboard-style focus).

    Delegates to the inner EditableText. This path does NOT mark the
    focus as pointer-driven, so the focus ring will be shown — use
    this for keyboard / API-initiated focus only. Pointer presses go
    through ``_handle_press`` which calls
    ``EditableText.request_focus_from_pointer`` to suppress the ring
    per MD3 spec.
    """
    self._editable.focus()

TextFieldStyle dataclass

TextFieldStyle(mode: TextFieldMode = 'filled', container_color: ColorSpec = SURFACE_CONTAINER_HIGHEST, indicator_color: ColorSpec = ON_SURFACE_VARIANT, indicator_width: float = 1.0, focused_indicator_color: ColorSpec = PRIMARY, focused_indicator_width: float = 2.0, error_indicator_color: ColorSpec = ERROR, text_color: ColorSpec = ON_SURFACE, label_color: ColorSpec = ON_SURFACE_VARIANT, focused_label_color: ColorSpec = PRIMARY, error_label_color: ColorSpec = ERROR, supporting_text_color: ColorSpec = ON_SURFACE_VARIANT, error_supporting_text_color: ColorSpec = ERROR, cursor_color: ColorSpec = PRIMARY, error_cursor_color: ColorSpec = ERROR, selection_color: ColorSpec = PRIMARY_CONTAINER, border_radius: float = 4.0, content_padding: Tuple[int, int, int, int] = (16, 16, 16, 16))

Style configuration for :class:TextField (M3-compliant).

The visual variant is captured by the :attr:mode field: "filled" draws an underline indicator with top-rounded container corners while "outlined" draws a full rectangular border. Use the :meth:filled and :meth:outlined factory methods to obtain the standard presets.

copy_with

copy_with(**changes) -> 'TextFieldStyle'

Create a new style instance with specified fields changed.

Source code in src/nuiitivet/material/styles/text_field_style.py
def copy_with(self, **changes) -> "TextFieldStyle":
    """Create a new style instance with specified fields changed."""
    return replace(self, **changes)

filled classmethod

filled() -> 'TextFieldStyle'

Default M3 Filled TextField style.

Source code in src/nuiitivet/material/styles/text_field_style.py
@classmethod
def filled(cls) -> "TextFieldStyle":
    """Default M3 Filled TextField style."""
    return cls(
        mode="filled",
        container_color=ColorRole.SURFACE_CONTAINER_HIGHEST,
        indicator_color=ColorRole.ON_SURFACE_VARIANT,
        border_radius=4.0,
        content_padding=(16, 8, 16, 8),  # Adjusted for label
    )

outlined classmethod

outlined() -> 'TextFieldStyle'

Default M3 Outlined TextField style.

Source code in src/nuiitivet/material/styles/text_field_style.py
@classmethod
def outlined(cls) -> "TextFieldStyle":
    """Default M3 Outlined TextField style."""
    return cls(
        mode="outlined",
        container_color=(0, 0, 0, 0),  # Transparent
        indicator_color=ColorRole.OUTLINE,
        focused_indicator_width=3.0,  # MD3: focused outline width = 3dp
        border_radius=4.0,
        content_padding=(16, 16, 16, 16),
    )

from_theme classmethod

from_theme(theme: 'Theme') -> 'TextFieldStyle'

Resolve the default :class:TextFieldStyle for the given theme.

Returns the theme's filled text field style if a Material theme extension is present, otherwise a fresh :meth:filled preset.

Source code in src/nuiitivet/material/styles/text_field_style.py
@classmethod
def from_theme(cls, theme: "Theme") -> "TextFieldStyle":
    """Resolve the default :class:`TextFieldStyle` for the given theme.

    Returns the theme's filled text field style if a Material theme
    extension is present, otherwise a fresh :meth:`filled` preset.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    theme_data = theme.extension(MaterialThemeData)
    if theme_data:
        return theme_data.filled_text_field_style
    return cls.filled()

DockedSearchBar

DockedSearchBar(value: Union[str, ReadOnlyObservableProtocol[str]] = '', *, content: Widget, is_open: Optional[Observable[bool]] = None, close_on_enter: bool = True, placeholder: str | None = None, leading_icon: IconLike = 'search', on_tap_leading_icon: Optional[Callable[[], None]] = None, trailing_icon: IconLike = None, on_tap_trailing_icon: Optional[Callable[[], None]] = None, on_change: Optional[Callable[[str], None]] = None, on_submit: Optional[Callable[[str], None]] = None, on_focus_change: Optional[FocusChangeCallback] = None, input_filter: Optional[InputFilterLike] = None, width: SizingLike = None, style: Optional[DockedSearchBarStyle] = None, key: Optional[str] = None)

Bases: ComposableWidget

Material Design 3 docked search (contained variant).

A :class:SearchBar with a container anchored 2dp below it, holding whatever the query currently calls for — recent searches, suggestions, results, a spinner, "no matches". There is one slot, content, and the application swaps what is inside it from its own observables; the widget only shows and hides the container. That also means the query pipeline keeps running while the container is closed, so gate it yourself if that matters.

When the container opens and closes. The state is a single observable, writable at any time by the application, that this widget drives from these triggers:

==================================== ================================== Trigger Effect ==================================== ================================== Focus gained Open — including on an empty query, which is where MD3 shows recent searches A tap on the bar Open — focus cannot re-fire when the bar already holds it, so the tap itself is a trigger User edits the text Open, even if it was just closed Enter Close when close_on_enter Escape Close, leaving the bar focused — typing reopens. With nothing to close, Escape is not claimed Focus lost, or a tap outside Close. The bar is not outside: a tap there moves the caret and the container stays up ==================================== ==================================

The edit trigger is what makes "Enter closes the panel, results render on the page" work: focus never changed, so without it the panel could not come back. It counts user edits only — assigning to value, or a write to the bound observable, does not reopen the container, so filling the bar in after a pick stays closed. The tap trigger covers the pointer half of the same gap.

Escape rides the window's back-event path, which closes the topmost overlay entry — this container, when it is open — without moving focus. When the container is closed the path declines the key, so an enclosing handler (a dialog, the navigator) still sees it.

Parameters:

Name Type Description Default
value Union[str, ReadOnlyObservableProtocol[str]]

Initial query text, or the observable holding it.

''
content Widget

Widget rendered inside the docked container.

required
is_open Optional[Observable[bool]]

Observable holding whether the container is open. Pass one to drive or observe it; when omitted an internal one is created and exposed as :attr:is_open.

None
close_on_enter bool

Whether Enter closes the container. The default suits a page that renders its own results; pass False to keep the container up and swap content to the results instead. The close runs before on_submit, so a search that wants the container to stay up can reopen it from inside its own callback.

True
placeholder str | None

Supporting text shown inside the bar while it is empty.

None
leading_icon IconLike

Icon source for the leading slot.

'search'
on_tap_leading_icon Optional[Callable[[], None]]

Makes the leading icon a tappable icon button.

None
trailing_icon IconLike

Icon source for the trailing slot.

None
on_tap_trailing_icon Optional[Callable[[], None]]

Makes the trailing icon a tappable icon button.

None
on_change Optional[Callable[[str], None]]

Callback invoked with the query as it changes, for a side effect of the change. The observable bound to value carries the same signal without it.

None
on_submit Optional[Callable[[str], None]]

Callback invoked with the query when Enter is pressed. Fires on every press, including a repeat on an unchanged query, and never on focus loss.

None
on_focus_change Optional[FocusChangeCallback]

Callback invoked as focus arrives and leaves, with (focused, source) -- the same signature as focusable(). It can arrive more than once with focused=True for a single acquisition, because the source is re-announced when the user switches from keyboard to pointer; focused=False arrives once.

None
input_filter Optional[InputFilterLike]

Rule applied to text as the user types it.

None
width SizingLike

Sizing for the box — see :class:SearchBar.

None
style Optional[DockedSearchBarStyle]

Custom style configuration.

None
Source code in src/nuiitivet/material/search.py
def __init__(
    self,
    value: Union[str, ReadOnlyObservableProtocol[str]] = "",
    *,
    content: Widget,
    is_open: Optional[Observable[bool]] = None,
    close_on_enter: bool = True,
    placeholder: str | None = None,
    leading_icon: IconLike = "search",
    on_tap_leading_icon: Optional[Callable[[], None]] = None,
    trailing_icon: IconLike = None,
    on_tap_trailing_icon: Optional[Callable[[], None]] = None,
    on_change: Optional[Callable[[str], None]] = None,
    on_submit: Optional[Callable[[str], None]] = None,
    on_focus_change: Optional[FocusChangeCallback] = None,
    input_filter: Optional[InputFilterLike] = None,
    width: SizingLike = None,
    style: Optional[DockedSearchBarStyle] = None,
    key: Optional[str] = None,
) -> None:
    super().__init__(key=key)
    self._width = width
    self._style = style if style is not None else DockedSearchBarStyle()
    self._close_on_enter = bool(close_on_enter)
    self._on_submit = on_submit
    self._on_focus_change = on_focus_change

    # Kept separate from ``core.focused`` so that an outside tap can close
    # the container (popup writes False here) without claiming the bar lost
    # focus.
    self._is_open: Observable[bool] = is_open if is_open is not None else Observable(False)

    # EditableText declines Enter when it has no on_submit, so the key
    # stays available to a shortcut. The wrapper is withheld unless Enter
    # has something to do here -- the app's callback, closing the
    # container, or both.
    wants_enter = on_submit is not None or self._close_on_enter

    self._core = _SearchBarCore(
        value=value,
        placeholder=placeholder,
        leading_icon=leading_icon,
        on_tap_leading_icon=on_tap_leading_icon,
        trailing_icon=trailing_icon,
        on_tap_trailing_icon=on_tap_trailing_icon,
        on_change=on_change,
        on_user_edit=self._handle_user_edit,
        on_submit=self._handle_submit if wants_enter else None,
        on_focus_change=self._handle_focus_change,
        input_filter=input_filter,
        style=self._style.bar,
    )

    self._container = _DockedContainer(
        content,
        style=self._style,
        bar_rect=lambda: self._core.global_visual_rect,
        viewport_height=self._viewport_height,
    )

value property

value: str

Return the current query text.

is_open property

is_open: Observable[bool]

Observable holding whether the docked container is open.

Writable: setting it opens or closes the container directly. The widget writes it too, on the triggers listed in the class docstring.

focus

focus() -> None

Programmatically focus the bar.

Source code in src/nuiitivet/material/search.py
def focus(self) -> None:
    """Programmatically focus the bar."""
    self._core.focus()

build

build() -> Widget

Return the pane, with a popup anchored to the inset bar.

The popup's offset is constant: OverlayPosition.anchored re-resolves the anchor rect on every layout pass, so anchoring to the bar (rather than to the pane) tracks the margin animation for free.

flip=False keeps the container below the bar even when the window is too short for it: it overflows downwards rather than opening upwards. Opening above would also be correct MD3 — turn it on if that is wanted. Either way the bar is never covered, because popup only ever shifts content along the cross axis.

Source code in src/nuiitivet/material/search.py
def build(self) -> Widget:
    """Return the pane, with a popup anchored to the inset bar.

    The popup's ``offset`` is constant: ``OverlayPosition.anchored``
    re-resolves the anchor rect on every layout pass, so anchoring to the
    bar (rather than to the pane) tracks the margin animation for free.

    ``flip=False`` keeps the container below the bar even when the window
    is too short for it: it overflows downwards rather than opening
    upwards. Opening above would also be correct MD3 — turn it on if that
    is wanted. Either way the bar is never covered, because ``popup`` only
    ever shifts content along the cross axis.
    """
    anchored = self._core.modifier(
        popup(
            self._container,
            is_open=self._is_open,
            anchor_passthrough=True,
            target_anchor="bottom-left",
            content_anchor="top-left",
            offset=(0.0, self._style.gap),
            flip=False,
        )
    )
    return _SearchPane(anchored, core=self._core, width=self._width)

SearchBar

SearchBar(value: Union[str, ReadOnlyObservableProtocol[str]] = '', *, placeholder: str | None = None, leading_icon: IconLike = 'search', on_tap_leading_icon: Optional[Callable[[], None]] = None, trailing_icon: IconLike = None, on_tap_trailing_icon: Optional[Callable[[], None]] = None, on_change: Optional[Callable[[str], None]] = None, on_submit: Optional[Callable[[str], None]] = None, on_focus_change: Optional[FocusChangeCallback] = None, input_filter: Optional[InputFilterLike] = None, width: SizingLike = None, style: Optional[SearchBarStyle] = None, key: Optional[str] = None)

Bases: ComposableWidget

Material Design 3 search bar (contained variant).

The bar is drawn inset inside the box this widget is given: 24dp on each side, animating to 12dp while focused. width therefore names the box, not the bar — which keeps the widget's footprint stable when it is focused, instead of reflowing its siblings.

There is no full-screen search widget. To build one, lay out your own screen and place a SearchBar in it; the bar brings its own margin animation with it.

Parameters:

Name Type Description Default
value Union[str, ReadOnlyObservableProtocol[str]]

Initial query text, or the observable holding it. Edits are written back to a writable observable, exactly as for TextField.

''
placeholder str | None

Supporting text shown inside the bar while it is empty.

None
leading_icon IconLike

Icon source (Symbol/str, or an Observable of them).

'search'
on_tap_leading_icon Optional[Callable[[], None]]

Makes the leading icon a tappable icon button.

None
trailing_icon IconLike

Icon source for the trailing slot.

None
on_tap_trailing_icon Optional[Callable[[], None]]

Makes the trailing icon a tappable icon button. The slot is generic — clearing the query is one use of it, not a built-in behaviour.

None
on_change Optional[Callable[[str], None]]

Callback invoked with the query as it changes, for a side effect of the change. The observable bound to value carries the same signal without it.

None
on_submit Optional[Callable[[str], None]]

Callback invoked with the query when Enter is pressed. Fires on every press, including a repeat on an unchanged query, and never on focus loss.

None
on_focus_change Optional[FocusChangeCallback]

Callback invoked as focus arrives and leaves, with (focused, source) -- the same signature as focusable(). It can arrive more than once with focused=True for a single acquisition, because the source is re-announced when the user switches from keyboard to pointer; focused=False arrives once.

None
input_filter Optional[InputFilterLike]

Rule applied to text as the user types it.

None
width SizingLike

Sizing for the box, not for the bar drawn inside it. The bar is the box minus the margins, so width=440 draws a 392dp bar that grows to 416dp when the user clicks into it, while the widget's own footprint stays 440dp and nothing beside it moves. The bar is capped at 720dp and centred when the box is wider; in a box too narrow for the 360dp minimum it shrinks to fit rather than overflowing.

None
style Optional[SearchBarStyle]

Custom style configuration.

None
Source code in src/nuiitivet/material/search.py
def __init__(
    self,
    value: Union[str, ReadOnlyObservableProtocol[str]] = "",
    *,
    placeholder: str | None = None,
    leading_icon: IconLike = "search",
    on_tap_leading_icon: Optional[Callable[[], None]] = None,
    trailing_icon: IconLike = None,
    on_tap_trailing_icon: Optional[Callable[[], None]] = None,
    on_change: Optional[Callable[[str], None]] = None,
    on_submit: Optional[Callable[[str], None]] = None,
    on_focus_change: Optional[FocusChangeCallback] = None,
    input_filter: Optional[InputFilterLike] = None,
    width: SizingLike = None,
    style: Optional[SearchBarStyle] = None,
    key: Optional[str] = None,
) -> None:
    super().__init__(key=key)
    self._width = width
    # Built once and reused across rebuilds so focus and cursor position
    # survive recomposition.
    self._core = _SearchBarCore(
        value=value,
        placeholder=placeholder,
        leading_icon=leading_icon,
        on_tap_leading_icon=on_tap_leading_icon,
        trailing_icon=trailing_icon,
        on_tap_trailing_icon=on_tap_trailing_icon,
        on_change=on_change,
        on_submit=on_submit,
        on_focus_change=on_focus_change,
        input_filter=input_filter,
        style=style,
    )

value property

value: str

Return the current query text.

focus

focus() -> None

Programmatically focus the bar.

Source code in src/nuiitivet/material/search.py
def focus(self) -> None:
    """Programmatically focus the bar."""
    self._core.focus()

build

build() -> Widget

Return the pane, with the bar inset inside it.

Source code in src/nuiitivet/material/search.py
def build(self) -> Widget:
    """Return the pane, with the bar inset inside it."""
    return _SearchPane(self._core, core=self._core, width=self._width)

DockedSearchBarStyle dataclass

DockedSearchBarStyle(bar: SearchBarStyle = SearchBarStyle(), container_color: ColorSpec = SURFACE_CONTAINER_HIGH, corner_radius: float = 12.0, gap: float = 2.0, min_height: float = 240.0, max_height_ratio: float = 2.0 / 3.0)

Style configuration for :class:~nuiitivet.material.search.DockedSearchBar.

MD3 reference: md.comp.search-view.contained.docked.*.

copy_with

copy_with(**changes) -> 'DockedSearchBarStyle'

Create a new style instance with specified fields changed.

Source code in src/nuiitivet/material/styles/search_bar_style.py
def copy_with(self, **changes) -> "DockedSearchBarStyle":
    """Create a new style instance with specified fields changed."""
    return replace(self, **changes)

SearchBarStyle dataclass

SearchBarStyle(container_color: ColorSpec = SURFACE_CONTAINER_HIGH, container_height: float = 56.0, margin: float = 24.0, focused_margin: float = 12.0, min_width: float = 360.0, max_width: float = 720.0, input_text_color: ColorSpec = ON_SURFACE, supporting_text_color: ColorSpec = ON_SURFACE_VARIANT, font_size: int = 16, leading_icon_color: ColorSpec = ON_SURFACE, trailing_icon_color: ColorSpec = ON_SURFACE_VARIANT, icon_size: int = 24, cursor_color: ColorSpec = PRIMARY, selection_color: ColorSpec = PRIMARY_CONTAINER, state_layer_color: ColorSpec = ON_SURFACE, focus_indicator_color: ColorSpec = SECONDARY, leading_space: float = 4.0, trailing_space: float = 4.0, icon_label_gap: float = 4.0)

Style configuration for :class:~nuiitivet.material.search.SearchBar.

MD3 reference: md.comp.search-bar.*, contained rows only.

copy_with

copy_with(**changes) -> 'SearchBarStyle'

Create a new style instance with specified fields changed.

Source code in src/nuiitivet/material/styles/search_bar_style.py
def copy_with(self, **changes) -> "SearchBarStyle":
    """Create a new style instance with specified fields changed."""
    return replace(self, **changes)

from_theme classmethod

from_theme(theme: 'Theme') -> 'SearchBarStyle'

Resolve the default :class:SearchBarStyle for the given theme.

The defaults are already theme roles rather than literal colours, so this returns the preset unless a Material theme extension overrides it.

Source code in src/nuiitivet/material/styles/search_bar_style.py
@classmethod
def from_theme(cls, theme: "Theme") -> "SearchBarStyle":
    """Resolve the default :class:`SearchBarStyle` for the given theme.

    The defaults are already theme roles rather than literal colours, so
    this returns the preset unless a Material theme extension overrides it.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    theme_data = theme.extension(MaterialThemeData)
    if theme_data:
        return theme_data.search_bar_style
    return cls()

TextStyle dataclass

TextStyle(color: ColorSpec = ON_SURFACE, font_family: str | None = None)

Immutable visual style for Material Text widgets (M3-compliant).

Use copy_with() to create style variants.

Material Design 3 Text specifications: - Default color: ON_SURFACE

Typography comes from the widget's type_scale and alignment from the widget itself, so neither lives here.

copy_with

copy_with(**changes) -> TextStyle

Create a new style instance with specified fields changed.

Example

error_style = TextStyle().copy_with(color=ColorRole.ERROR)

Source code in src/nuiitivet/material/styles/text_style.py
def copy_with(self, **changes) -> "TextStyle":
    """Create a new style instance with specified fields changed.

    Example:
        error_style = TextStyle().copy_with(color=ColorRole.ERROR)
    """
    return replace(self, **changes)

IconStyle dataclass

IconStyle(default_size: int = 24, padding: int = 0, family: str = 'outlined', color: ColorSpec = ON_SURFACE, font_family_priority: Tuple[str, ...] = ('Material Symbols Outlined', 'Material Symbols Rounded', 'Material Symbols Sharp', 'Material Icons'), custom_font_family: Optional[str] = None, style_to_family: dict[str, str] = None)

Immutable style for Icon widgets (M3準拠).

Material Design 3 Icon specifications: - Default size: 24dp - Default color: ON_SURFACE - Font family priority: Material Symbols → Material Icons

copy_with

copy_with(**changes) -> IconStyle

Create a new style instance with specified fields changed.

Source code in src/nuiitivet/material/styles/icon_style.py
def copy_with(self, **changes) -> "IconStyle":
    """Create a new style instance with specified fields changed."""
    return replace(self, **changes)

resolve_color

resolve_color(color: ColorSpec, theme: Theme | None = None) -> tuple[int, int, int, int]

Resolve ColorRole to an (r,g,b,a) tuple using the theme resolver.

Source code in src/nuiitivet/material/styles/icon_style.py
def resolve_color(self, color: ColorSpec, theme: "Theme | None" = None) -> tuple[int, int, int, int]:
    """Resolve ColorRole to an (r,g,b,a) tuple using the theme resolver."""
    from nuiitivet.theme.resolver import resolve_color_to_rgba

    return resolve_color_to_rgba(color, theme=theme)

get_font_family

get_font_family(style: str) -> str

Get font family name for given style.

Source code in src/nuiitivet/material/styles/icon_style.py
def get_font_family(self, style: str) -> str:
    """Get font family name for given style."""
    return self.style_to_family.get(style, self.font_family_priority[0])

DividerStyle dataclass

DividerStyle(color: ColorSpec = OUTLINE_VARIANT, thickness: int = 1, inset_left: int = 0, inset_right: int = 0)

Immutable style for the Divider widget.

Attributes:

Name Type Description
color ColorSpec

Line color. Defaults to the M3 Outline Variant color role.

thickness int

Line thickness in pixels. Defaults to 1 (1dp per M3 spec).

inset_left int

Left-side inset in pixels. Defaults to 0. For vertical orientation this is applied to the top side.

inset_right int

Right-side inset in pixels. Defaults to 0. For vertical orientation this is applied to the bottom side.

copy_with

copy_with(**changes: Any) -> 'DividerStyle'

Return a copy of this style with the given fields overridden.

Parameters:

Name Type Description Default
**changes Any

Fields to override.

{}

Returns:

Type Description
'DividerStyle'

A new :class:DividerStyle with the specified changes applied.

Source code in src/nuiitivet/material/styles/divider_style.py
def copy_with(self, **changes: Any) -> "DividerStyle":
    """Return a copy of this style with the given fields overridden.

    Args:
        **changes: Fields to override.

    Returns:
        A new :class:`DividerStyle` with the specified changes applied.
    """
    return replace(self, **changes)

ToolbarStyle dataclass

ToolbarStyle(color_scheme: ToolbarColorScheme = 'standard', background: ColorSpec = SURFACE_CONTAINER_HIGHEST, foreground: ColorSpec = ON_SURFACE, container_height: int = 64, content_padding: tuple[int, int, int, int] = (16, 0, 16, 0), item_gap: int = 8, corner_radius: int = 0, border_color: Optional[ColorSpec] = None, border_width: float = 0.0, elevation: float = 0.0)

Immutable style for Material toolbar widgets.

Parameters:

Name Type Description Default
color_scheme ToolbarColorScheme

Toolbar color scheme variant.

'standard'
background ColorSpec

Toolbar container background color.

SURFACE_CONTAINER_HIGHEST
foreground ColorSpec

Recommended foreground color for icon actions.

ON_SURFACE
container_height int

Visual container height in pixels.

64
content_padding tuple[int, int, int, int]

Internal content insets.

(16, 0, 16, 0)
item_gap int

Gap between action buttons.

8
corner_radius int

Container corner radius in pixels.

0
border_color Optional[ColorSpec]

Optional border color.

None
border_width float

Border width in pixels.

0.0
elevation float

Elevation level for shadow rendering.

0.0

copy_with

copy_with(**changes) -> 'ToolbarStyle'

Return a copy of this style with changed fields.

Source code in src/nuiitivet/material/styles/toolbar_style.py
def copy_with(self, **changes) -> "ToolbarStyle":
    """Return a copy of this style with changed fields."""
    return replace(self, **changes)

standard classmethod

standard() -> 'ToolbarStyle'

Return the standard toolbar style.

Source code in src/nuiitivet/material/styles/toolbar_style.py
@classmethod
def standard(cls) -> "ToolbarStyle":
    """Return the standard toolbar style."""
    return cls(
        color_scheme="standard",
        background=ColorRole.SURFACE_CONTAINER_HIGHEST,
        foreground=ColorRole.ON_SURFACE,
        container_height=64,
        content_padding=(16, 0, 16, 0),
        item_gap=8,
        corner_radius=0,
        border_color=None,
        border_width=0.0,
        elevation=0.0,
    )

vibrant classmethod

vibrant() -> 'ToolbarStyle'

Return the vibrant toolbar style.

Source code in src/nuiitivet/material/styles/toolbar_style.py
@classmethod
def vibrant(cls) -> "ToolbarStyle":
    """Return the vibrant toolbar style."""
    return cls(
        color_scheme="vibrant",
        background=ColorRole.PRIMARY_CONTAINER,
        foreground=ColorRole.ON_PRIMARY_CONTAINER,
        container_height=64,
        content_padding=(16, 0, 16, 0),
        item_gap=8,
        corner_radius=0,
        border_color=None,
        border_width=0.0,
        elevation=0.0,
    )

preset classmethod

preset(variant: ToolbarColorScheme = 'standard') -> 'ToolbarStyle'

Return the framework preset for variant, ignoring any theme.

This is what a toolbar renders with before it is mounted, and what :meth:from_theme falls back to when no Material theme is installed.

Parameters:

Name Type Description Default
variant ToolbarColorScheme

One of standard or vibrant. Unknown values fall back to standard.

'standard'

Returns:

Type Description
'ToolbarStyle'

The variant preset style.

Source code in src/nuiitivet/material/styles/toolbar_style.py
@classmethod
def preset(cls, variant: ToolbarColorScheme = "standard") -> "ToolbarStyle":
    """Return the framework preset for ``variant``, ignoring any theme.

    This is what a toolbar renders with before it is mounted, and what
    :meth:`from_theme` falls back to when no Material theme is installed.

    Args:
        variant: One of ``standard`` or ``vibrant``. Unknown values fall
            back to ``standard``.

    Returns:
        The variant preset style.
    """
    if str(variant or "standard").lower() == "vibrant":
        return cls.vibrant()
    return cls.standard()

from_theme classmethod

from_theme(theme: 'Theme', variant: ToolbarColorScheme = 'standard') -> 'ToolbarStyle'

Resolve the toolbar style from theme.

Parameters:

Name Type Description Default
theme 'Theme'

Theme instance.

required
variant ToolbarColorScheme

One of standard or vibrant. Only standard is carried by :class:MaterialThemeData; vibrant is an explicit opt-in and always returns its preset.

'standard'

Returns:

Type Description
'ToolbarStyle'

Resolved toolbar style.

Source code in src/nuiitivet/material/styles/toolbar_style.py
@classmethod
def from_theme(cls, theme: "Theme", variant: ToolbarColorScheme = "standard") -> "ToolbarStyle":
    """Resolve the toolbar style from ``theme``.

    Args:
        theme: Theme instance.
        variant: One of ``standard`` or ``vibrant``. Only ``standard`` is
            carried by :class:`MaterialThemeData`; ``vibrant`` is an
            explicit opt-in and always returns its preset.

    Returns:
        Resolved toolbar style.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    variant_name = str(variant or "standard").lower()
    if variant_name == "standard":
        theme_data = theme.extension(MaterialThemeData)
        if theme_data is not None:
            return theme_data.toolbar_style
    return cls.preset(variant)

CircularProgressIndicatorStyle dataclass

CircularProgressIndicatorStyle(active_indicator_color: ColorSpec = PRIMARY, track_color: ColorSpec = SECONDARY_CONTAINER, stop_indicator_color: ColorSpec = PRIMARY, disabled_active_alpha: float = 0.38, disabled_track_alpha: float = 0.12, size: float = 40.0, with_wave_size: float = 48.0, track_thickness: float = 4.0, track_active_space: float = 4.0, wave_amplitude: float = 1.6, wave_wavelength: float = 15.0, motion: Motion = EXPRESSIVE_DEFAULT_EFFECTS)

Bases: ProgressIndicatorStyle

Circular progress indicator geometry and motion tokens.

default classmethod

default() -> 'CircularProgressIndicatorStyle'

Create the default circular progress style.

Source code in src/nuiitivet/material/styles/progress_indicator_style.py
@classmethod
def default(cls) -> "CircularProgressIndicatorStyle":
    """Create the default circular progress style."""
    return cls()

flat classmethod

flat() -> 'CircularProgressIndicatorStyle'

Create a flat circular progress style preset.

Source code in src/nuiitivet/material/styles/progress_indicator_style.py
@classmethod
def flat(cls) -> "CircularProgressIndicatorStyle":
    """Create a flat circular progress style preset."""
    return cls(
        with_wave_size=40.0,
        wave_amplitude=0.0,
    )

from_theme classmethod

from_theme(theme: 'Theme', variant: str = 'default') -> 'CircularProgressIndicatorStyle'

Get circular progress style from theme.

Parameters:

Name Type Description Default
theme 'Theme'

Theme to load style from.

required
variant str

Variant name ("default" or "flat").

'default'

Returns:

Type Description
'CircularProgressIndicatorStyle'

CircularProgressIndicatorStyle resolved from theme or fallback preset.

Source code in src/nuiitivet/material/styles/progress_indicator_style.py
@classmethod
def from_theme(cls, theme: "Theme", variant: str = "default") -> "CircularProgressIndicatorStyle":
    """Get circular progress style from theme.

    Args:
        theme: Theme to load style from.
        variant: Variant name ("default" or "flat").

    Returns:
        CircularProgressIndicatorStyle resolved from theme or fallback preset.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    theme_data = theme.extension(MaterialThemeData)
    v = (variant or "").lower()

    if theme_data is not None:
        base = theme_data.circular_progress_indicator_style
        if v == "flat":
            return base.copy_with(with_wave_size=base.size, wave_amplitude=0.0)
        return base

    if v == "flat":
        return cls.flat()
    return cls.default()

LinearProgressIndicatorStyle dataclass

LinearProgressIndicatorStyle(active_indicator_color: ColorSpec = PRIMARY, track_color: ColorSpec = SECONDARY_CONTAINER, stop_indicator_color: ColorSpec = PRIMARY, disabled_active_alpha: float = 0.38, disabled_track_alpha: float = 0.12, track_thickness: float = 4.0, with_wave_height: float = 10.0, stop_indicator_size: float = 4.0, track_active_space: float = 4.0, stop_indicator_trailing_space: float = 0.0, wave_amplitude: float = 3.0, wave_wavelength: float = 40.0, indeterminate_wave_wavelength: float = 20.0, motion: Motion = EXPRESSIVE_DEFAULT_EFFECTS)

Bases: ProgressIndicatorStyle

Linear progress indicator geometry and motion tokens.

default classmethod

default() -> 'LinearProgressIndicatorStyle'

Create the default linear progress style.

Source code in src/nuiitivet/material/styles/progress_indicator_style.py
@classmethod
def default(cls) -> "LinearProgressIndicatorStyle":
    """Create the default linear progress style."""
    return cls()

flat classmethod

flat() -> 'LinearProgressIndicatorStyle'

Create a flat linear progress style preset.

Source code in src/nuiitivet/material/styles/progress_indicator_style.py
@classmethod
def flat(cls) -> "LinearProgressIndicatorStyle":
    """Create a flat linear progress style preset."""
    return cls(
        with_wave_height=4.0,
        wave_amplitude=0.0,
    )

from_theme classmethod

from_theme(theme: 'Theme', variant: str = 'default') -> 'LinearProgressIndicatorStyle'

Get linear progress style from theme.

Parameters:

Name Type Description Default
theme 'Theme'

Theme to load style from.

required
variant str

Variant name ("default" or "flat").

'default'

Returns:

Type Description
'LinearProgressIndicatorStyle'

LinearProgressIndicatorStyle resolved from theme or fallback preset.

Source code in src/nuiitivet/material/styles/progress_indicator_style.py
@classmethod
def from_theme(cls, theme: "Theme", variant: str = "default") -> "LinearProgressIndicatorStyle":
    """Get linear progress style from theme.

    Args:
        theme: Theme to load style from.
        variant: Variant name ("default" or "flat").

    Returns:
        LinearProgressIndicatorStyle resolved from theme or fallback preset.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    theme_data = theme.extension(MaterialThemeData)
    v = (variant or "").lower()

    if theme_data is not None:
        base = theme_data.linear_progress_indicator_style
        if v == "flat":
            return base.copy_with(with_wave_height=base.track_thickness, wave_amplitude=0.0)
        return base

    if v == "flat":
        return cls.flat()
    return cls.default()

Snackbar

Snackbar(message: str, *, padding: Optional[Union[int, Tuple[int, int, int, int]]] = None, style: Optional[SnackbarStyle] = None, key: Optional[str] = None)

Bases: ComposableWidget

Material Design Snackbar.

Displays a brief message at the bottom of the screen.

Source code in src/nuiitivet/material/snackbar.py
def __init__(
    self,
    message: str,
    *,
    padding: Optional[Union[int, Tuple[int, int, int, int]]] = None,
    style: Optional[SnackbarStyle] = None,
    key: Optional[str] = None,
) -> None:
    super().__init__(key=key)
    self.message = str(message)

    resolved_style = style if style is not None else SnackbarStyle()
    self.style = resolved_style
    self.padding = padding if padding is not None else resolved_style.padding

BasicDialogIntent dataclass

BasicDialogIntent(title: str | None = None, message: str | None = None, icon: Any | None = None)

Intent for showing a Material Basic Dialog.

Attributes:

Name Type Description
title str | None

The title of the dialog.

message str | None

The message body of the dialog.

icon Any | None

The icon to display. Can be a Widget or other supported type.

ColorRole

Bases: Enum

Material 3 Color Roles — canonical 26 roles used by M3.

SchemeVariant

Bases: Enum

Algorithm used to derive a tonal palette from a seed color.

Each member's value is the corresponding materialyoucolor.Variant attribute name.

Text

Text(label: Union[str, ReadOnlyObservableProtocol[Any]], *, width: SizingLike = None, height: SizingLike = None, padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0, style: Optional['TextStyle'] = None, 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, key: Optional[str] = None)

Bases: TextBase

Material text widget.

Defaults to the current Material theme TextStyle.

Initialize Material Text widget.

Parameters:

Name Type Description Default
label Union[str, ReadOnlyObservableProtocol[Any]]

The text content to display. Can be a string or an Observable.

required
width SizingLike

Width specification.

None
height SizingLike

Height specification.

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

Padding around the text.

0
style Optional['TextStyle']

Custom Material TextStyle (color, font_family).

None
type_scale Optional[TypeScaleToken]

MD3 type-scale token supplying typography. Defaults to Body Medium.

None
alignment Literal['start', 'center', 'end']

Horizontal text alignment ("start", "center", "end").

'start'
max_lines Optional[int]

Maximum number of lines (None = unbounded).

None
overflow Literal['visible', 'clip', 'ellipsis']

Overflow handling: "visible", "clip" or "ellipsis".

'visible'
truncation Literal['tail', 'head', 'middle']

Ellipsis position: "tail", "head" or "middle".

'tail'
soft_wrap bool

Whether to wrap at soft line breaks when width is bounded.

True
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/text.py
def __init__(
    self,
    label: Union[str, ReadOnlyObservableProtocol[Any]],
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    style: Optional["TextStyle"] = None,
    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,
    key: Optional[str] = None,
):
    """Initialize Material Text widget.

    Args:
        label: The text content to display. Can be a string or an Observable.
        width: Width specification.
        height: Height specification.
        padding: Padding around the text.
        style: Custom Material TextStyle (color, font_family).
        type_scale: MD3 type-scale token supplying typography. Defaults to
            Body Medium.
        alignment: Horizontal text alignment (``"start"``, ``"center"``,
            ``"end"``).
        max_lines: Maximum number of lines (``None`` = unbounded).
        overflow: Overflow handling: ``"visible"``, ``"clip"`` or ``"ellipsis"``.
        truncation: Ellipsis position: ``"tail"``, ``"head"`` or ``"middle"``.
        soft_wrap: Whether to wrap at soft line breaks when width is bounded.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    from nuiitivet.material.styles.text_style import TextStyle

    if style is not None and not isinstance(style, TextStyle):
        raise TypeError("style must be a material TextStyle")

    super().__init__(
        label=label,
        style=style,
        width=width,
        height=height,
        padding=padding,
        type_scale=type_scale,
        alignment=alignment,
        max_lines=max_lines,
        overflow=overflow,
        truncation=truncation,
        soft_wrap=soft_wrap,
        key=key,
    )

style property

style: TextStyleProtocol

Return the current text style, resolving from theme if necessary.

Navigator

Navigator(screen: Route | Widget | None = None, *, layer_composer: NavigationLayerComposer | None = None, key: str | None = None)

Bases: Navigator

Navigator that applies Material default transition specs.

Defaults its layer composer to :class:MaterialNavigationLayerComposer so that page transitions actually render their Material fade — including when the navigator is built directly via :meth:Navigator.intents / :meth:Navigator.routes (not only the implicit navigator MaterialApp wires up). Without it the core :class:_DefaultNavigationLayerComposer composites both routes at full opacity, so the transition never animates.

Source code in src/nuiitivet/material/navigator.py
def __init__(
    self,
    screen: Route | Widget | None = None,
    *,
    layer_composer: NavigationLayerComposer | None = None,
    key: str | None = None,
) -> None:
    super().__init__(
        screen,
        layer_composer=layer_composer or MaterialNavigationLayerComposer(),
        key=key,
    )

Overlay

Overlay(*, intent_resolver: IntentResolver | None = None, intents: Mapping[type[Any], Callable[[Any], Widget | Route]] | None = None, key: str | None = None)

Bases: Overlay

Overlay subclass that provides Material-specific helpers.

Source code in src/nuiitivet/material/overlay.py
def __init__(
    self,
    *,
    intent_resolver: IntentResolver | None = None,
    intents: Mapping[type[Any], Callable[[Any], Widget | Route]] | None = None,
    key: str | None = None,
) -> None:
    super().__init__(layer_composer=MaterialOverlayLayerComposer(), key=key)

    if intent_resolver is not None and intents is not None:
        raise ValueError("Specify only one of intent_resolver or intents")

    if intent_resolver is None:
        defaults: dict[type[Any], Callable[[Any], Widget | Route]] = {
            BasicDialogIntent: lambda i: OverlayRoute(
                builder=lambda: BasicDialog(
                    title=i.title,
                    message=i.message,
                    icon=i.icon,
                    actions=[
                        Button(
                            "OK",
                            # This resolver belongs to the overlay hosting the
                            # dialog, so close it directly rather than looking
                            # one up.
                            on_click=lambda: self.close(None),
                            width=80,
                            style=ButtonStyle.text(),
                        )
                    ],
                ),
                transition_spec=MaterialTransitions.dialog(),
            ),
            LoadingIntent: lambda _: OverlayRoute(
                builder=lambda: LoadingIndicator(),
                transition_spec=None,
            ),
        }
        if intents:
            defaults.update(intents)
        intent_resolver = _MappingIntentResolver(defaults)

    self._intent_resolver = intent_resolver

dialog

dialog(dialog: Widget | Any, *, dismiss_on_outside_tap: bool = True) -> OverlayHandle[Any]

Display a modal Material dialog.

Parameters:

Name Type Description Default
dialog Widget | Any

A :class:Widget to display as the dialog, or an intent resolved by the overlay's intent resolver (e.g. :class:BasicDialogIntent). To present a fully custom :class:Route, call :meth:show directly.

required
dismiss_on_outside_tap bool

Whether tapping the scrim dismisses the dialog. Defaults to True.

True

Returns:

Name Type Description
An OverlayHandle[Any]

class:OverlayHandle for manual dismissal.

Source code in src/nuiitivet/material/overlay.py
def dialog(
    self,
    dialog: Widget | Any,
    *,
    dismiss_on_outside_tap: bool = True,
) -> OverlayHandle[Any]:
    """Display a modal Material dialog.

    Args:
        dialog: A :class:`Widget` to display as the dialog, or an intent
            resolved by the overlay's intent resolver (e.g.
            :class:`BasicDialogIntent`). To present a fully custom
            :class:`Route`, call :meth:`show` directly.
        dismiss_on_outside_tap: Whether tapping the scrim dismisses the
            dialog. Defaults to ``True``.

    Returns:
        An :class:`OverlayHandle` for manual dismissal.
    """
    route = self._normalize_dialog_to_route(dialog)

    return self.show(
        route,
        backdrop=True,
        dismiss_on_outside_tap=dismiss_on_outside_tap,
    )

snackbar

snackbar(message: str | Snackbar, *, duration: float = 3.0) -> OverlayHandle[None]

Display a brief, non-blocking Material snackbar.

Parameters:

Name Type Description Default
message str | Snackbar

The message text, or a pre-built :class:Snackbar widget.

required
duration float

Seconds before the snackbar auto-dismisses. Defaults to 3.0.

3.0

Returns:

Name Type Description
An OverlayHandle[None]

class:OverlayHandle for the shown snackbar.

Source code in src/nuiitivet/material/overlay.py
def snackbar(
    self,
    message: str | Snackbar,
    *,
    duration: float = 3.0,
) -> OverlayHandle[None]:
    """Display a brief, non-blocking Material snackbar.

    Args:
        message: The message text, or a pre-built :class:`Snackbar` widget.
        duration: Seconds before the snackbar auto-dismisses. Defaults to ``3.0``.

    Returns:
        An :class:`OverlayHandle` for the shown snackbar.
    """
    widget: Widget = message if isinstance(message, Snackbar) else Snackbar(str(message))
    return self.show(
        widget,
        passthrough=True,
        timeout=float(duration),
        position=OverlayPosition.aligned("bottom-center", offset=(0.0, -24.0)),
        transition_spec=MaterialTransitions.snackbar(),
    )

loading

loading(indicator: Widget | Any | None = None) -> OverlayHandle[Any]

Show a loading indicator overlay and return a handle for manual dismissal.

Parameters:

Name Type Description Default
indicator Widget | Any | None

Widget or intent to display as the loading indicator. Defaults to the built-in :class:LoadingIndicator, resolved through the :class:LoadingIntent (overridable via the app's overlay_routes).

None

Returns:

Name Type Description
An OverlayHandle[Any]

class:OverlayHandle that can be closed via handle.close(None).

Source code in src/nuiitivet/material/overlay.py
def loading(
    self,
    indicator: Widget | Any | None = None,
) -> OverlayHandle[Any]:
    """Show a loading indicator overlay and return a handle for manual dismissal.

    Args:
        indicator: Widget or intent to display as the loading indicator.
            Defaults to the built-in :class:`LoadingIndicator`, resolved
            through the :class:`LoadingIntent` (overridable via the app's
            ``overlay_routes``).

    Returns:
        An :class:`OverlayHandle` that can be closed via ``handle.close(None)``.
    """
    if indicator is None:
        resolved: Widget | Route = self._intent_resolver.resolve(LoadingIntent())
    elif isinstance(indicator, Widget):
        resolved = indicator
    else:
        resolved = self._intent_resolver.resolve(indicator)
    return self.show(
        resolved,
        passthrough=True,
        timeout=None,
        position=OverlayPosition.aligned("center"),
    )

while_loading

while_loading(indicator: Widget | Any | None = None) -> WhileLoading

Return a context manager that shows a loading indicator for the duration of a block.

Use this form when the loading state is scoped to a with or async with block::

with MaterialOverlay.of(self).while_loading():
    do_work()

async with MaterialOverlay.of(self).while_loading():
    await fetch_data()

Internally delegates show/close to :meth:loading.

Parameters:

Name Type Description Default
indicator Widget | Any | None

Widget or intent to display as the loading indicator. Defaults to the built-in :class:LoadingIndicator.

None

Returns:

Name Type Description
A WhileLoading

class:WhileLoading context manager that shows the indicator on entry and closes it on exit.

Source code in src/nuiitivet/material/overlay.py
def while_loading(
    self,
    indicator: Widget | Any | None = None,
) -> WhileLoading:
    """Return a context manager that shows a loading indicator for the duration of a block.

    Use this form when the loading state is scoped to a ``with`` or ``async with`` block::

        with MaterialOverlay.of(self).while_loading():
            do_work()

        async with MaterialOverlay.of(self).while_loading():
            await fetch_data()

    Internally delegates show/close to :meth:`loading`.

    Args:
        indicator: Widget or intent to display as the loading indicator.
            Defaults to the built-in :class:`LoadingIndicator`.

    Returns:
        A :class:`WhileLoading` context manager that shows the indicator on entry and closes it on exit.
    """
    return WhileLoading(self, indicator)

side_sheet

side_sheet(sheet: Widget, *, side: Literal['right', 'left'] = 'right', dismiss_on_outside_tap: bool = True) -> OverlayHandle[Any]

Display a modal side sheet.

The slide-in edge is a placement concern owned by this method: side controls the sheet's alignment, transition direction, and which (inner, away-from-edge) corners are rounded. The corner rounding is applied here via the :func:corner_radius modifier, using the radius from SideSheet.style; the :class:SideSheet widget itself renders a square container.

Parameters:

Name Type Description Default
sheet Widget

SideSheet widget (or a wrapper such as one produced by .modifier(will_pop(...))) that defines content, headline, and styling.

required
side Literal['right', 'left']

Edge the sheet slides in from ("right" or "left"). Defaults to "right".

'right'
dismiss_on_outside_tap bool

Whether tapping the scrim dismisses the sheet. Defaults to True.

True
Source code in src/nuiitivet/material/overlay.py
def side_sheet(
    self,
    sheet: Widget,
    *,
    side: Literal["right", "left"] = "right",
    dismiss_on_outside_tap: bool = True,
) -> OverlayHandle[Any]:
    """Display a modal side sheet.

    The slide-in edge is a placement concern owned by this method: ``side``
    controls the sheet's alignment, transition direction, and which (inner,
    away-from-edge) corners are rounded.  The corner rounding is applied here
    via the :func:`corner_radius` modifier, using the radius from
    ``SideSheet.style``; the :class:`SideSheet` widget itself renders a
    square container.

    Args:
        sheet: SideSheet widget (or a wrapper such as one produced by
            ``.modifier(will_pop(...))``) that defines content, headline,
            and styling.
        side: Edge the sheet slides in from (``"right"`` or ``"left"``).
            Defaults to ``"right"``.
        dismiss_on_outside_tap: Whether tapping the scrim dismisses the sheet.
            Defaults to ``True``.
    """
    inner = _find_descendant(sheet, SideSheet)
    if inner is None:
        raise TypeError("side_sheet() requires a SideSheet widget (possibly wrapped by modifiers)")

    cr = float(inner.style.corner_radius)
    # Round only the inner (away-from-edge) corners: (tl, tr, br, bl).
    radius = (cr, 0.0, 0.0, cr) if side == "right" else (0.0, cr, cr, 0.0)
    presented = sheet.modifier(corner_radius(radius))
    alignment = "top-right" if side == "right" else "top-left"

    route = OverlayRoute(
        builder=lambda: presented,
        transition_spec=MaterialTransitions.side_sheet(side=side),
    )

    return self.show(
        route,
        backdrop=True,
        dismiss_on_outside_tap=bool(dismiss_on_outside_tap),
        position=OverlayPosition.aligned(alignment),
    )

bottom_sheet

bottom_sheet(sheet: Widget, *, dismiss_on_outside_tap: bool = True) -> OverlayHandle[Any]

Display a modal bottom sheet sliding up from the bottom edge.

Visual styling (background, size, corner radius) is fully owned by the :class:BottomSheet widget.

Parameters:

Name Type Description Default
sheet Widget

BottomSheet widget (or a wrapper such as one produced by .modifier(will_pop(...))) that defines content, headline, and styling.

required
dismiss_on_outside_tap bool

Whether tapping the scrim dismisses the sheet. Defaults to True.

True
Source code in src/nuiitivet/material/overlay.py
def bottom_sheet(
    self,
    sheet: Widget,
    *,
    dismiss_on_outside_tap: bool = True,
) -> OverlayHandle[Any]:
    """Display a modal bottom sheet sliding up from the bottom edge.

    Visual styling (background, size, corner radius) is fully owned by the
    :class:`BottomSheet` widget.

    Args:
        sheet: BottomSheet widget (or a wrapper such as one produced by
            ``.modifier(will_pop(...))``) that defines content, headline,
            and styling.
        dismiss_on_outside_tap: Whether tapping the scrim dismisses the sheet.
            Defaults to ``True``.
    """
    if _find_descendant(sheet, BottomSheet) is None:
        raise TypeError("bottom_sheet() requires a BottomSheet widget (possibly wrapped by modifiers)")
    route = OverlayRoute(
        builder=lambda: sheet,
        transition_spec=MaterialTransitions.bottom_sheet(),
    )

    return self.show(
        route,
        backdrop=True,
        dismiss_on_outside_tap=bool(dismiss_on_outside_tap),
        position=OverlayPosition.aligned("bottom-center"),
    )

WhileLoading

WhileLoading(overlay: _LoadingHost, indicator: Widget | Any | None)

Bases: AbstractContextManager[None], AbstractAsyncContextManager[None]

Context manager that shows a loading indicator for the duration of a block.

Returned by :meth:MaterialOverlay.while_loading. Supports both with and async with usage::

with MaterialOverlay.of(self).while_loading():
    do_work()

async with MaterialOverlay.of(self).while_loading():
    await fetch_data()
Source code in src/nuiitivet/material/overlay.py
def __init__(self, overlay: _LoadingHost, indicator: Widget | Any | None) -> None:
    self._overlay = overlay
    self._indicator = indicator
    self._handle: OverlayHandle[Any] | None = None

OverlayProtocol

Bases: OverlayProtocol, Protocol

The Material overlay surface a ViewModel depends on.

Exported as nuiitivet.material.OverlayProtocol, mirroring how nuiitivet.material.Overlay names :class:~nuiitivet.material.overlay.MaterialOverlay. Annotate an injected overlay with it so the ViewModel presents content without owning widgets::

class ItemViewModel:
    def __init__(self, overlay: nv.OverlayProtocol) -> None:
        self._overlay = overlay

    async def delete(self) -> None:
        await self._overlay.dialog(BasicDialogIntent(title="Delete?"))

Prefer passing intents rather than widgets: dialog and loading resolve them through the overlay's intent resolver, keeping widget construction in the View layer. The sheet methods still require a widget -- intent support for them is not implemented yet.

dialog

dialog(dialog: Widget | Any, *, dismiss_on_outside_tap: bool = True) -> OverlayHandle[Any]

Display a modal dialog from a widget or an intent.

Source code in src/nuiitivet/material/protocols.py
def dialog(
    self,
    dialog: Widget | Any,
    *,
    dismiss_on_outside_tap: bool = True,
) -> OverlayHandle[Any]:
    """Display a modal dialog from a widget or an intent."""
    ...

snackbar

snackbar(message: str | Snackbar, *, duration: float = 3.0) -> OverlayHandle[None]

Display a brief, non-blocking snackbar.

Source code in src/nuiitivet/material/protocols.py
def snackbar(
    self,
    message: str | Snackbar,
    *,
    duration: float = 3.0,
) -> OverlayHandle[None]:
    """Display a brief, non-blocking snackbar."""
    ...

loading

loading(indicator: Widget | Any | None = None) -> OverlayHandle[Any]

Show a loading indicator and return a handle for manual dismissal.

Source code in src/nuiitivet/material/protocols.py
def loading(
    self,
    indicator: Widget | Any | None = None,
) -> OverlayHandle[Any]:
    """Show a loading indicator and return a handle for manual dismissal."""
    ...

while_loading

while_loading(indicator: Widget | Any | None = None) -> WhileLoading

Return a (sync or async) context manager that shows a loading indicator.

Source code in src/nuiitivet/material/protocols.py
def while_loading(
    self,
    indicator: Widget | Any | None = None,
) -> WhileLoading:
    """Return a (sync or async) context manager that shows a loading indicator."""
    ...

side_sheet

side_sheet(sheet: Widget, *, side: Literal['right', 'left'] = 'right', dismiss_on_outside_tap: bool = True) -> OverlayHandle[Any]

Display a modal side sheet.

Source code in src/nuiitivet/material/protocols.py
def side_sheet(
    self,
    sheet: Widget,
    *,
    side: Literal["right", "left"] = "right",
    dismiss_on_outside_tap: bool = True,
) -> OverlayHandle[Any]:
    """Display a modal side sheet."""
    ...

bottom_sheet

bottom_sheet(sheet: Widget, *, dismiss_on_outside_tap: bool = True) -> OverlayHandle[Any]

Display a modal bottom sheet.

Source code in src/nuiitivet/material/protocols.py
def bottom_sheet(
    self,
    sheet: Widget,
    *,
    dismiss_on_outside_tap: bool = True,
) -> OverlayHandle[Any]:
    """Display a modal bottom sheet."""
    ...

ThemeFactory

Factory for creating Themes with Material Design configuration.

from_seed staticmethod

from_seed(seed_color: str, mode: str = 'light', name: str = '', *, variant: SchemeVariant = DEFAULT_VARIANT, contrast_level: float = DEFAULT_CONTRAST_LEVEL) -> Theme

Create a Material theme from a seed color.

variant and contrast_level default to the Material 3 defaults; see nuiitivet.material.theme.palette.from_seed.

Source code in src/nuiitivet/material/theme/material_theme.py
@staticmethod
def from_seed(
    seed_color: str,
    mode: str = "light",
    name: str = "",
    *,
    variant: SchemeVariant = DEFAULT_VARIANT,
    contrast_level: float = DEFAULT_CONTRAST_LEVEL,
) -> Theme:
    """Create a Material theme from a seed color.

    `variant` and `contrast_level` default to the Material 3 defaults; see
    `nuiitivet.material.theme.palette.from_seed`.
    """
    roles = from_seed(
        seed_color, dark=(mode == "dark"), variant=variant, contrast_level=contrast_level
    )

    material_data = MaterialThemeData(roles=roles)
    return Theme(
        mode=mode,
        extensions=[
            material_data,
            _material_scrollbar_theme_data(),
            _material_menubar_theme_data(),
        ],
        name=name,
    )

from_seed_pair staticmethod

from_seed_pair(seed_color: str, name: str = '', *, variant: SchemeVariant = DEFAULT_VARIANT, contrast_level: float = DEFAULT_CONTRAST_LEVEL) -> Tuple[Theme, Theme]

Create light and dark themes from a seed color.

Source code in src/nuiitivet/material/theme/material_theme.py
@staticmethod
def from_seed_pair(
    seed_color: str,
    name: str = "",
    *,
    variant: SchemeVariant = DEFAULT_VARIANT,
    contrast_level: float = DEFAULT_CONTRAST_LEVEL,
) -> Tuple[Theme, Theme]:
    """Create light and dark themes from a seed color."""
    return (
        MaterialThemeFactory.from_seed(
            seed_color, mode="light", name=name, variant=variant, contrast_level=contrast_level
        ),
        MaterialThemeFactory.from_seed(
            seed_color, mode="dark", name=name, variant=variant, contrast_level=contrast_level
        ),
    )

DockedToolbar

DockedToolbar(buttons: Sequence[Widget], *, style: Optional[ToolbarStyle] = None, key: Optional[str] = None)

Bases: _ToolbarBase

Material Design 3 docked toolbar.

This toolbar is edge-to-edge and therefore does not expose external padding.

Initialize DockedToolbar.

Parameters:

Name Type Description Default
buttons Sequence[Widget]

Widgets placed inside the toolbar. Prefer Button or IconButton; other widgets (including tooltip-wrapped buttons) are laid out as-is, but the edge-inset heuristic assumes button-sized children and degrades gracefully for larger ones.

required
style Optional[ToolbarStyle]

Optional toolbar style. Defaults to the theme's toolbar style, which itself falls back to ToolbarStyle.standard().

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/toolbar.py
def __init__(
    self,
    buttons: Sequence[Widget],
    *,
    style: Optional[ToolbarStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize DockedToolbar.

    Args:
        buttons: Widgets placed inside the toolbar. Prefer ``Button`` or
            ``IconButton``; other widgets (including tooltip-wrapped buttons)
            are laid out as-is, but the edge-inset heuristic assumes
            button-sized children and degrades gracefully for larger ones.
        style: Optional toolbar style. Defaults to the theme's toolbar
            style, which itself falls back to ``ToolbarStyle.standard()``.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    self._user_style = style
    self._applied_style = None
    # Read the preset directly rather than through ``self.style``: the theme
    # is unreachable until the widget is attached. The preset is what
    # ``ToolbarStyle.from_theme`` falls back to, so an unthemed app sees no
    # change; a themed one adopts its style on the first measure.
    effective_style = style if style is not None else ToolbarStyle.preset()
    row_children: list[Widget] = list(buttons)

    self._content = Row(
        row_children,
        width="wt",
        gap=effective_style.item_gap,
        main_alignment="space-between",
        cross_alignment="center",
        padding=effective_style.content_padding,
    )

    super().__init__(
        child=self._content,
        height=effective_style.container_height,
        padding=0,
        background_color=effective_style.background,
        border_color=effective_style.border_color,
        border_width=effective_style.border_width,
        corner_radius=effective_style.corner_radius,
        alignment="center",
        key=key,
    )

HorizontalFloatingToolbar

HorizontalFloatingToolbar(buttons: Sequence[Widget], *, padding: PaddingLike = 0, style: Optional[ToolbarStyle] = None, key: Optional[str] = None)

Bases: _FloatingToolbarBase

Material Design 3 horizontal floating toolbar.

Lays out action buttons in a row inside a fully rounded floating container.

Initialize HorizontalFloatingToolbar.

Parameters:

Name Type Description Default
buttons Sequence[Widget]

Widgets placed inside the toolbar. Prefer Button or IconButton; other widgets (including tooltip-wrapped buttons) are laid out as-is, but the edge-inset heuristic assumes button-sized children and degrades gracefully for larger ones.

required
padding PaddingLike

External padding around the floating toolbar.

0
style Optional[ToolbarStyle]

Optional toolbar style. Defaults to ToolbarStyle.standard().

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/toolbar.py
def __init__(
    self,
    buttons: Sequence[Widget],
    *,
    padding: PaddingLike = 0,
    style: Optional[ToolbarStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize HorizontalFloatingToolbar.

    Args:
        buttons: Widgets placed inside the toolbar. Prefer ``Button`` or
            ``IconButton``; other widgets (including tooltip-wrapped buttons)
            are laid out as-is, but the edge-inset heuristic assumes
            button-sized children and degrades gracefully for larger ones.
        padding: External padding around the floating toolbar.
        style: Optional toolbar style. Defaults to ``ToolbarStyle.standard()``.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(buttons, orientation="horizontal", padding=padding, style=style, key=key)

VerticalFloatingToolbar

VerticalFloatingToolbar(buttons: Sequence[Widget], *, padding: PaddingLike = 0, style: Optional[ToolbarStyle] = None, key: Optional[str] = None)

Bases: _FloatingToolbarBase

Material Design 3 vertical floating toolbar.

Lays out action buttons in a column inside a fully rounded floating container.

Initialize VerticalFloatingToolbar.

Parameters:

Name Type Description Default
buttons Sequence[Widget]

Widgets placed inside the toolbar. Prefer Button or IconButton; other widgets (including tooltip-wrapped buttons) are laid out as-is, but the edge-inset heuristic assumes button-sized children and degrades gracefully for larger ones.

required
padding PaddingLike

External padding around the floating toolbar.

0
style Optional[ToolbarStyle]

Optional toolbar style. Defaults to ToolbarStyle.standard().

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/toolbar.py
def __init__(
    self,
    buttons: Sequence[Widget],
    *,
    padding: PaddingLike = 0,
    style: Optional[ToolbarStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize VerticalFloatingToolbar.

    Args:
        buttons: Widgets placed inside the toolbar. Prefer ``Button`` or
            ``IconButton``; other widgets (including tooltip-wrapped buttons)
            are laid out as-is, but the edge-inset heuristic assumes
            button-sized children and degrades gracefully for larger ones.
        padding: External padding around the floating toolbar.
        style: Optional toolbar style. Defaults to ``ToolbarStyle.standard()``.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(buttons, orientation="vertical", padding=padding, style=style, key=key)

Tooltip

Tooltip(message: str, *, width: SizingLike = None, height: SizingLike = None, style: TooltipStyle | None = None, key: str | None = None)

Bases: ComposableWidget

Material Design 3 plain tooltip widget.

Initialize Tooltip.

Parameters:

Name Type Description Default
message str

Short plain-text tooltip message.

required
width SizingLike

Optional width sizing.

None
height SizingLike

Optional height sizing.

None
style TooltipStyle | None

Optional style token set. Defaults to TooltipStyle.standard().

None
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/tooltip_widgets.py
def __init__(
    self,
    message: str,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    style: TooltipStyle | None = None,
    key: str | None = None,
) -> None:
    """Initialize Tooltip.

    Args:
        message: Short plain-text tooltip message.
        width: Optional width sizing.
        height: Optional height sizing.
        style: Optional style token set. Defaults to TooltipStyle.standard().
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(width=width, height=height, key=key)
    self.message = str(message)
    self._user_style = style

style property

style: TooltipStyle

Return tooltip style resolved from user style or current theme.

RichTooltip

RichTooltip(supporting_text: str, *, subhead: str | None = None, action_label: str | None = None, on_action_click: Callable[[], None] | None = None, action_label_2: str | None = None, on_action_click_2: Callable[[], None] | None = None, width: SizingLike = None, height: SizingLike = None, style: RichTooltipStyle | None = None, key: str | None = None)

Bases: ComposableWidget

Material Design 3 rich tooltip widget.

Initialize RichTooltip.

Parameters:

Name Type Description Default
supporting_text str

Main explanatory text.

required
subhead str | None

Optional short title line.

None
action_label str | None

Optional primary text button label.

None
on_action_click Callable[[], None] | None

Optional callback for primary action.

None
action_label_2 str | None

Optional secondary text button label.

None
on_action_click_2 Callable[[], None] | None

Optional callback for secondary action.

None
width SizingLike

Optional width sizing.

None
height SizingLike

Optional height sizing.

None
style RichTooltipStyle | None

Optional style token set. Defaults to RichTooltipStyle.standard().

None
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/tooltip_widgets.py
def __init__(
    self,
    supporting_text: str,
    *,
    subhead: str | None = None,
    action_label: str | None = None,
    on_action_click: Callable[[], None] | None = None,
    action_label_2: str | None = None,
    on_action_click_2: Callable[[], None] | None = None,
    width: SizingLike = None,
    height: SizingLike = None,
    style: RichTooltipStyle | None = None,
    key: str | None = None,
) -> None:
    """Initialize RichTooltip.

    Args:
        supporting_text: Main explanatory text.
        subhead: Optional short title line.
        action_label: Optional primary text button label.
        on_action_click: Optional callback for primary action.
        action_label_2: Optional secondary text button label.
        on_action_click_2: Optional callback for secondary action.
        width: Optional width sizing.
        height: Optional height sizing.
        style: Optional style token set. Defaults to RichTooltipStyle.standard().
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(width=width, height=height, key=key)
    self.supporting_text = str(supporting_text)
    self.subhead = subhead
    self.action_label = action_label
    self.on_action_click = on_action_click
    self.action_label_2 = action_label_2
    self.on_action_click_2 = on_action_click_2
    self._user_style = style

style property

style: RichTooltipStyle

Return rich tooltip style resolved from user style or current theme.

preferred_size

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

Return preferred size clamped to [min_width, max_width] when auto-sized.

Source code in src/nuiitivet/material/tooltip_widgets.py
def preferred_size(
    self,
    max_width: Optional[int] = None,
    max_height: Optional[int] = None,
) -> Tuple[int, int]:
    """Return preferred size clamped to [min_width, max_width] when auto-sized."""
    if self.width_sizing.kind != "fixed":
        style = self.style
        effective_max = style.max_width
        if max_width is not None:
            effective_max = min(effective_max, max_width)
        w, h = super().preferred_size(max_width=effective_max, max_height=max_height)
        return max(w, style.min_width), h
    return super().preferred_size(max_width=max_width, max_height=max_height)

SideSheetStyle dataclass

SideSheetStyle(width: SizingLike = 400, height: SizingLike = 'wt', corner_radius: float = 16.0, background_color: ColorSpec = SURFACE_CONTAINER_LOW)

Immutable container style for a modal side sheet.

The framework wraps caller-supplied content in a container sized by this style. height defaults to "wt" so the sheet spans the full screen height; any other weight does the same (see the module docstring). corner_radius is applied to the inner (away-from-edge) corners only. background_color defaults to ColorRole.SURFACE_CONTAINER_LOW per M3 spec.

copy_with

copy_with(**changes) -> 'SideSheetStyle'

Return a copy with the given fields replaced.

Source code in src/nuiitivet/material/styles/sheet_style.py
def copy_with(self, **changes) -> "SideSheetStyle":
    """Return a copy with the given fields replaced."""
    return replace(self, **changes)

BottomSheetStyle dataclass

BottomSheetStyle(width: SizingLike = 'wt', height: SizingLike = None, corner_radius: float = 28.0, background_color: ColorSpec = SURFACE_CONTAINER_LOW)

Immutable container style for a modal bottom sheet.

The framework wraps caller-supplied content in a container sized by this style. width defaults to "wt" so the sheet spans the full screen width; any other weight does the same (see the module docstring). height=None means the container sizes to its content; a fixed number (height=400) is the way to ask for a partial-height sheet. corner_radius is applied to the top corners only. background_color defaults to ColorRole.SURFACE_CONTAINER_LOW per M3 spec.

copy_with

copy_with(**changes) -> 'BottomSheetStyle'

Return a copy with the given fields replaced.

Source code in src/nuiitivet/material/styles/sheet_style.py
def copy_with(self, **changes) -> "BottomSheetStyle":
    """Return a copy with the given fields replaced."""
    return replace(self, **changes)

StandardSideSheetStyle dataclass

StandardSideSheetStyle(width: SizingLike = 256, height: SizingLike = 'wt', corner_radius: float = 0.0, background_color: ColorSpec = SURFACE, show_divider: bool = True)

Immutable container style for a standard (docked) side sheet.

A standard side sheet is part of the layout and sits beside main content. It does not use an overlay or scrim.

width defaults to 256 per M3 token md.comp.sheet.side.docked.container.width. height defaults to "wt" so the sheet spans the full content area height; any other weight does the same (see the module docstring). corner_radius defaults to 0.0 per M3 token md.comp.sheet.side.docked.container.shape (corner.none). background_color defaults to ColorRole.SURFACE per M3 token md.comp.sheet.side.docked.standard.container.color (elevation level 0). show_divider defaults to True. When True, a vertical Divider is rendered on the edge facing the main content area. The divider color is governed by the theme's outlineVariant role per M3 token md.comp.sheet.side.docked.divider.color.

copy_with

copy_with(**changes) -> 'StandardSideSheetStyle'

Return a copy with the given fields replaced.

Source code in src/nuiitivet/material/styles/sheet_style.py
def copy_with(self, **changes) -> "StandardSideSheetStyle":
    """Return a copy with the given fields replaced."""
    return replace(self, **changes)

SideSheet

SideSheet(content: Widget, *, headline: Union[str, ObservableBase[str]], on_back: Optional[Callable[[], None]] = None, show_back_button: Union[bool, ObservableBase[bool]] = False, style: Optional[SideSheetStyle] = None, key: Optional[str] = None)

Bases: ComposableWidget, OverlayAware[None]

Modal side sheet container widget.

Renders an M3-compliant header (optional Back button, Headline, Close button) above content. Pass this widget to MaterialOverlay.side_sheet().

The header layout is fixed by M3 spec::

[ Back (optional) ]  [ Headline ]  [ Close ]
Note

The Back button is visible only when show_back_button is truthy and on_back is not None. Providing show_back_button=True alone without on_back will silently suppress the button.

The Close button always dismisses the sheet through the overlay's unified dismissal pipeline. To intercept the close (for unsaved changes, etc.), attach a will_pop modifier::

overlay.side_sheet(
    SideSheet(content, headline="Settings")
    .modifier(will_pop(on_will_pop=lambda: not has_unsaved_changes))
)

The slide-in edge and corner rounding are owned by MaterialOverlay.side_sheet(sheet, side=...), not by this widget.

Parameters:

Name Type Description Default
content Widget

Widget to display below the header.

required
headline Union[str, ObservableBase[str]]

Header title text (str or Observable[str]). Required by M3.

required
on_back Optional[Callable[[], None]]

Callback invoked when the Back icon button is pressed. Back button visibility is controlled separately by show_back_button.

None
show_back_button Union[bool, ObservableBase[bool]]

Whether to show the Back icon button. Accepts bool or Observable[bool] for dynamic toggling (e.g. driven by in-sheet navigation state). Defaults to False. The button is only rendered when this is truthy and on_back is not None.

False
style Optional[SideSheetStyle]

Container style. Defaults to :class:SideSheetStyle.

None

Initialize SideSheet.

Parameters:

Name Type Description Default
content Widget

Widget to display below the header.

required
headline Union[str, ObservableBase[str]]

Header title text (str or Observable[str]).

required
on_back Optional[Callable[[], None]]

Callback for the Back icon button press.

None
show_back_button Union[bool, ObservableBase[bool]]

Back button visibility (bool or Observable[bool]). Defaults to False. Rendered only when truthy and on_back is not None.

False
style Optional[SideSheetStyle]

Container style. Defaults to :class:SideSheetStyle.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/sheet.py
def __init__(
    self,
    content: Widget,
    *,
    headline: Union[str, ObservableBase[str]],
    on_back: Optional[Callable[[], None]] = None,
    show_back_button: Union[bool, ObservableBase[bool]] = False,
    style: Optional[SideSheetStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize SideSheet.

    Args:
        content: Widget to display below the header.
        headline: Header title text (str or Observable[str]).
        on_back: Callback for the Back icon button press.
        show_back_button: Back button visibility (bool or Observable[bool]).
            Defaults to ``False``. Rendered only when truthy **and** *on_back*
            is not ``None``.
        style: Container style. Defaults to :class:`SideSheetStyle`.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    _style = style if style is not None else SideSheetStyle()
    super().__init__(width=_style.width, height=_style.height, key=key)
    self._content = content
    self._headline = headline
    self._on_back = on_back
    self._show_back_button = show_back_button
    self._user_style = style

style property

Return resolved sheet style.

on_mount

on_mount() -> None

Mount and subscribe to show_back_button observable if provided.

Source code in src/nuiitivet/material/sheet.py
def on_mount(self) -> None:
    """Mount and subscribe to show_back_button observable if provided."""
    super().on_mount()
    if isinstance(self._show_back_button, ObservableBase):
        sub = self._show_back_button.subscribe(lambda _: self.rebuild())
        self.bind(sub)

build

build() -> Widget

Build the sheet: outer Box with header Row and content Column.

Source code in src/nuiitivet/material/sheet.py
def build(self) -> Widget:
    """Build the sheet: outer Box with header Row and content Column."""
    resolved_style = self.style

    # Header row: [Back slot] [Headline (weight)] [Close]
    # The back-button slot is always reserved (same width as IconButton default)
    # so the headline stays at a consistent horizontal position regardless of
    # whether the back button is visible.
    _BACK_SIZE = 40  # matches IconButton default size

    if self._resolve_show_back() and self._on_back is not None:
        back_slot: Widget = IconButton("arrow_back", on_click=self._on_back)
    else:
        back_slot = Box(width=_BACK_SIZE, height=_BACK_SIZE)

    header = Row(
        [
            back_slot,
            Box(
                Text(
                    self._headline,
                    style=TextStyle(color=ColorRole.ON_SURFACE_VARIANT),
                    type_scale=TypeScaleToken.from_size(22),
                ),
                width="wt",
                padding=(8, 0, 8, 0),
            ),
            IconButton("close", on_click=self._on_close_click if self._overlay_handle is not None else None),
        ],
        width="wt",
        height=72,
        padding=(4, 0, 4, 0),
        cross_alignment="center",
    )

    # Corner rounding is applied by ``MaterialOverlay.side_sheet()`` via the
    # ``corner_radius`` modifier (it depends on the slide-in edge, which this
    # widget does not know).  The container itself is square.
    return Box(
        Column(
            [header, self._content],
            width="wt",
        ),
        width=resolved_style.width,
        height=resolved_style.height,
        background_color=resolved_style.background_color,
        alignment="top-left",
    )

BottomSheet

BottomSheet(content: Widget, *, headline: Union[str, ObservableBase[str]], style: Optional[BottomSheetStyle] = None, key: Optional[str] = None)

Bases: ComposableWidget, OverlayAware[None]

Modal bottom sheet container widget.

Renders an M3-compliant header (Headline, Close button) above content. Pass this widget to MaterialOverlay.bottom_sheet().

The header layout is fixed by M3 spec::

[ Headline ]  [ Close ]

The Close button always dismisses the sheet through the overlay's unified dismissal pipeline. To intercept the close (for unsaved changes, etc.), attach a will_pop modifier.

Parameters:

Name Type Description Default
content Widget

Widget to display below the header.

required
headline Union[str, ObservableBase[str]]

Header title text (str or Observable[str]). Required by M3.

required
style Optional[BottomSheetStyle]

Container size, background, and shape options. Defaults to :class:BottomSheetStyle.

None

Initialize BottomSheet.

Parameters:

Name Type Description Default
content Widget

Widget to display below the header.

required
headline Union[str, ObservableBase[str]]

Header title text (str or Observable[str]).

required
style Optional[BottomSheetStyle]

Container style. Defaults to :class:BottomSheetStyle.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/sheet.py
def __init__(
    self,
    content: Widget,
    *,
    headline: Union[str, ObservableBase[str]],
    style: Optional[BottomSheetStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize BottomSheet.

    Args:
        content: Widget to display below the header.
        headline: Header title text (str or Observable[str]).
        style: Container style. Defaults to :class:`BottomSheetStyle`.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    _style = style if style is not None else BottomSheetStyle()
    super().__init__(width=_style.width, height=_style.height, key=key)
    self._content = content
    self._headline = headline
    self._user_style = style

style property

Return resolved sheet style.

build

build() -> Widget

Build the sheet: outer Box with header Row and content Column.

Source code in src/nuiitivet/material/sheet.py
def build(self) -> Widget:
    """Build the sheet: outer Box with header Row and content Column."""
    resolved_style = self.style

    header = Row(
        [
            Box(
                Text(
                    self._headline,
                    style=TextStyle(color=ColorRole.ON_SURFACE_VARIANT),
                    type_scale=TypeScaleToken.from_size(22),
                ),
                width="wt",
                padding=(8, 0, 8, 0),
            ),
            IconButton("close", on_click=self._on_close_click if self._overlay_handle is not None else None),
        ],
        width="wt",
        height=72,
        padding=(4, 0, 4, 0),
        cross_alignment="center",
    )

    # Round only the top corners.
    cr = float(resolved_style.corner_radius)
    corner_radius = (cr, cr, 0.0, 0.0)  # tl, tr, br, bl

    return Box(
        Column(
            [header, self._content],
            width="wt",
        ),
        width=resolved_style.width,
        height=resolved_style.height,
        corner_radius=corner_radius,
        background_color=resolved_style.background_color,
    )

StandardSideSheet

StandardSideSheet(content: Widget, *, opened: Union[bool, MutableObservableBase[bool]] = True, on_close_click: Optional[Callable[[], None]] = None, headline: Optional[Union[str, ObservableBase[str]]] = None, side: Literal['right', 'left'] = 'right', style: Optional[StandardSideSheetStyle] = None, key: Optional[str] = None)

Bases: ComposableWidget

Material Design 3 standard (docked) side sheet.

A standard side sheet is a permanent part of the layout, sitting beside the main content. It owns its open/close animation: the sheet stays mounted while its allocated width animates between the style width and zero::

opened: Observable[bool] = Observable(True)

Row([
    main_content,
    StandardSideSheet(panel_content, headline="Filters", opened=opened),
])

Toggling the sheet is a plain write to opened (opened.value = not opened.value). Conditionally rendering the sheet instead would unmount it and skip the animation.

The close icon button is rendered when the sheet can act on a press, i.e. when opened is a writable observable, when on_close_click is given, or both. With a literal bool opened and no callback there is nothing a press could do, so no button is shown.

Parameters:

Name Type Description Default
content Widget

Widget to display inside the sheet.

required
opened Union[bool, MutableObservableBase[bool]]

bool or writable Observable[bool] driving the expand/collapse animation. Defaults to True.

True
on_close_click Optional[Callable[[], None]]

Callback invoked when the close icon button is pressed. Supplying it disables the default auto-close: the sheet no longer writes opened.value = False and updating opened becomes the caller's responsibility. This is the interception point for confirm-before-close flows.

None
headline Optional[Union[str, ObservableBase[str]]]

Optional header title text (str or Observable[str]). When provided, an M3-compliant header row is rendered above content.

None
side Literal['right', 'left']

Edge the sheet is attached to ("right" or "left"). Defaults to "right". The collapse anchor is derived from it.

'right'
style Optional[StandardSideSheetStyle]

Container style. Defaults to :class:StandardSideSheetStyle.

None

Initialize StandardSideSheet.

Parameters:

Name Type Description Default
content Widget

Widget to display inside the sheet.

required
opened Union[bool, MutableObservableBase[bool]]

bool or writable Observable[bool]. Defaults to True.

True
on_close_click Optional[Callable[[], None]]

Callback for the close icon button. Supplying it disables the default opened.value = False auto-close.

None
headline Optional[Union[str, ObservableBase[str]]]

Optional header title (str or Observable[str]).

None
side Literal['right', 'left']

Attachment edge ("right" or "left"). Defaults to "right".

'right'
style Optional[StandardSideSheetStyle]

Container style. Defaults to :class:StandardSideSheetStyle.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/sheet.py
def __init__(
    self,
    content: Widget,
    *,
    opened: Union[bool, MutableObservableBase[bool]] = True,
    on_close_click: Optional[Callable[[], None]] = None,
    headline: Optional[Union[str, ObservableBase[str]]] = None,
    side: Literal["right", "left"] = "right",
    style: Optional[StandardSideSheetStyle] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize StandardSideSheet.

    Args:
        content: Widget to display inside the sheet.
        opened: ``bool`` or writable ``Observable[bool]``.  Defaults to
            ``True``.
        on_close_click: Callback for the close icon button.  Supplying it
            disables the default ``opened.value = False`` auto-close.
        headline: Optional header title (str or Observable[str]).
        side: Attachment edge (``"right"`` or ``"left"``).
            Defaults to ``"right"``.
        style: Container style.  Defaults to :class:`StandardSideSheetStyle`.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    _style = style if style is not None else StandardSideSheetStyle()
    # Only the height is declared on this node: the parent resolves a
    # child's cross-axis size from ``height_sizing``, so a percentage
    # height must be visible there.  The width stays ``auto`` because the
    # open/close animation works by having ``Collapsible`` report an
    # animating preferred width to the parent.
    super().__init__(height=_style.height, key=key)
    self._content = content
    self._opened = opened
    self._on_close_click = on_close_click
    self._headline = headline
    self.side = side
    self._user_style = style

style property

Return the resolved sheet style.

on_mount

on_mount() -> None

Mount and subscribe to headline observable if provided.

Source code in src/nuiitivet/material/sheet.py
def on_mount(self) -> None:
    """Mount and subscribe to headline observable if provided."""
    super().on_mount()
    if isinstance(self._headline, ObservableBase):
        sub = self._headline.subscribe(lambda _: self.rebuild())
        self.bind(sub)

build

build() -> Widget

Build the sheet: a Collapsible wrapping the sheet container Box.

Source code in src/nuiitivet/material/sheet.py
def build(self) -> Widget:
    """Build the sheet: a Collapsible wrapping the sheet container Box."""
    resolved_style = self.style

    # Optionally build the header row (headline + close button).
    body_parts: list[Widget] = []
    show_close = self._show_close_button()
    if self._headline is not None or show_close:
        header_children: list[Widget] = []
        if self._headline is not None:
            header_children.append(
                Box(
                    Text(
                        self._headline,
                        style=TextStyle(color=ColorRole.ON_SURFACE_VARIANT),
                        type_scale=TypeScaleToken.from_size(22),
                    ),
                    width=Sizing.weight(1),
                    padding=(8, 0, 8, 0),
                )
            )
        if show_close:
            header_children.append(IconButton("close", on_click=self._handle_close_click))
        body_parts.append(
            Row(
                header_children,
                width="wt",
                height=72,
                padding=(4, 0, 4, 0),
                cross_alignment="center",
            )
        )
    body_parts.append(self._content)

    # The body fills the container so that content declaring a weight height
    # gets the space left over by the header.
    content_col = Column(body_parts, width=Sizing.weight(1), height=Sizing.weight(1))

    # Optionally add a vertical Divider on the edge facing the main content.
    if resolved_style.show_divider:
        divider = VerticalDivider()
        if self.side == "right":
            inner: Widget = Row([divider, content_col], width="wt", height="wt")
        else:
            inner = Row([content_col, divider], width="wt", height="wt")
    else:
        inner = content_col

    container = Box(
        inner,
        width=resolved_style.width,
        height=resolved_style.height,
        background_color=resolved_style.background_color,
        alignment="top-left",
    )

    # The collapse anchor is the edge the sheet is docked to: the child is
    # laid out at its natural width while the allocated rect shrinks, so
    # the docked edge must stay pinned.
    alignment = ("end", "start") if self.side == "right" else ("start", "start")
    return Collapsible(
        container,
        opened=self._opened,
        axis="horizontal",
        alignment=alignment,
        motion=EXPRESSIVE_DEFAULT_SPATIAL,
    )

GroupButton

GroupButton(label: 'str | ObservableBase[str] | None' = None, icon: 'Symbol | str | ObservableBase | None' = None, *, selected: 'bool | MutableObservableBase[bool]' = False, on_change: Optional[BoolCallback] = None, disabled: 'bool | MutableObservableBase[bool]' = False, width: SizingLike = None, style: 'Optional[ButtonGroupStyle]' = None, key: Optional[str] = None)

Bases: InteractiveWidget

A single interactive segment in a ButtonGroup (Standard or Connected).

Handles position-aware corner-radius shape morphing via EXPRESSIVE_FAST_SPATIAL motion on press / release. set_position() is called exclusively by the containing _ButtonGroupBase during on_mount; it is not part of the public user API.

Parameters:

Name Type Description Default
label 'str | ObservableBase[str] | None'

Optional text label. Can be a plain str or a ObservableBase[str] for dynamic text.

None
icon 'Symbol | str | ObservableBase | None'

Optional icon. Accepts a Symbol, str icon name, or ObservableBase wrapping either.

None
selected 'bool | MutableObservableBase[bool]'

Initial selected (toggle) state. Pass an MutableObservableBase[bool] to bind to external state.

False
on_change Optional[BoolCallback]

Callback fired with the new bool selected state after each toggle. In ConnectedButtonGroup this callback is composed with the group-level selection logic.

None
disabled 'bool | MutableObservableBase[bool]'

Whether the item ignores pointer events.

False
width SizingLike

Optional width sizing. ConnectedButtonGroup overrides this to Sizing.weight(1) to achieve equal-width segments.

None
style 'Optional[ButtonGroupStyle]'

Optional style override. If omitted, the containing group's style is used; a group button standing on its own follows the theme's standard-group style.

None

Initialize GroupButton.

Parameters:

Name Type Description Default
label 'str | ObservableBase[str] | None'

Text label, or an observable string.

None
icon 'Symbol | str | ObservableBase | None'

Icon symbol, string name, or observable icon.

None
selected 'bool | MutableObservableBase[bool]'

Initial selected state, or an observable bool.

False
on_change Optional[BoolCallback]

Toggle-state change callback.

None
disabled 'bool | MutableObservableBase[bool]'

Disable interaction.

False
width SizingLike

Width sizing spec.

None
style 'Optional[ButtonGroupStyle]'

Visual style override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/button_group.py
def __init__(
    self,
    label: "str | ObservableBase[str] | None" = None,
    icon: "Symbol | str | ObservableBase | None" = None,
    *,
    selected: "bool | MutableObservableBase[bool]" = False,
    on_change: Optional[BoolCallback] = None,
    disabled: "bool | MutableObservableBase[bool]" = False,
    width: SizingLike = None,
    style: "Optional[ButtonGroupStyle]" = None,
    key: Optional[str] = None,
) -> None:
    """Initialize GroupButton.

    Args:
        label: Text label, or an observable string.
        icon: Icon symbol, string name, or observable icon.
        selected: Initial selected state, or an observable bool.
        on_change: Toggle-state change callback.
        disabled: Disable interaction.
        width: Width sizing spec.
        style: Visual style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    from nuiitivet.material.styles.button_group_style import StandardButtonGroupStyle

    if label is None and icon is None:
        raise ValueError("GroupButton requires at least one of label or icon")

    self._has_user_style = style is not None
    #: Set by the containing group, which resolves one style for the whole
    #: row and pushes it down; an item that has one must not pull its own.
    self._group_styled = False
    # Held in a local for everything below ``super().__init__()``: reads that
    # run before the widget is attached must not go through an accessor that
    # could reach for the theme, which is not resolvable yet. The preset is
    # what ``StandardButtonGroupStyle.from_theme`` falls back to, so an
    # unthemed app sees no change.
    effective_style: "ButtonGroupStyle" = style or StandardButtonGroupStyle.preset()
    self._style: "ButtonGroupStyle" = effective_style
    self._label = label
    self._icon = icon

    # on_change is interceptable by the containing group
    self._on_change: Optional[BoolCallback] = on_change

    # Selected state
    self._selected_external: "Optional[MutableObservableBase[bool]]" = None
    if hasattr(selected, "subscribe") and hasattr(selected, "value"):
        self._selected_external = cast("MutableObservableBase[bool]", selected)
        self._selected: bool = bool(self._selected_external.value)
    else:
        self._selected = bool(selected)

    # Corner animation state
    self._position: ButtonGroupPosition = "only"
    self._adjacent_animation: bool = True
    self._persistent_selected_pressed_shape: bool = False
    self._connected_inner_press_only: bool = False
    self._own_pressed: bool = False

    # Adjacent width-interaction state (Standard groups only).  Each item
    # exposes only a 0..1 "active" progress and its natural content-fit
    # width; the parent group layout (``_ButtonGroupRow``) reads these in a
    # single measure pass to grow the active item and compress its direct
    # neighbors, keeping the group width conserved (mirrors M3 Compose's
    # ButtonGroup, which avoids per-child layout jitter).  The width is NOT
    # animated per item here.
    self._base_width: float = float(effective_style.min_item_width)

    # Store child widget refs for colour updates
    self._text_widget: "Optional[Widget]" = None
    self._icon_widget_ref: "Optional[Widget]" = None

    # Compute initial effective colours
    bg, fg, bc, bw = self._effective_colors()

    # Build content child (stores text/icon refs)
    content = self._build_content(fg)

    # Initialize corner animation (no motion yet; motion is enabled in
    # set_position() so the initial position snap is immediate)
    initial_corners = self._compute_raw_idle_corners(
        effective_style.outer_corner_radius,
        effective_style.outer_corner_radius,  # "only" position: all outer
    )
    self._corner_anim: "Animatable[Tuple[float, float, float, float]]" = Animatable.vector(
        initial_value=initial_corners,
        converter=_CORNER_CONVERTER,
        motion=None,  # Enabled after first set_position()
    )

    # Active progress 0..1 (motion enabled in set_position()).  Drives the
    # parent-computed width interaction; ticks only request a re-layout.
    self._press_progress: "Animatable[float]" = Animatable(0.0, motion=None)

    super().__init__(
        child=content,
        on_click=self._handle_click,
        on_press=self._handle_press_down,
        on_release=self._handle_press_up,
        disabled=disabled,
        width=width,
        height=effective_style.container_height,
        # No box padding: the leading/trailing space is reserved via
        # ``preferred_size`` and rendered by *centering* the content (see
        # ``_side_space``).  This keeps the icon/label centred so the
        # pressed-width interaction compresses neighbours symmetrically.
        padding=0,
        alignment="center",
        background_color=bg,
        border_color=bc,
        border_width=bw,
        corner_radius=initial_corners,
        state_layer_color=effective_style.overlay_color or ColorRole.ON_SURFACE,
        key=key,
    )

    # Override state-layer opacities from style
    self._PRESS_OPACITY = effective_style.overlay_alpha
    self._HOVER_OPACITY = effective_style.overlay_alpha * 2 / 3

preferred_size

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

Return preferred size.

Connected groups enforce a visual minimum width (M3: 48dp for XS/S segments). Standard groups are content-fit: their 48dp spec value is an accessible tap-target requirement, not a visual width floor, so it is intentionally not applied to the rendered width here.

Parameters:

Name Type Description Default
max_width Optional[int]

Available width constraint.

None
max_height Optional[int]

Available height constraint.

None

Returns:

Type Description
Tuple[int, int]

(width, height) in pixels.

Source code in src/nuiitivet/material/button_group.py
def preferred_size(self, max_width: Optional[int] = None, max_height: Optional[int] = None) -> Tuple[int, int]:
    """Return preferred size.

    Connected groups enforce a visual minimum width (M3: 48dp for XS/S
    segments).  Standard groups are content-fit: their 48dp spec value is an
    accessible **tap-target** requirement, not a visual width floor, so it
    is intentionally not applied to the rendered width here.

    Args:
        max_width: Available width constraint.
        max_height: Available height constraint.

    Returns:
        ``(width, height)`` in pixels.
    """
    self._sync_theme_style()
    # Content is centred with zero box padding, so ``super`` returns the
    # bare content width; add the reserved leading + trailing space here so
    # the idle width still equals content + 2 × side-space.
    w, _h = super().preferred_size(max_width=max_width, max_height=max_height)
    w += 2 * self._side_space()
    if not self._adjacent_animation:  # Connected groups only
        w = max(w, self._style.min_item_width)
    return (int(w), self._style.container_height)

set_position

set_position(position: ButtonGroupPosition, adjacent_animation: bool = True) -> None

Configure this item's position within its group.

Called exclusively by _ButtonGroupBase.on_mount(). Snaps the corner radius to the idle value for the given position without animation, then arms the EXPRESSIVE_FAST_SPATIAL motion for subsequent press interactions.

Parameters:

Name Type Description Default
position ButtonGroupPosition

One of "start", "middle", "end", "only".

required
adjacent_animation bool

True for Standard groups (the active item's width grows and neighbors compress); False for Connected.

True
Source code in src/nuiitivet/material/button_group.py
def set_position(
    self,
    position: ButtonGroupPosition,
    adjacent_animation: bool = True,
) -> None:
    """Configure this item's position within its group.

    Called exclusively by ``_ButtonGroupBase.on_mount()``.  Snaps the
    corner radius to the idle value for the given position without
    animation, then arms the ``EXPRESSIVE_FAST_SPATIAL`` motion for
    subsequent press interactions.

    Args:
        position: One of ``"start"``, ``"middle"``, ``"end"``, ``"only"``.
        adjacent_animation: ``True`` for Standard groups (the active item's
            width grows and neighbors compress); ``False`` for Connected.
    """
    self._position = position
    self._adjacent_animation = adjacent_animation
    self._own_pressed = False

    # Capture the natural content-fit width (the parent layout grows/
    # compresses around this base).  Arm the MD3-spec spring on the active
    # progress so press/select transitions are smooth.
    self._base_width = float(self.preferred_size()[0])
    self._press_progress.snap_to(0.0)
    self._press_progress.set_motion(STANDARD_BUTTON_GROUP_WIDTH)

    idle = self._compute_target_corners(False)

    # Snap the animation to idle (no motion for position init)
    self._corner_anim.stop()
    # Directly set internal observable to avoid a spurious animation tick
    self._corner_anim._value.value = idle  # type: ignore[attr-defined]
    self._corner_anim._target = idle  # type: ignore[attr-defined]
    if self._corner_anim._state is not None:  # type: ignore[attr-defined]
        v = _CORNER_CONVERTER.to_vector(idle)
        state = self._corner_anim._state  # type: ignore[attr-defined]
        state.value = v.copy()
        state.start = v.copy()
        state.target = v.copy()

    # Enable expressive motion for future press interactions
    self._corner_anim._motion = EXPRESSIVE_FAST_SPATIAL  # type: ignore[attr-defined]
    v0 = _CORNER_CONVERTER.to_vector(idle)
    self._corner_anim._state = EXPRESSIVE_FAST_SPATIAL.create_state(v0, v0)  # type: ignore[attr-defined]

    # Apply immediately to Box's corner_radius (invalidates paint cache)
    self.corner_radius = idle

on_mount

on_mount() -> None

Subscribe to corner animation and external selected observable.

Source code in src/nuiitivet/material/button_group.py
def on_mount(self) -> None:
    """Subscribe to corner animation and external selected observable."""
    super().on_mount()

    # Subscribe to corner animation ticks
    self.bind(self._corner_anim.subscribe(self._on_corner_value_changed))

    # Subscribe to active-progress ticks: request a parent re-layout so the
    # group recomputes all widths in a single coordinated pass.
    self.bind(self._press_progress.subscribe(self._on_progress_changed))

    # Subscribe to external selected observable if provided
    if self._selected_external is not None:
        sub = self._selected_external.subscribe(lambda v: self._set_selected(bool(v)))
        self.bind(sub)

StandardButtonGroup

StandardButtonGroup(items: Sequence[GroupButton], *, style: 'Optional[StandardButtonGroupStyle]' = None, key: Optional[str] = None)

Bases: _ButtonGroupBase

A ButtonGroup that organises action or toggle segments horizontally.

Width fits the combined item widths. When a segment is activated (pressed) or selected, the MD3 adjacent interaction runs: the active segment animates its width, shape, and (via centered content) padding, while its direct neighbors shrink to compensate so the group's overall width stays stable. All transitions use M3 Expressive (EXPRESSIVE_FAST_SPATIAL) motion. Item selected states are independent — no group-level enforcement.

Parameters:

Name Type Description Default
items Sequence[GroupButton]

Between 2 and 5 GroupButton instances.

required
style 'Optional[StandardButtonGroupStyle]'

Visual style. Use StandardButtonGroupStyle.filled(), .tonal(), or .outlined(), optionally passing a size (e.g. StandardButtonGroupStyle.filled("m")).

None

Initialize StandardButtonGroup.

Parameters:

Name Type Description Default
items Sequence[GroupButton]

Between 2 and 5 GroupButton instances.

required
style 'Optional[StandardButtonGroupStyle]'

Visual style override. Defaults to the theme's standard button group style, which itself falls back to StandardButtonGroupStyle.filled() (size "s").

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/button_group.py
def __init__(
    self,
    items: Sequence[GroupButton],
    *,
    style: "Optional[StandardButtonGroupStyle]" = None,
    key: Optional[str] = None,
) -> None:
    """Initialize StandardButtonGroup.

    Args:
        items: Between 2 and 5 ``GroupButton`` instances.
        style: Visual style override.  Defaults to the theme's standard
            button group style, which itself falls back to
            ``StandardButtonGroupStyle.filled()`` (size ``"s"``).
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    from nuiitivet.material.styles.button_group_style import (
        StandardButtonGroupStyle as _Std,
    )

    self._has_user_style = style is not None
    eff_style = style if style is not None else _Std.preset()
    super().__init__(
        items,
        adjacent_animation=True,
        persistent_selected_pressed_shape=True,
        connected_inner_press_only=False,
        group_width=None,  # Fits content
        style=eff_style,
        key=key,
    )

ConnectedButtonGroup

ConnectedButtonGroup(items: Sequence[GroupButton], *, select_mode: Literal['single', 'multi'] = 'single', style: 'Optional[ConnectedButtonGroupStyle]' = None, key: Optional[str] = None)

Bases: _ButtonGroupBase

A ButtonGroup that functions as an option selector / view switcher.

Width expands to fill the containing widget (width="wt"). Items share space equally (Sizing.weight(1)). Only corner shapes animate on press — adjacent segment corners are unaffected. Selection is always enforced by the group.

Parameters:

Name Type Description Default
items Sequence[GroupButton]

Between 2 and 5 GroupButton instances.

required
select_mode Literal['single', 'multi']

"single" ensures at most one item is selected; "multi" allows any combination.

'single'
style 'Optional[ConnectedButtonGroupStyle]'

Visual style. Use ConnectedButtonGroupStyle.filled(), .tonal(), or .outlined(), optionally passing a size (e.g. ConnectedButtonGroupStyle.filled("m")).

None

Initialize ConnectedButtonGroup.

Parameters:

Name Type Description Default
items Sequence[GroupButton]

Between 2 and 5 GroupButton instances.

required
select_mode Literal['single', 'multi']

"single" or "multi" selection enforcement.

'single'
style 'Optional[ConnectedButtonGroupStyle]'

Visual style override. Defaults to the theme's connected button group style, which itself falls back to ConnectedButtonGroupStyle.filled() (size "s").

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/button_group.py
def __init__(
    self,
    items: Sequence[GroupButton],
    *,
    select_mode: Literal["single", "multi"] = "single",
    style: "Optional[ConnectedButtonGroupStyle]" = None,
    key: Optional[str] = None,
) -> None:
    """Initialize ConnectedButtonGroup.

    Args:
        items: Between 2 and 5 ``GroupButton`` instances.
        select_mode: ``"single"`` or ``"multi"`` selection enforcement.
        style: Visual style override.  Defaults to the theme's connected
            button group style, which itself falls back to
            ``ConnectedButtonGroupStyle.filled()`` (size ``"s"``).
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    from nuiitivet.material.styles.button_group_style import (
        ConnectedButtonGroupStyle as _Con,
    )

    self._has_user_style = style is not None
    eff_style = style if style is not None else _Con.preset()
    self._select_mode = select_mode

    super().__init__(
        items,
        adjacent_animation=False,
        persistent_selected_pressed_shape=False,
        connected_inner_press_only=True,
        group_width="wt",
        style=eff_style,
        key=key,
    )

on_mount

on_mount() -> None

Assign positions, set weight widths, and wire group selection logic.

Source code in src/nuiitivet/material/button_group.py
def on_mount(self) -> None:
    """Assign positions, set weight widths, and wire group selection logic."""
    super().on_mount()  # Calls _ButtonGroupBase.on_mount → set_position()

    # Equal-width distribution for connected layout
    for item in self._items:
        item.width_sizing = Sizing.weight(1)
        item.mark_needs_layout()

    # Intercept each item's on_change to apply group selection logic
    for i, item in enumerate(self._items):
        original_on_change = item._on_change

        def _make_wrapper(
            item_idx: int,
            orig_cb: Optional[BoolCallback],
        ) -> BoolCallback:
            def _wrapper(selected: bool) -> None:
                # 1. Item-level callback fires first
                if orig_cb is not None:
                    invoke_event_handler(
                        orig_cb,
                        selected,
                        error_key="group_button_item_on_change",
                        error_msg="GroupButton item on_change raised",
                        owner_name=type(item).__name__,
                    )
                # 2. Group selection logic
                self._handle_group_selection_change(item_idx, selected)

            return _wrapper

        item._on_change = _make_wrapper(i, original_on_change)

StandardButtonGroupStyle dataclass

StandardButtonGroupStyle(background: Optional[ColorSpec] = None, foreground: Optional[ColorSpec] = None, border_color: Optional[ColorSpec] = None, border_width: float = 0.0, selected_background: Optional[ColorSpec] = None, selected_foreground: Optional[ColorSpec] = None, container_height: int = 40, item_gap: int = 12, min_item_width: int = 48, inner_padding: int = 16, icon_size: int = 20, label_size: int = 14, icon_label_space: int = 8, outer_corner_radius: float = 20.0, pressed_outer_corner_radius: float = 12.0, pressed_inner_corner_radius: float = 12.0, pressed_width_multiplier: float = 0.15, overlay_color: Optional[ColorSpec] = None, overlay_alpha: float = 0.12)

Immutable style for StandardButtonGroup (M3-compliant).

All segments are independent fully-rounded pills. There is no junction-corner concept; inner_corner_radius always equals outer_corner_radius (exposed as a read-only property).

Use filled(), tonal(), or outlined() to create a preset, optionally passing a ButtonSize.

inner_corner_radius property

inner_corner_radius: float

Inner corner radius equals outer (fully-rounded pill).

selected_inner_corner_radius property

selected_inner_corner_radius: float

Not applicable; returns 0.0.

selected_border_color property

selected_border_color: Optional[ColorSpec]

No distinct selected border; falls back to border_color.

copy_with

copy_with(**changes: Any) -> 'StandardButtonGroupStyle'

Return a copy with the specified fields replaced.

Source code in src/nuiitivet/material/styles/button_group_style.py
def copy_with(self, **changes: Any) -> "StandardButtonGroupStyle":
    """Return a copy with the specified fields replaced."""
    return replace(self, **changes)

filled classmethod

filled(size: ButtonSize = 's') -> 'StandardButtonGroupStyle'

Create a filled-variant style.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'
Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def filled(cls, size: ButtonSize = "s") -> "StandardButtonGroupStyle":
    """Create a filled-variant style.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).
    """
    t = _STANDARD_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.SURFACE_CONTAINER_HIGHEST,
        foreground=ColorRole.ON_SURFACE,
        border_width=0.0,
        overlay_color=ColorRole.ON_SURFACE,
        overlay_alpha=0.08,
        selected_background=ColorRole.PRIMARY,
        selected_foreground=ColorRole.ON_PRIMARY,
        container_height=int(t["container_height"]),
        item_gap=int(t["item_gap"]),
        icon_size=int(t["icon_size"]),
        label_size=int(t["label_size"]),
        icon_label_space=int(t["icon_label_space"]),
        inner_padding=int(t["inner_padding"]),
        outer_corner_radius=float(t["outer_corner_radius"]),
        pressed_outer_corner_radius=float(t["pressed_corner_radius"]),
        pressed_inner_corner_radius=float(t["pressed_corner_radius"]),
    )

tonal classmethod

tonal(size: ButtonSize = 's') -> 'StandardButtonGroupStyle'

Create a tonal-variant style.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'
Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def tonal(cls, size: ButtonSize = "s") -> "StandardButtonGroupStyle":
    """Create a tonal-variant style.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).
    """
    t = _STANDARD_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.SECONDARY_CONTAINER,
        foreground=ColorRole.ON_SECONDARY_CONTAINER,
        border_width=0.0,
        overlay_color=ColorRole.ON_SURFACE,
        overlay_alpha=0.08,
        selected_background=ColorRole.SECONDARY,
        selected_foreground=ColorRole.ON_SECONDARY,
        container_height=int(t["container_height"]),
        item_gap=int(t["item_gap"]),
        icon_size=int(t["icon_size"]),
        label_size=int(t["label_size"]),
        icon_label_space=int(t["icon_label_space"]),
        inner_padding=int(t["inner_padding"]),
        outer_corner_radius=float(t["outer_corner_radius"]),
        pressed_outer_corner_radius=float(t["pressed_corner_radius"]),
        pressed_inner_corner_radius=float(t["pressed_corner_radius"]),
    )

outlined classmethod

outlined(size: ButtonSize = 's') -> 'StandardButtonGroupStyle'

Create an outlined-variant style.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'
Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def outlined(cls, size: ButtonSize = "s") -> "StandardButtonGroupStyle":
    """Create an outlined-variant style.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).
    """
    t = _STANDARD_SIZE_TOKENS[size]
    return cls(
        background=None,
        foreground=ColorRole.ON_SURFACE,
        border_color=ColorRole.OUTLINE,
        border_width=1.0,
        overlay_color=ColorRole.PRIMARY,
        overlay_alpha=0.08,
        selected_background=ColorRole.INVERSE_SURFACE,
        selected_foreground=ColorRole.INVERSE_ON_SURFACE,
        container_height=int(t["container_height"]),
        item_gap=int(t["item_gap"]),
        icon_size=int(t["icon_size"]),
        label_size=int(t["label_size"]),
        icon_label_space=int(t["icon_label_space"]),
        inner_padding=int(t["inner_padding"]),
        outer_corner_radius=float(t["outer_corner_radius"]),
        pressed_outer_corner_radius=float(t["pressed_corner_radius"]),
        pressed_inner_corner_radius=float(t["pressed_corner_radius"]),
    )

preset classmethod

preset() -> 'StandardButtonGroupStyle'

Return the framework preset, ignoring any theme.

This is what a standard button group renders with before it is mounted, and what :meth:from_theme falls back to when no Material theme is installed.

Returns:

Type Description
'StandardButtonGroupStyle'

The filled standard-group style at size "s".

Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def preset(cls) -> "StandardButtonGroupStyle":
    """Return the framework preset, ignoring any theme.

    This is what a standard button group renders with before it is
    mounted, and what :meth:`from_theme` falls back to when no Material
    theme is installed.

    Returns:
        The filled standard-group style at size ``"s"``.
    """
    return cls.filled("s")

from_theme classmethod

from_theme(theme: 'Theme | None') -> 'StandardButtonGroupStyle'

Resolve the standard button group style from theme.

Parameters:

Name Type Description Default
theme 'Theme | None'

The active theme, or None when there is none.

required

Returns:

Type Description
'StandardButtonGroupStyle'

Resolved standard-group style.

Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def from_theme(cls, theme: "Theme | None") -> "StandardButtonGroupStyle":
    """Resolve the standard button group style from ``theme``.

    Args:
        theme: The active theme, or ``None`` when there is none.

    Returns:
        Resolved standard-group style.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    if theme is not None:
        theme_data = theme.extension(MaterialThemeData)
        if theme_data is not None:
            return theme_data.standard_button_group_style
    return cls.preset()

ConnectedButtonGroupStyle dataclass

ConnectedButtonGroupStyle(background: Optional[ColorSpec] = None, foreground: Optional[ColorSpec] = None, border_color: Optional[ColorSpec] = None, border_width: float = 0.0, selected_background: Optional[ColorSpec] = None, selected_foreground: Optional[ColorSpec] = None, selected_border_color: Optional[ColorSpec] = None, container_height: int = 40, item_gap: int = 2, min_item_width: int = 48, icon_size: int = 20, label_size: int = 14, icon_label_space: int = 8, outer_corner_radius: float = 20.0, inner_corner_radius: float = 8.0, pressed_outer_corner_radius: float = 8.0, pressed_inner_corner_radius: float = 4.0, selected_inner_corner_radius: float = 0.0, overlay_color: Optional[ColorSpec] = None, overlay_alpha: float = 0.12)

Immutable style for ConnectedButtonGroup (M3-compliant).

Segments are tightly connected with distinct junction corners. Supports selection-state colours and a separate selected_inner_corner_radius.

Use filled(), tonal(), or outlined() to create a preset, optionally passing a ButtonSize.

copy_with

copy_with(**changes: Any) -> 'ConnectedButtonGroupStyle'

Return a copy with the specified fields replaced.

Source code in src/nuiitivet/material/styles/button_group_style.py
def copy_with(self, **changes: Any) -> "ConnectedButtonGroupStyle":
    """Return a copy with the specified fields replaced."""
    return replace(self, **changes)

filled classmethod

filled(size: ButtonSize = 's') -> 'ConnectedButtonGroupStyle'

Create a filled-variant style.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'
Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def filled(cls, size: ButtonSize = "s") -> "ConnectedButtonGroupStyle":
    """Create a filled-variant style.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).
    """
    t = _CONNECTED_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.SURFACE_CONTAINER_HIGHEST,
        foreground=ColorRole.ON_SURFACE,
        border_width=0.0,
        overlay_color=ColorRole.ON_SURFACE,
        overlay_alpha=0.08,
        selected_background=ColorRole.PRIMARY,
        selected_foreground=ColorRole.ON_PRIMARY,
        container_height=int(t["container_height"]),
        item_gap=int(t["item_gap"]),
        icon_size=int(t["icon_size"]),
        label_size=int(t["label_size"]),
        icon_label_space=int(t["icon_label_space"]),
        outer_corner_radius=float(t["outer_corner_radius"]),
        inner_corner_radius=float(t["inner_corner_radius"]),
        pressed_inner_corner_radius=float(t["pressed_inner_corner_radius"]),
    )

tonal classmethod

tonal(size: ButtonSize = 's') -> 'ConnectedButtonGroupStyle'

Create a tonal-variant style.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'
Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def tonal(cls, size: ButtonSize = "s") -> "ConnectedButtonGroupStyle":
    """Create a tonal-variant style.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).
    """
    t = _CONNECTED_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.SECONDARY_CONTAINER,
        foreground=ColorRole.ON_SECONDARY_CONTAINER,
        border_width=0.0,
        overlay_color=ColorRole.ON_SURFACE,
        overlay_alpha=0.08,
        selected_background=ColorRole.SECONDARY,
        selected_foreground=ColorRole.ON_SECONDARY,
        container_height=int(t["container_height"]),
        item_gap=int(t["item_gap"]),
        icon_size=int(t["icon_size"]),
        label_size=int(t["label_size"]),
        icon_label_space=int(t["icon_label_space"]),
        outer_corner_radius=float(t["outer_corner_radius"]),
        inner_corner_radius=float(t["inner_corner_radius"]),
        pressed_inner_corner_radius=float(t["pressed_inner_corner_radius"]),
    )

outlined classmethod

outlined(size: ButtonSize = 's') -> 'ConnectedButtonGroupStyle'

Create an outlined-variant style.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'
Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def outlined(cls, size: ButtonSize = "s") -> "ConnectedButtonGroupStyle":
    """Create an outlined-variant style.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).
    """
    t = _CONNECTED_SIZE_TOKENS[size]
    return cls(
        background=None,
        foreground=ColorRole.ON_SURFACE,
        border_color=ColorRole.OUTLINE,
        border_width=1.0,
        overlay_color=ColorRole.PRIMARY,
        overlay_alpha=0.08,
        selected_background=ColorRole.INVERSE_SURFACE,
        selected_foreground=ColorRole.INVERSE_ON_SURFACE,
        selected_border_color=ColorRole.OUTLINE,
        container_height=int(t["container_height"]),
        item_gap=int(t["item_gap"]),
        icon_size=int(t["icon_size"]),
        label_size=int(t["label_size"]),
        icon_label_space=int(t["icon_label_space"]),
        outer_corner_radius=float(t["outer_corner_radius"]),
        inner_corner_radius=float(t["inner_corner_radius"]),
        pressed_inner_corner_radius=float(t["pressed_inner_corner_radius"]),
    )

preset classmethod

preset() -> 'ConnectedButtonGroupStyle'

Return the framework preset, ignoring any theme.

This is what a connected button group renders with before it is mounted, and what :meth:from_theme falls back to when no Material theme is installed.

Returns:

Type Description
'ConnectedButtonGroupStyle'

The filled connected-group style at size "s".

Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def preset(cls) -> "ConnectedButtonGroupStyle":
    """Return the framework preset, ignoring any theme.

    This is what a connected button group renders with before it is
    mounted, and what :meth:`from_theme` falls back to when no Material
    theme is installed.

    Returns:
        The filled connected-group style at size ``"s"``.
    """
    return cls.filled("s")

from_theme classmethod

from_theme(theme: 'Theme | None') -> 'ConnectedButtonGroupStyle'

Resolve the connected button group style from theme.

Parameters:

Name Type Description Default
theme 'Theme | None'

The active theme, or None when there is none.

required

Returns:

Type Description
'ConnectedButtonGroupStyle'

Resolved connected-group style.

Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def from_theme(cls, theme: "Theme | None") -> "ConnectedButtonGroupStyle":
    """Resolve the connected button group style from ``theme``.

    Args:
        theme: The active theme, or ``None`` when there is none.

    Returns:
        Resolved connected-group style.
    """
    from nuiitivet.material.theme.theme_data import MaterialThemeData

    if theme is not None:
        theme_data = theme.extension(MaterialThemeData)
        if theme_data is not None:
            return theme_data.connected_button_group_style
    return cls.preset()

SplitButton

SplitButton(label: 'str | Any | None' = None, icon: 'Symbol | str | Any | None' = None, *, on_click: Optional[VoidCallback] = None, on_menu_toggle: Optional[BoolCallback] = None, menu_open: 'bool | MutableObservableBase[bool]' = False, disabled: 'bool | MutableObservableBase[bool]' = False, width: SizingLike = None, style: 'Optional[SplitButtonStyle]' = None, key: Optional[str] = None)

Bases: Box

Material Design 3 Expressive Split Button.

Combines a leading button (main action) with a trailing button (menu trigger). The two halves share an animated inner corner junction that morphs on hover and press. The trailing button's icon rotates 180° when the menu is opened.

Spec: https://m3.material.io/components/split-button/specs

Example::

SplitButton(
    "Start",
    icon="play_arrow",
    on_click=lambda: start_action(),
    on_menu_toggle=lambda open: handle_menu(open),
    style=SplitButtonStyle.filled("s"),
)

Initialize SplitButton.

Parameters:

Name Type Description Default
label 'str | Any | None'

Text label for the leading button. Either label or icon (or both) must be provided.

None
icon 'Symbol | str | Any | None'

Leading icon for the leading button. Accepts a :class:Symbol, a symbol name string, or a :class:ObservableBase.

None
on_click Optional[VoidCallback]

Callback invoked when the leading button is clicked.

None
on_menu_toggle Optional[BoolCallback]

Callback invoked with the new bool menu open state when the trailing button is clicked.

None
menu_open 'bool | MutableObservableBase[bool]'

Initial menu open (selected) state of the trailing button. Pass an :class:MutableObservableBase to bind externally.

False
disabled 'bool | MutableObservableBase[bool]'

Disables both button halves when True.

False
width SizingLike

Optional width sizing for the overall widget.

None
style 'Optional[SplitButtonStyle]'

Visual style. Defaults to SplitButtonStyle.filled("s").

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/split_button.py
def __init__(
    self,
    label: "str | Any | None" = None,
    icon: "Symbol | str | Any | None" = None,
    *,
    on_click: Optional[VoidCallback] = None,
    on_menu_toggle: Optional[BoolCallback] = None,
    menu_open: "bool | MutableObservableBase[bool]" = False,
    disabled: "bool | MutableObservableBase[bool]" = False,
    width: SizingLike = None,
    style: "Optional[SplitButtonStyle]" = None,
    key: Optional[str] = None,
) -> None:
    """Initialize SplitButton.

    Args:
        label: Text label for the leading button.  Either ``label`` or
            ``icon`` (or both) must be provided.
        icon: Leading icon for the leading button.  Accepts a
            :class:`Symbol`, a symbol name string, or a
            :class:`ObservableBase`.
        on_click: Callback invoked when the leading button is clicked.
        on_menu_toggle: Callback invoked with the new ``bool`` menu open
            state when the trailing button is clicked.
        menu_open: Initial menu open (selected) state of the trailing
            button.  Pass an :class:`MutableObservableBase` to bind
            externally.
        disabled: Disables both button halves when ``True``.
        width: Optional width sizing for the overall widget.
        style: Visual style.  Defaults to ``SplitButtonStyle.filled("s")``.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    if label is None and icon is None:
        raise ValueError("SplitButton requires at least one of label or icon")

    from nuiitivet.material.styles.split_button_style import SplitButtonStyle as _Style

    resolved_style: "SplitButtonStyle" = style or _Style.filled("s")

    leading_child = self._build_leading_content(label, icon, resolved_style)

    self._leading_btn = _SplitLeadingButton(
        child=leading_child,
        style=resolved_style,
        on_click=on_click,
        disabled=disabled,
    )
    self._trailing_btn = _SplitTrailingButton(
        style=resolved_style,
        on_menu_toggle=on_menu_toggle,
        menu_open=menu_open,
        disabled=disabled,
    )

    from nuiitivet.layout.row import Row

    row = Row(
        [self._leading_btn, self._trailing_btn],
        gap=resolved_style.between_space,
        cross_alignment="center",
    )

    super().__init__(child=row, width=width, key=key)

menu_open property

menu_open: bool

Whether the menu is currently open (trailing button selected).

Returns:

Type Description
bool

True when the menu is open.

SplitButtonStyle dataclass

SplitButtonStyle(background: Optional[ColorSpec] = None, foreground: Optional[ColorSpec] = None, border_color: Optional[ColorSpec] = None, border_width: float = 0.0, elevation: int = 0, overlay_color: Optional[ColorSpec] = None, overlay_alpha: float = 0.12, container_height: int = 40, between_space: int = 2, outer_corner_radius: float = 20.0, inner_corner_radius: float = 4.0, inner_corner_hovered_radius: float = 12.0, inner_corner_pressed_radius: float = 12.0, leading_leading_space: int = 16, leading_trailing_space: int = 12, trailing_icon_size: int = 22, trailing_leading_space: int = 13, trailing_trailing_space: int = 13, menu_icon_offset: int = -1, label_font_size: int = 14, icon_size: int = 20)

Immutable style for :class:SplitButton (M3 Expressive-compliant).

Use the filled, elevated, tonal, or outlined factory classmethods rather than constructing directly where possible.

All size-related tokens are driven by :data:SPLIT_BUTTON_SIZE_TOKENS.

copy_with

copy_with(**changes: Any) -> 'SplitButtonStyle'

Return a new style with the specified fields replaced.

Parameters:

Name Type Description Default
**changes Any

Fields to override.

{}

Returns:

Type Description
'SplitButtonStyle'

A new :class:SplitButtonStyle instance.

Source code in src/nuiitivet/material/styles/split_button_style.py
def copy_with(self, **changes: Any) -> "SplitButtonStyle":
    """Return a new style with the specified fields replaced.

    Args:
        **changes: Fields to override.

    Returns:
        A new :class:`SplitButtonStyle` instance.
    """
    return replace(self, **changes)

filled classmethod

filled(size: ButtonSize = 's') -> 'SplitButtonStyle'

Create a filled-variant style.

Uses Primary as the container colour.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'

Returns:

Type Description
'SplitButtonStyle'

A new :class:SplitButtonStyle instance.

Source code in src/nuiitivet/material/styles/split_button_style.py
@classmethod
def filled(cls, size: ButtonSize = "s") -> "SplitButtonStyle":
    """Create a filled-variant style.

    Uses ``Primary`` as the container colour.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).

    Returns:
        A new :class:`SplitButtonStyle` instance.
    """
    t = SPLIT_BUTTON_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.PRIMARY,
        foreground=ColorRole.ON_PRIMARY,
        border_width=0.0,
        overlay_color=ColorRole.ON_PRIMARY,
        overlay_alpha=0.12,
        **t,
    )

elevated classmethod

elevated(size: ButtonSize = 's') -> 'SplitButtonStyle'

Create an elevated-variant style.

Uses Surface as the container colour with elevation level 1.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'

Returns:

Type Description
'SplitButtonStyle'

A new :class:SplitButtonStyle instance.

Source code in src/nuiitivet/material/styles/split_button_style.py
@classmethod
def elevated(cls, size: ButtonSize = "s") -> "SplitButtonStyle":
    """Create an elevated-variant style.

    Uses ``Surface`` as the container colour with elevation level 1.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).

    Returns:
        A new :class:`SplitButtonStyle` instance.
    """
    t = SPLIT_BUTTON_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.SURFACE_CONTAINER_LOW,
        foreground=ColorRole.PRIMARY,
        border_width=0.0,
        elevation=1,
        overlay_color=ColorRole.PRIMARY,
        overlay_alpha=0.08,
        **t,
    )

tonal classmethod

tonal(size: ButtonSize = 's') -> 'SplitButtonStyle'

Create a tonal-variant style.

Uses SecondaryContainer as the container colour.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'

Returns:

Type Description
'SplitButtonStyle'

A new :class:SplitButtonStyle instance.

Source code in src/nuiitivet/material/styles/split_button_style.py
@classmethod
def tonal(cls, size: ButtonSize = "s") -> "SplitButtonStyle":
    """Create a tonal-variant style.

    Uses ``SecondaryContainer`` as the container colour.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).

    Returns:
        A new :class:`SplitButtonStyle` instance.
    """
    t = SPLIT_BUTTON_SIZE_TOKENS[size]
    return cls(
        background=ColorRole.SECONDARY_CONTAINER,
        foreground=ColorRole.ON_SECONDARY_CONTAINER,
        border_width=0.0,
        overlay_color=ColorRole.ON_SECONDARY_CONTAINER,
        overlay_alpha=0.12,
        **t,
    )

outlined classmethod

outlined(size: ButtonSize = 's') -> 'SplitButtonStyle'

Create an outlined-variant style.

Uses a transparent background with an Outline-coloured border.

Parameters:

Name Type Description Default
size ButtonSize

M3 size token preset ("xs""xl").

's'

Returns:

Type Description
'SplitButtonStyle'

A new :class:SplitButtonStyle instance.

Source code in src/nuiitivet/material/styles/split_button_style.py
@classmethod
def outlined(cls, size: ButtonSize = "s") -> "SplitButtonStyle":
    """Create an outlined-variant style.

    Uses a transparent background with an ``Outline``-coloured border.

    Args:
        size: M3 size token preset (``"xs"``–``"xl"``).

    Returns:
        A new :class:`SplitButtonStyle` instance.
    """
    t = SPLIT_BUTTON_SIZE_TOKENS[size]
    return cls(
        background=None,
        foreground=ColorRole.ON_SURFACE,
        border_color=ColorRole.OUTLINE,
        border_width=1.0,
        overlay_color=ColorRole.PRIMARY,
        overlay_alpha=0.08,
        **t,
    )

MaterialTransitionSpec dataclass

MaterialTransitionSpec(enter: TransitionDefinition, exit_: TransitionDefinition, barrier_mode: Literal['none', 'fade'] = 'none', enter_back: TransitionDefinition | None = None, exit_back: TransitionDefinition | None = None)

Material transition token for overlay/page lifecycle.

Carries enter / exit_ TransitionDefinitions plus a barrier_mode that controls scrim opacity behavior:

  • "none" : no scrim (page, snackbar)
  • "fade" : scrim fades in/out following progress (dialog, sheets)

enter_back / exit_back are the backward-direction (pop) variants. Directional transitions such as Shared Axis (Z) reverse their motion when navigating back, so a pop must not merely replay the forward enter / exit_. When either is None the resolver falls back to the forward definition, which keeps symmetric transitions (dialog, sheets, snackbar) unchanged.

DatePicker

DatePicker(value: ObservableProtocol[Optional[date]], *, on_change: Optional[Callable[[Optional[date]], None]] = None, on_confirm: Optional[Callable[[Optional[date]], None]] = None, on_cancel: Optional[Callable[[], None]] = None, min_date: Optional[date] = None, max_date: Optional[date] = None, labels: CalendarLabels = DEFAULT_CALENDAR_LABELS, style: Optional['DatePickerStyle'] = None, key: Optional[str] = None)

Bases: ComposableWidget

Material Design 3 inline calendar date picker.

An inline calendar widget that updates a shared observable value when the user selects a date. The picker always stays visible (not a dialog), which makes it composable with other widgets — :class:DockedDatePicker embeds one as its dropdown content.

MD3 container: 360×456dp, Large corner rounding (16dp).

Selecting a day updates value immediately; the MD3 action row confirms or abandons that selection. Standalone there is nothing to confirm to, so OK is inert and Cancel clears the selection. An embedder that owns a dismissal — :class:DockedDatePicker closing its dropdown — passes on_confirm and on_cancel to take over both buttons.

Parameters:

Name Type Description Default
value ObservableProtocol[Optional[date]]

Observable holding the currently selected :class:datetime.date (or None). Both reads and writes are performed on this object.

required
on_change Optional[Callable[[Optional[date]], None]]

Optional callback invoked after the value is updated.

None
on_confirm Optional[Callable[[Optional[date]], None]]

Optional callback invoked with value when OK is pressed. When omitted, OK does nothing.

None
on_cancel Optional[Callable[[], None]]

Optional callback invoked when Cancel is pressed. When omitted, Cancel clears value.

None
min_date Optional[date]

Earliest selectable date.

None
max_date Optional[date]

Latest selectable date.

None
labels CalendarLabels

Month names, weekday headers and first day of week the calendar renders with. The default is English and Sunday-first on every platform; it never reads the process locale.

DEFAULT_CALENDAR_LABELS
style Optional['DatePickerStyle']

Visual style. Defaults to :class:DatePickerStyle.

None

Initialize DatePicker.

Parameters:

Name Type Description Default
value ObservableProtocol[Optional[date]]

Observable holding the selected date (or None).

required
on_change Optional[Callable[[Optional[date]], None]]

Callback invoked when the user selects a date.

None
on_confirm Optional[Callable[[Optional[date]], None]]

Callback invoked with the selected date when OK is pressed.

None
on_cancel Optional[Callable[[], None]]

Callback invoked when Cancel is pressed; replaces the default "clear the selection" behavior.

None
min_date Optional[date]

Minimum selectable date.

None
max_date Optional[date]

Maximum selectable date.

None
labels CalendarLabels

Calendar display labels.

DEFAULT_CALENDAR_LABELS
style Optional['DatePickerStyle']

Optional style override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/date_picker.py
def __init__(
    self,
    value: ObservableProtocol[Optional[_Date]],
    *,
    on_change: Optional[Callable[[Optional[_Date]], None]] = None,
    on_confirm: Optional[Callable[[Optional[_Date]], None]] = None,
    on_cancel: Optional[Callable[[], None]] = None,
    min_date: Optional[_Date] = None,
    max_date: Optional[_Date] = None,
    labels: CalendarLabels = DEFAULT_CALENDAR_LABELS,
    style: Optional["DatePickerStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize DatePicker.

    Args:
        value: Observable holding the selected date (or None).
        on_change: Callback invoked when the user selects a date.
        on_confirm: Callback invoked with the selected date when OK is pressed.
        on_cancel: Callback invoked when Cancel is pressed; replaces the
            default "clear the selection" behavior.
        min_date: Minimum selectable date.
        max_date: Maximum selectable date.
        labels: Calendar display labels.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(key=key)
    self._value_obs = value
    self._on_change = on_change
    self._on_confirm = on_confirm
    self._on_cancel_cb = on_cancel
    self._min_date = min_date
    self._max_date = max_date
    self._labels = labels
    self._user_style = style

    # Initialise view to the currently selected month, or the current month.
    today = _Date.today()
    initial = getattr(value, "value", None)
    ref = initial if isinstance(initial, _Date) else today
    self._view_year = ref.year
    self._view_month = ref.month

    # View mode: "calendar" | "month" | "year"
    self._view_mode: Literal["calendar", "month", "year"] = "calendar"
    self._year_page_start: int = today.year - 3

    # Dropdown-arrow rotation: 0° (pointing down / menu closed) → 180°
    # (pointing up / menu open). Held on the persistent picker so the
    # animation survives the rebuild() triggered when toggling views.
    self._month_rotation: Animatable[float] = Animatable(0.0, motion=EXPRESSIVE_DEFAULT_SPATIAL)
    self._year_rotation: Animatable[float] = Animatable(0.0, motion=EXPRESSIVE_DEFAULT_SPATIAL)

    # Value last reflected in the built subtree. Used to suppress the
    # redundant rebuild that ``observe`` would otherwise trigger by applying
    # the current value immediately on (re)mount.
    self._synced_value: Any = initial

style property

style: 'DatePickerStyle'

Return the resolved date picker style.

on_mount

on_mount() -> None

Subscribe to external value changes to keep the display in sync.

Source code in src/nuiitivet/material/date_picker.py
def on_mount(self) -> None:
    """Subscribe to external value changes to keep the display in sync."""
    super().on_mount()
    self._synced_value = getattr(self._value_obs, "value", None)
    self.observe(self._value_obs, self._on_value_changed)

show_month

show_month(year: int, month: int) -> None

Scroll the calendar to year/month without changing the value.

Returns the picker to the calendar view if a month or year list is open.

Parameters:

Name Type Description Default
year int

Calendar year to display.

required
month int

Calendar month to display (1–12).

required
Source code in src/nuiitivet/material/date_picker.py
def show_month(self, year: int, month: int) -> None:
    """Scroll the calendar to ``year``/``month`` without changing the value.

    Returns the picker to the calendar view if a month or year list is open.

    Args:
        year: Calendar year to display.
        month: Calendar month to display (1–12).
    """
    changed = (
        self._view_year != year or self._view_month != month or self._view_mode != "calendar"
    )
    self._view_year = year
    self._view_month = month
    self._view_mode = "calendar"
    self._sync_rotation()
    if changed:
        self.rebuild()

build

build() -> Widget

Build the inline calendar container with navigation header and calendar.

Source code in src/nuiitivet/material/date_picker.py
def build(self) -> Widget:
    """Build the inline calendar container with navigation header and calendar."""
    style = self.style
    selected = getattr(self._value_obs, "value", None)
    shadows = elevation_shadows(style.elevation)

    nav_header = _MonthYearHeader(
        self._view_year,
        self._view_month,
        on_prev=self._go_prev_month,
        on_next=self._go_next_month,
        on_prev_year=self._go_prev_year,
        on_next_year=self._go_next_year,
        on_month_tap=self._toggle_month_view,
        on_year_tap=self._toggle_year_view,
        active_view=self._view_mode if self._view_mode in ("month", "year") else None,  # type: ignore[arg-type]
        month_rotation=self._month_rotation,
        year_rotation=self._year_rotation,
        variant="inline",
        labels=self._labels,
        style=style,
    )

    # Vertical layout per MD3 docked measurement (padding order = left, top,
    # right, bottom). Sections stack with no inter-section gap; the gaps come
    # from each section's own padding (component-box to component-box), and
    # sum to exactly the 460dp container:
    #   nav_header   : pad (4, 20, 4, 15)  -> 75dp  (20 + menu-button 40 + 15)
    #   weekday row  : pad (12, 15, 12, 8) -> 37dp  (15 + text 14 + 8)
    #   calendar grid: pad (12, 8, 12, 4)  -> 292dp (8 + 6*40+5*8 + 4)
    #   action row   : pad (12, 4, 12, 12) -> 56dp  (4 + button 40 + 12)
    # Resulting box gaps: top 20, below-header 30, below-weekday 16,
    # below-grid 8, below-button 12.
    # The docked nav header block is 75dp tall (20dp top + 40dp menu-button
    # + 15dp bottom). In a list view there is no action row (it is hidden,
    # matching MD3), so the list fills the remaining container height.
    _HEADER_BLOCK_H = 75
    list_height = int(style.container_height) - _HEADER_BLOCK_H

    if self._view_mode == "month":
        # Full container width so the scrollbar sits flush against the right
        # container edge with no dead margin beside it.
        body: Widget = _MonthList(
            self._view_month,
            on_select=self._select_month,
            list_height=list_height,
            item_width=style.container_width,
            labels=self._labels,
            style=style,
        )
    elif self._view_mode == "year":
        body = _YearList(
            self._view_year,
            on_select=self._select_year,
            list_height=list_height,
            item_width=style.container_width,
            style=style,
        )
    else:
        body = _CalendarGrid(
            self._view_year,
            self._view_month,
            selected_date=selected if isinstance(selected, _Date) else None,
            min_date=self._min_date,
            max_date=self._max_date,
            on_day_tap=self._on_day_tap,
            labels=self._labels,
            style=style,
        )

    # MD3 docked action buttons are 40dp tall; ButtonStyle.text() enforces a
    # 48dp min touch-target by default, so override min_height to keep the
    # visible button box at 40dp (12dp gap below it = MD3 action bottom).
    # The action row is only shown in the calendar view; the month/year list
    # menus replace the calendar area and hide the Cancel/OK buttons per MD3.
    column_children: list[Widget] = [nav_header, body]
    if self._view_mode == "calendar":
        action_btn_style = ButtonStyle.text().copy_with(container_height=40, min_height=40)
        action_row = Row(
            [
                Button("Cancel", on_click=self._on_cancel, style=action_btn_style),
                Button("OK", on_click=self._on_ok, style=action_btn_style),
            ],
            gap=16,
            main_alignment="end",
            padding=(12, 4, 12, 12),
            width=int(style.container_width),
        )
        column_children.append(action_row)

    return Box(
        background_color=style.background,
        corner_radius=style.corner_radius,
        shadows=shadows,
        width=style.container_width,
        height=style.container_height,
        child=Column(
            column_children,
            gap=0,
            height=int(style.container_height),
        ),
    )

DockedDatePicker

DockedDatePicker(*, value: ReadOnlyObservableProtocol[str], on_change: Optional[Callable[[str], None]] = None, on_submit: Optional[Callable[[str], None]] = None, on_focus_change: Optional[FocusChangeCallback] = None, date_format: DateFormat = DEFAULT_DATE_FORMAT, min_date: Optional[date] = None, max_date: Optional[date] = None, labels: CalendarLabels = DEFAULT_CALENDAR_LABELS, label: str = 'Date', supporting_text: str | ReadOnlyObservableProtocol[str | None] | None = None, is_error: bool | ReadOnlyObservableProtocol[bool] = False, style: Optional['DockedDatePickerStyle'] = None, key: Optional[str] = None)

Bases: ComposableWidget

Material Design 3 Docked Date Picker.

A text field with a trailing calendar icon button that opens a :class:DatePicker in a dropdown anchored below the field. The date can be entered either by typing it or by picking it from the calendar.

value is the field's text, not a date. The typed date is derived from it by the application::

self.date_text = nv.Observable("")
self.date = self.date_text.filter(nv.is_date, initial="").map(nv.parse_date)

nv.DockedDatePicker(value=self.date_text, label="Arrival")

Binding the text is what lets the application decide what an invalid date means. Half-typed input is a normal state of a field the user is allowed to type into, and only the application knows whether "06/1" should be shown as an error yet, or whether a perfectly parseable date is nonetheless unacceptable ("already booked"). So the widget reports no errors of its own: pass supporting_text and is_error -- derived from the same text -- and they have exactly one writer.

A date-bound field would have to keep the date and the text in step, and would have to own the error state in order to describe text that has no date. This binding removes both. :class:DatePicker, the inline calendar, keeps Observable[Optional[date]]: a widget's value type follows its primary input mechanism, and a calendar cannot be typed into.

Per MD3 the dropdown carries a Cancel/OK action row, so picking a day is a selection rather than a commit. The calendar edits an internal draft; only OK writes it into value. Cancel -- and any other dismissal, such as tapping outside the dropdown -- drops the draft, so an abandoned selection is never observable from value.

A read-only observable makes the field display-only, as it does for :class:TextField; the calendar's OK then has nowhere to write and does nothing.

date_format is one object rather than a parse and a format function, because the two directions must be inverses and two separate arguments cannot be checked for that. str(date_format) is its pattern, so the same object also spells the hint an application chooses to show.

MD3 reference: md.comp.date-picker.docked.*

Parameters:

Name Type Description Default
value ReadOnlyObservableProtocol[str]

Observable holding the field's text. Typing writes into it, and the calendar's OK writes the picked date into it formatted by format. Keyword-only, so call sites written against the pre-rename DockedDatePicker (the inline calendar, now :class:DatePicker) fail loudly instead of silently changing behavior.

required
on_change Optional[Callable[[str], None]]

Optional callback invoked with the text as it changes, by typing or by the calendar alike. The observable bound to value carries the same signal.

None
on_submit Optional[Callable[[str], None]]

Optional callback invoked with the text when the user presses Enter -- a request to act, not a value settling.

None
on_focus_change Optional[FocusChangeCallback]

Optional callback invoked as focus arrives and leaves. Where blur-triggered work belongs, such as reformatting a half-typed date once the user has left the field.

None
date_format DateFormat

How text is read as a date and how a picked date is written back. Reading is used only to decide which month the calendar opens on and which day it highlights, never to validate -- unparseable text simply leaves the calendar where it was. One object rather than a parse and a format, because the two have to be inverses and nothing could check that they were. Pass the same one the application derives its date with, and they agree by construction.

DEFAULT_DATE_FORMAT
min_date Optional[date]

Earliest date selectable in the calendar.

None
max_date Optional[date]

Latest date selectable in the calendar. The calendar cannot produce a date outside these bounds, but typing can: enforcing a range on typed text is the application's, via is_error. An application that wants both states the bounds in both places.

None
labels CalendarLabels

Month names, weekday headers and first day of week the dropdown calendar renders with. The default is English and Sunday-first on every platform; it never reads the process locale.

DEFAULT_CALENDAR_LABELS
label str

Floating label for the text field.

'Date'
supporting_text str | ReadOnlyObservableProtocol[str | None] | None

Text shown below the field. Empty by default: the widget has nothing of its own to say there, and the slot is where an application puts its error message. For a format hint, pass str(date_format).

None
is_error bool | ReadOnlyObservableProtocol[bool]

Whether to show the field in its error state. A separate axis from supporting_text: it recolors the whole field, so a field can be flagged without a message and carry one without being flagged.

False
style Optional['DockedDatePickerStyle']

Visual style. Defaults to :class:DockedDatePickerStyle.

None

Initialize DockedDatePicker.

Parameters:

Name Type Description Default
value ReadOnlyObservableProtocol[str]

Observable holding the field's text.

required
on_change Optional[Callable[[str], None]]

Callback invoked with the text as it changes.

None
on_submit Optional[Callable[[str], None]]

Callback invoked with the text when Enter is pressed.

None
on_focus_change Optional[FocusChangeCallback]

Callback invoked as focus arrives and leaves.

None
date_format DateFormat

How text is read as a date and written back.

DEFAULT_DATE_FORMAT
min_date Optional[date]

Earliest date selectable in the calendar.

None
max_date Optional[date]

Latest date selectable in the calendar.

None
labels CalendarLabels

Calendar display labels.

DEFAULT_CALENDAR_LABELS
label str

Text field label.

'Date'
supporting_text str | ReadOnlyObservableProtocol[str | None] | None

Text shown below the field. Empty by default.

None
is_error bool | ReadOnlyObservableProtocol[bool]

Whether to show the field in its error state.

False
style Optional['DockedDatePickerStyle']

Optional style override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/date_picker.py
def __init__(
    self,
    *,
    value: ReadOnlyObservableProtocol[str],
    on_change: Optional[Callable[[str], None]] = None,
    on_submit: Optional[Callable[[str], None]] = None,
    on_focus_change: Optional[FocusChangeCallback] = None,
    date_format: DateFormat = DEFAULT_DATE_FORMAT,
    min_date: Optional[_Date] = None,
    max_date: Optional[_Date] = None,
    labels: CalendarLabels = DEFAULT_CALENDAR_LABELS,
    label: str = "Date",
    supporting_text: str | ReadOnlyObservableProtocol[str | None] | None = None,
    is_error: bool | ReadOnlyObservableProtocol[bool] = False,
    style: Optional["DockedDatePickerStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize DockedDatePicker.

    Args:
        value: Observable holding the field's text.
        on_change: Callback invoked with the text as it changes.
        on_submit: Callback invoked with the text when Enter is pressed.
        on_focus_change: Callback invoked as focus arrives and leaves.
        date_format: How text is read as a date and written back.
        min_date: Earliest date selectable in the calendar.
        max_date: Latest date selectable in the calendar.
        labels: Calendar display labels.
        label: Text field label.
        supporting_text: Text shown below the field.  Empty by default.
        is_error: Whether to show the field in its error state.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(key=key)
    self._value_obs = value
    # The one writable cell this widget touches, and only when the calendar
    # commits. A read-only source displays only -- the same rule TextField
    # applies to typing, extended to the calendar.
    self._writable: Optional[ObservableProtocol[str]] = (
        cast("ObservableProtocol[str]", value) if isinstance(value, ObservableProtocol) else None
    )
    self._date_format = date_format
    self._user_style = style
    self._is_open: Observable[bool] = Observable(False)

    # The dropdown calendar writes here, not to ``value``. Opening seeds it
    # from the text; OK copies it back. Cancelling just drops it, so an
    # abandoned selection never reaches ``value``.
    self._draft_obs: Observable[Optional[_Date]] = Observable(date_format.parse(self._text()))

    style_ = self.style

    # Both the field and the calendar are built once and reused across
    # rebuild cycles: the field to preserve focus and cursor position, the
    # calendar to preserve the month being viewed while the dropdown reopens.
    from nuiitivet.material.text_fields import TextField
    from nuiitivet.material.styles.text_field_style import TextFieldStyle

    self._text_field: TextField = TextField(
        value,
        on_change=on_change,
        on_submit=on_submit,
        on_focus_change=on_focus_change,
        label=label,
        supporting_text=supporting_text,
        is_error=is_error,
        trailing_icon="calendar_today",
        on_tap_trailing_icon=self._toggle_dropdown,
        style=TextFieldStyle.outlined(),
        width=style_.field_width,
    )
    self._calendar = DatePicker(
        self._draft_obs,
        on_confirm=self._on_calendar_confirm,
        on_cancel=self._on_calendar_cancel,
        min_date=min_date,
        max_date=max_date,
        labels=labels,
        style=style_.calendar,
    )

style property

style: 'DockedDatePickerStyle'

Return the resolved docked date picker style.

on_mount

on_mount() -> None

Track the text so the calendar follows it, and the dropdown state.

Both observers only ever write to the internal draft, so neither can echo back into value -- there is nothing here to guard against.

Source code in src/nuiitivet/material/date_picker.py
def on_mount(self) -> None:
    """Track the text so the calendar follows it, and the dropdown state.

    Both observers only ever write to the internal draft, so neither can
    echo back into ``value`` -- there is nothing here to guard against.
    """
    super().on_mount()
    self.observe(self._value_obs, self._place_calendar)
    self.observe(self._is_open, self._on_open_changed)

build

build() -> Widget

Build the text field with its anchored calendar dropdown.

Source code in src/nuiitivet/material/date_picker.py
def build(self) -> Widget:
    """Build the text field with its anchored calendar dropdown."""
    return self._text_field.modifier(
        popup(
            self._calendar,
            is_open=self._is_open,
            target_anchor="bottom-left",
            content_anchor="top-left",
            offset=(0.0, self.style.dropdown_gap),
            # Stay below the field even when the window is too short for the
            # calendar: it overflows rather than opening upwards. Opening
            # above is the other reasonable answer -- turn ``flip`` back on
            # if that is wanted.
            flip=False,
        )
    )

ModalDatePicker

ModalDatePicker(*, init_value: Optional[date] = None, supporting_text: str = 'Select date', min_date: Optional[date] = None, max_date: Optional[date] = None, style: Optional['ModalDatePickerStyle'] = None, key: Optional[str] = None)

Bases: ComposableWidget, OverlayAware[Optional[date]]

Material Design 3 Modal Date Picker (single date selection).

When shown via overlay.dialog(ModalDatePicker(...)), the returned :class:OverlayHandle resolves to the selected date on confirmation or None on cancellation::

result = await overlay.dialog(ModalDatePicker())
if result.value is not None:
    selected_date: datetime.date = result.value

MD3 container: 360×524dp, Extra large corner rounding (28dp).

.. note:: Experimental implementation. This class does not yet fully comply with the MD3 Expressive specification. Known limitation: the icon button that toggles between :class:ModalDatePicker and :class:ModalDateInput is not implemented. Deferred: Nuiitivet prioritizes the Docked variant as a desktop-oriented framework.

Parameters:

Name Type Description Default
init_value Optional[date]

Pre-selected date shown when the picker opens.

None
supporting_text str

Small label shown at the top of the header (14pt).

'Select date'
min_date Optional[date]

Earliest selectable date.

None
max_date Optional[date]

Latest selectable date.

None
style Optional['ModalDatePickerStyle']

Visual style. Defaults to :class:ModalDatePickerStyle.

None

Initialize ModalDatePicker.

Parameters:

Name Type Description Default
init_value Optional[date]

Initial selected date.

None
supporting_text str

Small label shown at the top of the header (14pt).

'Select date'
min_date Optional[date]

Minimum selectable date.

None
max_date Optional[date]

Maximum selectable date.

None
style Optional['ModalDatePickerStyle']

Optional style override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/date_picker.py
def __init__(
    self,
    *,
    init_value: Optional[_Date] = None,
    supporting_text: str = "Select date",
    min_date: Optional[_Date] = None,
    max_date: Optional[_Date] = None,
    style: Optional["ModalDatePickerStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize ModalDatePicker.

    Args:
        init_value: Initial selected date.
        supporting_text: Small label shown at the top of the header (14pt).
        min_date: Minimum selectable date.
        max_date: Maximum selectable date.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(key=key)
    self._supporting_text = supporting_text
    self._min_date = min_date
    self._max_date = max_date
    self._user_style = style

    today = _Date.today()
    self._selected_date: Optional[_Date] = init_value

    if init_value is not None:
        self._view_year = init_value.year
        self._view_month = init_value.month
    else:
        self._view_year = today.year
        self._view_month = today.month

    self._showing_year_picker: bool = False

style property

style: 'ModalDatePickerStyle'

Return the resolved date picker style.

build

build() -> Widget

Build the modal picker with header, calendar (or year grid), and action buttons.

Source code in src/nuiitivet/material/date_picker.py
def build(self) -> Widget:
    """Build the modal picker with header, calendar (or year grid), and action buttons."""
    style = self.style
    shadows = elevation_shadows(style.elevation)

    nav_header = _MonthYearHeader(
        self._view_year,
        self._view_month,
        on_prev=self._go_prev_month,
        on_next=self._go_next_month,
        on_toggle_year_picker=self._toggle_year_picker,
        year_picker_active=self._showing_year_picker,
        variant="modal",
        # Same nav padding in both views so the dialog height is unchanged
        # when toggling the year picker (paired with growing the year grid
        # by the hidden action-row height). The 28dp left inset aligns the
        # "Month Year" label's left edge with the first weekday letter (the
        # weekday letters are centred in 48dp columns starting at 12dp, so
        # the first letter sits ~17dp in; 12 + 17 ≈ 29). The measurement does
        # not give a number, so this is a best-fit alignment.
        nav_padding=(28, 6, 12, 2),
        style=style,
    )

    # The dialog sizes to its content (Flex) so the 6-week calendar grid gets
    # its full height with the MD3 section paddings intact (a fixed token
    # height was sized for 5 weeks and clipped the action row).
    column_children: list[Widget] = [
        self._build_header(style, year_view=self._showing_year_picker),
        HorizontalDivider(),
        nav_header,
    ]

    if self._showing_year_picker:
        # Actions are hidden during year selection (no date is confirmable
        # in this view). The year grid is grown by the action-row height so
        # the dialog height stays constant when toggling the year picker.
        column_children.append(
            _YearChipGrid(
                self._view_year,
                on_select=self._select_year,
                list_height=_modal_calendar_body_height(style) + _MODAL_ACTION_ROW_HEIGHT,
                style=style,
            )
        )
    else:
        column_children.append(
            _CalendarGrid(
                self._view_year,
                self._view_month,
                selected_date=self._selected_date,
                min_date=self._min_date,
                max_date=self._max_date,
                on_day_tap=self._on_day_tap,
                style=style,
            )
        )
        column_children.append(
            Row(
                [
                    Button("Cancel", on_click=self._on_cancel, style=ButtonStyle.text()),
                    Button("OK", on_click=self._on_confirm, style=ButtonStyle.text()),
                ],
                gap=8,
                main_alignment="end",
                padding=(12, 4, 12, 12),
                width=int(style.container_width),
            )
        )

    return Box(
        background_color=style.background,
        corner_radius=style.corner_radius,
        shadows=shadows,
        width=style.container_width,
        child=Column(column_children, gap=0),
    )

ModalDateRangePicker

ModalDateRangePicker(*, init_value: Optional[Tuple[date, date]] = None, supporting_text: str = 'Select range', min_date: Optional[date] = None, max_date: Optional[date] = None, style: Optional['ModalDateRangePickerStyle'] = None, key: Optional[str] = None)

Bases: ComposableWidget, OverlayAware[Optional[Tuple[date, date]]]

Material Design 3 Modal Date Range Picker.

Allows the user to select a start and end date via two sequential taps. When shown via overlay.dialog(ModalDateRangePicker(...)), the returned :class:OverlayHandle resolves to (start, end) on confirmation or None on cancellation::

result = await overlay.dialog(ModalDateRangePicker())
if result.value is not None:
    start, end = result.value

MD3 container: 360×524dp, Extra large corner rounding (28dp).

Range selection flow
  • First tap sets the start date.
  • Second tap sets the end date (must be ≥ start; tapping before the start resets and begins a new selection from that date).

.. note:: Experimental implementation. This class does not yet fully comply with the MD3 Expressive specification. Known limitations: the icon button that toggles between :class:ModalDateRangePicker and a range-input variant (ModalDateRangeInput) is not implemented, and ModalDateRangeInput does not yet exist. Deferred: Nuiitivet prioritizes the Docked variant as a desktop-oriented framework.

Parameters:

Name Type Description Default
init_value Optional[Tuple[date, date]]

Pre-selected date range as (start, end) tuple.

None
supporting_text str

Small label shown at the top of the header (14pt).

'Select range'
min_date Optional[date]

Earliest selectable date.

None
max_date Optional[date]

Latest selectable date.

None
style Optional['ModalDateRangePickerStyle']

Visual style. Defaults to :class:ModalDateRangePickerStyle.

None

Initialize ModalDateRangePicker.

Parameters:

Name Type Description Default
init_value Optional[Tuple[date, date]]

Initial date range as (start, end) tuple.

None
supporting_text str

Small label shown at the top of the header (14pt).

'Select range'
min_date Optional[date]

Minimum selectable date.

None
max_date Optional[date]

Maximum selectable date.

None
style Optional['ModalDateRangePickerStyle']

Optional style override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/date_picker.py
def __init__(
    self,
    *,
    init_value: Optional[Tuple[_Date, _Date]] = None,
    supporting_text: str = "Select range",
    min_date: Optional[_Date] = None,
    max_date: Optional[_Date] = None,
    style: Optional["ModalDateRangePickerStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize ModalDateRangePicker.

    Args:
        init_value: Initial date range as (start, end) tuple.
        supporting_text: Small label shown at the top of the header (14pt).
        min_date: Minimum selectable date.
        max_date: Maximum selectable date.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(key=key)
    self._supporting_text = supporting_text
    self._min_date = min_date
    self._max_date = max_date
    self._user_style = style

    today = _Date.today()
    self._range_start: Optional[_Date] = None
    self._range_end: Optional[_Date] = None
    # "first": waiting for start; "second": waiting for end.
    self._range_state: Literal["first", "second"] = "first"

    if init_value is not None and len(init_value) == 2:
        self._range_start, self._range_end = init_value
        self._view_year = self._range_start.year
        self._view_month = self._range_start.month
    else:
        self._view_year = today.year
        self._view_month = today.month

    self._showing_year_picker: bool = False

style property

style: 'ModalDateRangePickerStyle'

Return the resolved date picker style.

build

build() -> Widget

Build the modal range picker with header, calendar (or year grid), and action buttons.

Source code in src/nuiitivet/material/date_picker.py
def build(self) -> Widget:
    """Build the modal range picker with header, calendar (or year grid), and action buttons."""
    style = self.style
    shadows = elevation_shadows(style.elevation)

    ok_disabled = self._range_start is None or self._range_end is None

    nav_header = _MonthYearHeader(
        self._view_year,
        self._view_month,
        on_prev=self._go_prev_month,
        on_next=self._go_next_month,
        on_toggle_year_picker=self._toggle_year_picker,
        year_picker_active=self._showing_year_picker,
        variant="modal",
        # Same nav padding in both views so the dialog height is unchanged
        # when toggling the year picker (paired with growing the year grid
        # by the hidden action-row height). The 28dp left inset aligns the
        # "Month Year" label's left edge with the first weekday letter (the
        # weekday letters are centred in 48dp columns starting at 12dp, so
        # the first letter sits ~17dp in; 12 + 17 ≈ 29). The measurement does
        # not give a number, so this is a best-fit alignment.
        nav_padding=(28, 6, 12, 2),
        style=style,
    )

    # Content-sized (Flex) so the 6-week grid keeps its full height and MD3
    # section paddings (the fixed token height was sized for 5 weeks).
    column_children: list[Widget] = [
        self._build_header(style, year_view=self._showing_year_picker),
        HorizontalDivider(),
        nav_header,
    ]

    if self._showing_year_picker:
        # Actions hidden during year selection; grow the grid by the action
        # row height so the dialog height is unchanged when toggling.
        column_children.append(
            _YearChipGrid(
                self._view_year,
                on_select=self._select_year,
                list_height=_modal_calendar_body_height(style) + _MODAL_ACTION_ROW_HEIGHT,
                style=style,
            )
        )
    else:
        column_children.append(
            _CalendarGrid(
                self._view_year,
                self._view_month,
                range_start=self._range_start,
                range_end=self._range_end,
                min_date=self._min_date,
                max_date=self._max_date,
                on_day_tap=self._on_day_tap,
                style=style,
            )
        )
        column_children.append(
            Row(
                [
                    Button("Cancel", on_click=self._on_cancel, style=ButtonStyle.text()),
                    Button("OK", on_click=self._on_confirm, style=ButtonStyle.text(), disabled=ok_disabled),
                ],
                gap=8,
                main_alignment="end",
                padding=(12, 4, 12, 12),
                width=int(style.container_width),
            )
        )

    return Box(
        background_color=style.background,
        corner_radius=style.corner_radius,
        shadows=shadows,
        width=style.container_width,
        child=Column(column_children, gap=0),
    )

ModalDateInput

ModalDateInput(*, init_value: Optional[date] = None, supporting_text: str = 'Enter date', input_label: str = 'Date', date_format: DateFormat = DEFAULT_DATE_FORMAT, min_date: Optional[date] = None, max_date: Optional[date] = None, style: Optional['ModalDateInputStyle'] = None, key: Optional[str] = None)

Bases: ComposableWidget, OverlayAware[Optional[date]]

Material Design 3 Modal Date Input.

Allows the user to type a date directly into a text field. When shown via overlay.dialog(ModalDateInput(...)), the returned :class:OverlayHandle resolves to the entered date on confirmation or None on cancellation::

result = await overlay.dialog(ModalDateInput())
if result.value is not None:
    entered: datetime.date = result.value

MD3 container: 328×512dp, Extra large corner rounding (28dp).

.. note:: Experimental implementation. This class does not yet fully comply with the MD3 Expressive specification. Known limitations: the icon button that toggles between :class:ModalDateInput and :class:ModalDatePicker is not implemented, and the range-input variant (ModalDateRangeInput) does not yet exist. Deferred: Nuiitivet prioritizes the Docked variant as a desktop-oriented framework.

Parameters:

Name Type Description Default
init_value Optional[date]

Optional initial date used to pre-populate the text field.

None
supporting_text str

Small label shown at the top of the header (14pt).

'Enter date'
input_label str

Label for the date text field.

'Date'
date_format DateFormat

How the typed date is read and rendered. Its pattern is also the hint shown below the field.

DEFAULT_DATE_FORMAT
min_date Optional[date]

Earliest acceptable date.

None
max_date Optional[date]

Latest acceptable date.

None
style Optional['ModalDateInputStyle']

Visual style. Defaults to :class:ModalDateInputStyle.

None

Initialize ModalDateInput.

Parameters:

Name Type Description Default
init_value Optional[date]

Initial date to pre-populate the text field.

None
supporting_text str

Small label shown at the top of the header (14pt).

'Enter date'
input_label str

Text field label.

'Date'
date_format DateFormat

How the typed date is read and rendered.

DEFAULT_DATE_FORMAT
min_date Optional[date]

Minimum acceptable date.

None
max_date Optional[date]

Maximum acceptable date.

None
style Optional['ModalDateInputStyle']

Optional style override.

None
key Optional[str]

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/material/date_picker.py
def __init__(
    self,
    *,
    init_value: Optional[_Date] = None,
    supporting_text: str = "Enter date",
    input_label: str = "Date",
    date_format: DateFormat = DEFAULT_DATE_FORMAT,
    min_date: Optional[_Date] = None,
    max_date: Optional[_Date] = None,
    style: Optional["ModalDateInputStyle"] = None,
    key: Optional[str] = None,
) -> None:
    """Initialize ModalDateInput.

    Args:
        init_value: Initial date to pre-populate the text field.
        supporting_text: Small label shown at the top of the header (14pt).
        input_label: Text field label.
        date_format: How the typed date is read and rendered.
        min_date: Minimum acceptable date.
        max_date: Maximum acceptable date.
        style: Optional style override.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(key=key)
    self._init_value = init_value
    self._supporting_text = supporting_text
    self._input_label = input_label
    self._date_format = date_format
    self._min_date = min_date
    self._max_date = max_date
    self._user_style = style

    # Internal observables for the text field
    self._text_obs: Observable[str] = Observable(date_format.format(init_value))
    self._supporting_text_obs: Observable[Optional[str]] = Observable(date_format.pattern)

    # Build the TextField once and reuse across rebuild cycles to preserve
    # focus state and cursor position.
    from nuiitivet.material.text_fields import TextField
    from nuiitivet.material.styles.text_field_style import TextFieldStyle

    self._text_field: TextField = TextField(
        self._text_obs,
        label=input_label,
        supporting_text=self._supporting_text_obs,
        style=TextFieldStyle.outlined(),
        width=self._resolved_field_width,
    )

style property

style: 'ModalDateInputStyle'

Return the resolved date picker style.

on_mount

on_mount() -> None

Subscribe to text changes to keep the header date display in sync.

Source code in src/nuiitivet/material/date_picker.py
def on_mount(self) -> None:
    """Subscribe to text changes to keep the header date display in sync."""
    super().on_mount()
    self.observe(self._text_obs, lambda _: self.rebuild())

build

build() -> Widget

Build the modal date input with header, text field, and action buttons.

Source code in src/nuiitivet/material/date_picker.py
def build(self) -> Widget:
    """Build the modal date input with header, text field, and action buttons."""
    style = self.style
    shadows = elevation_shadows(style.elevation)

    parsed_date = self._date_format.parse(self._text_obs.value)
    date_str = parsed_date.strftime("%b %d, %Y") if parsed_date is not None else "—"
    # Header layout (measurement image):
    # Supporting text: padding=(16, 24, 18, 24)
    # Headline row: headline + calendar_today icon (24dp, right-aligned)
    #   padding=(18, 24, 5, 24)
    # The header sizes to its content (supporting text + headline row); the
    # measurement reference does not box it to a fixed height, and forcing
    # ``header_height`` (120dp) here would leave ~11dp of dead slack that
    # shows up as an oversized gap above the divider.
    header = Box(
        width=style.container_width,
        child=Column(
            [
                Box(
                    padding=(24, 16, 24, 18),
                    child=Text(
                        self._supporting_text,
                        style=TextStyle(
                            color=style.header_supporting_text_color,
                        ),
                        type_scale=TypeScaleToken.from_size(int(style.header_supporting_text_font_size)),
                    ),
                ),
                Row(
                    [
                        # Pin the headline to its MD3 line-height box (32pt /
                        # 40dp). Centring the icon against this fixed line box
                        # (rather than the text's ink bounds, which grow with
                        # descenders like the "y" in "May") keeps the icon on
                        # the cap-height centre regardless of the month name.
                        Box(
                            height=40,
                            alignment="center-left",
                            child=Text(
                                date_str,
                                style=TextStyle(
                                    color=style.header_headline_color,
                                ),
                                type_scale=TypeScaleToken.from_size(int(style.header_headline_font_size)),
                            ),
                        ),
                        Icon("calendar_today", size=24),
                    ],
                    gap=8,
                    cross_alignment="center",
                    main_alignment="space-between",
                    padding=(24, 18, 24, 0),
                    width=int(style.container_width),
                ),
            ],
            gap=0,
        ),
    )

    action_row = Row(
        [
            Button("Cancel", on_click=self._on_cancel, style=ButtonStyle.text()),
            Button("OK", on_click=self._on_confirm, style=ButtonStyle.text()),
        ],
        gap=16,
        main_alignment="end",
        padding=(24, 4, 24, 12),
        width=int(style.container_width),
    )

    # Unlike the calendar/range modals, the date-input dialog has no fixed
    # body to fill: it sizes to its content (header + text field + actions).
    # The MD3 ``512dp`` container token would leave a large empty band, so
    # the height is intentionally left to wrap the content (the measurement
    # reference likewise sets no height on the outer box).
    return Box(
        background_color=style.background,
        corner_radius=style.corner_radius,
        shadows=shadows,
        width=style.container_width,
        child=Column(
            [
                header,
                # The divider owns its 10dp margins (top to the headline line
                # box, bottom toward the field). The outlined field's floating
                # label floats ~7dp above its outline, so an extra 7dp top
                # inset on the field keeps the *label text top* 10dp below the
                # divider line (not just the outline).
                HorizontalDivider(padding=(0, 10, 0, 10)),
                Box(
                    padding=(24, 6, 24, 4),
                    child=self._text_field,
                ),
                action_row,
            ],
            gap=0,
            width=int(style.container_width),
        ),
    )

DateFormat

DateFormat(pattern: str = 'mm/dd/yyyy', *, also_accepts: Sequence[str] = ())

How a date is written as text, and read back.

Parameters:

Name Type Description Default
pattern str

The format dates are rendered in, and the first one accepted when parsing. Also what :meth:__str__ returns, so it can be shown to the user as a hint.

'mm/dd/yyyy'
also_accepts Sequence[str]

Further patterns accepted when parsing, tried in order after pattern. Typing is worth being lenient about -- someone will enter 2026-06-10 into a mm/dd/yyyy field -- while output stays in one format.

()

Raises:

Type Description
ValueError

If any pattern is malformed. See :func:_compile.

Initialize DateFormat.

Source code in src/nuiitivet/material/date_format.py
def __init__(self, pattern: str = "mm/dd/yyyy", *, also_accepts: Sequence[str] = ()) -> None:
    """Initialize DateFormat."""
    self._pattern = pattern
    self._also_accepts: Tuple[str, ...] = tuple(also_accepts)
    self._formats: Tuple[str, ...] = tuple(_compile(p) for p in (pattern, *self._also_accepts))

pattern property

pattern: str

The output pattern, and the first one tried when parsing.

also_accepts property

also_accepts: Tuple[str, ...]

Further patterns accepted when parsing.

parse

parse(text: str) -> Optional[date]

Read text as a date.

A bound method, so it can be handed straight to an operator::

self.arrival = self.arrival_text.map(fmt.parse)

Parameters:

Name Type Description Default
text str

Raw user input. Surrounding whitespace is ignored.

required

Returns:

Type Description
Optional[date]

The date, or None when text matches no accepted pattern --

Optional[date]

which includes empty and half-typed text.

Source code in src/nuiitivet/material/date_format.py
def parse(self, text: str) -> Optional[_Date]:
    """Read ``text`` as a date.

    A bound method, so it can be handed straight to an operator::

        self.arrival = self.arrival_text.map(fmt.parse)

    Args:
        text: Raw user input.  Surrounding whitespace is ignored.

    Returns:
        The date, or ``None`` when ``text`` matches no accepted pattern --
        which includes empty and half-typed text.
    """
    stripped = text.strip()
    for fmt in self._formats:
        try:
            return _DateTime.strptime(stripped, fmt).date()
        except ValueError:
            continue
    return None

format

format(value: Optional[date]) -> str

Render value in :attr:pattern, or "" when unset.

Parameters:

Name Type Description Default
value Optional[date]

The date to render, or None.

required

Returns:

Type Description
str

Text that :meth:parse reads back as value.

Source code in src/nuiitivet/material/date_format.py
def format(self, value: Optional[_Date]) -> str:
    """Render ``value`` in :attr:`pattern`, or ``""`` when unset.

    Args:
        value: The date to render, or ``None``.

    Returns:
        Text that :meth:`parse` reads back as ``value``.
    """
    if value is None:
        return ""
    return value.strftime(self._formats[0])

matches

matches(text: str) -> bool

Whether :meth:parse can read text.

The predicate form, for the filter() step of a derived date::

self.arrival = self.arrival_text.filter(fmt.matches, initial="").map(fmt.parse)

Parameters:

Name Type Description Default
text str

Raw user input.

required

Returns:

Type Description
bool

True when text parses, False otherwise.

Source code in src/nuiitivet/material/date_format.py
def matches(self, text: str) -> bool:
    """Whether :meth:`parse` can read ``text``.

    The predicate form, for the ``filter()`` step of a derived date::

        self.arrival = self.arrival_text.filter(fmt.matches, initial="").map(fmt.parse)

    Args:
        text: Raw user input.

    Returns:
        ``True`` when ``text`` parses, ``False`` otherwise.
    """
    return self.parse(text) is not None

CalendarLabels dataclass

CalendarLabels(month_names: Tuple[str, ...] = ('January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'), weekday_labels: Tuple[str, ...] = ('M', 'T', 'W', 'T', 'F', 'S', 'S'), first_day_of_week: int = SUNDAY)

Names and week convention a calendar renders dates with.

Parameters:

Name Type Description Default
month_names Tuple[str, ...]

Twelve month names, January first, indexed by month - 1.

('January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December')
weekday_labels Tuple[str, ...]

Seven weekday column headers, Monday first -- indexed by :meth:datetime.date.weekday regardless of first_day_of_week.

('M', 'T', 'W', 'T', 'F', 'S', 'S')
first_day_of_week int

The weekday the grid's first column shows, numbered as :meth:datetime.date.weekday does (calendar.MONDAY is 0, calendar.SUNDAY is 6). Defaults to Sunday, matching the MD3 spec; a calendar that starts on another day is a deliberate deviation from MD3, chosen by the application.

SUNDAY

Raises:

Type Description
ValueError

If month_names does not hold 12 entries, weekday_labels does not hold 7, or first_day_of_week is outside 0-6.

weekday_columns

weekday_columns() -> Tuple[str, ...]

The weekday headers in grid column order.

Returns:

Type Description
Tuple[str, ...]

The seven labels starting at :attr:first_day_of_week.

Source code in src/nuiitivet/material/calendar_labels.py
def weekday_columns(self) -> Tuple[str, ...]:
    """The weekday headers in grid column order.

    Returns:
        The seven labels starting at :attr:`first_day_of_week`.
    """
    first = self.first_day_of_week
    return tuple(self.weekday_labels[(first + i) % 7] for i in range(7))

DatePickerStyle dataclass

DatePickerStyle(background: ColorSpec = SURFACE_CONTAINER_HIGH, elevation: int = 3, corner_radius: float = 16.0, container_width: float = 360.0, container_height: float = 460.0, date_cell_size: int = 40, date_cell_radius: float = 20.0, state_layer_size: int = 40, date_font_size: int = 16, date_selected_background: ColorSpec = PRIMARY, date_selected_text: ColorSpec = ON_PRIMARY, date_today_outline_color: ColorSpec = PRIMARY, date_today_text: ColorSpec = PRIMARY, date_unselected_text: ColorSpec = ON_SURFACE, date_outside_month_opacity: float = 0.38, weekday_text: ColorSpec = ON_SURFACE, range_active_indicator_background: ColorSpec = SECONDARY_CONTAINER, range_date_in_range_text: ColorSpec = ON_SECONDARY_CONTAINER, header_height: float = 64.0, header_headline_color: ColorSpec = ON_SURFACE_VARIANT, header_supporting_text_color: ColorSpec = ON_SURFACE_VARIANT, hover_state_layer_opacity: float = 0.08, focus_state_layer_opacity: float = 0.1, pressed_state_layer_opacity: float = 0.1, header_supporting_text_font_size: float = 14.0, header_headline_font_size: float = 32.0, menu_button_height: float = 40.0, menu_button_font_size: int = 14, menu_button_icon_size: int = 18, menu_button_text: ColorSpec = ON_SURFACE_VARIANT, menu_list_item_height: float = 48.0, menu_list_item_selected_background: ColorSpec = SECONDARY_CONTAINER, menu_list_item_text: ColorSpec = ON_SURFACE, menu_list_item_selected_text: ColorSpec = ON_SECONDARY_CONTAINER)

Bases: CalendarStyle

Style for :class:DatePicker (inline calendar).

MD3 calendar: 360×460dp container, Large corner rounding (16dp). Adds the month/year inline list-menu tokens to the shared calendar base.

DockedDatePickerStyle dataclass

DockedDatePickerStyle(calendar: DatePickerStyle = DatePickerStyle(), field_width: float = 360.0, dropdown_gap: float = 4.0)

Style for :class:DockedDatePicker (text field + anchored calendar).

Composes — rather than inherits — a :class:DatePickerStyle for the dropdown calendar, keeping the calendar tokens separate from the text-field and dropdown tokens.

copy_with

copy_with(**changes: Any) -> 'DockedDatePickerStyle'

Return a new style with the given fields overridden.

Parameters:

Name Type Description Default
**changes Any

Fields to override.

{}

Returns:

Type Description
'DockedDatePickerStyle'

New DockedDatePickerStyle instance with applied changes.

Source code in src/nuiitivet/material/styles/date_picker_style.py
def copy_with(self, **changes: Any) -> "DockedDatePickerStyle":
    """Return a new style with the given fields overridden.

    Args:
        **changes: Fields to override.

    Returns:
        New ``DockedDatePickerStyle`` instance with applied changes.
    """
    return replace(self, **changes)

ModalDatePickerStyle dataclass

ModalDatePickerStyle(background: ColorSpec = SURFACE_CONTAINER_HIGH, elevation: int = 3, corner_radius: float = 28.0, container_width: float = 360.0, container_height: float = 524.0, date_cell_size: int = 40, date_cell_radius: float = 20.0, state_layer_size: int = 40, date_font_size: int = 16, date_selected_background: ColorSpec = PRIMARY, date_selected_text: ColorSpec = ON_PRIMARY, date_today_outline_color: ColorSpec = PRIMARY, date_today_text: ColorSpec = PRIMARY, date_unselected_text: ColorSpec = ON_SURFACE, date_outside_month_opacity: float = 0.38, weekday_text: ColorSpec = ON_SURFACE, range_active_indicator_background: ColorSpec = SECONDARY_CONTAINER, range_date_in_range_text: ColorSpec = ON_SECONDARY_CONTAINER, header_height: float = 120.0, header_headline_color: ColorSpec = ON_SURFACE_VARIANT, header_supporting_text_color: ColorSpec = ON_SURFACE_VARIANT, hover_state_layer_opacity: float = 0.08, focus_state_layer_opacity: float = 0.1, pressed_state_layer_opacity: float = 0.1, header_supporting_text_font_size: float = 14.0, header_headline_font_size: float = 32.0, menu_button_height: float = 40.0, menu_button_font_size: int = 14, menu_button_icon_size: int = 18, menu_button_text: ColorSpec = ON_SURFACE_VARIANT, year_chip_width: float = 72.0, year_chip_height: float = 36.0, year_chip_radius: float = 18.0, year_chip_gap: int = 30, year_chip_selected_background: ColorSpec = PRIMARY, year_chip_selected_text: ColorSpec = ON_PRIMARY, year_chip_unselected_text: ColorSpec = ON_SURFACE_VARIANT)

Bases: CalendarStyle

Style for :class:ModalDatePicker (single-date dialog).

MD3 modal picker: 360×524dp container, Extra-large corner rounding (28dp). Adds the year-chip selection tokens to the shared calendar base.

ModalDateRangePickerStyle dataclass

ModalDateRangePickerStyle(background: ColorSpec = SURFACE_CONTAINER_HIGH, elevation: int = 3, corner_radius: float = 28.0, container_width: float = 360.0, container_height: float = 524.0, date_cell_size: int = 40, date_cell_radius: float = 20.0, state_layer_size: int = 40, date_font_size: int = 16, date_selected_background: ColorSpec = PRIMARY, date_selected_text: ColorSpec = ON_PRIMARY, date_today_outline_color: ColorSpec = PRIMARY, date_today_text: ColorSpec = PRIMARY, date_unselected_text: ColorSpec = ON_SURFACE, date_outside_month_opacity: float = 0.38, weekday_text: ColorSpec = ON_SURFACE, range_active_indicator_background: ColorSpec = SECONDARY_CONTAINER, range_date_in_range_text: ColorSpec = ON_SECONDARY_CONTAINER, header_height: float = 120.0, header_headline_color: ColorSpec = ON_SURFACE_VARIANT, header_supporting_text_color: ColorSpec = ON_SURFACE_VARIANT, hover_state_layer_opacity: float = 0.08, focus_state_layer_opacity: float = 0.1, pressed_state_layer_opacity: float = 0.1, header_supporting_text_font_size: float = 14.0, header_headline_font_size: float = 32.0, menu_button_height: float = 40.0, menu_button_font_size: int = 14, menu_button_icon_size: int = 18, menu_button_text: ColorSpec = ON_SURFACE_VARIANT, year_chip_width: float = 72.0, year_chip_height: float = 36.0, year_chip_radius: float = 18.0, year_chip_gap: int = 30, year_chip_selected_background: ColorSpec = PRIMARY, year_chip_selected_text: ColorSpec = ON_PRIMARY, year_chip_unselected_text: ColorSpec = ON_SURFACE_VARIANT, range_header_height: float = 128.0, range_headline_font_size: float = 22.0)

Bases: ModalDatePickerStyle

Style for :class:ModalDateRangePicker (date-range dialog).

Extends :class:ModalDatePickerStyle (same calendar, year chips and container) with the taller range-selection header tokens.

ModalDateInputStyle dataclass

ModalDateInputStyle(background: ColorSpec = SURFACE_CONTAINER_HIGH, elevation: int = 3, corner_radius: float = 28.0, container_width: float = 328.0, container_height: float = 512.0, header_headline_color: ColorSpec = ON_SURFACE_VARIANT, header_supporting_text_color: ColorSpec = ON_SURFACE_VARIANT, header_supporting_text_font_size: float = 14.0, header_headline_font_size: float = 32.0)

Style for :class:ModalDateInput (text-field date entry dialog).

Independent of the calendar pickers: the date-input dialog is a text-field form, so it shares none of the calendar/selection tokens — only the dialog container and header typography.

MD3 modal input: 328×512dp container, Extra-large corner rounding (28dp).

copy_with

copy_with(**changes: Any) -> 'ModalDateInputStyle'

Return a new style with the given fields overridden.

Parameters:

Name Type Description Default
**changes Any

Fields to override.

{}

Returns:

Type Description
'ModalDateInputStyle'

New ModalDateInputStyle instance with applied changes.

Source code in src/nuiitivet/material/styles/date_picker_style.py
def copy_with(self, **changes: Any) -> "ModalDateInputStyle":
    """Return a new style with the given fields overridden.

    Args:
        **changes: Fields to override.

    Returns:
        New ``ModalDateInputStyle`` instance with applied changes.
    """
    return replace(self, **changes)

Image

Image(source: bytes | None | ObservableBase[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', key: str | None = None)

Bases: Widget

Display a raster image from in-memory bytes.

Parameters:

Name Type Description Default
source bytes | None | ObservableBase[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

Initialize an Image widget.

Parameters:

Name Type Description Default
source bytes | None | ObservableBase[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'
key str | None

Stable widget identity for dev-bridge targeting and hot reload.

None
Source code in src/nuiitivet/widgets/image.py
def __init__(
    self,
    source: bytes | None | ObservableBase[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",
    key: str | None = None,
) -> 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.
        key: Stable widget identity for dev-bridge targeting and hot reload.
    """
    super().__init__(width=width, height=height, padding=padding, key=key)
    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 | ObservableBase[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, ObservableBase):
        self.observe(source, self._on_source_change)
    else:
        self._on_source_change(source)

preferred_size

preferred_size(max_width: int | None = None, max_height: int | None = None) -> tuple[int, int]

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

Source code in src/nuiitivet/widgets/image.py
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

paint(canvas, x: int, y: int, width: int, height: int) -> None

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

Source code in src/nuiitivet/widgets/image.py
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)

    if fit == "none":
        # Allow negative offsets so alignment works even when the image is
        # larger than the container (image is centered/aligned and clipped).
        dx = float(cx) + (float(cw) - draw_w) * fx
        dy = float(cy) + (float(ch) - draw_h) * fy
    else:
        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)):
        clip_r = make_rect(cx, cy, cw, ch)
        save_fn = getattr(canvas, "save", None)
        restore_fn = getattr(canvas, "restore", None)
        if callable(save_fn) and callable(restore_fn) and clip_r is not None:
            save_fn()
            clip_rect(canvas, clip_r)
            self._draw_image_rect(canvas, image, (0.0, 0.0, float(img_w), float(img_h)), (dx, dy, draw_w, draw_h))
            restore_fn()
            return

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

elevation_shadows

elevation_shadows(level: int) -> Shadows

Return the shadows for the given MD3 elevation level.

The result feeds the shadows() modifier directly::

widget.modifier(nv.shadows(nv.elevation_shadows(2)))

Parameters:

Name Type Description Default
level int

MD3 elevation level, clamped to the range 0-5.

required

Returns:

Type Description
Shadows

A tuple of Shadow layers ordered back to front (ambient, then

Shadows

key). Level 0 returns an empty tuple, which draws no shadow.

Source code in src/nuiitivet/material/theme/elevation.py
def elevation_shadows(level: int) -> Shadows:
    """Return the shadows for the given MD3 elevation level.

    The result feeds the ``shadows()`` modifier directly::

        widget.modifier(nv.shadows(nv.elevation_shadows(2)))

    Args:
        level: MD3 elevation level, clamped to the range 0-5.

    Returns:
        A tuple of ``Shadow`` layers ordered back to front (ambient, then
        key). Level 0 returns an empty tuple, which draws no shadow.
    """
    return _MD3_SHADOWS[max(0, min(5, level))]

parse_date

parse_date(text: str) -> Optional[date]

Read text as a date using :data:DEFAULT_DATE_FORMAT.

Parameters:

Name Type Description Default
text str

Raw user input.

required

Returns:

Type Description
Optional[date]

The date, or None when the text matches no accepted pattern.

Source code in src/nuiitivet/material/date_format.py
def parse_date(text: str) -> Optional[_Date]:
    """Read ``text`` as a date using :data:`DEFAULT_DATE_FORMAT`.

    Args:
        text: Raw user input.

    Returns:
        The date, or ``None`` when the text matches no accepted pattern.
    """
    return DEFAULT_DATE_FORMAT.parse(text)

format_date

format_date(value: Optional[date]) -> str

Render value as mm/dd/yyyy, or "" when unset.

Parameters:

Name Type Description Default
value Optional[date]

The date to render, or None.

required

Returns:

Type Description
str

Text that :func:parse_date reads back as value.

Source code in src/nuiitivet/material/date_format.py
def format_date(value: Optional[_Date]) -> str:
    """Render ``value`` as ``mm/dd/yyyy``, or ``""`` when unset.

    Args:
        value: The date to render, or ``None``.

    Returns:
        Text that :func:`parse_date` reads back as ``value``.
    """
    return DEFAULT_DATE_FORMAT.format(value)

is_date

is_date(text: str) -> bool

Whether :func:parse_date can read text as a date.

Written for the filter() step of a derived date, where the application wants the last valid date held while the user types an incomplete one::

self.date_text = nv.Observable("")
self.date = self.date_text.filter(nv.is_date, initial="").map(nv.parse_date)

The seed is filtered text, not a date, so it goes through map as well: initial="" reads back as None until the first valid date arrives.

Parameters:

Name Type Description Default
text str

Raw user input.

required

Returns:

Type Description
bool

True when text parses, False otherwise.

Source code in src/nuiitivet/material/date_format.py
def is_date(text: str) -> bool:
    """Whether :func:`parse_date` can read ``text`` as a date.

    Written for the ``filter()`` step of a derived date, where the application
    wants the last valid date held while the user types an incomplete one::

        self.date_text = nv.Observable("")
        self.date = self.date_text.filter(nv.is_date, initial="").map(nv.parse_date)

    The seed is filtered text, not a date, so it goes through ``map`` as well:
    ``initial=""`` reads back as ``None`` until the first valid date arrives.

    Args:
        text: Raw user input.

    Returns:
        ``True`` when ``text`` parses, ``False`` otherwise.
    """
    return DEFAULT_DATE_FORMAT.matches(text)