Class SegmentedControl

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

public final class SegmentedControl extends Widget<SegmentedControl>
A row of connected segments with a single selection, a compact alternative to a RadioButton group or tabs. The selected segment is marked by an accent indicator that slides between segments (animated like the TabbedPane indicator). Click a segment, or move with Left/Right when focused. Colours come from Theme; the box, the type and the gutters come from the resolved ControlSize row (SizeTokens), while the track border stays one weight at every step (Strokes).

Width: the track is exactly as wide as its segments need and no wider, centred in whatever box the parent assigns. That matters because the common parent (a column with STRETCH) hands every child the full row, and a pill track drawn edge to edge with four short segments huddled at its left end reads as broken. The margins either side belong to what is behind this control: a click landing on one selects nothing.

Overflow: when the segments do not fit, the strip becomes a clipped, scrollable viewport with a chevron at each edge, the dead side drawn disabled rather than dropped, since dropping it would resize the viewport and make the strip jump at the ends. The mouse wheel scrolls it, a chevron click scrolls by most of a viewport, and selecting a segment by any route (click, Left/Right, or setSelectedIndex(int)) scrolls it into view. Nothing is ever painted outside the track. There is no all-segments popup, which is the one part of the TabbedPane overflow story this control does not carry: its labels are handed in by the caller and are few and short by construction, and a control that fits in a toolbar row has nowhere to put a third piece of chrome.

Reading direction: the strip is a row of items in reading order, so it mirrors. The segments are placed from the edge reading starts at, the back chevron sits in that same gutter and points back towards the first segment, and Left selects the segment that is visually on the left: a keyboard that walked the other way from the pointer would be arguing with the screen. Home and End do not mirror, because they name the first and last segment rather than a side. Only the placement moves; the track, the viewport and each label inside its own segment are symmetric and stay where they are, and there is no mirror transform anywhere here.

