Search by

sugarcraft / candy-starter

detain

Skeleton repo for bootstrapping a SugarCraft TUI app — `composer create-project sugarcraft/candy-starter my-app`. Ships a runnable counter Model wired through Program, plus a docs-as-code walkthrough that maps each shipped file to the concept it demonstrates.

Package info

github.com/sugarcraft/candy-starter

Documentation

Type:project

pkg:composer/sugarcraft/candy-starter

Statistics

Installs: 1

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

dev-master 2026-11-03 23:42 UTC

This package is auto-updated.

Last update: 2026-11-05 01:01:11 UTC


README

candy-starter

CandyStarter

Formerly published as sugarcraft/candy-mold (renamed 2026-10).

CI codecov Packagist Version License PHP

demo

Skeleton repo for bootstrapping a SugarCraft TUI app. Start from this template and you've got a working app.

composer create-project sugarcraft/candy-starter my-app
cd my-app
./bin/start

and you'll see a working counter — quit it with q, Esc, or Ctrl+C. Replace src/Counter.php with your own Model, keep editing.

What you get

Every shipped file, and what it demonstrates:

my-app/
├── composer.json         # type: project — requires sugar-core + sugar-style
├── phpunit.xml           # failOnWarning gate; tests scan tests/, coverage covers src/
├── LICENSE               # MIT
├── README.md             # this walkthrough
├── CALIBER_LEARNINGS.md  # template gotchas (agent-facing; fine to delete downstream)
├── .gitignore            # vendor/, composer.lock, phpunit caches, *.gif
├── .assets/icon.png      # icon for the docs site
├── bin/start             # entry point — runs Program(new Counter())
├── src/
│   └── Counter.php       # demo Model: up/down/quit, styled border
├── tests/
│   ├── CounterTest.php                    # pinned behavior matrix — the test-shape reference
│   ├── ModelContractDocumentationTest.php # README fence vs interface drift guard
│   └── ReadmeSnippetsTest.php             # lint+execute every README php fence
└── .vhs/
    ├── start.tape        # CI gif recording of the running app
    └── start.gif         # the rendered demo (force-tracked; CI regenerates)

bin/start is a handful of meaningful lines: load the autoloader, instantiate your Model, hand it to Program::run(), then answer stopSignal() so a signal-stopped run exits 128+N (130 after SIGINT, 143 after kill) while cooperative quits keep their 0. The Program harness owns the event loop, render tick, signal installation, raw-mode setup, and alt-screen lifecycle — you only write Models. Inside the monorepo the same script runs as cd candy-starter && bin/start once the package-local vendor exists (per-lib composer install, or php ../scripts/refresh-deps.php --mode=linked from the repo root); it resolves the autoloader relative to its own file — the one <package>/vendor/autoload.php candidate — not the working directory.

Requirements

