Skip to content

Material Components

High-level widgets implementing Material Design 3.

material

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)

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
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,
) -> 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.
    """
    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",
    )

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,
        alignment="top-right",
        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)

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
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,
) -> 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.
    """
    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,
    )

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,
        alignment="top-right",
        anchor="bottom-left",
        offset=(-6.0, 6.0),
    )

App

App(content: Widget, *, overlay_routes: Mapping[type[Any], Callable[[Any], Route | Widget]] | None = None, width: WindowSizingLike = 'auto', height: WindowSizingLike = 'auto', background: ColorSpec = SURFACE, theme: Optional[Any] = None, title: str | None | ReadOnlyObservableProtocol[str | None] = None, chrome: OSChrome | CustomChrome | None = _UNSET, window_position: WindowPosition | None = None, resizable: bool = True)

Bases: App

Material Design application runner.

This class configures the App with Material Design defaults: - Material Theme (light/dark) - Material Overlay (Dialog, Loading) - Material Background color - Material Navigator (with Material page transitions)

Pass a Widget to use it directly as the root screen (with an implicit root MaterialNavigator), or pass a Navigator / Navigator.routes(...) / Navigator.intents(...) to customize the initial navigation stack.

Initialize a MaterialApp.

Parameters:

Name Type Description Default
content Widget

The root content. Can be a Widget (used as the initial screen under an implicit MaterialNavigator) or a Navigator (e.g. Navigator.routes(...) or Navigator.intents(...)) to customize the navigation stack.

required
overlay_routes Mapping[type[Any], Callable[[Any], Route | Widget]] | None

Optional mapping of Intent types to overlay builder functions.

None
width WindowSizingLike

Window width specification ("auto", fixed integer, etc.).

'auto'
height WindowSizingLike

Window height specification.

'auto'
background ColorSpec

Background color of the window. Defaults to Material Surface color.

SURFACE
theme Optional[Any]

The MaterialThemeFactory to use. Defaults to Light theme.

None
title str | None | ReadOnlyObservableProtocol[str | None]

OS window title.

None
chrome OSChrome | CustomChrome | None

Window decoration (OSChrome, CustomChrome, or None for bare borderless). Omitting defaults to OSChrome().

_UNSET
window_position WindowPosition | None

Initial window position.

None
resizable bool

Whether the window can be resized. Defaults to True.

True
Source code in src/nuiitivet/material/app.py
def __init__(
    self,
    content: Widget,
    *,
    overlay_routes: Mapping[type[Any], Callable[[Any], Route | Widget]] | None = None,
    width: WindowSizingLike = "auto",
    height: WindowSizingLike = "auto",
    background: ColorSpec = ColorRole.SURFACE,
    theme: Optional[Any] = None,
    title: str | None | ReadOnlyObservableProtocol[str | None] = None,
    chrome: OSChrome | CustomChrome | None = _UNSET,  # type: ignore[assignment]
    window_position: WindowPosition | None = None,
    resizable: bool = True,
) -> None:
    """Initialize a MaterialApp.

    Args:
        content: The root content. Can be a ``Widget`` (used as the initial
            screen under an implicit ``MaterialNavigator``) or a
            ``Navigator`` (e.g. ``Navigator.routes(...)`` or
            ``Navigator.intents(...)``) to customize the navigation stack.
        overlay_routes: Optional mapping of Intent types to overlay builder functions.
        width: Window width specification ("auto", fixed integer, etc.).
        height: Window height specification.
        background: Background color of the window. Defaults to Material Surface color.
        theme: The MaterialThemeFactory to use. Defaults to Light theme.
        title: OS window title.
        chrome: Window decoration (``OSChrome``, ``CustomChrome``, or
            ``None`` for bare borderless). Omitting defaults to ``OSChrome()``.
        window_position: Initial window position.
        resizable: Whether the window can be resized. Defaults to True.
    """
    if theme is None:
        theme = MaterialThemeFactory.light("#6750A4")

    def _overlay_factory() -> MaterialOverlay:
        return MaterialOverlay(intents=overlay_routes)

    super().__init__(
        content=content,
        width=width,
        height=height,
        title=title,
        chrome=chrome,
        background=background,
        theme=theme,
        overlay_factory=_overlay_factory,
        window_position=window_position,
        resizable=resizable,
    )

HorizontalDivider

HorizontalDivider(*, width: SizingLike = None, padding: PaddingLike = 0, style: Optional[DividerStyle] = 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.flex().

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

    Args:
        width: Width sizing override. Defaults to ``Sizing.flex()``.
        padding: Padding around the divider line.
        style: Optional :class:`~nuiitivet.material.styles.divider_style.DividerStyle`
            override. Falls back to the default ``DividerStyle`` when ``None``.
    """
    _pad_l, pad_t, _pad_r, pad_b = parse_padding(padding)
    resolved_width: SizingLike = Sizing.flex() 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,
    )

VerticalDivider

VerticalDivider(*, height: SizingLike = None, padding: PaddingLike = 0, style: Optional[DividerStyle] = 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.flex().

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

    Args:
        height: Height sizing override. Defaults to ``Sizing.flex()``.
        padding: Padding around the divider line.
        style: Optional :class:`~nuiitivet.material.styles.divider_style.DividerStyle`
            override. Falls back to the default ``DividerStyle`` when ``None``.
    """
    pad_l, _pad_t, pad_r, _pad_b = parse_padding(padding)
    resolved_height: SizingLike = Sizing.flex() 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,
    )

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)

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
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,
):
    """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")``.
    """
    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,
        **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)

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 :meth:FabStyle.primary (size "s", 56dp).

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,
):
    """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 :meth:`FabStyle.primary` (size ``"s"``, 56dp).
    """
    base_style = style if style is not None else FabStyle.primary()
    size = _fab_size_from_height(base_style.container_height)
    ext = EXTENDED_FAB_SIZE_TOKENS[size]
    self._container_height = ext["container_height"]

    effective_style: FabStyle = base_style.copy_with(  # type: ignore[assignment]
        label_font_size=ext["label_font_size"],
        icon_size=ext["icon_size"],
        corner_radius=ext["corner_radius"],
        container_height=ext["container_height"],
        spacing=ext["icon_label_space"],
        padding=(ext["leading_space"], 0, ext["trailing_space"], 0),
        min_width=ext["container_height"],
        min_height=ext["container_height"],
    )
    self._user_style = effective_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,
        **params,
    )
    # Clip the label as the container shrinks toward the circular footprint.
    self.clip_content = True
    self._sync_state_tokens()
    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)

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 :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
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,
):
    """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 :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.
    """
    self._user_style: FabStyle = style if style is not None else FabStyle.primary()
    self._user_padding = padding
    size = self._user_style.container_height
    self._user_height = size

    effective_style = self.style
    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,
        **params,
    )
    self._sync_state_tokens()
    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)

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
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,
):
    """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.
    """
    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,
        **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)

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 :meth:IconToggleButtonStyle.standard (size "s"). Use size-aware factories such as IconToggleButtonStyle.filled("m") to control sizing.

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,
):
    """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 :meth:`IconToggleButtonStyle.standard` (size
            ``"s"``).  Use size-aware factories such as
            ``IconToggleButtonStyle.filled("m")`` to control sizing.
    """
    self._icon_toggle_style = style if style is not None else IconToggleButtonStyle.standard()
    # Both selected/unselected share container_height per factory contract.
    self._icon_size = self._icon_toggle_style.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,
    )

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)

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 ToggleButtonStyle.filled("s").

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,
):
    """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 ``ToggleButtonStyle.filled("s")``.
    """
    self._toggle_style = style if style is not None else ToggleButtonStyle.filled("s")

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

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

