Class Spinner
Spinner.Mode.NUMERIC:new Spinner(min, max, step)shows a number (decimals inferred from the step);Spinner.Mode.TIME:time()showsHH: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.
[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
-
Nested Class Summary
Nested Classes -
Constructor Summary
ConstructorsConstructorDescriptionSpinner(double min, double max, double step) Numeric spinner over[min, max]stepping bystep(> 0). -
Method Summary
Modifier and TypeMethodDescriptionprotected floatDistance from this widget's top edge to its first text baseline, in logical points: the alignment reference forFlex.CrossAlignment.BASELINE.protected voidhandleUserChange(Change.Aspect aspect) Runs this widget's application handler for a change it has just announced.booleanWhether the value can be typed as well as stepped.booleanWhether the user is typing into the value right now.booleanWhether committed values are rounded to a multiple of the step.doublemax()Upper bound, inclusive.doublemin()Lower bound, inclusive.mode()Whether this spinner shows a number or a time.protected voidOneSPIN_BUTTONcarrying 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.protected booleanonAccessibilityAction(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.protected booleanonCanEdit(EditCommand command) Whether this widget can do an editing command now: copy with something selected, paste with something to paste, undo with something to take back.onChange(@Nullable DoubleConsumer listener) The application's response to the user changing the value: a step, a typed number, an assistive technology's request.protected voidonCharTyped(CharEvent event) Committed text input (focused widget first, then ancestors).protected booleanonEdit(EditCommand command) Does an editing command, as the user's own act.protected voidCalled when this widget takes keyboard focus.protected voidCalled when this widget loses keyboard focus.protected voidonKeyEvent(KeyEvent event) Key events (focused widget first, then ancestors).protected SizeonMeasure(Constraints constraints) Reports the size this widget wants withinconstraints.protected voidonMouseEvent(MouseEvent event) Mouse events (bubbling); callevent.consume()when handled.protected voidWidget's own background/content, in local coordinates.protected booleanonSyntheticAction(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.setEditable(boolean canType) Whether the value can be typed into (defaulttrue).setSnapToStep(boolean snap) Whether values are snapped onto the step grid (min + k * step).setValue(double newValue) Sets the value (clamped + snapped).text()static Spinnertime()A 24-hourHH:MMtime spinner (00:00–23:59, 1-minute step).static Spinnertime(int minMinutes, int maxMinutes, int stepMinutes) A time spinner bounded to[minMinutes, maxMinutes], stepping the minutes field bystepMinutes.doublevalue()The current value, always within[min, max].Methods inherited from class limn.scene.Widget
acceptsTextInput, accessibleLabelAccessKey, accessibleLabelTarget, accessibleLabelText, add, add, announceChange, backdropReach, baselineOffsetOf, caretRect, children, clipboard, clipHeight, clipsChildren, clipWidth, clipX, clipY, controlSize, cursor, declaredControlSize, declaredLayoutDirection, declaredLocale, focusArrivedBackward, focusArrivedByTraversal, height, hitTest, hitTestChild, id, imageCursor, inheritanceHost, invalidate, invalidate, invalidateAccessible, isEnabled, isFocusable, isFocused, isRightToLeft, isSelfEnabled, isShowing, isVisible, isVisibleByItsOwnFlag, layoutBox, layoutDirection, locale, localToScene, localToSceneBounds, markNeedsContainedLayout, markNeedsLayout, markNeedsLayoutInPlace, maxHeight, maxWidth, measure, minHeight, minWidth, moveChild, moveChild, moveChild, neutralBase, notifyChange, notifyTextEdit, observeChanges, onAccessibilityChild, onAccessibilityChildAction, onAccessibilityChildIdentity, onAttached, onDetached, onDragExit, onDragOver, onDragStart, onDrop, onHitTest, onLayout, onPaintOverlay, onPreedit, overlayPassesPointer, paintChildren, paintOutset, paintsDecoration, paintsFromBackdrop, paintWidget, parent, remove, remove, requestFocus, requestFocus, revealInView, revealInView, runHandler, scene, sceneNanos, sceneToLocal, self, setAccessibleDescribedBy, setAccessibleDescription, setAccessibleDescription, setAccessibleIgnored, setAccessibleLabelledBy, setAccessibleName, setAccessibleName, setAccessibleRole, setControlSize, setCursor, setEnabled, setFocusable, setId, setImageCursor, setInheritanceHost, setLayoutDirection, setLocale, setMaxHeight, setMaxWidth, setMinHeight, setMinWidth, setPresentedElsewhere, setTooltip, setTooltip, setVisible, shapeText, textRuler, tooltip, tooltipSource, width, x, y
-
Constructor Details
-
Spinner
public Spinner(double min, double max, double step) Numeric spinner over[min, max]stepping bystep(> 0).
-
-
Method Details
-
time
A 24-hourHH:MMtime spinner (00:00–23:59, 1-minute step). -
time
A time spinner bounded to[minMinutes, maxMinutes], stepping the minutes field bystepMinutes. -
setValue
Sets the value (clamped + snapped). AnnouncesVALUE/CODEwhen 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
Whether the value can be typed into (defaulttrue). 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
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 (
1200shown as1201on a grid anchored at 1), and rewrite it the moment the user nudges the field. Stepping still moves bystep; 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
Whether this spinner shows a number or a time. -
onChange
The application's response to the user changing the value: a step, a typed number, an assistive technology's request. Never forsetValue(double); to hear every change whatever caused it, watch the spinner instead.- Parameters:
listener- the handler, ornullto clear the slot- Returns:
- this spinner
- Throws:
IllegalStateException- if a handler is already registered
-
handleUserChange
Description copied from class:WidgetRuns this widget's application handler for a change it has just announced. Called byWidget.runHandler(limn.scene.Change), and so byWidget.notifyChange(limn.scene.Change), only for aUSERorigin 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 boxselectedIndex(), a text fieldtext(). 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: aSearchFieldthat overrode this for its own aspect and did not chain would silently deleteTextField'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:
handleUserChangein classWidget<Spinner>- Parameters:
aspect- what changed; the origin isUSERor this is not called
-
text
- Returns:
- the value as it is displayed (
"07:30"in time mode, a number otherwise).
-
baselineOffset
protected float baselineOffset()Description copied from class:WidgetDistance from this widget's top edge to its first text baseline, in logical points: the alignment reference forFlex.CrossAlignment.BASELINE. Defaultheight(): 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 istextRuler().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.
Flexguarantees that: it lays every child of a BASELINE line out at cross position 0 first, reads the baselines, then repositions withWidget.moveChild(limn.scene.Widget<?>, int), which moves without re-running layout.- Overrides:
baselineOffsetin classWidget<Spinner>
-
onMeasure
Description copied from class:WidgetReports the size this widget wants withinconstraints. 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
ControlSizeand theLayoutDirectiononce each here and thread them down; never read either in a constructor. The locale needs no threading: it is in scope, andI18n.locale()answers it wherever text is resolved. -
onPaint
Description copied from class:WidgetWidget's own background/content, in local coordinates. -
onMouseEvent
Description copied from class:WidgetMouse events (bubbling); callevent.consume()when handled.- Overrides:
onMouseEventin classWidget<Spinner>
-
onCharTyped
Description copied from class:WidgetCommitted text input (focused widget first, then ancestors).- Overrides:
onCharTypedin classWidget<Spinner>
-
onCanEdit
Description copied from class:WidgetWhether 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.
-
onEdit
Description copied from class:WidgetDoes an editing command, as the user's own act. Called on the first widget, from the one with the keyboard upward, whoseWidget.onCanEdit(limn.scene.EditCommand)answeredtrue. -
onKeyEvent
Description copied from class:WidgetKey events (focused widget first, then ancestors).- Overrides:
onKeyEventin classWidget<Spinner>
-
onFocusGained
protected void onFocusGained()Description copied from class:WidgetCalled when this widget takes keyboard focus. Default: nothing.- Overrides:
onFocusGainedin classWidget<Spinner>
-
onFocusLost
protected void onFocusLost()Description copied from class:WidgetCalled when this widget loses keyboard focus. Default: nothing.- Overrides:
onFocusLostin classWidget<Spinner>
-
onAccessibility
OneSPIN_BUTTONcarrying 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
I18nStringat 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, aLabelpointed at it withsetLabelFor, 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()— thestepfield in numeric mode, and 60 in time mode while the arrows are on the hours. That is a deliberate divergence fromSlider, 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 andsetSnapToStep(boolean)can turn the grid off while the increment staysstep. 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 readssnapToSteporeditable, neither setter owes aninvalidateAccessible(); a later step that published the snap grid instead would create that debt.No
EDITABLEstate, which is not a deferral:editablegates the inline text editor alone, and the value stays settable throughSET_VALUEand 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. NoHAS_POPUPand noSHOW_MENUeither, unlikeTextField: 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): atmaxthe upper half is narrowed withAccessibility.disabled()and atminthe lower half is, and the publish step then withdraws thePRESSeach declares, so a reader says "unavailable" instead of offering an act that does nothing. What stood here until 2026-09-15 was that a declaredENABLEDis ignored so a reader must infer the bound from the value against the bounds — the first half untrue sinceAccessibility#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 < maxandvalue > min, read from the same fields in the same pass: the arrow a reader is told is unavailable is exactly the one drawn indisabledText, and the node cannot say operable where the pixels say dead. The paint's conditions areenabled && value < maxandenabled && value > min, and theenabledhalf is deliberately not repeated here: it is the publish step's, which inherits the owner'sENABLEDdown onto every synthetic child and lets this method narrow and never widen (Accessibility.disabled()), so a disabled spinner's two arrows are published withoutENABLEDand without theirPRESSwhatever the value — which the demo's dialog's transcript shows, fourbutton "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 throughsettle(double, double), which clamps to the bound and returns false when the value did not move, and reachesmaxfrom anywhere below it whatever the snap grid. A spinner whose own node isINCREMENT-at-maxis left alone by this: that is a value-bearing node stepping past the end of its own range, the shapeVerbPolicyRatchetTestreads 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_VALUEcancels 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 whatpaintButtonsdraws, 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 atx = 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 halfregionAttests. NooffScreen: 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:
onAccessibilityin classWidget<Spinner>- Parameters:
a- the node being described
-
onAccessibilityAction
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; seeonSyntheticAction(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.
onKeyEventturns 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_VALUEtakes a number or a text. A text is read byparse(java.lang.String), the same reader a typed commit goes through, in the spinner's own language and with the same tolerances —42,1,5with the comma most of the world types,7:30on 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.minpassNaNthrough untouched andMath.rintkeeps it, so an unguarded set would store it and publish it forever. It then cancels the edit for the reasonsetValue(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 throughsetValue, 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; aNaNor 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:
onAccessibilityActionin classWidget<Spinner>- Parameters:
action- what was askedarg- the value forSET_VALUE; ignored by the two steps- Returns:
- whether the request ran
-
onSyntheticAction
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
commitEditor 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#performAccessibleActiongates 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 anINVOKEDfor 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 becausecommitEdit()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:
onSyntheticActionin classWidget<Spinner>- Parameters:
key- which half, and any other key is refusedaction- what was asked, which for these two is a press and nothing elsearg- unused: a press carries no argument- Returns:
- whether the request ran
-