Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SpinBox

SpinBox preview

SpinBox — numeric input with increment/decrement buttons.

Public types

KindName
enumWrapMode — Out-of-range behavior when stepping past min or max
enumStepType — Step-size policy for each key/button press
enumWheelMode — When the mouse wheel is allowed to adjust the value
enumWidthPolicy — How the SpinBox decides its horizontal size envelope
constSPIN_BOX_STEP_BUTTON_WIDTH — Minimum total width
constSPIN_BOX_STEP_BUTTON_HEIGHT — Painted height of one stacked step button, in dp
structSpinBox — Numeric input with step buttons
traitSpinValue — Numeric primitive that a SpinBox can hold

Public functions

SpinBox

SpinValue

ReturnsFunction
Constructors
Selffrom_f64_saturating(v: f64)
Option<Self>parse(s: &str)
Builder methods
Selfsaturating_add(rhs: Self)
Selfsaturating_sub(rhs: Self)
Selfsaturating_mul_u32(rhs: u32)
Methods
f64to_f64()
Stringformat(decimals: u8)
Self { /* default implementation */ }clamp_value(min: Self, max: Self)
Associated functions
boolis_integer()
boolis_valid_input_char(c: char)

Detailed description

A generic composite over SpinValue (integer and floating-point primitives), pairing the TextInputField editing primitive with a stacked pair of up/down step buttons. Semantics are a synthesis of Qt's QSpinBox / QDoubleSpinBox, WinUI 3's NumberBox, GTK's GtkSpinButton, and the W3C ARIA spinbutton role.