Create a new style instance with specified fields changed.

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

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

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

Card

Card(child: ChildSpec, *, width: SizingLike = None, height: SizingLike = None, padding: PaddingLike = 0, alignment: AlignmentLike = 'start', style: Optional[CardStyle] = 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
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,
) -> 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.
    """
    self._child_spec: ChildSpec = child
    self._user_style: Optional[CardStyle] = style

    # Resolve style
    final_style = self.style

    # Resolve shadow
    _shadow = md3_elevation_to_shadow(final_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=final_style.background,
        border_width=final_style.border_width,
        border_color=final_style.border_color,
        corner_radius=final_style.border_radius,
        shadow_blur=_shadow.sigma,
        shadow_color=_shadow.color,
        shadow_offset=_shadow.offset,
        alignment=alignment,
    )

    self._content_scope_id: Optional[str] = None
    self._theme_subscription: Optional[Callable[[object], None]] = None

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

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

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
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,
):
    """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.
    """
    from nuiitivet.theme.theme import Theme
    from nuiitivet.material.styles.chip_style import ChipStyle

    style_for_content = style if style is not None else ChipStyle.from_theme(Theme.of(self), self._variant)

    children: list[Widget] = []
    if leading_icon is not None:
        children.append(_chip_icon(leading_icon, color=style_for_content.foreground))
    children.append(_chip_text(label, style_for_content.foreground))

    content = _chip_content(
        children,
        spacing=style_for_content.spacing,
        has_icon=leading_icon is not None,
        style=style_for_content,
    )

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

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)

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
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,
):
    """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.
    """
    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

    from nuiitivet.theme.theme import Theme
    from nuiitivet.material.styles.chip_style import ChipStyle

    style_for_content = style if style is not None else ChipStyle.from_theme(Theme.of(self), self._variant)

    content = self._build_content(style_for_content)

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

    self._apply_selected_visuals()

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)

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
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,
):
    """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.
    """
    self._on_trailing_icon_click = on_trailing_icon_click
    self._trailing_icon_widget: Optional[Icon] = None
    self._trailing_icon_tap_target: Optional[Widget] = None

    from nuiitivet.theme.theme import Theme
    from nuiitivet.material.styles.chip_style import ChipStyle

    style_for_content = style if style is not None else ChipStyle.from_theme(Theme.of(self), self._variant)

    children: list[Widget] = []
    if leading_icon is not None:
        children.append(_chip_icon(leading_icon, color=style_for_content.foreground))
    children.append(_chip_text(label, style_for_content.foreground))
    trailing_icon_widget = _chip_icon(trailing_icon, color=style_for_content.foreground)
    self._trailing_icon_widget = trailing_icon_widget
    if on_trailing_icon_click is None:
        self._trailing_icon_tap_target = trailing_icon_widget
        children.append(trailing_icon_widget)
    else:
        trailing_icon_button = InteractiveWidget(
            child=trailing_icon_widget,
            on_click=on_trailing_icon_click,
            focusable=True,
            padding=0,
            corner_radius=999,
            state_layer_color=style_for_content.state_layer_color,
        )
        self._trailing_icon_tap_target = trailing_icon_button
        children.append(trailing_icon_button)

    content = _chip_content(children, spacing=style_for_content.spacing, has_icon=True, style=style_for_content)

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

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)

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
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,
):
    """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.
    """
    from nuiitivet.theme.theme import Theme
    from nuiitivet.material.styles.chip_style import ChipStyle

    style_for_content = style if style is not None else ChipStyle.from_theme(Theme.of(self), self._variant)

    children: list[Widget] = []
    if leading_icon is not None:
        children.append(_chip_icon(leading_icon, color=style_for_content.foreground))
    children.append(_chip_text(label, style_for_content.foreground))

    content = _chip_content(
        children,
        spacing=style_for_content.spacing,
        has_icon=leading_icon is not None,
        style=style_for_content,
    )

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

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)

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
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,
):
    """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.
    """
    super().__init__()
    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)

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
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,
) -> 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.
    """
    super().__init__(width=int(size), height=int(size), padding=padding)
    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)

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
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,
) -> 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.
    """
    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),
    )

style property

style: CircularProgressIndicatorStyle

Return effective circular progress indicator style.

IndeterminateCircularProgressIndicator

IndeterminateCircularProgressIndicator(*, disabled: bool | ObservableProtocol[bool] = False, size: int | None = None, padding: PaddingArg = 0, style: CircularProgressIndicatorStyle | 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
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,
) -> 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.
    """
    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),
    )

style property

style: CircularProgressIndicatorStyle

Return effective circular progress indicator style.

IndeterminateLinearProgressIndicator

IndeterminateLinearProgressIndicator(*, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = '1%', padding: PaddingArg = 0, style: LinearProgressIndicatorStyle | 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.

'1%'
padding PaddingArg

Padding around the indicator.

0
style LinearProgressIndicatorStyle | None

Optional style override.

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

    Args:
        disabled: Disabled state.
        width: Width sizing.
        padding: Padding around the indicator.
        style: Optional style override.
    """
    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),
    )

style property

style: LinearProgressIndicatorStyle

Return effective linear progress indicator style.

LinearProgressIndicator

LinearProgressIndicator(value: float | ObservableProtocol[float] = 0.0, *, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = '1%', padding: PaddingArg = 0, style: LinearProgressIndicatorStyle | 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.

'1%'
padding PaddingArg

Padding around the indicator.

0
style LinearProgressIndicatorStyle | None

Optional style override.

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 = "1%",
    padding: PaddingArg = 0,
    style: LinearProgressIndicatorStyle | 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.
    """
    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),
    )

style property

style: LinearProgressIndicatorStyle

Return effective linear progress indicator style.

Menu

Menu(items: list[MenuItem | SubMenuItem | MenuDivider], *, on_dismiss: Callable[[], None] | None = None, style: MenuStyle | None = None)

Bases: InteractiveWidget

Material Design 3 vertical menu popup surface.

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
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,
) -> None:
    """Initialize Menu.

    Args:
        items: Flat menu entries list.
        on_dismiss: Called when menu is dismissed by keyboard.
        style: Optional menu style override.
    """
    self.items = list(items)
    self.on_dismiss = on_dismiss
    self.style = style or MenuStyle.standard()
    self._focus_index = -1
    self._focusable_items: list[MenuItem] = []

    _shadow = md3_elevation_to_shadow(self.style.elevation)

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

    super().__init__(
        child=self._column,
        on_click=None,
        state_layer_color=self.style.state_layer_color,
        padding=(0, self.style.container_vertical_padding, 0, self.style.container_vertical_padding),
        background_color=self.style.background,
        corner_radius=self.style.corner_radius,
        shadow_blur=_shadow.sigma,
        shadow_color=_shadow.color,
        shadow_offset=_shadow.offset,
    )

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

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)

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
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,
) -> 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).
    """
    self.label = label
    self.leading_icon = leading_icon
    self.trailing = trailing
    self._menu_style: MenuStyle = MenuStyle.standard()
    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.flex(),
        height=resolved_height,
        background_color=None,
        padding=0,
        corner_radius=0,
    )
    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)

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
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,
) -> None:
    """Initialize SubMenuItem.

    Args:
        label: Item label.
        items: Submenu entries.
        leading_icon: Optional leading icon.
        disabled: Whether this item is disabled.
    """
    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,
    )

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)

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.light_dismiss.

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
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,
) -> 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.
    """
    super().__init__()

    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 light-dismiss overlay for scrim + outside-tap close.
    self._inner = self._fab.modifier(
        light_dismiss(
            self._list,
            is_open=self._is_open,
            alignment="top-right",
            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)

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
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,
):
    """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).
    """
    # 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)

    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, _ObservableValue[int]] = 0, on_select: Optional[Callable[[int], None]] = None, expanded: Union[bool, _ObservableValue[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)

Bases: 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, _ObservableValue[int]]

