← All 41 libraries

CandyFocus

CandyFocus

Focus-ring state machine — ordered focusable regions with Tab/Shift-Tab traversal

original — no upstream parallel focus layout navigation

CandyFocus code coverage

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.

Install

composer require sugarcraft/candy-focus

Quickstart

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;

Behaviour

Exactly one focused regionA non-empty ring always has exactly one focused region; an empty ring focuses nothing (current() is null, index() is -1).
register() appendsAdds to the traversal order and focuses the region only when the ring was empty; re-registering an existing id is a no-op.
unregister() preserves focusKeeps focus on the same region where possible; removing the focused region shifts focus to whatever takes its slot (clamped to the end), and emptying the ring clears focus.
Wrap-around traversalnext() / previous() wrap past the ends and are no-ops with fewer than two regions.
reorder() in one stepReplaces the traversal order: ids are deduped (first wins), focus stays on the current region if it survives (otherwise the first region is focused), new ids are added and missing ids dropped.
Disabled regionsdisable() 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.
Iterable, countable, serialisableImplements 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.
ImmutableEvery mutator returns a new 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".
Dependency-freeNo rendering or key decoding of its own — the owning model wires keys to it and reads isFocused() when drawing. Composes with any candy-core model.

Use it for

Source & demos

Try the quickstart →

API

ClassMethodDescription
FocusRingnew(): selfAn empty ring with nothing registered or focused
FocusRingof(string ...$ids): selfA ring of regions (duplicates dropped), focusing the first
FocusRingofStrict(string ...$ids): selfLike of(), but throws InvalidArgumentException on a duplicate id
FocusRingregister(string $id): selfAdd an enabled region to the end of the traversal order
FocusRingunregister(string $id): selfRemove a region, preserving focus where possible
FocusRingreorder(string ...$ids): selfReplace the traversal order, keeping focus on the current id when it survives
FocusRingfocus(string $id): selfFocus a specific registered region
FocusRingnext(): selfTab traversal — move to the next enabled region (wrapping)
FocusRingprevious(): selfShift-Tab traversal — move to the previous enabled region (wrapping)
FocusRingdisable(string $id): self / enable(string $id): selfExclude a region from / restore it to traversal; no-ops for unknown ids or ids already in that state
FocusRingisEnabled(string $id): boolWhether $id is registered and not disabled
FocusRingenabledIds(): list<string> / disabledIds(): list<string>Enabled / disabled ids in traversal order
FocusRingenabledCount(): int / disabledCount(): intEnabled / disabled region counts
FocusRingcurrent(): ?stringThe focused region id, or null when empty
FocusRingisFocused(string $id): boolWhether $id is the focused region
FocusRinghas(string $id): boolWhether $id is registered
FocusRingindex(): intFocused position, or -1 when empty
FocusRingids(): list<string>Registered region ids in traversal order
FocusRingcount(): intNumber of registered regions (Countable, so count($ring) works too)
FocusRingisEmpty(): boolWhether the ring has no regions
FocusRinggetIterator(): Traversable<int, string>foreach support — every registered id (disabled ones included) in traversal order
FocusRingjsonSerialize(): array['ids' => …, 'index' => …, 'disabled' => …] snapshot for json_encode()