composer require sugarcraft/candy-corePHP 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();- PHP 8.1+ (PHP 8.4+ recommended on Windows for FFI
SetConsoleCtrlHandlersupport) mbstring,intl(for grapheme width)pcntl(signal handling β POSIX only; not available on Windows)FFIextension (required on Windows for raw TTY support)react/event-loop^1.6 (Composer)- Windows 10 version 1809+ (for
ENABLE_VIRTUAL_TERMINAL_PROCESSING)
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.
- 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.
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.
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])
);- 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.
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).
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.
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.
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.
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.
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.
cd candy-core && composer install && vendor/bin/phpunit
