sugarcraft / sugar-focus
Focus-ring state machine — an ordered set of focusable regions with wrap-around Tab/Shift-Tab traversal for full-window TUI layouts. Dependency-free.
Requires
- php: ^8.3
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 23:31:34 UTC
README
Formerly published as
sugarcraft/candy-focus(renamed 2026-10).
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 "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 returned by current()
an accent border when you render. The ring carries no rendering or key decoding
of its own, so it composes with any sugar-core model without pulling in
dependencies.
Install
composer require sugarcraft/sugar-focus
Quick start
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 sugar-core model's update():
use SugarCraft\Core\KeyType; use SugarCraft\Core\Msg\KeyMsg; if ($msg instanceof KeyMsg && $msg->type === KeyType::Tab && !$msg->ctrl && !$msg->alt) { $ring = $msg->shift ? $this->ring->previous() : $this->ring->next(); return [$this->withRing($ring), null]; }
On the wire, Shift+Tab arrives as Tab with shift: true — sugar-core's
InputReader decodes ESC [ Z (CSI-Z, the literal "Backtab" sequence
terminals send for Shift+Tab) into that shape. Ctrl+Tab and
Ctrl+Shift+Tab also arrive as plain Tab: basic terminals cannot
separate them from bare Tab until kitty keyboard negotiation is on, so the leg
is gated on !$msg->ctrl && !$msg->alt and leaves those chords free for other
bindings. (On sugar-core builds predating the ESC [ Z decoder arm the shift
leg never fires at all.)
…and let each region style itself in view():
$style = $ring->isFocused('sidebar') && $ring->isEnabled('sidebar') ? $accentBorder : $plainBorder;
The isEnabled() conjunct matters: focus can legally park on a disabled
region, so isFocused() alone can paint an accent border on a dimmed panel.
Behaviour
- A non-empty ring always has exactly one focused region; an empty ring
focuses nothing (
current()isnull,index()is-1). register()appends to the traversal order and focuses the region only when the ring was empty; re-registering an existing id is a no-op.unregister()keeps focus on the same region where possible; removing the focused region lands focus on the nearest enabled survivor at or after the vacated slot (wrapping around), falling back to the clamped slot only when every survivor is disabled; emptying the ring clears focus.next()/previous()wrap around and are no-ops with fewer than two enabled regions — with one exception: focus parked on a disabled region beats the no-op and moves to the nearest enabled survivor.reorder()replaces the traversal order in one step: ids are deduped (first wins), focus stays on the current region if it survives (otherwise focus lands on the first enabled survivor — index 0 only when every survivor is disabled), new ids are added and missing ids dropped.- Immutable — every mutator returns a new
FocusRingand leaves the receiver untouched, so it slots into the immutable-model architecture. No-op calls (registering an existing id, focusing an unknown, disabled or already-focused id, traversal with nowhere to go, reordering an empty ring…) return the same instance, so$new === $oldcheaply detects "nothing changed".
Disabled regions
disable()keeps a region registered but makesnext()/previous()skip it;enable()puts it back. Both are no-ops for unknown ids or ids already in that state.- Disabling the focused region does not move focus —
current()still names it. The followingnext()orprevious()call moves focus to the nearest enabled region — even a sole enabled survivor. focus()refuses a disabled id: it is a no-op, just like focusing an unknown or already-focused region. Restore order relies on this — focus first, then re-apply the disabled flags (see the persistence recipe below).- Once focus sits on the only enabled region,
next()/previous()are no-ops; with every region disabled they are no-ops too. - Paint the accent with
isFocused($id) && isEnabled($id). Focus can legally park on a disabled region —disable()never moves it, andunregister()lands on the vacated slot when every survivor is disabled — soisFocused()alone can highlight a dimmed panel. register()always adds an enabled region (a re-registered id was unregistered first, so it comes back enabled by construction).unregister()clears the removed id's flag, andreorder()keeps flags only for ids that survive — so a region dropped while disabled and later re-added comes back enabled.
Iteration, counting and persistence
FocusRingimplementsIteratorAggregate,CountableandJsonSerializable:foreach ($ring as $id)walks every registered id (disabled ones included) in traversal order,count($ring)is the number of registered regions, andjson_encode($ring)produces{"ids":[…],"focused":…,"index":n,"disabled":[…]}.- The snapshot's
disabledlist is in traversal order and always holds strings, numeric ids such as"1"included.focusednames the id directly (drift-proof against reordering) whileindexrides along for readers of older snapshots. Restore with the inverse ofjsonSerialize():
$ring = FocusRing::restore(json_decode($json, true));
restore() clamps a stale index into the surviving ring instead of
trusting it (a session file from before a layout change cannot crash
startup), drops disabled entries for regions that no longer exist,
prefers the focused id over the positional leg when both are present, and
still loads legacy positional-only snapshots. Malformed ids throw
InvalidArgumentException; everything else degrades fail-soft.
Modals and focus traps
The ring has no built-in layer concept: a modal dialog is just more regions, and the trap is the disabled set. The recipe:
$saved = $ring->current(); // what the dialog interrupts $ring = $ring->register('dialog-field') // the dialog's focusables ->focus('dialog-field'); foreach (['sidebar', 'grid', 'filter'] as $id) { $ring = $ring->disable($id); // background unreachable } // On close — re-enable the background first, then drop the dialog: foreach (['sidebar', 'grid', 'filter'] as $id) { $ring = $ring->enable($id); } $ring = $ring->unregister('dialog-field')->focus($saved);
Mind the edges: unregister() of the focused region lands on the nearest
enabled survivor, so bring the background back before removing the dialog
when you want the saved region exactly; and reorder() drops the disabled
flags of any id it removes — rebuilding background order through reorder()
deliberately clears stale state, re-disable afterwards if the panels were
meant to stay out of traversal. A first-class suspend() / ofLayer()
primitive is a deliberate omission for now; this disable/enable dance is the
supported path.
API
| Method | Description |
|---|---|
FocusRing::new() |
An empty ring. |
FocusRing::of(string ...$ids) |
A ring of regions (duplicates dropped), focusing the first. |
FocusRing::ofStrict(string ...$ids) |
Like of(), but throws InvalidArgumentException on a duplicate id. |
FocusRing::restore(array $snapshot): self |
Inverse of jsonSerialize() — clamps stale indices, drops unknown disabled entries, loads legacy positional snapshots. |
register(string $id): self |
Add an enabled region to the end of the order. |
unregister(string $id): self |
Remove a region, preserving focus where possible. |
reorder(string ...$ids): self |
Replace the traversal order, keeping focus on the current id when it survives. |
focus(string $id): self |
Focus a specific registered, enabled region (no-op otherwise). |
next(): self / previous(): self |
Tab / Shift-Tab traversal (wrapping, skipping disabled regions). |
nextOf(string $id): ?string / previousOf(string $id): ?string |
Pure query: the id traversal would land on if focus sat on $id (focus not moved; null for unregistered ids). |
focusFirst(): self / focusLast(): self |
Jump to the first / last enabled region (no-op when none is enabled). |
disable(string $id): self / enable(string $id): self |
Exclude a region from / restore it to traversal. |
isEnabled(string $id): bool |
Whether $id is registered and not disabled (false conflates unregistered with disabled — use has() to tell them apart). |
enabledIds(): list<string> / disabledIds(): list<string> |
Enabled / disabled ids in traversal order. |
enabledCount(): int / disabledCount(): int |
Enabled / disabled region counts. |
current(): ?string |
The focused region id, or null. |
isFocused(string $id): bool |
Whether $id is the focused region (paint with && isEnabled($id)). |
has(string $id): bool |
Whether $id is registered. |
index(): int |
Focused position into all registered ids, or -1 when empty (enabledIndex() for the "k of n" enabled ordinal). |
enabledIndex(): ?int |
Focused region's 0-based ordinal into enabledIds(), or null when the ring is empty or focus is parked on a disabled region. |
ids(): list<string> |
Registered region ids in traversal order. |
count(): int / isEmpty(): bool |
Size helpers; count($ring) works too (Countable). |
getIterator(): Traversable<int, string> |
foreach support — every registered id in traversal order. |
jsonSerialize(): array |
['ids' => …, 'focused' => …, 'index' => …, 'disabled' => …] snapshot for json_encode(); restore() reads it back. |
License
MIT © Joe Huss
Credits & inspiration
The focus ring is an original SugarCraft design — no 1:1 Go counterpart.
It plays the role charmbracelet/bubbles' focus handling and sugar-dashboard's
FocusManager play in their stacks, but as a standalone, dependency-free
ring, so the attribution above is by analogy only.
Runnable example: examples/focus-ring.php