The currently selected index (int or Observable).

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

Callback when an item is selected.

None
expanded Union[bool, _ObservableValue[bool]]

Whether the rail is expanded (bool or Observable).

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
Source code in src/nuiitivet/material/navigation_rail.py
def __init__(
    self,
    children: Sequence[RailItem],
    *,
    index: Union[int, _ObservableValue[int]] = 0,
    on_select: Optional[Callable[[int], None]] = None,
    expanded: Union[bool, _ObservableValue[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,
) -> None:
    """Initialize NavigationRail.

    Args:
        children: The rail items to display.
        index: The currently selected index (int or Observable).
        on_select: Callback when an item is selected.
        expanded: Whether the rail is expanded (bool or Observable).
        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.
    """
    self._is_expanded = expanded.value if isinstance(expanded, _ObservableValue) 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)

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

    self._item_buttons: list[_RailItemButton] = []

    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[_ObservableValue[int]] = None
    self._index_subscription = None
    if isinstance(index, _ObservableValue):
        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[_ObservableValue[bool]] = None
    self._expanded_subscription = None
    if isinstance(expanded, _ObservableValue):
        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.

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: str, label: str, *, small_badge: Optional[ReadOnlyObservableProtocol[bool]] = None, large_badge: Optional[ReadOnlyObservableProtocol[Optional[str]]] = None, style: Optional[NavigationRailStyle] = 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 str

The icon name to display.

required
label str

The label text to display.

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

    Args:
        icon: The icon name to display.
        label: The label text to display.
        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.
    """
    super().__init__()

    if not isinstance(icon, str):
        raise TypeError(f"icon must be str, got {type(icon)}")
    if not isinstance(label, str):
        raise TypeError(f"label must be str, got {type(label)}")

    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)

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,
):
    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.
    touch_target = int(self._style.default_touch_target) if self._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,
    )

    # 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,
            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

        focus_stroke = max(1.0, float(3.0 * (touch_sz / 48.0)))
        focus_offset = float(2.0 * (touch_sz / 48.0))

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

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

        fg_hex = roles.get(ColorRole.ON_SURFACE, "#000000")
        stroke_color = skcolor(fg_hex, 0.54)
        stroke_p = make_paint(color=stroke_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
        overlay_alpha = 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 is_keyboard_focus:
            focus_alpha = 0.12
            prim = roles.get(ColorRole.PRIMARY, "#000000")
            focus_col = skcolor(prim, focus_alpha)
            focus_p = make_paint(color=focus_col, style="stroke", stroke_width=focus_stroke, aa=True)
            try:
                radius = float(state_diam / 2.0 + focus_offset + (focus_stroke / 2.0))
                canvas.drawCircle(
                    cx + touch_sz / 2.0,
                    cy + touch_sz / 2.0,
                    radius,
                    focus_p,
                )
            except Exception:
                ox = cx + touch_sz / 2.0 - (state_diam / 2.0 + 4.0)
                oy = cy + touch_sz / 2.0 - (state_diam / 2.0 + 4.0)
                side = state_diam + 8.0
                draw_oval(canvas, make_rect(ox, oy, side, side), focus_p)

        val = self.value
        selection_progress = self._get_selection_progress()
        if selection_progress > 1e-6:
            prim = roles.get(ColorRole.PRIMARY, "#000000")
            fill_p = make_paint(color=skcolor(prim, 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"
            onp = roles.get(ColorRole.ON_PRIMARY, "#000000")
            mark_p = make_paint(
                color=skcolor(onp, 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

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)

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
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,
) -> 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.
    """
    self.option_value = value
    self._style = style

    # Touch-target size is style-driven (MD3 fixes the axis -> style only).
    touch_target = int(self._style.default_touch_target) if self._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,
    )

    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 self.should_show_focus_ring:
            focus_stroke = float(cast(float, sizes["focus_stroke"]))
            focus_offset = float(cast(float, sizes["focus_offset"]))
            focus_color = roles.get(ColorRole.PRIMARY, "#000000")
            focus_size = state_layer_size + (focus_offset * 2.0)
            focus_rect = make_rect(
                cx + (touch_sz - focus_size) / 2.0,
                cy + (touch_sz - focus_size) / 2.0,
                focus_size,
                focus_size,
            )
            focus_paint = make_paint(
                color=skcolor(focus_color, self.style.focus_alpha),
                style="stroke",
                stroke_width=focus_stroke,
                aa=True,
            )
            if focus_rect is not None and focus_paint is not None:
                draw_oval(canvas, focus_rect, focus_paint)
    except Exception:
        exception_once(_logger, "radio_button_paint_exc", "RadioButton paint raised")

RadioGroup

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

Bases: Container

Container that manages a single selected value for descendant RadioButtons.

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
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,
) -> 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.
    """
    if not isinstance(child, Widget):
        raise TypeError(f"child must be Widget, got {type(child)}")
    super().__init__(child=child)

    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

value property writable

value: object | None

Current selected value.

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)

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
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,
) -> 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.
    """
    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).
    touch_target = int(self._style.default_touch_target) if self._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,
    )

    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

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 self.should_show_focus_ring:
            focus_stroke = float(cast(float, sizes["focus_stroke"]))
            focus_offset = float(cast(float, sizes["focus_offset"]))
            focus_color = roles.get(ColorRole.PRIMARY, "#000000")
            focus_size = state_layer_size + (focus_offset * 2.0)
            focus_rect = make_rect(
                thumb_x + (thumb_d - focus_size) / 2.0,
                thumb_y + (thumb_d - focus_size) / 2.0,
                focus_size,
                focus_size,
            )
            focus_paint = make_paint(
                color=skcolor(focus_color, self.style.focus_alpha),
                style="stroke",
                stroke_width=focus_stroke,
                aa=True,
            )
            if focus_rect is not None and focus_paint is not None:
                draw_oval(canvas, focus_rect, focus_paint)
    except Exception:
        exception_once(_logger, "switch_paint_exc", "Switch paint raised")

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 = '1%', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = 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.

'1%'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

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 = "1%",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = 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.
    """
    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,
    )

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 = '1%', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = 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.

'1%'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

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 = "1%",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = 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.
    """
    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,
    )

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 = '1%', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = 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.

'1%'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

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 = "1%",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = 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.
    """
    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,
    )

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 = '1%', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = 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.

'1%'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

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 = "1%",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = 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.
    """
    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,
    )

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 = '1%', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = 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.

'1%'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

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 = "1%",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = 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.
    """
    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,
    )

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 = '1%', padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None, style: Optional['SliderStyle'] = 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.

'1%'
padding Optional[Tuple[int, int] | Tuple[int, int, int, int] | int]

Slider padding.

None
style Optional['SliderStyle']

Optional SliderStyle override.

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 = "1%",
    padding: Optional[Tuple[int, int] | Tuple[int, int, int, int] | int] = None,
    style: Optional["SliderStyle"] = 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.
    """
    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,
    )

