PHPackages                             sugarcraft/candy-core - 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. [CLI &amp; Console](/categories/cli)
4. /
5. sugarcraft/candy-core

ActiveLibrary[CLI &amp; Console](/categories/cli)

sugarcraft/candy-core
=====================

PHP port of charmbracelet/bubbletea — Elm-architecture TUI runtime.

29.1k↑27900%1PHP

Since Jun 29Pushed 1mo agoCompare

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

READMEChangelogDependenciesVersions (1)Used By (1)

[![candy-core](.assets/icon.png)](.assets/icon.png)

SugarCraft
==========

[](#sugarcraft)

[![CI](https://github.com/detain/sugarcraft/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/detain/sugarcraft/actions/workflows/ci.yml)[![codecov](https://camo.githubusercontent.com/54db0aa90baeb614fb7dadfd7ee6df405e4b11a372d4fa7d81572e33c5aa227d/68747470733a2f2f636f6465636f762e696f2f67682f64657461696e2f737567617263726166742f6272616e63682f6d61737465722f67726170682f62616467652e7376673f666c61673d63616e64792d636f7265)](https://app.codecov.io/gh/detain/sugarcraft?flags%5B0%5D=candy-core)[![Packagist Version](https://camo.githubusercontent.com/c0f3cee1c862c3d1c6bd440b4e741bcdd6c2df8d005cfdcb197bf056b9a94705/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f737567617263726166742f63616e64792d636f72653f6c6162656c3d7061636b6167697374)](https://packagist.org/packages/sugarcraft/candy-core)[![License](https://camo.githubusercontent.com/7013272bd27ece47364536a221edb554cd69683b68a46fc0ee96881174c4214c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e737667)](LICENSE)[![PHP](https://camo.githubusercontent.com/628fefe1c5b54ddf235a4ae97c88042e64fab9f439ecf4e07b20a841fb61d878/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d254532253839254135382e322d3838393262662e737667)](https://www.php.net/)

[![demo](.vhs/counter.gif)](.vhs/counter.gif)

```
composer require sugarcraft/candy-core
```

PHP port of [charmbracelet/bubbletea](https://github.com/charmbracelet/bubbletea) — the Elm-architecture TUI runtime at the heart of the Charmbracelet stack.

```
use SugarCraft\Core\{Cmd, KeyType, Model, Msg, Program};
use SugarCraft\Core\Msg\{KeyMsg, WindowSizeMsg};

final class Counter implements Model
{
    public function __construct(public readonly int $count = 0) {}
    public function init(): ?\Closure { return null; }

    public function update(Msg $msg): array
    {
        if ($msg instanceof KeyMsg) {
            return match (true) {
                $msg->type === KeyType::Char && $msg->rune === 'q' => [$this, Cmd::quit()],
                $msg->type === KeyType::Up    => [new self($this->count + 1), null],
                $msg->type === KeyType::Down  => [new self($this->count - 1), null],
                default => [$this, null],
            };
        }
        return [$this, null];
    }

    public function view(): string { return "count: $this->count\n(↑/↓ to change, q to quit)"; }
}

(new Program(new Counter()))->run();
```

Requirements
------------

[](#requirements)

- PHP 8.1+ (PHP 8.4+ recommended on Windows for FFI `SetConsoleCtrlHandler` support)
- `mbstring`, `intl` (for grapheme width)
- `pcntl` (signal handling — POSIX only; not available on Windows)
- `FFI` extension (required on Windows for raw TTY support)
- `react/event-loop` ^1.6 (Composer)
- Windows 10 version 1809+ (for `ENABLE_VIRTUAL_TERMINAL_PROCESSING`)

Architecture
------------

[](#architecture)

- **`Model`** — your app implements `init()`, `update(Msg)`, `view()`, `subscriptions()`.
- **`Msg`** — marker interface for events. Built-ins: `KeyMsg`, `WindowSizeMsg`, `QuitMsg`.
- **`Cmd`** — `Closure(): ?Msg`. Async work whose result is dispatched as a Msg. Helpers in `Cmd::quit()`, `Cmd::batch()`, `Cmd::send()`.
- **`Program`** — orchestrator. Sets up TTY, runs the ReactPHP event loop, dispatches Msgs, drives renders at the configured framerate.
- **`InputReader`** — stateful byte-stream parser; handles split escape sequences across reads.
- **`Renderer`** — minimal cursor-home + erase + write. Diff-based renderer is a follow-up.
- **`Util/`** — `Ansi`, `Color`, `ColorProfile`, `Width`, `Tty`, `TtyDetect`, `RawMode`, `Open` foundation utilities, shared with CandySprinkles. `RawMode::enable($stream)` / `RawMode::disable($stream)` is the portable `stty`-based raw-mode toggle for the controlling terminal — a safe no-op on non-tty streams.
- **`Subscription`** — value object: id, Kind, params, produce closure.
- **`Subscriptions`** — immutable collection with `withTick()`, `withKey()`, `withSignal()`, `withCustom()`, `all()`, `has()`.
- **`Kind`** — backed enum: Tick / Key / Signal / Custom.
- **`SubscriptionCapable`** — trait providing the default `subscriptions(): null`.
- **`ScreenStack`** — immutable stack with `push()` / `pop()` / `current()` / `breadcrumb()` / `isEmpty()` / `count()`.
- **`Screen`** — value object: `model`, optional `title`, `onEnter` closure, `onExit` closure.
- **`ScreenStackCapable`** — interface: `screens(): ScreenStack`. Implement to activate screen-stack routing.
- **`RootModelWithScreenStack`** — concrete root model owning a `ScreenStack`; routes `ScreenStackPushedMsg` / `ScreenStackPoppedMsg` internally, delegates `view()` to the active screen.
- **`PushScreenCmd`** / **`PopScreenCmd`** — Cmd factories that emit `ScreenStackPushedMsg` / `ScreenStackPoppedMsg`.
- **`ScreenStackPushedMsg`** / **`ScreenStackPoppedMsg`** — infrastructure messages dispatched through `Program::send()`.
- **`Component`** — interface extending `Model`; adds `onMount(): ?Closure` (fired when mounted into a `Composite`) and `onUnmount(): ?Closure` (fired when removed). Return a `Cmd` to run immediately or `null` for no-op.
- **`Composite`** — `Model` that manages a list of child `Component`s. Tracks `pendingCmds` and drains them during reconcile, calling child `onMount()`/`onUnmount()` Cmds synchronously. Children are reconciled by ordinal position — reordering triggers lifecycle events on affected children.
- **`WorkerPool`** — bounded-concurrency pool (default 4 workers) for offloading CPU-bound tasks to subprocesses via serialized callables.
- **`WorkerCmd`** — `Cmd` factory: `WorkerCmd::run($pool, $callable)` dispatches a task to the pool and yields a `WorkerResultMsg` when complete.
- **`WorkerResultMsg`** — result `Msg` carrying `$result`, `$error`, and `$workerId` from a pool worker.

Subscriptions
-------------

[](#subscriptions)

Elm-style subscription reconciliation lets a Model declare recurring events (ticks, key events, signals) without managing timers manually. After each `update()` cycle the runtime diffs the returned `Subscriptions` set against the active one — new subscriptions start, dropped ones cancel, stable ones keep running.

```
use SugarCraft\Core\{Kind, Model, Msg, Program, Subscriptions};
use SugarCraft\Core\Cmd\SubscribeCmd;

final class Clock implements Model
{
    public function __construct(public readonly int $ticks = 0) {}

    public function init(): ?\Closure { return null; }

    public function update(Msg $msg): array
    {
        // tick handling...
        return [$this, null];
    }

    public function view(): string { return "ticks: $this->ticks\n"; }

    public function subscriptions(): ?Subscriptions
    {
        return (new Subscriptions())->withTick('clock-tick', 1.0, fn () => new TickMsg());
    }
}
```

The `SubscriptionCapable` trait satisfies `Model::subscriptions()` with a null default — use it in Models that don't need subscriptions:

```
use SugarCraft\Core\{Model, SubscriptionCapable};

final class StaticModel implements Model
{
    use SubscriptionCapable;
    // ... no subscriptions() needed
}
```

Screen / ScreenStack
--------------------

[](#screen--screenstack)

Modal and sub-screen workflows use an immutable `ScreenStack` owned by a `ScreenStackCapable` root model. The `Program` routes `init()` / `update()` / `view()` to the active screen automatically, while infrastructure messages (`ScreenStackPushedMsg` / `ScreenStackPoppedMsg`) stay in the root model.

```
use SugarCraft\Core\{Cmd, KeyType, Model, Msg, Program, RootModelWithScreenStack, Screen, ScreenStack};
use SugarCraft\Core\Cmd\{PushScreenCmd, PopScreenCmd};
use SugarCraft\Core\Msg\{KeyMsg, ScreenStackPushedMsg, ScreenStackPoppedMsg};

final class DetailScreen implements Model
{
    public function __construct(public readonly string $id) {}
    public function init(): ?\Closure { return null; }
    public function update(Msg $msg): array
    {
        if ($msg instanceof KeyMsg && $msg->rune === 'b') {
            return [$this, Cmd::pop()];
        }
        return [$this, null];
    }
    public function view(): string { return "Detail: {$this->id}\n(press b to go back)\n"; }
}

// Root model owns the stack and handles infrastructure messages.
final class App implements Model, \SugarCraft\Core\ScreenStackCapable
{
    use \SugarCraft\Core\SubscriptionCapable;
    public function __construct(public ScreenStack $screens = new ScreenStack()) {}
    public function screens(): ScreenStack { return $this->screens; }
    public function init(): ?\Closure { return null; }

    public function update(Msg $msg): array
    {
        if ($msg instanceof ScreenStackPushedMsg) {
            return [new self($this->screens->push($msg->screen)), null];
        }
        if ($msg instanceof ScreenStackPoppedMsg) {
            if ($this->screens->isEmpty()) return [$this, null];
            $popped = $this->screens->current();
            return [new self($this->screens->pop()), null];
        }
        if ($msg instanceof KeyMsg && $msg->rune === 'n') {
            return [$this, new PushScreenCmd(new Screen(new DetailScreen('item-1'), title: 'Item 1'))];
        }
        return [$this, null];
    }

    public function view(): string
    {
        $active = $this->screens->isEmpty()
            ? new \SugarCraft\Core\Model\Anonymous(fn() => "Push a screen with n\n")
            : $this->screens->current()->model;
        return $active->view();
    }
}

(new Program(new App()))->run();
```

`examples/screen-stack.php` is a runnable demo showing 3-deep push / pop.

Demos
-----

[](#demos)

### Counter Model

[](#counter-model)

[![counter](.vhs/counter.gif)](.vhs/counter.gif)

### Timer

[](#timer)

[![timer](.vhs/timer.gif)](.vhs/timer.gif)

Status
------

[](#status)

- **Phase 0** (foundation utilities): 🟢 complete.
- **Phase 3** (runtime): 🟢 v1 — Program loop, mouse (cell-motion + all-motion + SGR 1006), focus / blur, bracketed paste, full function-key set including F13–F63 and the Kitty PUA range, the cell-diff "cursed" renderer (synchronized output 2026 + unicode mode 2027), inline-mode rendering, declarative `View` struct, plus the v2 Cmd surface (`Suspend` / `Interrupt` / `Resume` / `Exec` / `Sequence` / `Every` / `Printf` / `Raw` / `wait` / `kill` / `releaseTerminal` / `restoreTerminal`).

See [../CONVERSION.md](../CONVERSION.md) for the full roadmap and the [v2 parity sweep](../CONVERSION.md#phase-11--v2-parity-sweep-bubble-tea--lipgloss--bubbles)table tracking each Bubble Tea v2 / Lipgloss v2 / Bubbles v2 feature.

Companion libraries
-------------------

[](#companion-libraries)

SugarCraft is the foundation — the rest of the SugarCraft stack builds on it. From the same monorepo:

- **CandySprinkles** (← lipgloss) — declarative styling + layout.
- **SugarBits** (← bubbles) — 14 prebuilt components.
- **SugarPrompt** (← huh) — multi-page form library.
- **SugarCharts** (← ntcharts) — sparkline / bar / line / heatmap / OHLC.
- **CandyShell** (← gum) — composer-installable CLI of 13 subcommands.
- **CandyShine** (← glamour) — Markdown → ANSI renderer.
- **CandyZone** (← bubblezone) — mouse-zone tracker.
- **HoneyBounce** (← harmonica) — spring physics + Newtonian projectile sim.
- **CandyKit** (← fang) — opinionated CLI presentation helpers.
- **CandyFreeze** (← freeze) — code → SVG screenshot.
- **CandyWish** (← wish) — SSH server middleware framework.
- **SugarSpark** (← sequin) — ANSI escape-sequence inspector.

See the matchup table in [../MATCHUPS.md](../MATCHUPS.md) for status, package names, and namespace mappings.

Localization (i18n)
-------------------

[](#localization-i18n)

candy-core ships a tiny zero-dep translation registry that the rest of the SugarCraft monorepo plugs into. Every library owns a **namespace**(`core`, `charts`, `prompt`, …) and a `lang/.php` file per locale — call sites look strings up by fully-qualified key.

```
use SugarCraft\Core\I18n\T;

T::setLocale(T::detect());                 // 'en' / 'fr' / 'de' from $LANG
echo T::t('core.color.invalid_hex', ['hex' => '#zz']);
// => "invalid hex color: #zz"
```

Each library exposes a thin `Lang::t($key, $params)` wrapper with its namespace baked in, so call sites stay short:

```
use SugarCraft\Core\Lang;

throw new \InvalidArgumentException(
    Lang::t('color.invalid_hex', ['hex' => $hex])
);
```

### Adding a new locale

[](#adding-a-new-locale)

1. Copy `candy-core/lang/en.php` to `candy-core/lang/.php`(e.g. `fr.php`).
2. Translate the values, keeping keys and `{placeholders}` intact.
3. Set the locale at app startup with `T::setLocale('fr')` or `T::setLocale(T::detect())`.

Lookup chain: **exact locale → base language → `en` → raw key**. So a single `fr.php` automatically serves `fr-fr`, `fr-ca`, `fr-be`, etc. — only add a regional file (e.g. `pt-br.php`) when the wording genuinely diverges from the base language. A forgotten string is visible, never a fatal error.

See [`LOCALES.md`](https://github.com/detain/sugarcraft/blob/master/LOCALES.md)in the SugarCraft monorepo for the recommended set of codes plus a list of every base language a contributor can target.

### Application-level overrides

[](#application-level-overrides)

Apps can ship their own translations of any library's strings without patching upstream:

```
T::overrideNamespace('charts', '/etc/myapp/lang/charts');
```

See the [`SugarCraft\Core\I18n\T`](src/I18n/T.php) docblock for the full API surface (`register`, `translate`, `setLocale`, `locale`, `detect`, `overrideNamespace`, `reset`).

Composing Cmds
--------------

[](#composing-cmds)

The runtime ships several Cmd combinators. The cheat-sheet below maps Bubble Tea idioms to the PHP equivalents:

NeedUseRun several Cmds in parallel`Cmd::batch(...$cmds)`Run several Cmds one-after-the-other`Cmd::sequence(...$cmds)`Schedule a Msg in N seconds`Cmd::tick($seconds, fn () => $msg)`Schedule a Msg on every wall-clock multiple of N seconds`Cmd::every($seconds, fn () => $msg)`Dispatch a Msg right away`Cmd::send($msg)`Quit the program`Cmd::quit()`Hard-kill (after `quit` failed)`$program->kill()` (from outside the loop)Print text above the program region`Cmd::println($s)` / `Cmd::printf($fmt, ...)`Drop bytes onto the wire`Cmd::raw($bytes)`Suspend on Ctrl+Z, resume on SIGCONT`Cmd::suspend()` (returns to a `ResumeMsg`)Run an external program (`$EDITOR`)`Cmd::exec($cmd, $args, fn ($exit) => $msg)``init()` returns a Cmd (or null) to fire once at startup. `update()`returns `[Model, ?Cmd]` — the runtime applies the Cmd, dispatches its Msg, and feeds the result back into `update()`.

The `examples/` directory has runnable demos for each pattern: [`counter`](examples/counter.php) (basic), [`timer`](examples/timer.php)(tick scheduling), [`realtime`](examples/realtime.php) (self-rescheduling tick), [`sequence`](examples/sequence.php) (`Cmd::sequence`), [`send-msg`](examples/send-msg.php) (custom Msg + `Cmd::tick`), [`tabs`](examples/tabs.php) (state-driven view selection), [`views`](examples/views.php) (multi-view switcher), [`splash`](examples/splash.php) (animated splash → main view), [`suspend`](examples/suspend.php) (`Cmd::suspend` + `ResumeMsg`), [`mouse`](examples/mouse.php), [`focus-blur`](examples/focus-blur.php), [`window-size`](examples/window-size.php), [`print-key`](examples/print-key.php), [`set-window-title`](examples/set-window-title.php), and [`prevent-quit`](examples/prevent-quit.php).

Alt-screen vs inline mode
-------------------------

[](#alt-screen-vs-inline-mode)

Pass `useAltScreen: true` (the default) to `ProgramOptions` and the runtime takes over the alt-screen — the user's previous content is preserved underneath, and `Cmd::quit()` restores it. Best for fullscreen TUIs.

Pass `useAltScreen: false` + `inlineMode: true` for a program that shares scrollback with the surrounding shell. The runtime saves the cursor on first frame and restores it after each repaint, so preceding shell output stays visible. Pair with `Cmd::println()` to emit lines that scroll above the program region.

A typical CandyShell prompt (`gum input`-style) uses inline mode; a fullscreen filter (`gum filter`-style) uses alt-screen.

Tutorial — building a shopping list
-----------------------------------

[](#tutorial--building-a-shopping-list)

Every SugarCraft program is three things: a **Model** (the state), an **update** (state transitions), and a **view** (a string). Here's a shopping list that walks through all three.

```
use SugarCraft\Core\{Cmd, KeyType, Model, Msg, Program};
use SugarCraft\Core\Msg\KeyMsg;

final class ShoppingList implements Model
{
    /** @param list $items @param array $bought */
    public function __construct(
        public readonly array $items,
        public readonly array $bought = [],
        public readonly int $cursor = 0,
    ) {}

    // 1. init() runs once at startup. Return a Cmd or null.
    public function init(): ?\Closure { return null; }

    // 2. update() takes a Msg and returns [newModel, ?Cmd].
    public function update(Msg $msg): array
    {
        if (!$msg instanceof KeyMsg) {
            return [$this, null];
        }
        return match (true) {
            $msg->type === KeyType::Char && $msg->rune === 'q'
                => [$this, Cmd::quit()],
            $msg->type === KeyType::Up
                => [new self($this->items, $this->bought, max(0, $this->cursor - 1)), null],
            $msg->type === KeyType::Down
                => [new self($this->items, $this->bought, min(count($this->items) - 1, $this->cursor + 1)), null],
            $msg->type === KeyType::Space => [
                new self(
                    $this->items,
                    [...$this->bought, $this->cursor => !($this->bought[$this->cursor] ?? false)],
                    $this->cursor,
                ),
                null,
            ],
            default => [$this, null],
        };
    }

    // 3. view() renders the current state. Pure function — no side effects.
    public function view(): string
    {
        $lines = ["Shopping list:\n"];
        foreach ($this->items as $i => $name) {
            $cursor = $i === $this->cursor ? '>' : ' ';
            $check  = ($this->bought[$i] ?? false) ? '[x]' : '[ ]';
            $lines[] = "  $cursor $check $name";
        }
        $lines[] = "\n(↑/↓ to move, space to toggle, q to quit)";
        return implode("\n", $lines);
    }
}

(new Program(new ShoppingList(['eggs', 'milk', 'bread', 'candy'])))->run();
```

Three rules carry through to every program:

1. **Model is immutable.** `update()` returns a *new* Model, never mutates the receiver. This buys you snapshot debugging, time travel, undo — all for free.
2. **Cmds run async.** `update()` decides what should happen; the runtime applies the resulting Cmd. A Cmd is a closure returning a Msg.
3. **view() is pure.** Same Model in → same string out. Side effects (writing to disk, hitting an HTTP endpoint, blinking the cursor) all live in Cmds, never in `view()`.

Once you've internalised the loop, every other SugarCraft feature is just a richer Msg or a more interesting Cmd.

Debugging tips
--------------

[](#debugging-tips)

The renderer owns stdout — printing to it from `update()` or `view()`will be overwritten on the next frame. The two ways to surface debug info from inside a running program:

1. **Log to a file.** Tail the file from another terminal: ```
    error_log('counter is now ' . $count . "\n", 3, '/tmp/candy.log');
    ```

    ```
    $ tail -f /tmp/candy.log
    ```
2. **Use `Cmd::println()`.** Lines emitted via `Cmd::println()`print *above* the program region (alt-screen and inline mode both honour this) — perfect for "I got here" prints during development.

Other gotchas:

- **Don't return `null` from `Model::view()`.** The runtime expects a string. Return `''` for an empty frame.
- **Don't block the main thread** in `update()` or `view()` — the runtime won't pump frames while you're sleeping. Long work goes in a Cmd that emits a Msg when it finishes.
- **Test the Model in isolation.** Drive `update()` with scripted Msgs in PHPUnit; the runtime is irrelevant for state-machine testing. (See `candy-core/tests/Model/` for the pattern.)
- **Profile with `--bail`.** If a render is slow, the cell-diff renderer skips unchanged regions — make sure your `view()` is deterministic so the diff stays cheap.

Mouse support — cell-motion vs all-motion
-----------------------------------------

[](#mouse-support--cell-motion-vs-all-motion)

Pass `MouseMode::CellMotion` (only emit motion events while a button is held) or `MouseMode::AllMotion` (emit every motion event including bare-cursor moves) to `ProgramOptions`. Pick:

- **`CellMotion`** when the model only cares about clicks + drags (most apps). Fewer Msgs flow through `update()`, lighter on the parser, plays nicely with terminal copy/paste because the user can hold Shift to bypass mouse capture.
- **`AllMotion`** when the model reacts to hover state (tooltips, fancy cursor effects, drag-preview overlays). Trade: every motion event lands in `update()`, so use a `MouseMode::CellMotion`stub for non-hover frames if perf bites.

`MouseMsg` carries a `MouseAction` enum (`Press` / `Release` / `Motion` / `WheelUp` / `WheelDown`) and 1-based `col` / `row`coordinates. The four `MouseClickMsg` / `MouseReleaseMsg` / `MouseMotionMsg` / `MouseWheelMsg` subclasses let you match by class when that's more convenient.

`examples/mouse.php` is a runnable demonstrator.

ProgramOptions Builder
----------------------

[](#programoptions-builder)

`ProgramOptions` has a 16-parameter constructor — use the fluent builder to avoid long argument lists:

```
use SugarCraft\Core\ProgramOptions;
use SugarCraft\Core\ProgramOptions\ProgramOptionsBuilder;

$opts = (new ProgramOptionsBuilder())
    ->withModel(new Counter())
    ->withInitialModelFromCmd(fn () => new Counter(42))
    ->withThrowOnQuit(false)
    ->withCatchSignals(false)
    ->withSignificantKeyEventsOnly(true)
    ->build();

(new Program($opts))->run();
```

`build()` validates required fields and throws `\InvalidArgumentException` on missing required. The existing 16-arg `new ProgramOptions(...)` ctor is preserved for full back-compatibility.

Program helpers
---------------

[](#program-helpers)

### withLogger

[](#withlogger)

Inject a PSR-3 logger to receive runtime events. Default is `Psr\Log\NullLogger`:

```
use Psr\Log\NullLogger;
use SugarCraft\Core\Program;

$program = (new Program(new Counter()))
    ->withLogger(new NullLogger());   // immutable — returns new instance
```

### withExceptionHandler

[](#withexceptionhandler)

Register a callable that fires when an uncaught exception escapes `update()` or `view()`. Default re-throws:

```
use SugarCraft\Core\Program;

$program = (new Program(new Counter()))
    ->withExceptionHandler(fn (\Throwable $t) => error_log($t->getMessage()));
```

### lastFrameDuration

[](#lastframeduration)

Seconds elapsed for the most recent render cycle — useful for adaptive framerate:

```
use SugarCraft\Core\Program;

$program = new Program(new Counter());
$program->run();
echo $program->lastFrameDuration();  // 0.0 before first render, >0 after
```

ProgressReporter
----------------

[](#progressreporter)

The `ProgressReporter` interface reports ongoing operation progress — consumed by `sugar-post` and `candy-forms` in later steps:

```
use SugarCraft\Core\ProgressReporter;
use SugarCraft\Core\Progress\CallbackProgressReporter;

interface ProgressReporter
{
    /** @param int $current 0-based current step @param int $total Total steps @param string|null $label Optional label */
    public function report(int $current, int $total, ?string $label = null): void;
}
```

Default implementations:

```
use SugarCraft\Core\Progress\CallbackProgressReporter;
use SugarCraft\Core\Progress\SilentProgressReporter;

// Callback — fires a closure with ($current, $total, $label)
$reporter = CallbackProgressReporter::new(
    fn (int $current, int $total, ?string $label) => printf("%d/%d %s\n", $current, $total, $label ?? '')
);
$reporter->report(3, 10, 'downloading');

// Silent — no-op (assertable via spy in tests)
$reporter = new SilentProgressReporter();
```

UndoActionType
--------------

[](#undoactiontype)

Typed enum replacing ad-hoc string-prefix detection for undo routing (`str_starts_with($desc, 'delete ')` patterns in super-candy / candy-mines / candy-tetris):

```
use SugarCraft\Core\Undo\UndoActionType;
use SugarCraft\Core\Undo\UndoAction;

enum UndoActionType: string
{
    case Delete = 'delete';
    case Move   = 'move';
    case Rename = 'rename';
    case Copy   = 'copy';
    case Custom = 'custom';
}

// Readonly value object pairing a type with its payload + display label
$action = UndoAction::new(UndoActionType::Delete, ['path' => '/tmp/foo'], 'delete foo');
echo $action->type->value;   // 'delete'
echo $action->label;          // 'delete foo'
```

Test
----

[](#test)

```
cd candy-core && composer install && vendor/bin/phpunit
```

###  Health Score

28

—

LowBetter than 52% of packages

Maintenance59

Moderate activity, may be stable

Popularity29

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 99.5% 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 (221 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (1 commits)")

---

Tags

ansibubble-teabubbleteacandycorecliconsoleelm-architectureevent-loopframeworkfunctionalmodel-update-viewphp-portphp-tuireactivereactphpsignalsterminaltuityped-phpwidget-framework

### Embed Badge

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

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

###  Alternatives

[illuminate/console

The Illuminate Console package.

13046.0M6.8k](/packages/illuminate-console)[styleci/cli

The CLI tool for StyleCI

71470.5k9](/packages/styleci-cli)[winbox/args

Windows command-line formatter

20720.9k21](/packages/winbox-args)[mallardduck/laravel-traits

A collection of useful Laravel snippets in the form of easy to use traits.

136.2k2](/packages/mallardduck-laravel-traits)

PHPackages © 2026

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