Class Spinner

java.lang.Object
limn.scene.Widget<Spinner>
limn.components.Spinner

public final class Spinner extends Widget<Spinner>
A stepper input for a bounded value, in two modes:
  • Spinner.Mode.NUMERIC: new Spinner(min, max, step) shows a number (decimals inferred from the step);
  • Spinner.Mode.TIME: time() shows HH:MM, where the up/down arrows adjust the focused field (hours by 60, minutes by the step) and Left/Right (or a click) move between the two fields.

Adjust with the up/down buttons or the keyboard:

  • Up/Down, and Left/Right in numeric mode = ±step;
  • Shift + arrow = ±10 steps, the same as PageUp/PageDown;
  • Alt + arrow = one unit of the last displayed digit, for the values a step deliberately overshoots. Alt wins if Shift is also held;
  • PageUp/PageDown = ±10 steps, Home/End = min/max.
Time mode ignores Shift and Alt: its two fields already are the coarse/fine split. The value is clamped to [min, max]; onChange fires on user changes.

The value can also be typed. Type a digit and the spinner turns into a one-line text field with the old value selected, so the first keystroke replaces it; click into the value to place the caret instead. Enter commits, Escape restores, and moving focus away commits. A number outside the bounds is clamped. Text that does not read as a number is refused: the value stands, the editor closes on it, and the border flashes the theme's danger colour for a moment, so that a refusal is not mistaken for a commit that happened to land on the number already there. A screen reader is told the same thing in words, politely and in the spinner's own language: what was typed, and the value that stands (“1.234” not accepted, still 7). The input method is not turned on while it is typed in: what a spinner takes is digits, a sign and a separator, which a keyboard gives it directly.

Numbers are written and read in the language in effect, the spinner's effective locale: its decimal separator, its minus sign and its digits, never grouped, so a spinner stepping by a quarter shows a half as 0,50 in German, and a negative value in Swedish starts with a true minus sign (U+2212). Typing is more forgiving than the display: a leading + is accepted, the hyphen every keyboard types stands for whatever minus sign the language writes, and in a number with no decimal separator of the language's own a comma stands for one, as does a point wherever the language does not group digits with points. Where it does, a point is not guessed at: 1.234 typed in German is refused rather than read as a little over one or as twelve hundred. Time mode's clock face is HH:MM in every language, and only its digits change.

Copy and paste work in both states. With no edit in progress Ctrl/Cmd+C copies the whole value as shown; while editing it copies the selection, and Ctrl/Cmd+X cuts it. Pasting replaces the value when nothing is being edited and inserts at the caret when something is. Within an edit, Ctrl/Cmd+Z and Ctrl/Cmd+Y undo and redo the typing.

Stepping never goes away: Up/Down step even mid-edit, taking the typed text as the number to step from, and Left/Right resume stepping (or switching time fields) once the edit is committed. Typed text that does not read as a number is refused by a step as Enter refuses it, with the same flash and the same sentence, and the step is not taken: the edit stays open on the value that stands. setEditable(boolean) turns typing off entirely.

A programmatic setValue(double) cancels an edit in progress, because the half-typed text is no longer about the number now in the field and committing it later would silently undo whatever moved it.

The mouse wheel does not change the value, and must not: a spinner in a scrolling panel would otherwise swallow the wheel whenever the pointer crossed it, stopping the scroll and editing a field the user was only scrolling past. The wheel passes through to the scrollable ancestor.

Reading right to left the stepper column and the value swap sides, and the arrows that name the value swap with them: Left raises it and Right lowers it, because the low end of a horizontal axis sits on the side reading starts from. Home and End do not swap; they name min and max, which are not sides. Neither do time mode's Left and Right: those pick the hours and the minutes of an HH:MM run, and a run of digits keeps its own left-to-right order inside a right-to-left form, so the fields do not move and neither does the key that selects one. The up and down arrows are drawn on the vertical axis and are unchanged.