Symbol dataclass

Symbol(name: str, codepoint: str)

Material symbol descriptor.

Symbols

Material Symbols constants (auto-generated).

TextField

TextField(value: Union[str, ObservableProtocol[str]] = '', on_change: Optional[Callable[[str], None]] = 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 | ObservableProtocol[bool] | None = None, error_text: str | ReadOnlyObservableProtocol[str | None] | None = None, disabled: bool | ObservableProtocol[bool] = False, width: SizingLike = 200, padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0, style: Optional[TextFieldStyle] = None)

Bases: InteractiveWidget

A text input widget base class.

Note

The constructor TextField(value=observable) establishes a one-way binding. Changes in the observable will update the text field, but user input will NOT update the observable automatically.

For two-way binding, use the TextField.two_way(observable) factory method.

Parameters: - value: Initial text value (str or TextEditingValue) OR External observable - on_change: Callback when value changes - 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 - trailing_icon: Icon source (Symbol/str or Observable of them) - 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 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, ObservableProtocol[str]]

Initial text value or observable.

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

Callback when value changes.

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 | ObservableProtocol[bool] | None

Whether to use error colors for the field.

None
error_text str | ReadOnlyObservableProtocol[str | None] | None

Deprecated alias for supporting_text.

None
disabled bool | ObservableProtocol[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
Source code in src/nuiitivet/material/text_fields.py
def __init__(
    self,
    value: Union[str, ObservableProtocol[str]] = "",
    on_change: Optional[Callable[[str], None]] = 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 | ObservableProtocol[bool] | None = None,
    error_text: str | ReadOnlyObservableProtocol[str | None] | None = None,
    disabled: bool | ObservableProtocol[bool] = False,
    width: SizingLike = 200,
    padding: Union[int, Tuple[int, int], Tuple[int, int, int, int]] = 0,
    style: Optional[TextFieldStyle] = None,
):
    """Initialize TextField.

    Args:
        value: Initial text value or observable.
        on_change: Callback when value changes.
        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 to use error colors for the field.
        error_text: Deprecated alias for supporting_text.
        disabled: Whether the text field is disabled.
        width: Width specification.
        padding: Padding around the text field.
        style: Custom style configuration.
    """
    super().__init__(
        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: ObservableProtocol[bool] | None = None
    self._disabled_source: ObservableProtocol[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

    self._legacy_error_text_mode = supporting_text is None and error_text is not None and is_error is None

    supporting_text_value: str | None
    supporting_source = supporting_text if supporting_text is not None else error_text
    if hasattr(supporting_source, "subscribe") and hasattr(supporting_source, "value"):
        self._supporting_text_source = cast("ReadOnlyObservableProtocol[str | None]", supporting_source)
        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_source) if supporting_source is not None else None

    initial_is_error: bool
    if hasattr(is_error, "subscribe") and hasattr(is_error, "value"):
        self._is_error_source = cast("ObservableProtocol[bool]", is_error)
        try:
            initial_is_error = bool(self._is_error_source.value)
        except Exception:
            initial_is_error = False
    elif is_error is None:
        initial_is_error = self._legacy_error_text_mode and supporting_text_value is not None
    else:
        initial_is_error = bool(is_error)

    initial_disabled: bool
    if hasattr(disabled, "subscribe") and hasattr(disabled, "value"):
        self._disabled_source = cast("ObservableProtocol[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.leading_icon = _build_text_field_icon(leading_icon, arg_name="leading_icon")
    self.trailing_icon = _build_text_field_icon(trailing_icon, arg_name="trailing_icon")
    self._on_tap_leading_icon = on_tap_leading_icon
    self._on_tap_trailing_icon = on_tap_trailing_icon
    self.supporting_text = supporting_text_value
    self.is_error = initial_is_error

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

    self._user_style = style

    self._on_change = on_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,
        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)
    self._label_progress = Animatable(
        1.0 if has_text else 0.0,
        motion=EXPRESSIVE_DEFAULT_EFFECTS,
    )
    self._label_progress.subscribe(lambda _: self.invalidate())

    # Indicator Animations
    init_ind_width = style.indicator_width
    self._anim_indicator_width = Animatable(
        float(init_ind_width),
        motion=EXPRESSIVE_DEFAULT_EFFECTS,
    )
    self._anim_indicator_width.subscribe(lambda _: self.invalidate())

    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,
    )
    self._anim_indicator_color.subscribe(lambda _: self.invalidate())

    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,
    )
    self._anim_label_color.subscribe(lambda _: self.invalidate())

    # 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.

error_text property writable

error_text: str | None

Deprecated alias for supporting_text.

two_way classmethod

two_way(value: ObservableProtocol[str], *, on_change: Optional[Callable[[str], None]] = None, **kwargs) -> TTextField

Create a two-way bound TextField.

Parameters:

Name Type Description Default
value ObservableProtocol[str]

The observable value to bind to.

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

Optional callback when value changes (in addition to updating the observable).

None
**kwargs

Additional arguments passed to the constructor.

{}

Returns:

Type Description
TTextField

Review instance of the TextField class.

Source code in src/nuiitivet/material/text_fields.py
@classmethod
def two_way(
    cls: type[TTextField],
    value: ObservableProtocol[str],
    *,
    on_change: Optional[Callable[[str], None]] = None,
    **kwargs,
) -> TTextField:
    """Create a two-way bound TextField.

    Args:
        value: The observable value to bind to.
        on_change: Optional callback when value changes (in addition to updating the observable).
        **kwargs: Additional arguments passed to the constructor.

    Returns:
        Review instance of the TextField class.
    """

    def _bound_on_change(new_text: str) -> None:
        try:
            value.value = new_text
        except Exception:
            exception_once(_logger, "text_field_two_way_set_value_exc", "TextField.two_way failed to set value")
        if on_change is not None:
            try:
                on_change(new_text)
            except Exception:
                exception_once(_logger, "text_field_two_way_on_change_exc", "TextField.two_way on_change raised")

    return cls(value=value, on_change=_bound_on_change, **kwargs)

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

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)

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
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,
):
    """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.
    """
    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,
    )

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)

Bases: Navigator

Navigator that applies Material default transition specs.

Source code in src/nuiitivet/navigation/navigator.py
def __init__(
    self,
    screen: Route | Widget | None = None,
    *,
    layer_composer: NavigationLayerComposer | None = None,
) -> None:
    """Initialize a Navigator with a single initial screen.

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

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

Overlay

Overlay(*, intent_resolver: IntentResolver | None = None, intents: Mapping[type[Any], Callable[[Any], Widget | Route]] | 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,
) -> None:
    super().__init__(layer_composer=MaterialOverlayLayerComposer())

    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",
                            on_click=lambda: Overlay.root().close(None),
                            width=80,
                            style=ButtonStyle.text(),
                        )
                    ],
                ),
                transition_spec=MaterialTransitions.dialog(),
            ),
            LoadingIntent: lambda _: OverlayRoute(
                builder=lambda: LoadingIndicator(),
                transition_spec=None,
                barrier_dismissible=False,
            ),
        }
        if intents:
            defaults.update(intents)
        intent_resolver = _MappingIntentResolver(defaults)

    self._intent_resolver = intent_resolver

loading

loading(indicator: Widget | Route | Any | None = None) -> OverlayHandle[Any]

Show a loading indicator overlay and return a handle for manual dismissal.

Parameters:

Name Type Description Default
indicator Widget | Route | Any | None

Widget, Route, or intent to display as the loading indicator. Defaults to the built-in :class:LoadingIndicator.

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 | Route | Any | None = None,
) -> OverlayHandle[Any]:
    """Show a loading indicator overlay and return a handle for manual dismissal.

    Args:
        indicator: Widget, Route, or intent to display as the loading indicator.
            Defaults to the built-in :class:`LoadingIndicator`.

    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, Route)):
        resolved = indicator
    else:
        resolved = self._intent_resolver.resolve(indicator)
    return self.show_modeless(
        resolved,
        timeout=None,
        position=OverlayPosition.alignment("center"),
    )

