This document captures design and implementation notes for Spinner.
For end-user usage and examples, see Spinner.
TerminalApp (no user input required)ControlTonesrc/XenoAtom.Terminal.UI/Controls/Spinner.cssrc/XenoAtom.Terminal.UI/Styling/SpinnerStyle.cssrc/XenoAtom.Terminal.UI/Styling/SpinnerStyles.cs (built-in catalog)src/XenoAtom.Terminal.UI.Tests/SpinnerTests.cssamples/ControlsDemo/Demos/SpinnerDemo.csProgressTaskColumns.Spinner(...) creates a spinner cell for ProgressTaskGroup.Spinner : Visual, IAnimatedVisual (sealed)new Spinner()
IsActive = true, Tone = ControlTone.Primary.new Spinner(string label)
Label to the provided value. (There is an implicit conversion convenience in the UI layer that allows using
strings as visuals in many places.)Label : Visual? (bindable)
IsActive : bool (bindable)
Tone : ControlTone (bindable)
The control itself does not define a Style property; it uses the environment SpinnerStyle.
Spinner participates in the application animation loop via IAnimatedVisual:
NextAnimationTick returns a timestamp (stopwatch ticks) for the next frame changeAdvanceAnimation(timestamp) advances the frame when enough time has elapsed and returns whether the visual changedAnimation is enabled only when all conditions are true:
App is not null)IsActive is trueIsVisible is trueOtherwise:
_nextTick is set to long.MaxValueThe spinner caches the resolved SpinnerStyle instance by reference. When GetStyle<SpinnerStyle>() returns a
different object reference, the spinner:
_frameIndex to 0This makes style swaps deterministic and immediate.
Spinner is a single-row control (height 1).
Let frameWidth = max(1, SpinnerStyle.FrameWidth).
Label is null:
(frameWidth, 1) clamped by constraints.Label is present:
[0 .. Infinite] (unbounded)[0 .. 1] (single row)frameWidth + 1 + label.DesiredSize.Width (1 cell spacing)If a label is present, it is arranged to the right of the spinner frame:
finalRect.X + frameWidth + 1If there is no space for the label, it is not arranged.
Render writes:
(Bounds.X, Bounds.Y) using SpinnerStyle.Resolve(theme, IsEnabled, Tone).Style.None render with a
stable baseline.Spinner frames can be multi-rune strings, but they must have a stable cell width (SpinnerStyle.FrameWidth).
At render time, the spinner truncates the frame string to the number of cells it can display:
min(frameWidth, Bounds.Width)TerminalTextUtility.TryGetIndexAtCell(...) to avoid splitting grapheme clusters or
multi-cell runes incorrectly.SpinnerStyle defines:
Name : stringInterval : TimeSpan (frame rate)Frames : ReadOnlySpan<string>FrameWidth : int (cell width of each frame, validated at construction time)TextStyle : TextStyle (default: bold)Foreground : Color? optional overrideConstruction rules:
interval must be >= 0frames must be non-emptyTerminalTextUtility.GetWidth(...)FrameWidth must be > 0SpinnerStyle.Resolve(theme, enabled, tone):
Foreground override or tone and theme colorsTextStyleTextStyle.Dim when the control is disabledSpinnerStyles provides many built-in styles (single-cell and multi-cell). Many frames use non-ASCII glyphs and some
use emoji. Specs and docs should avoid embedding those glyph characters directly; refer to style names instead.
Spinner_Animates_Without_User_Input validates that frames advance over time under TerminalAppTestDriver.SpinnerStyle_Rejects_Different_FrameWidths validates frame width enforcement at style construction.theme.ForegroundTextStyle()).