sugarcraft / sugar-core
PHP port of charmbracelet/bubbletea — Elm-architecture TUI runtime.
Requires
- php: ^8.3
- ext-intl: *
- ext-mbstring: *
- react/event-loop: ^1.6
- react/promise: ^3.3
- sugarcraft/sugar-pty: dev-master
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:02:56 UTC
README
SugarCraft
Formerly published as
sugarcraft/candy-core(renamed 2026-10).
composer require sugarcraft/sugar-core
sugar-core — the Model–Update–View TUI runtime for PHP 8.3+: every app is a
Model, an update() that folds messages into new state, and a view()
that renders it — driven by an event loop with subscriptions, commands and
a full-screen diffing renderer.
use SugarCraft\Core\{Cmd, KeyType, Model, Msg, Program, Subscriptions}; 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)"; } // Model::subscriptions() is part of the contract — return null when // the model needs none (or `use SubscriptionCapable;` to get that // default for free). public function subscriptions(): ?Subscriptions { return null; } } (new Program(new Counter()))->run();
Requirements
- PHP 8.3+ (PHP 8.4+ recommended on Windows for FFI
SetConsoleCtrlHandlersupport) mbstring— required, and declared asext-mbstringincomposer.json. Escape decoding and clipboard paths callmb_*functions on hot paths.intl— required, and declared asext-intlincomposer.json.Width's grapheme segmentation is built on ICU viagrapheme_extract(). Without it every width measure falls back to per-codepoint counting, which is self-consistent but wrong for any cluster — a skin-toned emoji measures 4 cells instead of 2 — and the cell-budget invariants the TUI renderers rely on stop holding. It was a silent requirement before the 2026-08 hardening pass: the manifest omitted it while this list already named it.pcntl(signal handling — POSIX only; not available on Windows)FFIextension (required on Windows for raw TTY support)react/event-loop^1.6 (Composer)sugarcraft/sugar-pty@dev— the runtime's raw-mode/termios backend. sugar-core and sugar-pty hard-require each other (sugar-pty consumes core's primitives); CI injects the path-repo closure so the cycle resolves inside the monorepo, and standalone installs take both from Packagist.Util\Tty\Termiostherefore appears in a public constructor type (ProgramOptions::$termios).- Windows 10 version 1809+ (for
ENABLE_VIRTUAL_TERMINAL_PROCESSING)
Install note: sibling
sugarcraft/*packages track@devand the monorepo setsminimum-stability: devwithprefer-stable: true. A consumer pinning stable releases only should mirror that pair in its own root manifest or install fails to resolve.
Environment variables
Every environment variable sugar-core reads, who reads it, and what happens when it is unset. Apps may of course read their own; this is the library's full surface.
| Variable | Read by | When unset |
|---|---|---|
TERM |
Util\ColorProfile::detect() |
falls through to the Ascii tier |
COLORTERM |
Util\ColorProfile::detect() |
no early TrueColor bail |
TERM_PROGRAM |
Util\ColorProfile::detect(), Util\Tty\EnvDetect (mintty), Util\Clipboard (backend plan) |
no known-terminal match |
NO_COLOR |
Util\ColorProfile::detect() |
colour stays enabled |
CLICOLOR |
Util\ColorProfile::detect() (CLICOLOR=0 opts out of colour) |
no opt-out signal |
CLICOLOR_FORCE |
Util\ColorProfile::detect() |
no force (truthy = non-empty, not 0, not false) |
FORCE_COLOR |
Util\ColorProfile::detect() |
no force |
WT_SESSION |
Util\ColorProfile::detect() |
not Windows Terminal |
CI |
Util\ColorProfile::detect() |
not treated as CI |
GITHUB_ACTIONS |
Util\ColorProfile::detect() |
not treated as CI |
GITLAB_CI |
Util\ColorProfile::detect() |
not treated as CI |
BUILDKITE |
Util\ColorProfile::detect() |
not treated as CI |
WSL_DISTRO_NAME |
Util\Tty\EnvDetect (WSL id), Util\Open (opener choice) |
not WSL by this signal |
WSL_INTEROP |
Util\Tty\EnvDetect (WSL id), Util\Open (opener choice) |
not WSL by this signal |
MSYSTEM |
Util\Tty\EnvDetect (MSYS2 id) |
not MSYS2 |
MINTTY_SHORTCUT |
Util\Tty\EnvDetect (mintty id) |
not mintty by this signal |
OSTYPE |
Util\Tty\EnvDetect (cygwin id) |
not Cygwin |
PATH |
Util\Executable\Locator (the search path locate() walks, after any $extraPath argument) |
empty — nothing is found unless the caller passed $extraPath |
PATHEXT |
Util\Executable\Locator (Windows extensions; POSIX never reads it) |
DEFAULT_WINDOWS_EXTENSIONS = .COM, .EXE, .BAT, .CMD |
COLUMNS |
Util\Tty\PosixBackend (size fallback when stdout is not a tty) |
ignored (read as 0) — next fallback wins |
LINES |
Util\Tty\PosixBackend (same shape as COLUMNS) |
ignored (read as 0) — next fallback wins |
HOME |
WorkerPool (worker environment seed) |
/tmp |
VISUAL |
Util\Editor (discovery: explicit override → VISUAL → EDITOR → platform default) |
falls to EDITOR |
EDITOR |
Util\Editor |
falls to the platform default editor |
TMPDIR |
PHP's sys_get_temp_dir(), consumed by Util\Editor's temp-file scaffold |
system temp (/tmp) |
LC_ALL |
I18n\T::detect() (chain LC_ALL → LC_MESSAGES → LANG; $_SERVER first, then getenv()) |
falls to LC_MESSAGES |
LC_MESSAGES |
I18n\T::detect() |
falls to LANG |
LANG |
I18n\T::detect() |
locale en |
TMUX |
Util\Clipboard (backend plan) |
not inside tmux |
DISPLAY |
Util\Clipboard (X11 backend plan) |
no X11 clipboard candidate |
WAYLAND_DISPLAY |
Util\Clipboard (Wayland backend plan) |
no Wayland clipboard candidate |
Color-profile detection order
ColorProfile::detect() decides in exactly this order (the docblock on
the enum carries the same list; both are derived from the code):
CLICOLOR_FORCEorFORCE_COLORtruthy counts as a terminal; otherwise, when a stdout resource is passed and it is not a tty →NoTty(soNO_COLORon a pipe yieldsNoTty, never escape bytes).TERM=dumb→NoTtyunless forced.NO_COLORtruthy →Ascii(decoration kept, per no-color.org) — it outranks the force variables because it is consulted first.CLICOLOR=0→Ascii(the BSD opt-out: colour disabled, decoration kept), unless a force variable already won.- Forced (
CLICOLOR_FORCE/FORCE_COLOR) →TrueColor. COLORTERM=truecoloror24bit→TrueColor.- Known
TERM_PROGRAMvalues → the matched tier (iTerm/WezTerm/VS Code/ Kitty/alacritty/Ghostty/Hyper →TrueColor; Apple_Terminal →Ansi256). WT_SESSIONset →TrueColor.TERMcontainstruecolorordirect→TrueColor; contains256→Ansi256; containscoloror equalsxterm/screen/tmux→Ansi.- Any CI variable truthy (
CI,GITHUB_ACTIONS,GITLAB_CI,BUILDKITE) →Ansi. - Fallback →
Ascii.
Colour note: the runtime's own renderer emits SGR unconditionally —
ColorProfile tells your code which tier the terminal supports, but it
is not a suppression switch inside the renderer. If your app must honour
NO_COLOR end-to-end, consult ColorProfile::detect() and emit plain
text yourself; there is no automatic downgrade.
Architecture
Model— your app implementsinit(),update(Msg),view(),subscriptions().Msg— marker interface for events. Built-ins:KeyMsg,WindowSizeMsg,QuitMsg(the full catalogue is indexed under Message catalogue).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.- Timer accuracy — on a libuv-backed loop (
ExtUvLoop, picked automatically whenext-uvis installed) a timer's deadline is computed against a clock refreshed once per loop iteration, so a timer armed after long blocking work in a callback fires early by that much. Keep callbacks short; see the notes onProgram::run()for the measured numbers and the safe paths. - Timers in tests — a PHPUnit process runs the loop in short bursts with long synchronous gaps, which is the same hazard writ large: a safety timer can fire instantly. Suites that arm one should pin a clock-fresh loop from
tests/bootstrap.phpwith sugar-testing'sSugarCraft\Testing\LoopPin::pinStableClock(). InputReader— stateful byte-stream parser; handles split escape sequences across reads.Renderer— line-diff full-screen renderer by default (repaints only changed rows inside a DEC 2026 synchronized-output update); passcellDiffRenderer: truefor the per-cell "cursed" diff. Both are implemented; there is no plain full-repaint mode.Util/—Ansi,Color,ColorProfile,Width,Tty,TtyDetect,RawMode,Openfoundation utilities, shared with SugarStyle.RawMode::enable($stream)/RawMode::disable($stream)is the portablestty-based raw-mode toggle for the controlling terminal — a safe no-op on non-tty streams.KeyBindings(inKeys/) — immutable action→tokens key registry (finding m27): named actions each own a lowercase token set,matches(KeyMsg, id)answers "does this press actuate that action" (press-only, m11; case-folded, m26; both space wire shapes normalize tospace),withOverride(id, ...tokens)returns a new registry, andlabel(id)renders the derived display column help text is composed from — so a user remap lands in dispatch AND the UI from one map. Modifier chords are a declared non-goal (bare letters match as they always did).Semaphore(inUtil/) — bounded-concurrency permit pool over ReactPHP promises:Semaphore::new(6)->run(fn() => fetch($url))caps in-flight work, parks the rest FIFO, and returns each permit when its promise settles. Share one pool across every component touching the same scarce resource, andclose()it when the owner is retired so parked waiters fail (withSemaphoreClosedException) instead of pending. Distinct fromWorkerPool(subprocess concurrency) — see its docblock.LruMap(inUtil/) — fixed-capacity least-recently-used map:get()promotes,peek()reads without disturbing recency,put()evicts the longest-unused entry past the cap,evictions()reports how often that happened, and an optionalmaxCapacitygrowth guard capsresize(). Iterates most-recently-used first;keys()is the string-safe key view. The size bound that complements a TTL cache; it does not replace one.Subscription— value object: id, Kind, params, produce closure.Subscriptions— immutable collection withwithTick(),withKey(),withSignal(),withCustom(),all(),has().Kind— pure enum: Tick / Key / Signal / Custom.SubscriptionCapable— trait providing the defaultsubscriptions(): null.
Composition primitives
ScreenStack— immutable stack withpush()/pop()/current()/breadcrumb()/isEmpty()/count().Screen— value object:model, optionaltitle,onEnterclosure,onExitclosure.ScreenStackCapable— interface:screens(): ScreenStack. Implement to activate screen-stack routing.RootModelWithScreenStack— concrete root model owning aScreenStack; routesScreenStackPushedMsg/ScreenStackPoppedMsginternally, delegatesview()to the active screen.PushScreenCmd/PopScreenCmd(inSugarCraft\Core\Cmd) — Cmd factories that emitScreenStackPushedMsg/ScreenStackPoppedMsg.ScreenStackPushedMsg/ScreenStackPoppedMsg— infrastructure messages dispatched through the loop; always handled by the root model, never by the active screen.Component— interface extendingModel; addsonMount(): ?Closure(fired when mounted into aComposite) andonUnmount(): ?Closure(fired when removed). Return aCmdto run immediately ornullfor no-op.Composite—Modelthat manages a list of childComponents. TrackspendingCmdsand drains them during reconcile, calling childonMount()/onUnmount()Cmds synchronously. Children are reconciled by presence: a child that stays in the list keeps its state; adding or removing triggers lifecycle events. Reordering alone does not re-run lifecycle.Panes/Pane/Rect— split-region bookkeeping for apps that manage multiple viewports. Geometry is advisory: the runtime does not clip a pane'sview()output to itsRect— models must size their own frames (see Viewport fit).WorkerPool— bounded-concurrency pool (default 4 workers) for offloading CPU-bound tasks to subprocesses. Tasks are named global functions or serializedSerializable/closure-free callables; aClosurecannot cross the process boundary, so dispatching one returns an already-rejected promise. Worker children runphpwithout your application autoloader: only PHP built-ins and whatever the worker's own script loads are resolvable inside the child.WorkerCmd—Cmdfactory:WorkerCmd::run($pool, $callable)dispatches a task to the pool and yields aWorkerResultMsgwhen complete.WorkerResultMsg— resultMsgcarrying$result,$error, and$workerIdfrom a pool worker.
Subscriptions
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\{Model, Msg, Program, Subscriptions}; /** App-local message — sugar-core ships no TickMsg; the produce closure * returns whatever Msg your update() understands. */ final class TickMsg implements Msg {} 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 { if ($msg instanceof TickMsg) { return [new self($this->ticks + 1), null]; } return [$this, null]; } public function view(): string { return "ticks: $this->ticks\n"; } public function subscriptions(): ?Subscriptions { return (new Subscriptions())->withTick('clock-tick', 1.0, static fn (): Msg => new TickMsg()); } } (new Program(new Clock()))->run();
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; // ... init()/update()/view() still required; no subscriptions() needed }
Two reconciliation facts are worth knowing (both pinned by tests):
- Ids are the identity. A subscription is stable while its
idis present across cycles. Changing the closure or interval under the same id keeps the originally-started timer — to change cadence or producer, use a different id. Duplicate ids in oneSubscriptionscollapse to the first entry. SubscribeCmdis transient. ItsSubscriptionsMsgreconciles the given set immediately, but the nextupdate()cycle re-diffs againstModel::subscriptions(), which cancels anything the model itself does not declare. For durable subscriptions, return them fromsubscriptions(); useSubscribeCmdonly for one-shot or model-declared-parity sets.
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's model, writes the screen's next model back
into the stack, and keeps infrastructure messages
(ScreenStackPushedMsg / ScreenStackPoppedMsg) on the root model.
use SugarCraft\Core\{Cmd, KeyType, Model, Msg, Program, ScreenStack, ScreenStackCapable, SubscriptionCapable}; 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, new PopScreenCmd()]; } return [$this, null]; } public function view(): string { return "Detail: {$this->id}\n(press b to go back)\n"; } public function subscriptions(): ?Subscriptions { return null; } } // Minimal root: the base layer is a plain string; the active screen (if // any) replaces it. Infrastructure push/pop messages rebuild the stack. final class App implements Model, ScreenStackCapable { use 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) { return [new self($this->screens->pop()), null]; } if ($this->screens->isEmpty() && $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 { return $this->screens->isEmpty() ? "Push a screen with n\n" : (string) $this->screens->current()->model->view(); } } (new Program(new App()))->run();
sugar-core also ships RootModelWithScreenStack, a ready-made root model
with this exact routing pre-implemented — extend-free to use, with
pushedIds / poppedIds tracking for tests.
examples/screen-stack.php is a runnable demo showing 3-deep push / pop.
Viewport fit
The runtime paints whatever view() returns. It does not clip frames to
the terminal, and Pane/Rect carry bounds purely as advisory metadata —
no code path in the renderer reads them. A frame wider than the viewport
soft-wraps and shifts every following row, which is why border-alignment
defects surface so often across TUI apps. Read WindowSizeMsg (1-based
$cols / $rows arrive at startup and on every resize), size your content
to it, and truncate with Util\Width::truncateAnsi() so SGR bytes survive
the cut.
Demos
Counter Model
Timer
Status
- Foundation utilities: 🟢 complete.
- Runtime: 🟢 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 Cmd surface (Suspend/Interrupt/Exec/Sequence/Every/Printf/Rawand more — a resume is deliberately not a Cmd: SIGCONT dispatches aResumeMsgfrom the runtime) and the Program control methods (wait/kill/releaseTerminal/restoreTerminal). - Terminal recovery: 🟢
setupTerminal()arms a one-shot shutdown backstop.run()'sfinallyrestores the terminal on every path it can see; adie(),exit()or zend bailout that skips pendingfinallyblocks still lands on the registered shutdown function, so the library that entered the alt screen is the library that leaves it and consumers do not need to write their ownbinguard. The hook holds aWeakReferenceand compares the install pid at fire time — finished Programs are never pinned alive by the shutdown queue, and a forked child exiting never releases the parent's terminal.
Companion libraries
SugarCraft is the foundation — the rest of the SugarCraft stack builds on it. From the same monorepo:
- SugarStyle — declarative styling + layout.
- SugarWidgets — prebuilt terminal components.
- SugarForms — multi-page form library.
- SugarCharts — sparkline / bar / line / heatmap / OHLC.
- CandyScriptui — composer-installable CLI (
gum-style subcommands). - SugarMarkdown — Markdown → ANSI renderer.
- SugarMouse — mouse-zone tracker.
- SugarSpring — spring physics + Newtonian projectile sim.
- SugarCli — opinionated CLI presentation helpers.
- CandyCodeshot — code → SVG screenshot.
- SugarSshServer — SSH server middleware framework.
- CandyAnsiInspect — ANSI escape-sequence inspector.
See the matchup table in docs/MATCHUPS.md for status, package names, and namespace mappings.
Localization (i18n)
sugar-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]) );
Scope of the registry: it translates validation and user-facing refusal
messages — the strings the library routes through Lang::t() (indexed in
lang/en.php). Low-level programming errors (\InvalidArgumentException
for bad internal arguments, LogicException for protocol misuse) stay raw
SPL strings by design: they mark developer bugs, not product copy, and
translating them would promise a coverage the registry does not target.
Adding a new locale
- Copy
sugar-core/lang/en.phptosugar-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 key missing from your locale file falls
back to the English value, so partial locales surface as mixed-language
text, never a fatal error; tests/LocaleKeyParityTest fails the build when
a shipped locale drifts from the en key set.
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, charset, t, reset).
Composing Cmds
The runtime ships several Cmd combinators. The cheat-sheet below maps common orchestration needs to the PHP combinators:
| 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($command, captureOutput: false, onComplete: fn (int $exitCode) => $msg) |
| Offload to a worker subprocess | WorkerCmd::run($pool, $callable) → WorkerResultMsg |
| Replace the subscription set for this cycle | new SubscribeCmd($subscriptions) |
init() returns a Cmd (or null) to fire once at startup — for the active
model only: a screen pushed later is not re-inited; initialise it in the
Screen's onEnter or in its first update() instead. 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), simple
(minimal quit-on-key), 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),
altscreen-toggle,
panes,
screen-stack,
mouse, focus-blur,
window-size, print-key,
set-window-title, and
prevent-quit.
Message catalogue
Built-in Msg classes under SugarCraft\Core\Msg\ (all implement the
Msg marker interface; addressed messages implement
ComponentAddressedMsg so a Composite can route by child address):
| Group | Classes |
|---|---|
| Keyboard | KeyMsg, KeyPressMsg, KeyReleaseMsg, KeyRepeatMsg |
| Mouse | MouseMsg, MouseClickMsg, MouseReleaseMsg, MouseMotionMsg, MouseWheelMsg |
| Paste | PasteMsg, PasteStartMsg, PasteEndMsg |
| Lifecycle | QuitMsg, SuspendMsg, ResumeMsg, InterruptMsg, Empty-family via RawMsg |
| Terminal | WindowSizeMsg ($cols/$rows), CursorPositionMsg, BackgroundColorMsg, ForegroundColorMsg, CursorColorMsg, ColorProfileMsg, TerminalVersionMsg, ModeReportMsg, CapabilityMsg, KeyboardEnhancementsMsg, ClipboardMsg |
| Focus | FocusGainedMsg, FocusLostMsg |
| Screen stack | ScreenStackPushedMsg, ScreenStackPoppedMsg |
| Composition | AddComponentMsg, RemoveComponentMsg, ComponentAddressedMsg (interface) |
| Async / infra | ExceptionMsg, ExecMsg, EnvMsg, PrintMsg, RawMsg, BatchMsg, SequenceMsg, SubscriptionsMsg, SuggestionsReadyMsg, TickRequest (Msg-shaped request), WorkerResultMsg |
Alt-screen vs inline mode
The shipped default is neither: useAltScreen: false and
inlineMode: false paint over the current screen with the diff renderer.
Pass useAltScreen: true 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 CandyScriptui 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, Subscriptions}; 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); } public function subscriptions(): ?Subscriptions { return null; } } (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.
An uncaught exception thrown from update() or view() reaches the
program's exception handler (default: rethrow), which means a raw PHP
stack trace with local paths lands on the recovered screen — informative
for the developer, alarming for the user. Install withExceptionHandler()
if you want to log it and resume (or show a friendlier frame) instead.
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. (Seesugar-core/tests/ProgramTest.phpfor the pattern.) - Keep
view()deterministic. The line-diff (and especially the cell-diff,cellDiffRenderer: true) renderer skips unchanged rows only when the frame is byte-stable — randomised or time-dependent output defeats the diff and repaints everything every frame.
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 enable it only while a hover-sensitive view is up (View->mouseMode) instead of process-wide.
MouseMsg carries a MouseAction enum (Press / Release /
Motion) and 1-based $x / $y coordinates. The wheel is not an
action: wheel events arrive as Press with MouseButton::WheelUp /
MouseButton::WheelDown. 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
ProgramOptions is a 24-parameter value object (readonly promoted
properties). Pass it as Program's second argument:
use SugarCraft\Core\{Program, ProgramOptions}; $program = new Program( new Counter(), new ProgramOptions(framerate: 60.0, useAltScreen: true, mouseMode: MouseMode::CellMotion), ); $program->run();
For code that assembles options incrementally there is
SugarCraft\Core\ProgramOptions\ProgramOptionsBuilder — a fluent mirror
of the same 24 options (withFramerate(), withUseAltScreen(),
withMouseMode(), withInlineMode(), …); build() returns the
ProgramOptions and performs no validation beyond the types:
use SugarCraft\Core\Program; use SugarCraft\Core\ProgramOptions\ProgramOptionsBuilder; $opts = (new ProgramOptionsBuilder()) ->withUseAltScreen(true) ->withFramerate(60.0) ->withCellDiffRenderer(true) ->build(); (new Program(new Counter(), $opts))->run();
Program helpers
withLogger
Inject a PSR-3-shaped logger to receive runtime lifecycle events.
The default is the library's own SugarCraft\Core\Util\NullLogger (a
silent sink — sugar-core does not depend on psr/log); any object
exposing info() / error() / warning() is accepted, and missing
methods are skipped:
use SugarCraft\Core\Program; use SugarCraft\Core\Util\NullLogger; $program = (new Program(new Counter())) ->withLogger(new NullLogger()); // immutable — returns new instance
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
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
The ProgressReporter interface reports ongoing operation progress. It is
a shared vocabulary shipped by the foundation; as of today no monorepo
package consumes it — apps (or future library releases) adopt it directly:
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
Typed enum for apps that want structured undo routing instead of
string-prefix detection (str_starts_with($desc, 'delete ') patterns):
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 Insert = 'insert'; case Modify = 'modify'; 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'
Atomic durable JSON files (Util\AtomicJsonFile)
The pattern eight sibling libs consolidate onto: AtomicJsonFile::path($file) ->write($data) serialises to a 0600 temp inode, fsyncs, then
rename()s, so a reader never sees a half-written file, and takes a
Semaphore-guarded lock so concurrent processes serialise.
Operator notes:
- Stale
.locksidecar. The lock lives in<file>.lock. If a process isSIGKILLed while holding it, the sidecar can outlive its flock lease; on a multi-uid host the residue may even belong to another user, and every later write fails until an operator removes the stale.lock(the flock itself auto-releases when the owning process is gone — the unlink is purely cosmetic hygiene, safe tormwhenflock -nproves nobody holds it). - Publish permissions. Resulting file modes derive from the process
umask (opt-in
withPermissions()tightens them explicitly); a world-readable umask publishes world-readable state files.
Renamed packages
When a package in this monorepo is renamed, apps should keep working for users who still set old env vars or have data in old directories. Two shims in SugarCraft\Core\Util cover that:
LegacyEnv::import(['SUGARCRUSH_' => 'CANDY_CODE_'])— copies every env var set under a pre-rename prefix onto the new prefix, never overwriting a var the user already set under the new name, and returns the imported old names (sorted) so the app can print a deprecation notice. Call it at the very top ofbin/<app>, right after the autoloader.LegacyPaths::migrate($oldDir, $newDir)— one-time atomic move of a pre-rename data/config directory (parents created 0700); returns a human-readable notice, and never merges when the new path already exists.
Every line that spells an old name must carry the @rename-keep marker, or tools/check-rename-residue.php will flag it.
Test
cd sugar-core && composer install && vendor/bin/phpunit
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.