while_loading

while_loading(indicator: Widget | Route | 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 | Route | Any | None

Widget, Route, or intent to display as the loading indicator. Defaults to the built-in :class:LoadingIndicator.

None

Returns:

Name Type Description
A WhileLoading

class:LoadingScope 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 | Route | 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, Route, or intent to display as the loading indicator.
            Defaults to the built-in :class:`LoadingIndicator`.

    Returns:
        A :class:`LoadingScope` context manager that shows the indicator on entry and closes it on exit.
    """
    return WhileLoading(self, indicator)

side_sheet

side_sheet(sheet: Widget, *, dismiss_on_outside_tap: bool = True) -> OverlayHandle[Any]

Display a modal side sheet.

The sheet's position, corner radii, and transition direction are derived from sheet.side. Visual styling (background, size, corner radius) is fully owned by the :class:SideSheet widget.

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
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,
    *,
    dismiss_on_outside_tap: bool = True,
) -> OverlayHandle[Any]:
    """Display a modal side sheet.

    The sheet's position, corner radii, and transition direction are derived
    from ``sheet.side``.  Visual styling (background, size, corner radius) is
    fully owned by the :class:`SideSheet` widget.

    Args:
        sheet: SideSheet 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``.
    """
    inner = _find_descendant(sheet, SideSheet)
    if inner is None:
        raise TypeError("side_sheet() requires a SideSheet widget (possibly wrapped by modifiers)")
    alignment = "top-right" if inner.side == "right" else "top-left"

    route = OverlayRoute(
        builder=lambda: sheet,
        transition_spec=MaterialTransitions.side_sheet(side=inner.side),
        barrier_dismissible=bool(dismiss_on_outside_tap),
    )

    return self.show_modal(
        route,
        dismiss_on_outside_tap=bool(dismiss_on_outside_tap),
        position=OverlayPosition.alignment(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(),
        barrier_dismissible=bool(dismiss_on_outside_tap),
    )

    return self.show_modal(
        route,
        dismiss_on_outside_tap=bool(dismiss_on_outside_tap),
        position=OverlayPosition.alignment("bottom-center"),
    )

WhileLoading

WhileLoading(overlay: 'MaterialOverlay', indicator: Widget | Route | 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: "MaterialOverlay", indicator: Widget | Route | Any | None) -> None:
    self._overlay = overlay
    self._indicator = indicator
    self._handle: OverlayHandle[Any] | None = None

ThemeFactory

Factory for creating Themes with Material Design configuration.

from_seed staticmethod

from_seed(seed_color: str, mode: str = 'light', name: str = '') -> Theme

Create Material theme from seed color.

Source code in src/nuiitivet/material/theme/material_theme.py
@staticmethod
def from_seed(seed_color: str, mode: str = "light", name: str = "") -> Theme:
    """Create Material theme from seed color."""
    light_roles, dark_roles = from_seed(seed_color)
    roles = dark_roles if mode == "dark" else light_roles

    material_data = MaterialThemeData(roles=roles)
    return Theme(
        mode=mode,
        extensions=[material_data, _material_scrollbar_theme_data()],
        name=name,
    )

from_seed_pair staticmethod

from_seed_pair(seed_color: str, name: str = '') -> 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 = "") -> Tuple[Theme, Theme]:
    """Create light and dark themes from a seed color."""
    return (
        MaterialThemeFactory.from_seed(seed_color, mode="light", name=name),
        MaterialThemeFactory.from_seed(seed_color, mode="dark", name=name),
    )

DockedToolbar

DockedToolbar(buttons: Sequence[MaterialButtonBase], *, style: Optional[ToolbarStyle] = None)

Bases: Box

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[MaterialButtonBase]

Action buttons placed inside the toolbar.

required
style Optional[ToolbarStyle]

Optional toolbar style. Defaults to ToolbarStyle.standard().

None
Source code in src/nuiitivet/material/toolbar.py
def __init__(
    self,
    buttons: Sequence[MaterialButtonBase],
    *,
    style: Optional[ToolbarStyle] = None,
) -> None:
    """Initialize DockedToolbar.

    Args:
        buttons: Action buttons placed inside the toolbar.
        style: Optional toolbar style. Defaults to ``ToolbarStyle.standard()``.
    """
    self._user_style = style
    effective_style = self.style
    children = _validate_buttons(buttons)
    row_children: list[Widget] = list(children)

    content = Row(
        row_children,
        width="100%",
        gap=effective_style.item_gap,
        main_alignment="space-between",
        cross_alignment="center",
        padding=effective_style.content_padding,
    )

    super().__init__(
        child=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",
    )

style property

style: ToolbarStyle

Return toolbar style from explicit style or default style.

HorizontalFloatingToolbar

HorizontalFloatingToolbar(buttons: Sequence[MaterialButtonBase], *, padding: PaddingLike = 0, style: Optional[ToolbarStyle] = 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[MaterialButtonBase]

Action buttons placed inside the toolbar.

required
padding PaddingLike

External padding around the floating toolbar.

0
style Optional[ToolbarStyle]

Optional toolbar style. Defaults to ToolbarStyle.standard().

None
Source code in src/nuiitivet/material/toolbar.py
def __init__(
    self,
    buttons: Sequence[MaterialButtonBase],
    *,
    padding: PaddingLike = 0,
    style: Optional[ToolbarStyle] = None,
) -> None:
    """Initialize HorizontalFloatingToolbar.

    Args:
        buttons: Action buttons placed inside the toolbar.
        padding: External padding around the floating toolbar.
        style: Optional toolbar style. Defaults to ``ToolbarStyle.standard()``.
    """
    super().__init__(buttons, orientation="horizontal", padding=padding, style=style)

VerticalFloatingToolbar

VerticalFloatingToolbar(buttons: Sequence[MaterialButtonBase], *, padding: PaddingLike = 0, style: Optional[ToolbarStyle] = 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[MaterialButtonBase]

Action buttons placed inside the toolbar.

required
padding PaddingLike

External padding around the floating toolbar.

0
style Optional[ToolbarStyle]

Optional toolbar style. Defaults to ToolbarStyle.standard().

None
Source code in src/nuiitivet/material/toolbar.py
def __init__(
    self,
    buttons: Sequence[MaterialButtonBase],
    *,
    padding: PaddingLike = 0,
    style: Optional[ToolbarStyle] = None,
) -> None:
    """Initialize VerticalFloatingToolbar.

    Args:
        buttons: Action buttons placed inside the toolbar.
        padding: External padding around the floating toolbar.
        style: Optional toolbar style. Defaults to ``ToolbarStyle.standard()``.
    """
    super().__init__(buttons, orientation="vertical", padding=padding, style=style)

Tooltip

Tooltip(message: str, *, width: SizingLike = None, height: SizingLike = None, style: TooltipStyle | 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
Source code in src/nuiitivet/material/tooltip.py
def __init__(
    self,
    message: str,
    *,
    width: SizingLike = None,
    height: SizingLike = None,
    style: TooltipStyle | 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().
    """
    super().__init__(width=width, height=height)
    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)

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
Source code in src/nuiitivet/material/tooltip.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,
) -> 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().
    """
    super().__init__(width=width, height=height)
    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.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 = '100%', 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 "100%" so the sheet spans the full screen height. 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 = '100%', 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 "100%" so the sheet spans the full screen width. height=None means the container sizes to its content. 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 = '100%', 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 "100%" so the sheet spans the full content area height. 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, ReadOnlyObservableProtocol[str]], side: Literal['right', 'left'] = 'right', on_back: Optional[Callable[[], None]] = None, show_back_button: Union[bool, ReadOnlyObservableProtocol[bool]] = False, style: Optional[SideSheetStyle] = 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))
)

Parameters:

Name Type Description Default
content Widget

Widget to display below the header.

required
headline Union[str, ReadOnlyObservableProtocol[str]]

Header title text (str or Observable[str]). Required by M3.

required
side Literal['right', 'left']

Edge the sheet slides in from ("right" or "left"). Defaults to "right".

'right'
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, ReadOnlyObservableProtocol[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, ReadOnlyObservableProtocol[str]]

Header title text (str or Observable[str]).

required
side Literal['right', 'left']

Edge the sheet slides in from. Defaults to "right".

'right'
on_back Optional[Callable[[], None]]

Callback for the Back icon button press.

None
show_back_button Union[bool, ReadOnlyObservableProtocol[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
Source code in src/nuiitivet/material/sheet.py
def __init__(
    self,
    content: Widget,
    *,
    headline: Union[str, ReadOnlyObservableProtocol[str]],
    side: Literal["right", "left"] = "right",
    on_back: Optional[Callable[[], None]] = None,
    show_back_button: Union[bool, ReadOnlyObservableProtocol[bool]] = False,
    style: Optional[SideSheetStyle] = None,
) -> None:
    """Initialize SideSheet.

    Args:
        content: Widget to display below the header.
        headline: Header title text (str or Observable[str]).
        side: Edge the sheet slides in from. Defaults to ``"right"``.
        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`.
    """
    _style = style if style is not None else SideSheetStyle()
    super().__init__(height=_style.height)
    self._content = content
    self._headline = headline
    self.side = side
    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, ReadOnlyObservableProtocol):
        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 (flex)] [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="100%",
                padding=(8, 0, 8, 0),
            ),
            IconButton("close", on_click=self._on_close_click if self._overlay_handle is not None else None),
        ],
        width="100%",
        height=72,
        padding=(4, 0, 4, 0),
        cross_alignment="center",
    )

    # Apply per-corner radius: round only the inner (away-from-edge) corners.
    cr = float(resolved_style.corner_radius)
    if self.side == "right":
        corner_radius = (cr, 0.0, 0.0, cr)  # tl, tr, br, bl
    else:
        corner_radius = (0.0, cr, cr, 0.0)  # tl, tr, br, bl

    return Box(
        Column(
            [header, self._content],
            width="100%",
        ),
        width=resolved_style.width,
        height=resolved_style.height,
        corner_radius=corner_radius,
        background_color=resolved_style.background_color,
        alignment="top-left",
    )

BottomSheet

BottomSheet(content: Widget, *, headline: Union[str, ReadOnlyObservableProtocol[str]], style: Optional[BottomSheetStyle] = 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, ReadOnlyObservableProtocol[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, ReadOnlyObservableProtocol[str]]

Header title text (str or Observable[str]).

required
style Optional[BottomSheetStyle]

Container style. Defaults to :class:BottomSheetStyle.

None
Source code in src/nuiitivet/material/sheet.py
def __init__(
    self,
    content: Widget,
    *,
    headline: Union[str, ReadOnlyObservableProtocol[str]],
    style: Optional[BottomSheetStyle] = 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`.
    """
    _style = style if style is not None else BottomSheetStyle()
    super().__init__(width=_style.width)
    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="100%",
                padding=(8, 0, 8, 0),
            ),
            IconButton("close", on_click=self._on_close_click if self._overlay_handle is not None else None),
        ],
        width="100%",
        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="100%",
        ),
        width=resolved_style.width,
        height=resolved_style.height,
        corner_radius=corner_radius,
        background_color=resolved_style.background_color,
    )

StandardSideSheet

StandardSideSheet(content: Widget, *, headline: Optional[Union[str, ReadOnlyObservableProtocol[str]]] = None, side: Literal['right', 'left'] = 'right', on_close: Optional[Callable[[], None]] = None, style: Optional[StandardSideSheetStyle] = 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 does not manage its own open/close animation; wrap it in :class:~nuiitivet.layout.collapsible.Collapsible when animated expand/collapse is needed::

Row([
    main_content,
    Collapsible(
        StandardSideSheet(
            panel_content,
            headline="Filters",
            on_close=vm.close_panel,
        ),
        opened=vm.panel_open,
        axis="horizontal",
        alignment="top_right",
    ),
])

Parameters:

Name Type Description Default
content Widget

Widget to display inside the sheet.

required
headline Optional[Union[str, ReadOnlyObservableProtocol[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".

'right'
on_close Optional[Callable[[], None]]

Callback invoked when the close icon button is pressed. When None, no close button is rendered in the header.

None
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
headline Optional[Union[str, ReadOnlyObservableProtocol[str]]]

Optional header title (str or Observable[str]).

None
side Literal['right', 'left']

Attachment edge ("right" or "left"). Defaults to "right".

'right'
on_close Optional[Callable[[], None]]

Callback for the close icon button. No button is shown when None.

None
style Optional[StandardSideSheetStyle]

Container style. Defaults to :class:StandardSideSheetStyle.

None
Source code in src/nuiitivet/material/sheet.py
def __init__(
    self,
    content: Widget,
    *,
    headline: Optional[Union[str, ReadOnlyObservableProtocol[str]]] = None,
    side: Literal["right", "left"] = "right",
    on_close: Optional[Callable[[], None]] = None,
    style: Optional[StandardSideSheetStyle] = None,
) -> None:
    """Initialize StandardSideSheet.

    Args:
        content: Widget to display inside the sheet.
        headline: Optional header title (str or Observable[str]).
        side: Attachment edge (``"right"`` or ``"left"``).
            Defaults to ``"right"``.
        on_close: Callback for the close icon button.  No button is shown
            when ``None``.
        style: Container style.  Defaults to :class:`StandardSideSheetStyle`.
    """
    super().__init__()
    self._content = content
    self._headline = headline
    self.side = side
    self._on_close = on_close
    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, ReadOnlyObservableProtocol):
        sub = self._headline.subscribe(lambda _: self.rebuild())
        self.bind(sub)

build

build() -> Widget

Build the sheet: outer Box with optional header Row and content.

Source code in src/nuiitivet/material/sheet.py
def build(self) -> Widget:
    """Build the sheet: outer Box with optional header Row and content."""
    resolved_style = self.style

    # Optionally build the header row (headline + close button).
    body_parts: list[Widget] = []
    if self._headline is not None or self._on_close is not None:
        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.flex(1),
                    padding=(8, 0, 8, 0),
                )
            )
        if self._on_close is not None:
            header_children.append(IconButton("close", on_click=self._on_close))
        body_parts.append(
            Row(
                header_children,
                width="100%",
                height=72,
                padding=(4, 0, 4, 0),
                cross_alignment="center",
            )
        )
    body_parts.append(self._content)

    content_col = Column(body_parts, width=Sizing.flex(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="100%", height="100%")
        else:
            inner = Row([content_col, divider], width="100%", height="100%")
    else:
        inner = content_col

    return Box(
        inner,
        width=resolved_style.width,
        height=resolved_style.height,
        background_color=resolved_style.background_color,
    )

GroupButton

GroupButton(label: 'str | ReadOnlyObservableProtocol[str] | None' = None, icon: 'Symbol | str | ReadOnlyObservableProtocol | None' = None, *, selected: 'bool | ObservableProtocol[bool]' = False, on_change: Optional[BoolCallback] = None, disabled: 'bool | ObservableProtocol[bool]' = False, width: SizingLike = None, style: 'Optional[ButtonGroupStyle]' = 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 | ReadOnlyObservableProtocol[str] | None'

Optional text label. Can be a plain str or a ReadOnlyObservableProtocol[str] for dynamic text.

None
icon 'Symbol | str | ReadOnlyObservableProtocol | None'

Optional icon. Accepts a Symbol, str icon name, or ReadOnlyObservableProtocol wrapping either.

None
selected 'bool | ObservableProtocol[bool]'

Initial selected (toggle) state. Pass an ObservableProtocol[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 | ObservableProtocol[bool]'

Whether the item ignores pointer events.

False
width SizingLike

Optional width sizing. ConnectedButtonGroup overrides this to Sizing.flex(1) to achieve equal-width segments.

None
style 'Optional[ButtonGroupStyle]'

Optional style override. If omitted the filled preset is used.

None

Initialize GroupButton.

Parameters:

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

Text label, or an observable string.

None
icon 'Symbol | str | ReadOnlyObservableProtocol | None'

Icon symbol, string name, or observable icon.

None
selected 'bool | ObservableProtocol[bool]'

Initial selected state, or an observable bool.

False
on_change Optional[BoolCallback]

Toggle-state change callback.

None
disabled 'bool | ObservableProtocol[bool]'

Disable interaction.

False
width SizingLike

Width sizing spec.

None
style 'Optional[ButtonGroupStyle]'

Visual style override.

None
Source code in src/nuiitivet/material/button_group.py
def __init__(
    self,
    label: "str | ReadOnlyObservableProtocol[str] | None" = None,
    icon: "Symbol | str | ReadOnlyObservableProtocol | None" = None,
    *,
    selected: "bool | ObservableProtocol[bool]" = False,
    on_change: Optional[BoolCallback] = None,
    disabled: "bool | ObservableProtocol[bool]" = False,
    width: SizingLike = None,
    style: "Optional[ButtonGroupStyle]" = 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.
    """
    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
    self._style: "ButtonGroupStyle" = style or StandardButtonGroupStyle.filled()
    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[ObservableProtocol[bool]]" = None
    if hasattr(selected, "subscribe") and hasattr(selected, "value"):
        self._selected_external = cast("ObservableProtocol[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(self._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(
        self._style.outer_corner_radius,
        self._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=self._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=self._style.overlay_color or ColorRole.ON_SURFACE,
    )

    # Override state-layer opacities from style
    self._PRESS_OPACITY = self._style.overlay_alpha
    self._HOVER_OPACITY = self._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.
    """
    # 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)

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 StandardButtonGroupStyle.filled() (size "s").

None
Source code in src/nuiitivet/material/button_group.py
def __init__(
    self,
    items: Sequence[GroupButton],
    *,
    style: "Optional[StandardButtonGroupStyle]" = None,
) -> None:
    """Initialize StandardButtonGroup.

    Args:
        items: Between 2 and 5 ``GroupButton`` instances.
        style: Visual style override.  Defaults to
            ``StandardButtonGroupStyle.filled()`` (size ``"s"``).
    """
    from nuiitivet.material.styles.button_group_style import (
        StandardButtonGroupStyle as _Std,
    )

    eff_style = style if style is not None else _Std.filled()
    super().__init__(
        items,
        adjacent_animation=True,
        persistent_selected_pressed_shape=True,
        connected_inner_press_only=False,
        group_width=None,  # Fits content
        style=eff_style,
    )

ConnectedButtonGroup

ConnectedButtonGroup(items: Sequence[GroupButton], *, select_mode: Literal['single', 'multi'] = 'single', style: 'Optional[ConnectedButtonGroupStyle]' = None)

Bases: _ButtonGroupBase

A ButtonGroup that functions as an option selector / view switcher.

Width expands to fill the containing widget (width="100%"). Items share space equally (Sizing.flex(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 ConnectedButtonGroupStyle.filled() (size "s").

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,
) -> None:
    """Initialize ConnectedButtonGroup.

    Args:
        items: Between 2 and 5 ``GroupButton`` instances.
        select_mode: ``"single"`` or ``"multi"`` selection enforcement.
        style: Visual style override.  Defaults to
            ``ConnectedButtonGroupStyle.filled()`` (size ``"s"``).
    """
    from nuiitivet.material.styles.button_group_style import (
        ConnectedButtonGroupStyle as _Con,
    )

    eff_style = style if style is not None else _Con.filled()
    self._select_mode = select_mode

    super().__init__(
        items,
        adjacent_animation=False,
        persistent_selected_pressed_shape=False,
        connected_inner_press_only=True,
        group_width="100%",
        style=eff_style,
    )

on_mount

on_mount() -> None

Assign positions, set flex widths, and wire group selection logic.

Source code in src/nuiitivet/material/button_group.py
def on_mount(self) -> None:
    """Assign positions, set flex 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.flex(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"]),
    )

