sugarcraft / sugar-forms
Foundation lib for form primitives (TextInput, TextArea, ItemList, Viewport, FilePicker, Field interface, Confirm, Form) extracted from sugar-widgets, with the interactive huh-style form engine folded in.
Requires
- php: ^8.3
- sugarcraft/sugar-async: dev-master
- sugarcraft/sugar-cellbuffer: dev-master
- sugarcraft/sugar-constraints: dev-master
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-fuzzy: dev-master
- sugarcraft/sugar-style: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/sugar-testing: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-17 07:24:23 UTC
README
Formerly published as
sugarcraft/candy-forms(renamed 2026-10).
Form library (port of charmbracelet/huh)
and foundation lib for form and input primitives — the raw component
implementations that sugar-widgets re-exports. The former standalone prompt
façade has been folded in here, so the full interactive form engine and its
primitives ship as a single standalone dependency.
Install
composer require sugarcraft/sugar-forms
Role
SugarForms owns the full primitive + form surface:
- Input primitives —
TextInput,TextArea,ItemList,Viewport,FilePicker,Cursor,Scrollbar,Spinner. - Form engine —
Fieldinterface +Field\{Input,Text,Confirm,Select, MultiSelect,Note,FilePicker},Form,Group,KeyMap,Theme,Fuzzy\FuzzyMatcher, andValidator\{Required,Email,MinLength,MaxLength,Pattern}.
sugar-widgets (SugarCraft\Widgets\*) exposes these classes as thin
class_alias shims pointing back here, so existing imports keep working
while the canonical implementation lives in SugarCraft\Forms\*.
Quickstart
A single-field form:
<?php use SugarCraft\Forms\Field\Input; use SugarCraft\Forms\Form; use SugarCraft\Forms\Validator\Required; $form = Form::new( Input::new('name') ->withTitle('What is your name?') ->withValidator(new Required()) ); echo $form->view();
Pre-filling fields
Show a form's current values before the user touches anything — handy for
"edit settings" screens. Input::withValue() seeds the editable text (it is
the submitted value until edited; pass '' to clear). Select pre-selects
by value with withSelected() (no-op when the value isn't an option) or by
0-based index with withSelectedIndex() (negatives clamp to the first option).
use SugarCraft\Forms\Field\Input; use SugarCraft\Forms\Field\Select; Input::new('name')->withValue('Ada'); // edit box starts as "Ada" Select::new('theme')->withOptions('Nocturne', 'Daylight', 'Midnight') ->withSelected('Midnight'); // or ->withSelectedIndex(2)
Standalone spinner (now lives here, was in sugar-widgets):
<?php use SugarCraft\Forms\Spinner\Spinner; use SugarCraft\Forms\Spinner\Style; $spinner = Spinner::new(Style::dot()); // in your model's init(): return $spinner->init(); // in your model's update(): [$spinner, $cmd] = $spinner->update($msg); // in your model's view(): "loading {$spinner->view()}"
Forms (huh-style)
Multi-page Group wizards, six themes, per-field validators, conditional skip predicates. The form is itself a SugarCraft Model — drop it into a Program.
use SugarCraft\Forms\Form; use SugarCraft\Forms\Field\{Input, Confirm, Select, Note}; $form = Form::new( Note::new('welcome')->title('Onboarding')->desc('A few quick questions.'), Input::new('name')->title('Your name?')->placeholder('Ada Lovelace'), Confirm::new('newsletter')->title('Subscribe to the newsletter?'), Select::new('lang')->title('Favorite language?')->options('PHP', 'Go', 'Rust', 'Python'), );
Every field exposes short-form aliases (
title,desc,placeholder,width,height,validator,options,min,max, …). The long forms (withTitle,withDescription, …) work identically — pick whichever reads better at the call site.
Field types
| Field | Description | Notable knobs |
|---|---|---|
Input |
Single-line text (wraps SugarCraft\Widgets\TextInput) |
withPlaceholder, withCharLimit, withWidth, withPrompt, withValidator(\Closure), withTitleFunc / withDescriptionFunc, withPassword(bool, string $echo = '*'), withSuggestions(list<string>), withSuggestionsFunc(\Closure(string):list<string>), withFuzzySuggestions(list<string>), withAsyncSuggestions(callable $fetcher, int $debounceMs = 150) |
Text |
Multi-line text editor | withCharLimit, withMaxLines, withShowLineNumbers, withValidator |
Confirm |
Yes/no boolean | withAffirmative/withNegative, withValidator(\Closure(bool):?string), withTitleFunc, withDescriptionFunc |
Select |
Single-choice list (wraps SugarCraft\Widgets\ItemList) |
withOptions(...), withTitleFunc, withDescriptionFunc, withFuzzySuggestions(list<string>), withAsyncSuggestions(callable $fetcher, int $debounceMs = 150), withEnum(\BackedEnum::class) |
MultiSelect |
Multi-choice list (j/k vim keys + space to toggle) | withOptions(...), withLimit(int) |
Note |
Read-only paragraph; skipped by tab navigation | withTitle, withDescription, withHeight(int), withNext(bool), withNextLabel(string) (turns it into an interactive button page) |
FilePicker |
Filesystem picker (wraps SugarCraft\Widgets\FilePicker) |
withCwd, withAllowDirs, withAllowFiles, withShowSize, withShowHidden |
Date |
Calendar-grid date picker (YYYY-MM-DD value, arrow-walked grid) |
withValue(?string), withWidth, withValidator(\Closure) |
Slider |
Numeric range slider (keyboard drag, snapping step) | withMin / withMax / withStep, `withValue(int |
Color |
Hex color picker with swatch preview | withValue(string $hex), withTruecolor(bool), withValidator |
All fields share a common navigation contract: Tab / ↓ advances,
Shift+Tab / ↑ retreats, Enter on the last interactive field submits,
Esc / Ctrl+C aborts. Skippable fields (e.g. plain Notes) are passed
over silently.
Forms and groups
Form::new(...$fields) is a single-page form. For multi-page flows build
with Form::groups(Group::new(...$fields), …). Each group carries its own
title / description / hide-predicate / theme override:
Form::groups( Group::new( Input::new('name')->withTitle('Your name?'), Confirm::new('proceed')->withTitle('Continue?'), )->withTitle('Step 1'), Group::new( Note::new('done')->withTitle('Thanks!')->withNext()->withNextLabel('Finish'), ) ->withTheme(Theme::dracula()) ->withShowHelp(false) ->withHideFunc(fn (array $v) => $v['proceed'] !== true), );
Form-level chainables
| Method | What it does |
|---|---|
withTheme(Theme) |
Switch the colour palette. |
withAccessible(bool) |
Render plain label: value text — for screen readers / dumb terminals. |
withShowHelp(bool) |
Toggle the help footer. |
withShowErrors(bool) |
Toggle the inline ! error line on validation failures. |
withWidth(int), withHeight(int) |
Pin the rendered geometry. |
withTimeout(int $ms) |
Auto-abort after $ms of wall clock. |
withKeyMap(KeyMap) (or the Form constructor's $keyMap prop) |
Override the bindings for Next / Prev / Submit / Quit (and per-field nav) on a single form. |
validateAll(): array<string,string> |
Run all field validators and return [fieldKey => errorMessage] for fields that failed. Use after Form::run() to collect cross-field validation failures that cannot be expressed per-field. |
Reading values after submit
values() returns every visible field keyed by key(). For typed access
call getString, getInt, getBool, getArray. For inspecting validation
state during a run use errors(), hasErrors(), getFocusedField(),
keyBinds(), help().
Themes & accessibility
Stock themes ship as static factories on SugarCraft\Forms\Theme:
ansi() (default), plain(), charm(), dracula(), catppuccin(),
base16(), base(). Pass one to Form::withTheme(...). The accessibility
mode flips the entire form to plain-text rendering — useful when you detect
NO_COLOR=1 or TERM=dumb.
Validators and dynamic labels
Every value-producing field supports withValidator(\Closure). The closure
runs on every keystroke (or every value flip for Confirm) and returns
null for valid or an error string. The error renders inline beneath the
field and shows up in Form::errors().
Use withTitleFunc(\Closure(): string) / withDescriptionFunc(...) on any
field to compute labels lazily — handy when the label depends on values from
a previous group.
Built-in validators
SugarCraft\Forms\Validator provides five ready-made validators
(Required, Email, MinLength, MaxLength, Pattern). All implement the
Validator interface (returns true on valid, an error string on
invalid). Multiple withValidator() calls chain, first error wins.
| Class | Error message | Notes |
|---|---|---|
Required |
Value is required |
Fails on empty string only |
Email |
Must be a valid email address |
Skipped when empty; uses filter_var |
MinLength(int $min) |
Must be at least N characters |
Uses mb_strlen (UTF-8 safe) |
MaxLength(int $max) |
Must be no more than N characters |
Uses mb_strlen (UTF-8 safe) |
Pattern(string $pattern, string $message) |
$message |
Skipped when empty; uses preg_match |
Custom validators implement SugarCraft\Forms\Validator\Validator:
final class NoSpaces implements Validator { public function validate(string $input): true|string { if (str_contains($input, ' ')) { return 'Spaces are not allowed'; } return true; } }
Predicate validation and error summaries
Input / Text also take withValidation(fn($v) => bool, $message) — a
predicate alternative to withValidator. Form::withErrorSummary(true)
renders every failed field's message above the form when submit fails.
Fuzzy suggestions
Input and Select support withFuzzySuggestions() — candidates are
ranked by Smith-Waterman local-alignment score (sugar-fuzzy) and filtered to
positive scores. The fuzzy() short alias is equivalent. For programmatic
scoring use SugarCraft\Fuzzy\Matcher\SmithWatermanMatcher directly
(SugarCraft\Forms\Fuzzy\FuzzyMatcher remains a deprecated alias of it;
scoring defaults in SugarCraft\Fuzzy\ScoringProfile: match +3,
mismatch -3, gap open -5, gap extend -1, adjacent bonus +5).
Async suggestions
withAsyncSuggestions(callable $fetcher, int $debounceMs = 150) (alias
async()) fetches list<string> suggestions off the keystroke path with a
debounce; SuggestionsReadyMsg arrives via the event loop when fresh
results land.
ActionSpinner
A standalone blocking spinner for scripts and CLIs that need a visible
"working…" indicator without a full Model–Update–View program (folded in
from the retired prompt façade; the canonical class is SugarCraft\Forms\ActionSpinner):
use SugarCraft\Forms\ActionSpinner; use SugarCraft\Forms\Spinner\Style as SpinnerStyle; ActionSpinner::new() ->withTitle('Crunching numbers...') ->withStyle(SpinnerStyle::dot()) ->withAction(static function () { // ... long-running work ... // Communicate results out-of-band: the action runs in a forked // child on pcntl hosts; in-memory mutations are not visible to // the parent after run() returns. }) ->run();
Fork semantics on pcntl hosts: the action executes in a forked child.
Return values and in-memory mutations are not visible to the parent; a
throwing action surfaces as a \RuntimeException from run() (the original
exception type/message cannot cross the fork boundary). The child is bounded
by a wall-clock ceiling (default 600 s; tune with
withMaxRuntimeSeconds()). On expiry the reaper escalates TERM→KILL and
run() throws — "was terminated" (child reaped) or "could not be
terminated" with the abandoned child's pid named. Communicate results via
tempfile, pipe, database, or other out-of-band mechanism. On hosts without
pcntl_fork, the action runs inline (no animation, no fork) and exceptions
propagate normally.
Demos
Shared foundations
sugar-forms is built on five shared packages:
- sugar-cellbuffer — Cell-grid model +
Buffer::toAnsi()for rendering glyphs and styles to ANSI bytes. TextInput / TextArea use the buffer path internally; their snapshot tests pin byte-for-byte output. - sugar-constraints —
LayoutSolver+ constraint types (Constraint::length,Constraint::fill, etc.).Form::withConstraints()routes through the solver when an explicit constraint set is provided. - sugar-testing —
Assertions::assertGoldenAnsiandAssertions::assertCellGridsnapshot helpers. Render fixtures live intests/fixtures/; setUPDATE_GOLDENS=1to regenerate them. - sugar-fuzzy —
SmithWatermanMatcherscores. Select / MultiSelect filter delegates to it internally; the existing publicwithFilter(callable)injection point is preserved for custom filters. - sugar-async —
Async\Await+CancelledExceptionfor async suggestion fetchers.withAsyncSuggestionsstores aCancellationSourceon the model and cancels the previous timer before scheduling the next, so rapid keystrokes only fire one network request. - Vim keybindings —
VimKeyHandlerprovides a shared vim mode handler (VimState: Insert/Normal/Visual/VisualLine;VimAction: CursorLeft, CursorRight, DeleteChar, YankLine, …). sugar-widgets and sugar-readline delegate to it — new bindings benefit all 3 libs at once. Text objects (ci",da{,yiw, …) resolve throughVimKeyHandler::handleTextObject():VimOperator(c/d/y) +TextObjectScope(i/a) + a target key feedTextObject::resolve(), which returns the[start, end)character range on the buffer — innermost bracket pair wins, quotes pair left-to-right, unmatched delimiters return null (vim's beep).
The legacy SugarCraft\Forms\Fuzzy\FuzzyMatcher (step-07 back-compat shim)
remains as a deprecated alias — it delegates to SugarCraft\Fuzzy\Matcher\SmithWatermanMatcher
with no behavioural divergence.
Dependencies
sugarcraft/sugar-core— Model–Update–View TUI runtimesugarcraft/sugar-style— Declarative styling
Versioning
Sibling sugarcraft/* requirements are pinned to dev-master and the
manifest carries minimum-stability: dev. That is intentional while the
monorepo pre-dates 1.0: every library moves together and published installs
resolve siblings from Packagist dev-master (path repositories are
injected by CI at build time, never committed). At the stable release the
pins become proper ^1.0 constraints — track this in the release plan
(E736 plan 7.4).
Snapshot tests
Render output is covered by golden-file snapshot tests. Fixture files live
in tests/fixtures/ with a .golden extension and are compared against
actual ANSI byte output via SugarCraft\Testing\Snapshot\Assertions::assertGoldenAnsi().
To re-record fixtures after intentional output changes:
UPDATE_GOLDENS=1 vendor/bin/phpunit
License
MIT
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.






