Focus-ring state machine — ordered focusable regions with Tab/Shift-Tab traversal
A tiny, dependency-free focus ring for full-window terminal UIs: an ordered set of focusable regions with a single focused member and wrap-around Tab/Shift-Tab traversal. It is the app-wide "which panel has focus" state for a TUI layout — register each focusable region (sidebar, content grid, filter bar…), map Tab to next() and Shift-Tab to previous(), and give the region current() returns an accent border when you render. The ring carries no rendering or key decoding of its own, so it composes with any candy-core model without pulling in dependencies. Original SugarCraft component — no 1:1 upstream; inspired by focus-traversal in charmbracelet/bubbles and sugar-dash's FocusManager.
composer require sugarcraft/candy-focus
use SugarCraft\Focus\FocusRing;
$ring = FocusRing::of('sidebar', 'grid', 'filter'); // 'sidebar' is focused
$ring = $ring->next(); // → 'grid'
$ring = $ring->next(); // → 'filter'
$ring = $ring->next(); // → 'sidebar' (wraps)
$ring = $ring->previous(); // → 'filter' (wraps the other way)
$ring = $ring->focus('grid'); // jump straight to a region
$ring->current(); // 'grid'
$ring->isFocused('grid'); // true
Wire it into a candy-core model's update(), then let each region style itself in view():
use SugarCraft\Core\KeyType;
use SugarCraft\Core\Msg\KeyMsg;
// …in update():
if ($msg instanceof KeyMsg && $msg->type === KeyType::Tab) {
$ring = $msg->shift ? $this->ring->previous() : $this->ring->next();
return [$this->withRing($ring), null];
}
// …and in view():
$style = $ring->isFocused('sidebar') ? $accentBorder : $plainBorder;
current() is null, index() is -1).next() / previous() wrap past the ends and are no-ops with fewer than two regions.disable() keeps a region registered but makes traversal skip it; enable() puts it back. Disabling the focused region does not move focus — the next next() / previous() carries it to the nearest enabled region. register() always adds an enabled region, and a region dropped while disabled comes back enabled.IteratorAggregate, Countable and JsonSerializable: foreach walks every registered id, count($ring) counts them, and json_encode($ring) gives {"ids":[…],"index":n,"disabled":[…]} with string ids you can feed back in to restore a session.FocusRing and leaves the receiver untouched, so it slots into the immutable-model (TEA) pattern. No-op calls return the same instance, so $new === $old cheaply detects "nothing changed".isFocused() when drawing. Composes with any candy-core model.isFocused() in view() to highlight the active region.| Class | Method | Description |
|---|---|---|
| FocusRing | new(): self | An empty ring with nothing registered or focused |
| FocusRing | of(string ...$ids): self | A ring of regions (duplicates dropped), focusing the first |
| FocusRing | ofStrict(string ...$ids): self | Like of(), but throws InvalidArgumentException on a duplicate id |
| FocusRing | register(string $id): self | Add an enabled region to the end of the traversal order |
| FocusRing | unregister(string $id): self | Remove a region, preserving focus where possible |
| FocusRing | reorder(string ...$ids): self | Replace the traversal order, keeping focus on the current id when it survives |
| FocusRing | focus(string $id): self | Focus a specific registered region |
| FocusRing | next(): self | Tab traversal — move to the next enabled region (wrapping) |
| FocusRing | previous(): self | Shift-Tab traversal — move to the previous enabled region (wrapping) |
| FocusRing | disable(string $id): self / enable(string $id): self | Exclude a region from / restore it to traversal; no-ops for unknown ids or ids already in that state |
| FocusRing | isEnabled(string $id): bool | Whether $id is registered and not disabled |
| FocusRing | enabledIds(): list<string> / disabledIds(): list<string> | Enabled / disabled ids in traversal order |
| FocusRing | enabledCount(): int / disabledCount(): int | Enabled / disabled region counts |
| FocusRing | current(): ?string | The focused region id, or null when empty |
| FocusRing | isFocused(string $id): bool | Whether $id is the focused region |
| FocusRing | has(string $id): bool | Whether $id is registered |
| FocusRing | index(): int | Focused position, or -1 when empty |
| FocusRing | ids(): list<string> | Registered region ids in traversal order |
| FocusRing | count(): int | Number of registered regions (Countable, so count($ring) works too) |
| FocusRing | isEmpty(): bool | Whether the ring has no regions |
| FocusRing | getIterator(): Traversable<int, string> | foreach support — every registered id (disabled ones included) in traversal order |
| FocusRing | jsonSerialize(): array | ['ids' => …, 'index' => …, 'disabled' => …] snapshot for json_encode() |