from_theme classmethod

from_theme(theme: 'Theme | None', variant: str, size: ButtonSize = 's') -> 'StandardButtonGroupStyle'

Create a style from a theme and variant name.

Parameters:

Name Type Description Default
theme 'Theme | None'

The active theme (currently unused; colours are role-based).

required
variant str

One of "filled", "tonal", or "outlined".

required
size ButtonSize

M3 size token preset.

's'
Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def from_theme(
    cls,
    theme: "Theme | None",
    variant: str,
    size: ButtonSize = "s",
) -> "StandardButtonGroupStyle":
    """Create a style from a theme and variant name.

    Args:
        theme: The active theme (currently unused; colours are role-based).
        variant: One of ``"filled"``, ``"tonal"``, or ``"outlined"``.
        size: M3 size token preset.
    """
    if variant == "tonal":
        return cls.tonal(size)
    if variant == "outlined":
        return cls.outlined(size)
    return cls.filled(size)

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"]),
    )

from_theme classmethod

from_theme(theme: 'Theme | None', variant: str, size: ButtonSize = 's') -> 'ConnectedButtonGroupStyle'

Create a style from a theme and variant name.

Parameters:

Name Type Description Default
theme 'Theme | None'

The active theme (currently unused; colours are role-based).

required
variant str

One of "filled", "tonal", or "outlined".

