PHP port of charmbracelet/lipgloss β declarative styling and layout for terminal UIs.
composer require sugarcraft/candy-sprinklesuse SugarCraft\Sprinkles\Style;
echo Style::new()
->bold()
->fg('#ff5f87') // accepts hex string or Color instance
->on('#1e1e2e') // background β reads naturally in chains
->pad(0, 2) // CSS-style 1/2/4-arg padding
->of('hello, candy world')
->render() . "\n";fg / bg / on / pad / mg / of are short-form ergonomic aliases.
The upstream-mirroring full names (foreground / background / padding
/ margin / setString) work identically β pick whichever reads better at
the call site:
use SugarCraft\Core\Util\Color;
echo Style::new()
->bold()
->foreground(Color::hex('#ff5f87'))
->background(Color::hex('#1e1e2e'))
->padding(0, 2)
->setString('hello, candy world')
->render() . "\n";use SugarCraft\Sprinkles\Layout;
use SugarCraft\Sprinkles\Position;
// Side-by-side
echo Layout::joinHorizontal(Position::TOP, $left, $right);
// Top-down
echo Layout::joinVertical(Position::LEFT, $header, $body, $footer);
// Place inside a fixed rectangle
echo Layout::place(40, 10, Position::CENTER, Position::CENTER, 'centered text');use SugarCraft\Sprinkles\Border;
use SugarCraft\Sprinkles\Table\Table;
use SugarCraft\Sprinkles\Style;
$styled = Table::new()
->headers('Name', 'Age')
->row('Alice', '30')
->row('Bob', '25')
->border(Border::rounded())
->styleFunc(static fn(int $row, int $col): Style
=> $row === Table::HEADER_ROW
? Style::new()->bold()
: Style::new())
->render();
echo $styled;use SugarCraft\Sprinkles\Listing\{Enumerator, ItemList};
use SugarCraft\Sprinkles\Tree\Tree;
echo ItemList::new()
->items(['Apples', 'Bananas', 'Cherries'])
->enumerator(Enumerator::roman())
->render();
echo Tree::new()
->root('Documents')
->child(Tree::new()->root('Travel')->child('Italy.md')->child('Japan.md'))
->child('Resume.pdf')
->render();Styleβ every lipgloss prop (~40with*()methods): fg/bg/border colours (incl. per-side), bold/italic/underline/strikethrough/faint/blink/ reverse, padding/margin (1/2/4-arg shorthand + per-side), width/height, maxWidth/maxHeight, align (Align/VAlign), inline, transform, tabWidth, marginBackground, colorWhitespace. Plus 21 getters and 15unset*().Borderβnormal(),rounded(),thick(),double(),block(),hidden(). Per-side toggles viaStyle::border*.AdaptiveColor/CompleteColor/CompleteAdaptiveColorβ pick the right concrete colour at render time perColorProfile(TrueColor / 256 / Ansi) or per dark-vs-light background.LightDarkβ pick helper for dark-bg vs light-bg colour schemes.LayoutβPlace,PlaceHorizontal,PlaceVertical,JoinHorizontal,JoinVertical,Width,Height,Size(all package-level layout primitives from lipgloss).PositionβTOP / LEFT / CENTER / RIGHT / BOTTOMfloats for layout anchors.Listing\ItemList+Listing\Enumeratorβ bullet, dash, asterisk, arabic, alphabet, roman, romanUpper, decimal, none. Nested sublists + per-item style hooks.Tree\Tree+Tree\Enumeratorβ default / rounded / ascii connector sets; per-section style overrides; custom indenter.Table\Tableβheaders/row(s)/border/align/headerAlign/rowAlign/styleFunc/ per-side border toggles /width/offset/clearRows/data(Data).Table\Dataβ row-reader interface (rows/columns/at($r, $c)). Default implTable\StringData::fromMatrix(iterable).Outputβ top-levelprint/println/sprint/printf/fprint($stream, β¦)(style-agnostic).Rendererβ per-writer rendering context:withColorProfile/withHasDarkBackground/newStyle()/lightDark()/resolveAdaptive(AdaptiveColor)/fromEnvironment().Paletteβ named ANSI 16-slot constants (Black / Red / β¦ BrightWhite) +hasDarkBackground()helper.UnderlineStyleβ enum (None / Single / Double / Curly / Dotted / Dashed) for SGR4:Nsub-style emit.
Sprinkles\Renderer bundles a colour profile + a dark-background
flag so you can branch behaviour without threading the values
through every Style call:
$r = Renderer::new()
->withColorProfile(ColorProfile::Ansi256) // forced 256-colour
->withHasDarkBackground(false); // light terminal
$style = $r->newStyle()->bold()->foreground(...);
$pick = $r->lightDark(); // closure(light, dark)Each Style owns a colorProfile() setter too β the renderer is
just a convenience wrapper. Renderer::fromEnvironment() calls
ColorProfile::detect() for you (consults NO_COLOR,
CLICOLOR_FORCE, TERM_PROGRAM, etc.).
PHP's stream model is coarser than Go's, so the writer-binding
NewRenderer(out) shape is not mirrored. Pair with
Output::fprint($stream, ...) when you need to write to a
specific stream.
Util\Color ships the lipgloss-equivalent helpers:
| Method | Returns |
|---|---|
Color::hex('#ff5f87') |
RGB |
Color::ansi(13) |
named ANSI slot |
Color::ansi256(213) |
xterm-256 |
$c->blend($other, $t) |
linear LERP, t β [0, 1] |
Color::blend1D($a, $b, int $steps) |
list of N stops |
Color::blend2D($tl, $tr, $bl, $br, $w, $h) |
2D grid |
$c->lighten($amount) / darken($amount) |
luminance Β±amount |
$c->alpha($amount) |
premultiply alpha onto a backdrop |
$c->complementary() |
hue + 180Β° |
Named ANSI slots live on Sprinkles\Palette:
Palette::Red, Palette::BrightWhite, etc. Use
Palette::hasDarkBackground() for a no-args terminal-detect.
Style::hyperlink($url) wraps the rendered output in \x1b]8;;URLβ¦
escapes; modern terminals turn it into a clickable link, the rest
fall through to plain text. Combine with Style::underline() +
Style::underlineStyle(UnderlineStyle::Curly) (the SGR 4:3
sub-style) and Style::underlineColor(Color::hex('#ff0000')) to
emit a wavy red spell-check-style underline that degrades cleanly
to a plain SGR 4 underline on terminals that don't speak the
sub-style.
Every with*() setter returns a new Style β the receiver is
never mutated. Style::copy() returns a shallow clone for those
moments you want a known checkpoint to branch from.
Style::inherit($parent) merges unset props from the parent.
Inheritable properties: bold / italic / underline / strike /
faint / blink / reverse / fg / bg / borderFg / borderBg
(only if the child hasn't set them). Layout / structural properties
(width / height / padding / margin / border / borderSides) don't
inherit β every component is layout-independent. This matches
lipgloss v2's "explicit wins" rule.
Sprinkles\Layout exposes the three measurement helpers from
lipgloss's package level:
Layout::Width($s)β visible cell width of a (possibly ANSI-coloured, multibyte) string.Layout::Height($s)β number of\n-separated lines.Layout::Size($s)β[width, height]tuple.
All three strip ANSI escapes before measuring, honour East-Asian
wide cells, and round the same way Style::render() does β so
you can Layout::Width($style->render('xx')) to budget a
component's footprint reliably.
Table::styleFunc(\Closure(int $row, int $col): Style) runs once
per cell to pick its style. Row index Table::HEADER_ROW (constant
-1) identifies the header.
$striped = Table::new()
->headers('Name', 'Score')
->rows([['Alice', '93'], ['Bob', '87']])
->styleFunc(static fn (int $r, int $c): Style =>
$r === Table::HEADER_ROW
? Style::new()->bold()
: ($r % 2 === 0 ? Style::new()->faint() : Style::new()),
);The four border-section flags (borderHeader / borderRow /
borderColumn / borderTop/Right/Bottom/Left) decide which
separators draw. Defaults: rounded outer + header rule + column
verticals; row separators off.
A Style carries a ColorProfile (TrueColor / Ansi256 / Ansi /
NoTty). render() downsamples every colour to that tier before
emit:
TrueColor β SGR 38;2;R;G;B (24-bit)
Ansi256 β SGR 38;5;N (xterm-256 nearest match)
Ansi β SGR 30..37 / 90..97 (named slots)
NoTty β no SGR at all (clean text)
Use Style::colorProfile(ColorProfile::Ansi) for a forced
downgrade or Renderer::fromEnvironment() to auto-detect from
NO_COLOR / CLICOLOR_FORCE / TERM / COLORTERM /
TERM_PROGRAM / WT_SESSION / CI markers.
cd candy-sprinkles && composer install && vendor/bin/phpunit