Sizes follow the widget's ControlSize. At XSMALL each stepper half is 18 × 12 pt, below fine-motor comfort, so a dense form should treat the keyboard as the primary way to adjust the value there.


 Spinner qty = new Spinner(0, 99, 1).setValue(1);
 Spinner alarm = Spinner.time().setValue(7 * 60 + 30); // 07:30
 
  • Constructor Details

    • Spinner

      public Spinner(double min, double max, double step)
      Numeric spinner over [min, max] stepping by step (> 0).
  • Method Details

    • time

      public static Spinner time()
      A 24-hour HH:MM time spinner (00:00–23:59, 1-minute step).
    • time

      public static Spinner time(int minMinutes, int maxMinutes, int stepMinutes)
      A time spinner bounded to [minMinutes, maxMinutes], stepping the minutes field by stepMinutes.
    • setValue

      public Spinner setValue(double newValue)
      Sets the value (clamped + snapped). Announces VALUE/CODE when it moved and reaches no handler: a caller's write is not the user operating the spinner. Cancels an edit in progress; see the class comment.
    • setEditable

      public Spinner setEditable(boolean canType)
      Whether the value can be typed into (default true). Turning it off leaves a pure stepper: no caret, no clipboard, and Left/Right keep stepping (or switching time fields) as they always did.
    • isEditable

      public boolean isEditable()
      Whether the value can be typed as well as stepped.
    • isEditing

      public boolean isEditing()
      Whether the user is typing into the value right now.
    • value

      public double value()
      The current value, always within [min, max]. Minutes since midnight in time mode.
    • setSnapToStep

      public Spinner setSnapToStep(boolean snap)
      Whether values are snapped onto the step grid (min + k * step). On by default: a spinner that owns its value keeps it tidy.

      Turn it off when the spinner displays a value it does not own: a property inspector bound to a document, a field showing a number that came from a file or another tool. Snapping would silently misreport such a value (1200 shown as 1201 on a grid anchored at 1), and rewrite it the moment the user nudges the field. Stepping still moves by step; it just does so from the value actually held.

    • isSnapToStep

      public boolean isSnapToStep()
      Whether committed values are rounded to a multiple of the step.
    • min

      public double min()
      Lower bound, inclusive.
    • max

      public double max()
      Upper bound, inclusive.
    • mode

      public Spinner.Mode mode()
      Whether this spinner shows a number or a time.
    • onChange

      public Spinner onChange(@Nullable DoubleConsumer listener)
      The application's response to the user changing the value: a step, a typed number, an assistive technology's request. Never for setValue(double); to hear every change whatever caused it, watch the spinner instead.
      Parameters:
      listener - the handler, or null to clear the slot
      Returns:
      this spinner
      Throws:
      IllegalStateException - if a handler is already registered
    • handleUserChange

      protected void handleUserChange(Change.Aspect aspect)
      Description copied from class: Widget
      Runs this widget's application handler for a change it has just announced. Called by Widget.runHandler(limn.scene.Change), and so by Widget.notifyChange(limn.scene.Change), only for a USER origin and after every watcher has run.

      A component overrides it once and switches on the aspect, reading its own accessors for the payload: a slider passes value(), a combo box selectedIndex(), a text field text(). A component with no handler does not override it.

      An override that does not recognise the aspect must call super, because a subclass of a component that has a handler inherits that component's dispatch through this method: a SearchField that overrode this for its own aspect and did not chain would silently delete TextField's. The ordering lives in the base class, this is the one link in it a subclass can break, and the contract test drives every subclass through its parent's gestures to catch one that does.

      Overrides:
      handleUserChange in class Widget<Spinner>
      Parameters:
      aspect - what changed; the origin is USER or this is not called
    • text

      public String text()
      Returns:
      the value as it is displayed ("07:30" in time mode, a number otherwise).
    • baselineOffset

      protected float baselineOffset()
      Description copied from class: Widget
      Distance from this widget's top edge to its first text baseline, in logical points: the alignment reference for Flex.CrossAlignment.BASELINE. Default height(): align on the bottom edge, the correct fallback for a widget with no text. Text-bearing components override with the expression they already paint with; a single-line control's is textRuler().centredBaseline(font, height()), which stands the line's capitals in the middle of the box.

      Valid only once this widget has been given a box. Flex guarantees that: it lays every child of a BASELINE line out at cross position 0 first, reads the baselines, then repositions with Widget.moveChild(limn.scene.Widget<?>, int), which moves without re-running layout.

      Overrides:
      baselineOffset in class Widget<Spinner>
    • onMeasure

      protected Size onMeasure(Constraints constraints)
      Description copied from class: Widget
      Reports the size this widget wants within constraints. Called once per layout pass, and the result is cached against the constraints and the resolved axes (size step, layout direction, locale), so it must be a pure function of them and of this widget's own state.

      Resolve the ControlSize and the LayoutDirection once each here and thread them down; never read either in a constructor. The locale needs no threading: it is in scope, and I18n.locale() answers it wherever text is resolved.

      Specified by:
      onMeasure in class Widget<Spinner>
    • onPaint

      protected void onPaint(Canvas canvas)
      Description copied from class: Widget
      Widget's own background/content, in local coordinates.
      Overrides:
      onPaint in class Widget<Spinner>
    • onMouseEvent

      protected void onMouseEvent(MouseEvent event)
      Description copied from class: Widget
      Mouse events (bubbling); call event.consume() when handled.
      Overrides:
      onMouseEvent in class Widget<Spinner>
    • onCharTyped

      protected void onCharTyped(CharEvent event)
      Description copied from class: Widget
      Committed text input (focused widget first, then ancestors).
      Overrides:
      onCharTyped in class Widget<Spinner>
    • onCanEdit

      protected boolean onCanEdit(EditCommand command)
      Description copied from class: Widget
      Whether this widget can do an editing command now: copy with something selected, paste with something to paste, undo with something to take back. Scene.canEdit(limn.scene.EditCommand) asks the widget that has the keyboard and then each of its ancestors, and a menu's item of an editing role is enabled by the answer.

      A widget answers for the commands it takes from the keyboard, and does for the command what it does for the key.

      Overrides:
      onCanEdit in class Widget<Spinner>
      Returns:
      false here: a widget edits nothing until it says so
    • onEdit

      protected boolean onEdit(EditCommand command)
      Description copied from class: Widget
      Does an editing command, as the user's own act. Called on the first widget, from the one with the keyboard upward, whose Widget.onCanEdit(limn.scene.EditCommand) answered true.
      Overrides:
      onEdit in class Widget<Spinner>
      Returns:
      whether it was done
    • onKeyEvent

      protected void onKeyEvent(KeyEvent event)
      Description copied from class: Widget
      Key events (focused widget first, then ancestors).
      Overrides:
      onKeyEvent in class Widget<Spinner>
    • onFocusGained

      protected void onFocusGained()
      Description copied from class: Widget
      Called when this widget takes keyboard focus. Default: nothing.
      Overrides:
      onFocusGained in class Widget<Spinner>
    • onFocusLost

      protected void onFocusLost()
      Description copied from class: Widget
      Called when this widget loses keyboard focus. Default: nothing.
      Overrides:
      onFocusLost in class Widget<Spinner>
    • onAccessibility

      protected void onAccessibility(Accessibility a)
      One SPIN_BUTTON carrying the value, its bounds, what one increment moves it by and the clock face or the number as it is shown, over the two halves of the stepper column.

      No name. This widget holds no I18nString at all — no caption, no placeholder, no title — so there is nothing to hand over by reference, and a string synthesised here would be built once per damaged frame to conclude that nothing had moved. The name arrives from outside through the three routes that already exist: setAccessibleName, a Label pointed at it with setLabelFor, or the tooltip, which the walk takes when nothing else supplied one. The colour picker's eleven steppers are named the second way. Focusable and nameless is therefore the intended state of a bare spinner, and it is legal: the walk warns about a focusable widget with no role and never about one with no name.

      The published step is what one increment moves the value by, which is increment() — the step field in numeric mode, and 60 in time mode while the arrows are on the hours. That is a deliberate divergence from Slider, whose step is documented as the grid a set snaps onto, and it is not a slip: the two readings differ here, because time mode snaps onto whole minutes whatever the step and setSnapToStep(boolean) can turn the grid off while the increment stays step. It is also the only allocation-free way the tree carries which of the two fields the arrows are on. What is given up is that a field switch raises no event — the difference emits a value change only when the value moves — so a reader that re-reads the node hears the new step and one that only listens does not. Because nothing published reads snapToStep or editable, neither setter owes an invalidateAccessible(); a later step that published the snap grid instead would create that debt.

      No EDITABLE state, which is not a deferral: editable gates the inline text editor alone, and the value stays settable through SET_VALUE and steppable through both verbs when it is off, so the bit would tell a reader that a value the same node accepts a set for cannot be edited. No HAS_POPUP and no SHOW_MENU either, unlike TextField: this widget has no context menu, its clipboard is reached from Ctrl/Cmd+C, X and V alone, and a reader therefore has no route to copy the value. That is a stated cost rather than an oversight. And no orientation, which the class fixes for neither axis.

      Nothing here reads the hover region, the held direction or the focus fade. The fade damages this widget on every frame it runs for and the hover moves on every pointer crossing, so a hook that published either would re-copy the whole tree for a mouse the reader is not using.

      An arrow that cannot move the value any further says so (since 2026-09-16, the rule of the refused calendar day and SegmentedControl's dead chevron carried onto every stepper in the toolkit): at max the upper half is narrowed with Accessibility.disabled() and at min the lower half is, and the publish step then withdraws the PRESS each declares, so a reader says "unavailable" instead of offering an act that does nothing. What stood here until 2026-09-15 was that a declared ENABLED is ignored so a reader must infer the bound from the value against the bounds — the first half untrue since Accessibility#disabled() arrived on 2026-09-14, the second half an inference the owner has now declined to ask a reader to make. It is heard on every spinner in the toolkit, twenty-two arrows in a colour picker among them.

      The two predicates are the bound halves of paintButtons' own, value < max and value > min, read from the same fields in the same pass: the arrow a reader is told is unavailable is exactly the one drawn in disabledText, and the node cannot say operable where the pixels say dead. The paint's conditions are enabled && value < max and enabled && value > min, and the enabled half is deliberately not repeated here: it is the publish step's, which inherits the owner's ENABLED down onto every synthetic child and lets this method narrow and never widen (Accessibility.disabled()), so a disabled spinner's two arrows are published without ENABLED and without their PRESS whatever the value — which the demo's dialog's transcript shows, four button "Increase" [disabled, ...] rows with no actions. So the two conditions agree by inheritance, not by being the same expression, and this hook has only the bound to read. They are also exactly right about the verb, because nothing here wraps: nudge(double) goes through settle(double, double), which clamps to the bound and returns false when the value did not move, and reaches max from anywhere below it whatever the snap grid. A spinner whose own node is INCREMENT-at-max is left alone by this: that is a value-bearing node stepping past the end of its own range, the shape VerbPolicyRatchetTest reads off the facet for a scroll bar and a rail too, and not an arrow.

      The tree does not model the inline edit. There is no text facet, so the node always publishes the committed value and its display form: while the user is typing, what a reader hears is the last committed number and lags the screen until Enter, Escape or focus loss; there is no caret, no selection and no way to review the half-typed text; and a reader's SET_VALUE cancels the edit outright. Building the facet properly needs the caret box, the caret offset with its side, the selection and a witness for the editor's text, none of which this widget exposes today.

      The two arrow boxes are derived from the two expressions regionAt(limn.components.SizeTokens, float, float) is written in terms of rather than from what paintButtons draws, so the published rectangle is the hit region by construction and stays truthful when a parent squeezes this widget narrower than the column — onMeasure(limn.scene.Constraints)'s floor is a request, and a spinner that was never laid out has width zero. They mirror with the column, which reading right to left is the one at x = 0. The lower box takes the remainder of the height rather than a second half, so the two tile the column exactly and the lower one matches the open-ended half regionAt tests. No offScreen: the column is inside this widget's own rounded clip whenever the widget is on screen.

      Two nameless buttons per spinner is a real cost, and twenty-two of them in a colour picker's tree, where a native stepper is one element. They are kept because each is a separately hit-testable region with its own box, its own hover and its own disabled paint, and an operable box is not deleted.

      The box, the language, the enabled, visible, showing, focusable and focused bits and the focus and scroll-into-view verbs are the walk's.

      Overrides:
      onAccessibility in class Widget<Spinner>
      Parameters:
      a - the node being described
    • onAccessibilityAction

      protected boolean onAccessibilityAction(Accessible.Action action, Accessible.Argument arg)
      The widget's own two steps and a set, each reaching the path the keyboard reaches, because Up and Down are the keys an assistive technology's increment and decrement stand for.

      So a step mid-edit goes through stepFromTyped(java.lang.Runnable) exactly as Up does: the number on screen is adopted silently, the step is the one reported change, and the edit stays open with the editor re-seeded from the committed value, so the published value and the typed text agree afterwards. Text it cannot read is refused as Up refuses it, flash and sentence alike, and no step is taken. The arrow buttons take the other path and end the edit instead; see onSyntheticAction(long, limn.accessibility.Accessible.Action, limn.accessibility.Accessible.Argument), which is the finding this widget produced — the two gestures are two different private paths, and each verb reaches the one its own box reaches.

      Neither step mirrors. onKeyEvent turns Left and Right round under a right-to-left layout and leaves Up and Down alone, and these two are the Up and Down sense: a direction of the value, not a side of the box. A hook built out of the mirrored horizontal pair would run backwards in Arabic and Hebrew.

      SET_VALUE takes a number or a text. A text is read by parse(java.lang.String), the same reader a typed commit goes through, in the spinner's own language and with the same tolerances — 42, 1,5 with the comma most of the world types, 7:30 on a time spinner, any known digit set — because a platform's value pattern hands over the string a client spoke or typed (Value.SetValue("42") on Windows, once the bridge routes it here), and a reader that could only set by number could not set at all. It refuses anything that is not a number, in either form, before the clamp, which is where the real defect would be: Math.max/Math.min pass NaN through untouched and Math.rint keeps it, so an unguarded set would store it and publish it forever. It then cancels the edit for the reason setValue(double)'s own documentation gives — the half-typed text is no longer about the number now in the field — and applies through the from-the-user path, never through setValue, which is the silent one and would move the value while telling the application nothing.

      A text that does not read is refused as a typed commit is: the border flashes, and the sentence a refused commit gets is said (“1.234” not accepted, still 7). A number that is not finite gets neither. The platform cannot report the refusal: a bridge answers the client from the published tree before the set reaches this method, so a client that wrote a text is told it was taken whatever happens here, and without the sentence the only sign would be a value that never moved. A text is what a person dictated or typed into an assistive technology's own prompt, and that person may be watching the field as much as listening to it, so the flash is theirs too; a NaN or an infinity is a client's defect, and flashing or saying it would show the user somebody else's bug.

      The enabled check is the one the key path keeps for itself; the scene has already re-checked the ancestor chain, showing and modality before the call arrives. What a reader hears is the value change the next publish's difference produces; no verb here is acknowledged with an invocation, on any platform.

      Overrides:
      onAccessibilityAction in class Widget<Spinner>
      Parameters:
      action - what was asked
      arg - the value for SET_VALUE; ignored by the two steps
      Returns:
      whether the request ran
    • onSyntheticAction

      protected boolean onSyntheticAction(long key, Accessible.Action action, Accessible.Argument arg)
      A press on one half of the stepper column, reaching the pointer path, because each half's published box is the box a click lands in.

      commitEdit() first, which is verbatim what a press on region 1 or 2 does and for the reason it gives: reaching for the arrows is leaving the text, so the step lands on the number the user typed rather than on the one it replaced. That is the whole difference from the parent node's two verbs, which adopt the typed number silently and leave the edit open.

      startHold(int) is deliberately not armed. A press from an assistive technology has no release to stop it, so an armed auto-repeat would step the value every 55 ms until it hit a bound, with nothing the user could do about it.

      The scene raises the invocation for a press that returned true, which is the acknowledgement a press owes; the value change reaches a reader as the next publish's difference.

      An arrow at its bound is refused here too, by the same two bounds the hook publishes and before commitEdit or anything else runs. Such an arrow publishes no verb, so a press can only arrive from a platform working off a stale snapshot — but nothing between that platform and this method rereads the node: Scene#performAccessibleAction gates a synthetic press on the owner's enabled chain, which a narrowed child does not narrow. Until 2026-09-16 the refusal was left to the answer alone, and with an edit open there was nothing to refuse it: the press committed the typed number on the way to a step it could not take, so the value moved, the application's handler ran and the scene spoke an INVOKED for a dead arrow.

      The answer is then whether anything moved, kept as the second line of defence for a live arrow that clamps anyway (a NaN bound, a snap grid that lands on the value it left). It is read off the value rather than off nudge(double) alone because commitEdit() above it can move the value too: reaching for the arrows is leaving the text, verbatim what a click on this box does, and a press that adopted a typed number and then could not step did change something. SegmentedControl's dead chevron answers the same way, by whether the scroll moved.

      Overrides:
      onSyntheticAction in class Widget<Spinner>
      Parameters:
      key - which half, and any other key is refused
      action - what was asked, which for these two is a press and nothing else
      arg - unused: a press carries no argument
      Returns:
      whether the request ran