required
size ButtonSize

M3 size token preset.

's'
Source code in src/nuiitivet/material/styles/button_group_style.py
@classmethod
def from_theme(
    cls,
    theme: "Theme | None",
    variant: str,
    size: ButtonSize = "s",
) -> "ConnectedButtonGroupStyle":
    """Create a style from a theme and variant name.

    Args:
        theme: The active theme (currently unused; colours are role-based).
        variant: One of ``"filled"``, ``"tonal"``, or ``"outlined"``.
        size: M3 size token preset.
    """
    if variant == "tonal":
        return cls.tonal(size)
    if variant == "outlined":
        return cls.outlined(size)
    return cls.filled(size)

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 | ObservableProtocol[bool]' = False, disabled: 'bool | ObservableProtocol[bool]' = False, width: SizingLike = None, style: 'Optional[SplitButtonStyle]' = 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:ReadOnlyObservableProtocol.

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 | ObservableProtocol[bool]'

Initial menu open (selected) state of the trailing button. Pass an :class:ObservableProtocol to bind externally.

False
disabled 'bool | ObservableProtocol[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
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 | ObservableProtocol[bool]" = False,
    disabled: "bool | ObservableProtocol[bool]" = False,
    width: SizingLike = None,
    style: "Optional[SplitButtonStyle]" = 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:`ReadOnlyObservableProtocol`.
        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:`ObservableProtocol` 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")``.
    """
    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)

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

Return a new style with the specified fields replaced.

Parameters:

Name Type Description Default
**changes

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

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)

DockedDatePicker

DockedDatePicker(value: ObservableProtocol[Optional[date]], *, on_change: Optional[Callable[[Optional[date]], None]] = None, min_date: Optional[date] = None, max_date: Optional[date] = None, style: Optional['DockedDatePickerStyle'] = None)

Bases: ComposableWidget

Material Design 3 Docked 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).

MD3 container: 360×456dp, Large corner rounding (16dp).

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
min_date Optional[date]

Earliest selectable date.

None
max_date Optional[date]

Latest selectable date.

None
style Optional['DockedDatePickerStyle']

Visual style. Defaults to :class:DockedDatePickerStyle.

None

Initialize DockedDatePicker.

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
min_date Optional[date]

Minimum selectable date.

None
max_date Optional[date]

Maximum selectable date.

None
style Optional['DockedDatePickerStyle']

Optional style override.

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,
    min_date: Optional[_Date] = None,
    max_date: Optional[_Date] = None,
    style: Optional["DockedDatePickerStyle"] = None,
) -> None:
    """Initialize DockedDatePicker.

    Args:
        value: Observable holding the selected date (or None).
        on_change: Callback invoked when the user selects a date.
        min_date: Minimum selectable date.
        max_date: Maximum selectable date.
        style: Optional style override.
    """
    super().__init__()
    self._value_obs = value
    self._on_change = on_change
    self._min_date = min_date
    self._max_date = max_date
    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)

style property

style: 'DockedDatePickerStyle'

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.observe(self._value_obs, lambda _: self.rebuild())

build

build() -> Widget

Build the docked picker container with navigation header and calendar.

Source code in src/nuiitivet/material/date_picker.py
def build(self) -> Widget:
    """Build the docked picker container with navigation header and calendar."""
    style = self.style
    selected = getattr(self._value_obs, "value", None)
    shadow = md3_elevation_to_shadow(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="docked",
        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,
            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,
            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(
            [
                Box(width=0),  # spacer to push buttons right
                Button("Cancel", on_click=self._on_cancel, style=action_btn_style),
                Button("OK", on_click=lambda: None, 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,
        shadow_blur=shadow.sigma,
        shadow_color=shadow.color,
        shadow_offset=shadow.offset,
        width=style.container_width,
        height=style.container_height,
        child=Column(
            column_children,
            gap=0,
            height=int(style.container_height),
        ),
    )

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)

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. See #230 <https://github.com/yuksblog/nuiitivet/issues/230>_ for tracking.

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
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,
) -> 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.
    """
    super().__init__()
    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
    shadow = md3_elevation_to_shadow(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,
        shadow_blur=shadow.sigma,
        shadow_color=shadow.color,
        shadow_offset=shadow.offset,
        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)

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. See #230 <https://github.com/yuksblog/nuiitivet/issues/230>_ for tracking.

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
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,
) -> 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.
    """
    super().__init__()
    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
    shadow = md3_elevation_to_shadow(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,
        shadow_blur=shadow.sigma,
        shadow_color=shadow.color,
        shadow_offset=shadow.offset,
        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: str = 'mm/dd/yyyy', min_date: Optional[date] = None, max_date: Optional[date] = None, style: Optional['ModalDateInputStyle'] = 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. See #230 <https://github.com/yuksblog/nuiitivet/issues/230>_ for tracking.

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 str

Format hint shown as supporting text (e.g. "mm/dd/yyyy").

'mm/dd/yyyy'
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 str

Format hint shown below the text field.

'mm/dd/yyyy'
min_date Optional[date]

Minimum acceptable date.

None
max_date Optional[date]

Maximum acceptable date.

None
style Optional['ModalDateInputStyle']

Optional style override.

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: str = "mm/dd/yyyy",
    min_date: Optional[_Date] = None,
    max_date: Optional[_Date] = None,
    style: Optional["ModalDateInputStyle"] = 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: Format hint shown below the text field.
        min_date: Minimum acceptable date.
        max_date: Maximum acceptable date.
        style: Optional style override.
    """
    super().__init__()
    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
    init_text = init_value.strftime("%m/%d/%Y") if init_value else ""
    self._text_obs: Observable[str] = Observable(init_text)
    self._supporting_text_obs: Observable[Optional[str]] = Observable(date_format)

    # 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.two_way(
        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
    shadow = md3_elevation_to_shadow(style.elevation)

    parsed_date = _parse_date(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,
        shadow_blur=shadow.sigma,
        shadow_color=shadow.color,
        shadow_offset=shadow.offset,
        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),
        ),
    )

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)