This or a TabbedPane? Ask who owns the content. A segmented control owns none: it takes labels and hands back an index, so the caller keeps the content and switches it, which is what makes it the one that belongs in a toolbar row, a form, or inside another widget that already owns what changes. A tabbed pane is the opposite: you hand it the pages and it owns them, laying out and painting only the selected one, and carrying the overflow story that owning an unknown number of pages requires. Looking alike is not the test; a row of labels is what both are, and what they do is where they split.

  • Constructor Details

    • SegmentedControl

      public SegmentedControl(List<String> segments)
      A control over the given fixed segment labels; the first is selected. Needs at least one.

      Each label is wrapped as a literal, exactly as the String constructors of Button, Label and Checkbox do. A literal cannot follow the interface's language, so a control whose captions should is built from the localizable constructor instead.

      Parameters:
      segments - the captions; at least one
      Throws:
      IllegalArgumentException - if the list is empty
    • SegmentedControl

      public SegmentedControl(I18nString... segments)
      A control whose segment captions follow the UI language; the first is selected.

      Every other name in the accessible tree is resolved under the subtree's own language, and a caption held as a bare string is the one that cannot be: it is fixed at construction, so a screen reader reads it in whatever language the caller happened to build it in.

      Parameters:
      segments - the captions; at least one
      Throws:
      IllegalArgumentException - if none is given
      NullPointerException - if any of them is null
  • Method Details

    • segmentCount

      public int segmentCount()
      Returns:
      how many segments this control has; always at least one
    • segment

      public String segment(int index)
      Parameters:
      index - a segment in [0, segmentCount)
      Returns:
      that segment's caption, resolved in this widget's own language
      Throws:
      IndexOutOfBoundsException - if index is not a segment
    • segmentSource

      public I18nString segmentSource(int index)
      Parameters:
      index - a segment in [0, segmentCount)
      Returns:
      the localizable string behind segment(int)
      Throws:
      IndexOutOfBoundsException - if index is not a segment
    • onSelect

      public SegmentedControl onSelect(@Nullable IntConsumer listener)
      The application's response to the user choosing a segment: a click, an arrow key, an assistive technology's select. Called with the chosen index. Never for setSelectedIndex(int), which is a caller's write; to hear every change whatever caused it, watch the control instead.

      One handler at a time: null clears it, and setting a second while one is set throws IllegalStateException.

    • 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<SegmentedControl>
      Parameters:
      aspect - what changed; the origin is USER or this is not called
    • selectedIndex

      public int selectedIndex()
      The selected segment's index; never negative, since the control refuses an empty segment list and so always has exactly one selection. There is nothing to clear to.
    • setSelectedIndex

      public SegmentedControl setSelectedIndex(int index)
      Selects a segment and scrolls it into view. The change is announced to the watchers as the code's and does not reach onSelect, which answers the user. Re-selecting the current segment still reveals it (a caller asking for a segment is asking to be shown it) but changes nothing and announces nothing; that early return is what keeps two controls bound to each other from recursing, so do not remove it. UI thread only.
      Parameters:
      index - a segment in [0, segmentCount)
      Throws:
      IndexOutOfBoundsException - if index is not a segment. Arrowing past an end is not this: keyboard traversal stops at the end rather than raising.
    • 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<SegmentedControl>
    • 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<SegmentedControl>
    • clipsChildren

      protected boolean clipsChildren()
      Yes, and vacuously: this control paints its segments itself and has no child widgets, so there is nothing a clip could fail to contain.

      Answered because it is what Widget.markNeedsContainedLayout() requires, and that is the difference between a selection repainting this control and repainting the window. It clips no paint of this control's own — the focus ring still reaches out through paintOutset() — since the hook concerns paintChildren alone.

      Overrides:
      clipsChildren in class Widget<SegmentedControl>
    • onLayout

      protected void onLayout()
      Description copied from class: Widget
      Containers position children here (measure + Widget.layoutBox(float, float, float, float) per child).
      Overrides:
      onLayout in class Widget<SegmentedControl>
    • onPaint

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

      protected float paintOutset()
      The focus ring reaches Strokes.FOCUS_RING_OUTSET beyond the box, and the scene inflates a widget's damage by 1 + paintOutset(). Without this the ring's fade-out sheds stale pixels under partial rendering.

      Declares reach only; it moves no ink.

      Overrides:
      paintOutset in class Widget<SegmentedControl>
    • onFocusGained

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

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

      protected void onAccessibility(Accessibility a)
      What this strip is to an assistive technology: one horizontal Accessible.Role.RADIO_GROUP holding one Accessible.Role.RADIO_BUTTON per segment, plus the two scroll arrows while it overflows.

      A radio group and not a tab list, for the reason this class's own documentation gives: it owns no content, it takes labels and hands back an index, so there is no page for a tab to select and "tab, 1 of 4" would offer navigation that leads nowhere. The selection facet is single and always satisfied, which is this control's documented invariant on both halves — the constructor refuses an empty segment list, and the selected index is never negative because there is nothing to clear to. The active descendant is not declared here and could not be: the publish step resolves it from the first node in this subtree that says it is active, which is the selected segment below.

      The scroll facet is published on every frame rather than only while the strip overflows, so that resizing past the fitting point moves two numbers instead of making a whole facet appear and disappear. Every value is read from the fields the layout settled and from this widget's own maximum, so the facet cannot drift from the clamp that produced it, and it is derived from overflowing rather than from the arithmetic alone: that predicate carries the half-point slop below which every path here declines to scroll at all. The offset is a distance from the leading edge in both directions, so the percentage is published unflipped; the vertical pair is the axis's nothing-to-scroll answer.

      Where the segment boxes come from, which is the whole difficulty of this widget. A segment's box is neither the widget's box divided by the count nor a raw edge out of bounds: the track takes only what the segments need and centres itself in whatever a stretching parent handed over, the viewport is inset by the two chevron gutters, the scroll offset is subtracted, and reading right to left a span is placed by its end. cellLeft(boolean, float, float) folds all four together and is called here rather than re-derived, so the box a reader is given is the box a click lands in. Tree order stays the reading order in both directions; only the coordinates mirror.

      The geometry is guarded and the children are not, because bounds is null until the first layout and that state reaches a published tree: a strip inside an invisible subtree is skipped by the layout and still walked, so an unguarded index would throw inside the publish step. What a reader is told there — how many segments there are and which one is selected — does not depend on a layout having run, and gaining the children on the first one would be a structure change for nothing.

      The hover index is deliberately not published. It is a pointer affordance, and a fact derived from it would republish the whole tree on every mouse move; so are the indicator's slide, the focus fade and the segment it is resting on. Nothing here formats a string: every name is an I18nString this widget holds, handed over by reference, so a frame damaged by any of those compares equal and allocates nothing.

      The group names itself with nothing. A radio group's name is the question it asks, which only the application has, and this is a public class an application reaches with setAccessibleName, a bound caption or a tooltip. Every segment carries a real name, so an unnamed group still announces its selection.

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

      protected boolean onSyntheticAction(long key, Accessible.Action action, Accessible.Argument arg)
      Selects a segment or scrolls the strip, through the same private paths a click takes.

      A select reaches choose(int), which is literally what the click branch calls, so an assistive technology's select clamps the same way, reveals the same way and fires onSelect through the same USER seam a click does — including the early return that changes nothing and fires nothing when the segment was already selected. Re-selecting the current segment is still accepted, because it still reveals, which is the answer the pointer gets there.

      A press on an arrow scrolls by the same expression the click branch uses, where the sign is the arrow's logical identity and the offset is logical too, so it needs no mirroring. The answer is whether the offset moved: a dead arrow is published disabled and without the verb above, and its scroll clamps to nothing here, so a press that arrived on it anyway is refused rather than reported done.

      A segment answers SELECT alone and never FOCUS: the selection is the cursor here, so a focus that did not select would be a lie and one that did would be SELECT under another name. The walk grants FOCUS only to a focusable widget node, and a segment is not one, so the verb is neither published nor performed.

      No enabled guard of its own, and none is owed. The scene's dispatcher already walks this widget and every ancestor for the enabled flag, refuses an owner that is not showing, refuses a modal-blocked window and refuses anything outside the layer that owns input, all before this runs. It runs on the UI thread inside the posted task, which is what setSelectedIndex(int)'s own thread check asks for.

      Overrides:
      onSyntheticAction in class Widget<SegmentedControl>
      Parameters:
      key - a segment's index, or one of the two chevron keys
      action - what is being asked
      arg - unused; every verb here is parameterless
      Returns:
      whether this control did it
    • onMouseEvent

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

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