Behaviour

  • Value binding: a Signal<T> is the single source of truth. Typing and stepping update it; external writes re-format the editable text.
  • Commit model: the user can type freely (subject to the per-character input filter, which only the default parser has: a custom value_from_text is handed every character). The value is committed on Enter or on focus loss — at commit time the text is parsed, clamped into [min, max] (or wrapped, per WrapMode), and reformatted. Invalid input reverts to the last known good value.
  • Stepping over typed text: every step (key, wheel, button, assistive Increment / Decrement) starts from what the field shows. Text typed and not yet committed is read first, exactly as Enter would read it, and the step moves on from there: type 35, press Up with a step of 5, and the value is 40, reported once. Text that cannot be read steps nothing; the value goes back into the field, and the next press steps from it. This is QAbstractSpinBox::stepBy.
  • Keyboard:
    • Up / Down → ±single_step
    • PageUp / PageDown → ±page_step (default: 10 × single_step)
    • Enter → commit (stays focused)
    • Home / End stay bound to the text cursor. QAbstractSpinBox routes both to its inner QLineEdit, and WinUI's NumberBox, Blink, Avalonia and jQuery UI bind neither; typing the number reaches min and max anyway. See docs/range-keyboard.md.
    • A chord holding Ctrl, Alt or Super is not the spin box's and falls through to the application; Shift does not change the step.
  • Mouse wheel: adjusts by single_step — wheel down decreases, wheel up increases, matching QAbstractSpinBox, GtkSpinButton and WinUI's NumberBox. Gated by wheel_mode (default: only when focused, to avoid accidental scroll changes).
  • Buttons: up/down buttons stack to the right of the field by default; can be hidden with button_layout.
  • Special value text: when the current value equals min and special_value_text is set, the field shows that string instead of the formatted number (Qt's "Auto" / "None" / "Unlimited" affordance). It stays while the field has focus, as in Qt: keyboard focus selects it, so a number typed replaces it.
  • Adaptive step: with StepType::Adaptive, the effective step tracks the decimal magnitude of the current value (Qt's AdaptiveDecimalStepType). Useful for values that span many orders of magnitude in the same control.
  • Locale: the number follows the active locale's decimal separator, digits and minus sign (localized, on by default); thousands separators are opt-in (use_grouping, off by default, as in Qt). Display, commit parse, stepping and the per-character input filter all read one NumberPresentation, which a live language switch replaces, so they cannot disagree about which separator the field is using: a French user sees 12,5, types 12,5, and the numeric keypad's . still works. Rendering is a string transform over the value's own Display, never an f64 round-trip, so a SpinBox<i64> stays exact past 2^53. Turn it off for a number that is an identifier rather than a quantity (port, version component, database id). With no I18nManager installed the active locale is the C locale and this is a no-op.
  • Custom formatter / parser: full override via text_from_value and value_from_text; together they let you implement currency, percentages with stored fraction, hex, duration, anything. A custom formatter/parser owns the whole convention — it is not re-punctuated by the locale layer.

Accessibility

The editing field is the spin button: one AccessKit node with Role::SpinButton, the label as its name, the numeric value, min, max, step and jump, the field's text as its value and its text runs, and the Increment, Decrement, SetValue, and Focus actions. It is the node that holds focus, so a focus change reports the field's name and value once, and a step moves the number a screen reader is following. The composite's own node is structure and collapses; it names the field as its accessibility_proxy, so an access_label, a FormLayout label, an access_described_by or a tooltip given to the spin box lands on the field. The suffix is painted and not part of the text, so it is not announced.

SetValue accepts either payload shape, because both are sent in the field: a number (macOS setAccessibilityValue: with an NSNumber, AT-SPI's Value.SetCurrentValue) is clamped and published as sent; a string (an NSString, the automation set_value tool) takes the same parse Enter takes, so a custom value_from_text and the locale's decimal separator are honoured, and an unparseable one reverts the display and is reported unhandled. A read_only spin box advertises and services none of the three mutating actions. The step buttons are structurally part of the SpinBox and publish no separate a11y nodes; stepping is the spin button's Increment and Decrement.

Example

use teksilo::widgets::{SpinBox, WrapMode};

let font_size = ctx.signal(12_i32);
ctx.add(
    SpinBox::new(font_size, 4, 72)
        .single_step(1)
        .page_step(10)
        .suffix(" pt"),
);

let gain_db = ctx.signal(0.0_f32);
ctx.add(
    SpinBox::new(gain_db, -60.0, 12.0)
        .single_step(0.5)
        .decimals(1)
        .suffix(" dB")
        .wrap_mode(WrapMode::Clamp),
);

Touch and pen

The step buttons are the controls sweep's one unreachable target, and the reason is recorded rather than papered over. Each is 18 x 13 dp inside a trailing column exactly its own width and exactly two buttons tall, so a Widget::hit_outset has nowhere to grow (an outset never escapes its parent); the field beside them takes presses of its own, so the miss-only slop pass has an eligible bubble owner at distance zero; and two conforming targets stacked need 88 dp of column, which no density projection of the field produces. Reaching the floor here is a layout change — the desktop stacked pair replaced by a side-by-side −/+ at coarse densities, as Material does — and that is a design decision, not a targeting one. The value stays fully reachable by keyboard (Up/Down, PageUp/PageDown) and by the Increment / Decrement assistive actions.

One thing the sweep did fix. A step still fires on the press and arms hold-to-repeat from it (Qt's QAbstractSpinBox convention — there is no release to start a repeat from), but the repeat now stops on a PointerCancel: a pan claimant winning the press used to leave the box stepping for the rest of the session, because a cancel is terminal and no PointerUp follows it.

What the box does about panning, it does by omission. It declares no touch_action and makes no pan claim, so its subtree keeps the default TouchAction::AUTO and a finger that comes to rest on it and then drags is won by the enclosing scroller rather than changing the value. The on_scroll handler below is a wheel handler and not a pan; it never sees a finger.

Two tests in spin_box/tests.rs cover that, and only one of them can see the second half of it. a_finger_pan_over_the_spin_box_scrolls_its_container shows the pan reaching the scroller, but its box has the default WheelMode::Focused and its fixture never focuses the field, so the on_scroll below declines before the pan question is reached — a pan that DID arrive would leave the value unchanged there anyway. a_finger_pan_over_a_hover_wheel_spin_box_scrolls_its_container is the one that can observe the claim: with WheelMode::Hover the handler runs for every scroll that reaches the box, so a claim would step the value once per synthesised sample.

P22, the scrollables migration, looked at the explicit touch_action(PAN_Y) this file was asked for and decided against it. It is a narrowing on top of the behaviour above rather than a restatement of it: it would additionally forbid a horizontal pan and a pinch through the field — a real cost to a spin box in a horizontally-scrolling toolbar, or on a pinch-zoomable page — and it would buy nothing the omission does not already give, because the claimant chain visits only the pan candidates and never the generic bubble, so a boundary pan cannot reach the on_scroll below however far it travels. The omission is the decision; the hover-wheel test named above is what can witness it. P24 owns spin_box/step_button.rs.

Density

The picture above is the widget at TargetDensity::Compact, the mouse-and-keyboard ladder. Below is the same subject on the same canvas with only the ladder changed, so what moves is the density and nothing else — where the subject no longer fits, that is what the denser targets cost it at that size. See docs/density-and-targets.md.

Touch

SpinBox at Touch density

API reference

📖 Full rustdoc API for this module

pub enum WrapMode

Import: use teksilo::widgets::WrapMode;

Out-of-range behavior when stepping past min or max.

Set via SpinBox::wrap_mode.

#![allow(unused)]
fn main() {
pub enum WrapMode { /* variants */ }
}

Variants

  • Clamp — Clamp to min / max (default).
  • Wrap — Wrap around: past max jumps to min, past min jumps to max. Matches Qt's QAbstractSpinBox::wrapping.

pub enum StepType

Import: use teksilo::widgets::StepType;

Step-size policy for each key/button press.

Set via SpinBox::step_type.

#![allow(unused)]
fn main() {
pub enum StepType { /* variants */ }
}

Variants

  • Fixed — Always step by single_step (default).
  • Adaptive — Step by the decimal power-of-ten immediately below the current value's magnitude — e.g. values 1–9 step by 1, 10–99 by 10, 100–999 by 100. Matches Qt's AdaptiveDecimalStepType. Integer types honor the same rule using the magnitude of the absolute value.

pub enum WheelMode

Import: use teksilo::widgets::WheelMode;

When the mouse wheel is allowed to adjust the value.

Set via SpinBox::wheel_mode.

#![allow(unused)]
fn main() {
pub enum WheelMode { /* variants */ }
}

Variants

  • Focused — Wheel adjusts only when the field is focused. Default — prevents accidental changes when the user is scrolling a larger surrounding view.
  • Hover — Wheel adjusts whenever the pointer is over the widget.
  • Disabled — Wheel never adjusts the value; events bubble to the surrounding scroll container.

pub enum WidthPolicy

Import: use teksilo::widgets::spin_box::WidthPolicy;

How the SpinBox decides its horizontal size envelope.

Chosen via the width, width_chars, and fill_width builder methods — the enum itself is the storage, not a separate public configuration API.

#![allow(unused)]
fn main() {
pub enum WidthPolicy { /* variants */ }
}

Variants

  • Pixels (f32) — Cap the widget at a fixed logical-pixel width. Default is DEFAULT_PREFERRED_WIDTH (120 dp), matching Qt's QSpinBox sizeHint.
  • Chars (u32) — Size the widget to fit this many reference digits ('0') plus the configured suffix, padding, and step buttons. Measurement uses the theme font at build time.
  • Fill — Let the widget expand horizontally to fill whatever space the parent offers. Equivalent to an infinite pixel cap.

pub const SPIN_BOX_STEP_BUTTON_WIDTH

Import: use teksilo::widgets::spin_box::SPIN_BOX_STEP_BUTTON_WIDTH;

Minimum total width. Below this the stacked step buttons stop fitting next to the field. Widgets narrower than this are enforced to the minimum at layout time via MinSize. Painted width of one stacked step button, in dp.

Below the 24 dp WCAG floor, and no hit mechanism reaches it — see the "Touch and pen" section of this module and StepButton::children for the measurement. The two steps are separate nodes stacked in a trailing column exactly one button wide, so there is nothing for Widget::hit_outset to grow into and nothing for partition_targets to carve; growing the paint instead would widen every numeric field on a form. Named here so the hit mechanisms and the chrome arithmetic below read the same number.

#![allow(unused)]
fn main() {
pub const SPIN_BOX_STEP_BUTTON_WIDTH: f32 = 18.0;
}

pub const SPIN_BOX_STEP_BUTTON_HEIGHT

Import: use teksilo::widgets::spin_box::SPIN_BOX_STEP_BUTTON_HEIGHT;

Painted height of one stacked step button, in dp. Only a fallback — the real height is half the field's inner height (see build_step_buttons).

#![allow(unused)]
fn main() {
pub const SPIN_BOX_STEP_BUTTON_HEIGHT: f32 = 12.0;
}

pub struct SpinBox

Import: use teksilo::widgets::SpinBox;

Numeric input with step buttons. Generic over SpinValue — pre-implemented for i32, i64, u32, u64, usize, f32, and f64.

#![allow(unused)]
fn main() {
pub struct SpinBox<T: SpinValue> { /* fields */ }
}

Methods

pub fn new(value: Signal<T>, min: T, max: T) -> Self

Construct a new SpinBox bound to value with the given inclusive range. min must be ≤ max.

pub fn style(mut self, style: impl teksilo_core::styles::SpinBoxStyle) -> Self

Per-call style override. Higher precedence than the theme-wide style_slots.spin_box slot.

pub fn single_step(mut self, step: T) -> Self

Set the step size for Up / Down / single wheel tick / button tap.

pub fn page_step(mut self, step: T) -> Self

Set the step size for PageUp / PageDown. When unset, defaults to 10 × single_step at build time.

pub fn decimals(mut self, decimals: u8) -> Self

Number of decimal places shown for floating-point types. Ignored for integer types.

pub fn localized(mut self, on: bool) -> Self

Whether the number follows the active locale's conventions — decimal separator, digits, and minus sign. On by default.

A French user sees 12,5, not 12.5, and can type either: the commit path de-localizes before parsing, and the input filter accepts both the locale's separator and the ASCII one, so a numeric keypad still works.

Turn it off for a number that is an identifier rather than a quantity — a port number, a version component, a database id, a pixel offset in a file format. Those read wrong grouped or re-punctuated, and their conventional form is the C-locale one.

Localization is a string transform over the value's own Display, not a round-trip through f64, so a SpinBox<i64> keeps full precision past 2^53.

With no I18nManager installed the active locale resolves to the C locale, so this is a no-op in tests and in apps that have not opted into i18n.

pub fn use_grouping(mut self, on: bool) -> Self

Whether the displayed number carries thousands separators. Off by default, matching Qt (QAbstractSpinBox:: isGroupSeparatorShown is false unless asked for).

Separators help a large read-only quantity and get in the way of a field being typed into, so this is opt-in per SpinBox rather than a locale-wide default. Grouping follows the locale's own group sizes, including the Indic lakh system (12,34,567).

Has no effect when localized is off.

pub fn suffix(mut self, text: impl Into<String>) -> Self

Qt-style non-editable trailing unit (e.g. " %", " px", " dB"). Rendered flush-right inside the field's border; the caret cannot enter it.

pub fn special_value_text(mut self, text: impl Into<LocalizedString>) -> Self

Text shown in place of the formatted value when the current value equals min. Use for "Auto", "None", "Off", "Unlimited" affordances where the minimum has special semantics. The text stays while the field has focus, as Qt's specialValueText does: keyboard focus selects it, so a number typed replaces it, and a step moves off it.

pub fn wrap_mode(mut self, mode: WrapMode) -> Self

Set the out-of-range behavior when stepping past min or max (default: Clamp).

pub fn step_type(mut self, step_type: StepType) -> Self

Set the step-size policy (default: Fixed). Use StepType::Adaptive for values that span many orders of magnitude.

pub fn button_layout(mut self, layout: ButtonLayout) -> Self

Override the step-button layout (default: Stacked — stacked up/down buttons to the right of the field).

pub fn show_buttons(mut self, show: bool) -> Self

Convenience wrapper over button_layout: true → ButtonLayout::Stacked, false → ButtonLayout::Hidden. Matches the Int UI guideline that SpinBoxes in dense forms often hide the step buttons to reduce visual noise and let keyboard / wheel carry the affordance — pass .show_buttons(false) on those call sites.

pub fn wheel_mode(mut self, mode: WheelMode) -> Self

Set when the mouse wheel adjusts the value (default: Focused — only when the inner field holds focus).

pub fn width(mut self, width: f32) -> Self

Cap the widget's horizontal size at a fixed logical-pixel width. If the parent offers less, the SpinBox shrinks (down to the internal 72 dp / 48 dp floor that keeps the buttons and field from overlapping). Default: 120 dp, matching Qt QSpinBox sizeHint and Int UI form density.

#![allow(unused)]
fn main() {
use teksilo_widgets::SpinBox;
use teksilo_core::signal::Signal;
let v = Signal::new(0_i32);
let _w = SpinBox::new(v.clone(), 0, 9999).width(80.0);        // narrow
let _w = SpinBox::new(v.clone(), 0, 9999).width(200.0);       // wider
let _w = SpinBox::new(v.clone(), 0, 9999).fill_width();       // stretch to parent
let _w = SpinBox::new(v.clone(), 0, 9999).width_chars(5);     // "fits 5 digits"
}

pub fn width_chars(mut self, chars: u32) -> Self

Size the widget to fit exactly chars reference digits plus the configured suffix, padding, and step buttons. The measurement uses the actual theme font at build time (same SharedTypesetter the field draws with), so values stay right under runtime theme switches and HiDPI scale changes.

#![allow(unused)]
fn main() {
use teksilo_widgets::SpinBox;
use teksilo_core::signal::Signal;
let port = Signal::new(8080_i32);
let pct = Signal::new(0_i32);
let _w = SpinBox::new(port, 0, 65_535).width_chars(5);           // 5 digits
let _w = SpinBox::new(pct, 0, 100).suffix(" %").width_chars(3);  // 3 + " %"
}

pub fn fill_width(mut self) -> Self

Let the widget expand to fill the horizontal space offered by its parent, instead of capping at width. Use inside toolbars, inspector panels, or an Expand::horizontal column that should stretch with the surrounding layout.

pub fn label(mut self, label: impl Into<LocalizedString>) -> Self

Set the accessible name announced by screen readers as the control's label. ARIA requires spin buttons to have a label; when none is set here the caller is responsible for labelling via a FormLayout line, access_labelled_by or access_label on the spin box, which all reach the editing field that holds focus and publishes the spin button.

pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self

Set the placeholder text shown in the field when it is empty.

pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self

Set the enabled state, statically or reactively. Forwarded to the arena at build time via ctx.enabled_when(spinbox_id, self.enabled.clone()).

pub fn read_only(mut self, read_only: bool) -> Self

Make the value uneditable: no typing, no keyboard or wheel stepping, no step buttons, and no assistive-technology Increment / Decrement / SetValue. Matches QAbstractSpinBox::readOnly, which likewise stops stepBy. The field keeps focus and selection, so the value can still be read and copied.

pub fn text_from_value(mut self, f: impl Fn(T) -> LocalizedString + 'static) -> Self

Override the value → display-string conversion. Receives the raw value; returns whatever string should appear in the field. Suffix and special_value_text still apply on top of the returned string.

pub fn value_from_text(mut self, f: impl Fn(&str) -> Option<T> + 'static) -> Self

Override the parse step. Receives the field's raw text (without the suffix, which is never part of the editable content); returns Some(value) to accept or None to reject. Invalid input reverts to the last good value on commit.

Installing a parser also lifts the per-character input filter, which admits only what the default numeric parse can read. The parser owns the text convention, so a month field reading "march" as 3 can be typed by name: every character reaches the field, and whatever the parser refuses is reverted at commit.

pub fn on_value_changed(mut self, f: impl Fn(T, &mut EventContext) + 'static) -> Self

Closure fired each time the value is committed (keyboard step, button tap, wheel tick, Enter, blur). Bound observers on the value signal also see every change; use this hook when the caller needs an EventContext (e.g. to fire an intent).

pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self

Attach a plain single-line tooltip shown after a hover delay.

Mutually exclusive with rich_tooltip, rich_tooltip_content, and composite_tooltip — each setter clears the other two so the last call wins.

pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self

Attach a rich tooltip looked up by registry key.

The key must match a TooltipContent registered in the application's tooltip registry. Mutually exclusive with tooltip, rich_tooltip_content, and composite_tooltip.

pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self

Attach a rich tooltip with inline content (no registry key required). Mutually exclusive with tooltip, rich_tooltip, and composite_tooltip.

pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self

Attach a composite tooltip whose body is an arbitrary widget tree. Mutually exclusive with tooltip, rich_tooltip, and rich_tooltip_content.

pub fn value(&self) -> Signal<T>

The bound numeric value signal.

pub trait SpinValue

Import: none — SpinValue is public in a private module of teksilo-widgets and nothing re-exports it.

Numeric primitive that a SpinBox can hold.

Sealed: only the primitive integer and floating-point types implement this. See the module docs for the rationale.

Implementations must provide lossless parsing and round-trip formatting (parse(format(v, d)) == Some(v) for any finite value v and decimals d). Arithmetic is saturating so clamping into [min, max] after a step cannot overflow.

#![allow(unused)]
fn main() {
pub trait SpinValue: sealed::Sealed + Copy + PartialOrd + Debug + 'static { /* associated items below */ }
}

Associated items

fn to_f64(self) -> f64;

Lossless widening to f64. Used for AccessKit's numeric value / min / max / step properties and for StepType::Adaptive decimal analysis.

fn from_f64_saturating(v: f64) -> Self;

Narrowing from f64 with saturation at the type's full range. For integers the conversion truncates toward zero, matching Rust's as conversion semantics.

fn parse(s: &str) -> Option<Self>;

Parse a user-entered string. Leading/trailing whitespace is ignored. Returns None for syntactically invalid input (but NOT for out-of-range values — the SpinBox clamps separately so users can type past the bound and see the reformatted clamped result after blur).

fn format(self, decimals: u8) -> String;

Format for display.

For integer types, decimals is ignored. For floats, the value is rendered with exactly decimals digits after the decimal point — no scientific notation, no thousands separator. Formatter closures on SpinBox (text_from_value) override this.

fn saturating_add(self, rhs: Self) -> Self;

Saturating addition. Out-of-type-range results clamp at MAX (or MIN for negative overflow on signed types).

fn saturating_sub(self, rhs: Self) -> Self;

Saturating subtraction. See saturating_add.

fn saturating_mul_u32(self, rhs: u32) -> Self;

Saturating multiplication by a positive integer. Used for page_step = multiplier × single_step when the caller omits a page step.

fn is_integer() -> bool;

Whether this type has integer semantics (no fractional component, no decimal separator in the default format path). Controls the default character filter and whether decimals has any effect.

fn is_valid_input_char(c: char) -> bool;

Default per-character input filter for the editable field. Admits digits and, for signed types, -; float types also admit ., +, e, E. Callers can override the whole filter on the SpinBox builder.

fn clamp_value(self, min: Self, max: Self) -> Self { /* default implementation */ }

Clamp into an inclusive range. Falls through to PartialOrd.