Shared style base for the calendar-based date pickers (MD3 Expressive).

Holds the tokens consumed by the calendar widgets shared across :class:DockedDatePickerStyle, :class:ModalDatePickerStyle and :class:ModalDateRangePickerStyle (the calendar grid, day cells and the month/year navigation header). Variant-specific selection views add their own tokens in the respective subclass.

Defaults match the MD3 docked picker. Subclasses override the container and header dimensions for their variant.

copy_with

copy_with(**changes) -> _S

Return a new style with the given fields overridden.

Parameters:

Name Type Description Default
**changes

Fields to override.

{}

Returns:

Type Description
_S

New style instance of the same type with applied changes.

Source code in src/nuiitivet/material/styles/date_picker_style.py
def copy_with(self: _S, **changes) -> _S:
    """Return a new style with the given fields overridden.

    Args:
        **changes: Fields to override.

    Returns:
        New style instance of the same type with applied changes.
    """
    return replace(self, **changes)

DockedDatePickerStyle dataclass

DockedDatePickerStyle(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: DatePickerStyle

Style for :class:DockedDatePicker (inline calendar).

MD3 docked picker: 360×460dp container, Large corner rounding (16dp). Adds the month/year inline list-menu tokens to the shared calendar base.

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: DatePickerStyle

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

Return a new style with the given fields overridden.

Parameters:

Name Type Description Default
**changes

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) -> "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 | ReadOnlyObservableProtocol[bytes | None], *, fit: Fit = 'contain', width: SizingLike = None, height: SizingLike = None, padding: int | tuple[int, int] | tuple[int, int, int, int] = 0, alignment: AlignmentLike = 'center')

Bases: Widget

Display a raster image from in-memory bytes.

Parameters:

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

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

required
fit Fit

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

'contain'
alignment AlignmentLike

Content alignment in the allocated content rect.

'center'
width SizingLike

Width sizing.

None
height SizingLike

Height sizing.

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

Space around content.

0

Initialize an Image widget.

Parameters:

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

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

required
fit Fit

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

'contain'
width SizingLike

Width sizing.

None
height SizingLike

Height sizing.

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

Space around content.

0
alignment AlignmentLike

Content alignment in the allocated content rect.

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

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

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

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

preferred_size

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