candycore / candy-core
PHP port of charmbracelet/bubbletea — Elm-architecture TUI runtime.
Requires
- php: ^8.1
- react/event-loop: ^1.6
Requires (Dev)
- phpunit/phpunit: ^10.5
This package is not auto-updated.
Last update: 2026-05-08 01:52:15 UTC
README
SugarCraft
composer require sugarcraft/candy-core
PHP port of 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
- PHP 8.1+
mbstring,intl(for grapheme width)pcntl(signal handling — POSIX only)react/event-loop^1.6 (Composer)
Architecture
Model— your app implementsinit(),update(Msg),view().Msg— marker interface for events. Built-ins:KeyMsg,WindowSizeMsg,QuitMsg.Cmd—Closure(): ?Msg. Async work whose result is dispatched as a Msg. Helpers inCmd::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,Openfoundation utilities, shared with CandySprinkles.
Demos
Counter Model
Timer
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
Viewstruct, plus the v2 Cmd surface (Suspend/Interrupt/Resume/Exec/Sequence/Every/Printf/Raw/wait/kill/releaseTerminal/restoreTerminal).
See ../CONVERSION.md for the full roadmap and the v2 parity sweep table tracking each Bubble Tea v2 / Lipgloss v2 / Bubbles v2 feature.
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 for status, package names, and namespace mappings.
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/<locale>.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
- Copy
candy-core/lang/en.phptocandy-core/lang/<locale>.php(e.g.fr.php). - Translate the values, keeping keys and
{placeholders}intact. - Set the locale at app startup with
T::setLocale('fr')orT::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
in the SugarCraft monorepo for the recommended set of codes plus a list
of every base language a contributor can target.
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 docblock for the
full API surface (register, translate, setLocale, locale,
detect, overrideNamespace, reset).
Composing Cmds
The runtime ships several Cmd combinators. The cheat-sheet below maps Bubble Tea idioms to the PHP equivalents:
| Need | Use |
|---|---|
| Run 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 (basic), timer
(tick scheduling), realtime (self-rescheduling
tick), sequence (Cmd::sequence),
send-msg (custom Msg + Cmd::tick),
tabs (state-driven view selection),
views (multi-view switcher),
splash (animated splash → main view),
suspend (Cmd::suspend + ResumeMsg),
mouse, focus-blur,
window-size, print-key,
set-window-title, and
prevent-quit.
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
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<string> $items @param array<int,bool> $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:
- Model is immutable.
update()returns a new Model, never mutates the receiver. This buys you snapshot debugging, time travel, undo — all for free. - Cmds run async.
update()decides what should happen; the runtime applies the resulting Cmd. A Cmd is a closure returning a Msg. - 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
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:
- 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
- Use
Cmd::println(). Lines emitted viaCmd::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
nullfromModel::view(). The runtime expects a string. Return''for an empty frame. - Don't block the main thread in
update()orview()— 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. (Seecandy-core/tests/Model/for the pattern.) - Profile with
--bail. If a render is slow, the cell-diff renderer skips unchanged regions — make sure yourview()is deterministic so the diff stays cheap.
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:
CellMotionwhen the model only cares about clicks + drags (most apps). Fewer Msgs flow throughupdate(), lighter on the parser, plays nicely with terminal copy/paste because the user can hold Shift to bypass mouse capture.AllMotionwhen the model reacts to hover state (tooltips, fancy cursor effects, drag-preview overlays). Trade: every motion event lands inupdate(), so use aMouseMode::CellMotionstub 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.
Test
cd candy-core && composer install && vendor/bin/phpunit

