PHPackages                             sugarcraft/candy-focus - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. sugarcraft/candy-focus

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

sugarcraft/candy-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.

0643↓50%PHP

Since Jul 12Pushed 3w agoCompare

[ Source](https://github.com/sugarcraft/candy-focus)[ Packagist](https://packagist.org/packages/sugarcraft/candy-focus)[ RSS](/packages/sugarcraft-candy-focus/feed)WikiDiscussions master Synced 2w ago

READMEChangelogDependenciesVersions (1)Used By (0)

candy-focus
===========

[](#candy-focus)

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 candy-core model without pulling in dependencies.

Install
-------

[](#install)

```
composer require sugarcraft/candy-focus
```

Quick start
-----------

[](#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 candy-core model's `update()`:

```
if ($msg instanceof KeyMsg && $msg->type === KeyType::Tab) {
    $ring = $msg->shift ? $this->ring->previous() : $this->ring->next();
    return [$this->withRing($ring), null];
}
```

…and let each region style itself in `view()`:

```
$style = $ring->isFocused('sidebar') ? $accentBorder : $plainBorder;
```

Behaviour
---------

[](#behaviour)

- A **non-empty ring always has exactly one focused region**; an empty ring focuses nothing (`current()` is `null`, `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 shifts focus to whatever takes its slot (clamped to the end), and emptying the ring clears focus.
- `next()` / `previous()` wrap around and are no-ops with fewer than two regions.
- **Immutable** — every mutator returns a new `FocusRing` and leaves the receiver untouched, so it slots into the immutable-model (TEA) pattern.

API
---

[](#api)

MethodDescription`FocusRing::new()`An empty ring.`FocusRing::of(string ...$ids)`A ring of regions (duplicates dropped), focusing the first.`register(string $id): self`Add a region to the end of the order.`unregister(string $id): self`Remove a region, preserving focus where possible.`focus(string $id): self`Focus a specific registered region.`next(): self` / `previous(): self`Tab / Shift-Tab traversal (wrapping).`current(): ?string`The focused region id, or `null`.`isFocused(string $id): bool`Whether `$id` is the focused region.`has(string $id): bool`Whether `$id` is registered.`index(): int`Focused position, or `-1` when empty.`ids(): list`Registered region ids in traversal order.`count(): int` / `isEmpty(): bool`Size helpers.License
-------

[](#license)

MIT © Joe Huss

###  Health Score

25

—

LowBetter than 35% of packages

Maintenance62

Regular maintenance activity

Popularity19

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/b1036e0717211b8030b83cbe729e8ba6ba442fdbd5285fb97a39d7dcfe339342?d=identicon)[detain](/maintainers/detain)

---

Top Contributors

[![detain](https://avatars.githubusercontent.com/u/1364504?v=4)](https://github.com/detain "detain (7 commits)")

### Embed Badge

![Health badge](/badges/sugarcraft-candy-focus/health.svg)

```
[![Health](https://phpackages.com/badges/sugarcraft-candy-focus/health.svg)](https://phpackages.com/packages/sugarcraft-candy-focus)
```

###  Alternatives

[flowpack/listable

Tiny extension for listing things

35225.9k7](/packages/flowpack-listable)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