PHP 8.3+ with ext-intl and ext-ffi (both arrive transitively via sugar-core → sugar-pty; the skeleton declares no ext-* of its own, and composer create-project fails loudly with a platform error if one is missing — enable it with your distro's php-intl / php-ffi packages or pecl install ffi). A TTY-capable, colour terminal is assumed for interactive use — see the openTty note under "Forking checklist" for piped-stream setups.

Anatomy of a SugarCraft Model

A Model is four required methods — omit any one and PHP refuses to instantiate your class with a contains 1 abstract method fatal:

use SugarCraft\Core\Model;
use SugarCraft\Core\Msg;
use SugarCraft\Core\Subscriptions;

final class Greeter implements Model
{
    public function init(): ?\Closure { return null; }                     // optional startup Cmd (timers, fetch...)
    public function update(Msg $msg): array { return [$this, null]; }      // [next Model, optional Cmd]
    public function view(): string { return 'hello'; }                     // the current frame
    public function subscriptions(): ?Subscriptions { return null; }       // live Msg sources (ticks, streams...)
}

Only the return of init() is optional — the method itself is required, and returning null just means "no startup Cmd". subscriptions() is likewise required; return null when the model needs no subscriptions. And note the fence above narrows view(): string deliberately: the interface's contract is string|View (the View value object adds cursor placement and window-title control), so a plain-string app may narrow — but if you ever want those extras your return type must widen back to string|View.

The shape follows the MVC-style Model–Update–View architecture. State lives on the value object, transitions are pure functions, side effects (timers, HTTP, file I/O) get scheduled as Cmds rather than executed inline. The demo schedules only Cmd::quit(); sugar-core's Cmd also offers every(), tick(), promise() and friends — return one from init() or update() and the harness runs it, feeding whatever Msg it produces back through update().

update() returns the next Model — a fresh instance whenever state changes, the current one whenever it doesn't. It never mutates $this: that's why the demo declares public readonly int $n, and why the "no change" arm can honestly return $this.

Beyond keys

Keys are not the only traffic update() sees. The harness also dispatches WindowSizeMsg (once at startup, then on every resize) — the demo's one fixed box needs no layout recompute, so it drops them, but any width-aware app must capture that Msg before its key filter or resize handling is dead on arrival. Async work you schedule with Cmd::promise() reports failure as an ExceptionMsg, and only there: a Model that swallows it loses its errors in silence. src/Counter.php sketches both arms, commented, at the top of update().

Trust boundary

Program writes whatever view() returns to the terminal unmediated — the styled frame is the one place your bytes become the user's screen. So render only text you control. Anything from outside — file contents, network responses, another user's input — goes through sugar-core's Sanitize::untrusted() first (or untrustedForMarkedFrames() when the frame carries image/mouse-zone markers): it strips the terminal-control payload and the private-use U+E000 sentinel arena that forged zone markers would abuse. The demo renders an integer it owns, so it needs none — but the line where your app starts rendering the world is exactly the line this rule bites.

Common next steps

Want to… Reach for…
Add a text input sugarcraft/sugar-forms — TextInput
Show a spinner while loading sugarcraft/sugar-forms — Spinner
Render Markdown help text sugarcraft/sugar-markdown — Renderer
Tail a log into a scrollable pane sugarcraft/sugar-forms — Viewport
Build a multi-page wizard sugarcraft/sugar-forms — Group
Plot a sparkline sugarcraft/sugar-charts — Sparkline
Style the frame (bold, faint, colour) sugarcraft/sugar-style — Style, Theme (the demo already frames itself with a Style)
Translate your UI strings sugarcraft/sugar-core — Lang::t() with lang/<code>.php lookup files (see LOCALES.md)
Make it ssh-accessible sugarcraft/sugar-ssh-server

Add the dep, import its classes, return them from view(). Most are renderers on the shared Style-based vocabulary — but not all of them are pure: sugar-ssh-server runs an authenticated server around your Program, and sugar-core's Lang reads locale files at boot. Check each package's own README for setup and side effects instead of assuming the render-on-call shape. The demo itself covers the happy path only — one static frame, no colour beyond the border, and no loading or empty state — and the rows above are where you take it from there.

Forking checklist

The six things you actually have to change after create-project:

  1. composer.json — rename name (and description) to your app.
  2. bin/start — swap the use App\Counter; import for your own Model.
  3. src/ and tests/ — replace the demo Model and its matrix; keep the App\ / App\Tests\ namespaces or update the PSR-4 prefixes in composer.json to match.
  4. composer.lock — the shipped .gitignore ignores it, which is right for this template repo but wrong for the app you fork it into: apps ship a lockfile for reproducible, auditable builds. Delete the ignore line and commit the lock.
  5. Before shipping a real product, pin your sibling deps to a released constraint — the skeleton rides dev-master (sugarcraft/sugar-core, sugarcraft/sugar-style), which is deliberately bleeding-edge so the template never goes stale.
  6. Re-run vendor/bin/phpunit (or composer test — the skeleton ships that script alias).

The template reads no environment variables — configuration is in code, via ProgramOptions (framerate, alt-screen takeover, mouse tracking, raw-mode details; see sugar-core). Nothing is switched on for you: the shipped demo runs inline (useAltScreen is off by default) at 60 fps with the cursor hidden, and reaches for mouse tracking or alt-screen only when you pass the options yourself. ./bin/start | cat won't get raw mode because it has no tty; pass new ProgramOptions(openTty: true) to make the program grab /dev/tty so it still sees keys on piped streams.

Testing

Inside a create-project app the dependencies are already installed — just:

vendor/bin/phpunit

From a plain clone, install first (composer install), then the same command.

The included tests/CounterTest.php shows how to test update() deterministically by constructing Msg objects directly. No event loop, no terminal, no mocking — just call methods and assert the returned tuple.

Panic handler (optional)

SugarCraft supports an opt-in panic handler that catches uncaught exceptions and displays a styled backtrace when the app crashes. To enable it:

composer require sugarcraft/sugar-log

Then uncomment the panic handler block in bin/start:

// use SugarCraft\Log\Log;
// Log::installPanicHandler();

Note: In a monorepo development setup, add a path-repo to composer.json repositories[] first:

{ "type": "path", "url": "../sugar-log", "options": { "symlink": true } }

That block is development scaffolding only — never ship it. Remove it before publishing, or before any composer create-project of your app: a path repository in a published manifest is a hard fatal for every consumer, who has no ../sugar-log next to their project.

One more production note about the handler itself: the styled backtrace prints absolute paths and the arguments of every frame — exactly what you want while debugging, and sensitive data if a crash ever renders on a shared or recorded terminal. For shipped apps, configure sugar-log to route uncaught errors to a log sink (file, syslog) and reserve on-screen backtraces for debug builds.

License

MIT.

Credits & inspiration

Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.