← All libraries

CandyFocus

CandyFocus

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

original — no upstream parallel focus layout navigation

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():

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.
ImmutableEvery mutator returns a new FocusRing and leaves the receiver untouched, so it slots into the immutable-model (TEA) pattern.
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
FocusRingregister(string $id): selfAdd a region to the end of the traversal order
FocusRingunregister(string $id): selfRemove a region, preserving focus where possible
FocusRingfocus(string $id): selfFocus a specific registered region
FocusRingnext(): selfTab traversal — move to the next region (wrapping)
FocusRingprevious(): selfShift-Tab traversal — move to the previous region (wrapping)
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
FocusRingisEmpty(): boolWhether the ring has no regions