Search by

sugarcraft / sugar-veil

detain

PHP port of rmhubbert/bubbletea-overlay — modal/overlay compositing for terminal UIs. Composite foreground content over a background at any position (Top/Right/Bottom/Left/Center) with optional cell (column/row) offsets.

Package info

github.com/sugarcraft/sugar-veil

pkg:composer/sugarcraft/sugar-veil

Statistics

Installs: 2 622

Dependents: 3

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-10-03 09:20 UTC

This package is auto-updated.

Last update: 2026-10-03 15:34:09 UTC


README

sugar-veil

CI codecov Packagist Version License PHP

SugarVeil

PHP port of rmhubbert/bubbletea-overlay — modal/overlay compositing for terminal UIs. Composite one string (foreground) over another (background) at any position with optional cell (column/row) offsets.

Features

  • 9 position modes: Top, Right, Bottom, Left, Center, and the 4 corners (TopRight, BottomRight, BottomLeft, TopLeft)
  • Cell-precise offsets: X/Y offsets (in columns/rows) fine-tune any position
  • Pure rendering: composites any background + foreground strings
  • Works with any TUI framework: render your models first, then composite
  • Backdrop dimming: truecolor opacity blend (0–100) applied to plain background lines during assembly
  • Animated transitions: Slide, Fade, and Scale animations driven by honey-bounce CubicBezier easing
  • Z-index stacking: control render order across multiple overlays via withZIndex()
  • Stack content channel: withContent(string) gives a veil its own foreground so VeilStack layers render their boxes, not just their dim
  • Click-outside dismiss: detect when a mouse click falls outside a veil's zone via withClickOutsideDismiss()
  • Auto-size: compute veil dimensions from content rather than fixed width/height via withAutoSize()
  • Border chrome: wrap veil content in a terminal border via withBorder()
  • VeilStack: ordered collection of veils sorted by z-index for rendering layered overlays

Install

composer require sugarcraft/sugar-veil

Quick Start

use SugarCraft\Veil\Position;
use SugarCraft\Veil\Veil;

$veil = Veil::new();

// Background: a 40x10 box
$bg = "┌──────────────────────────────────────┐\n" .
      "│         Main Application             │\n" .
      "│                                      │\n" .
      "│   [content]                          │\n" .
      "└──────────────────────────────────────┘";

// Foreground: a smaller overlay
$fg = "╔════════╗\n║ MODAL  ║\n╚════════╝";

// Composite fg centered over bg
$output = $veil->composite($fg, $bg, Position::CENTER, Position::CENTER);
echo $output;

Positioning

$veil->composite(
    string  $foreground,
    string  $background,
    Position $vertical,    // TOP | CENTER | BOTTOM
    Position $horizontal,  // LEFT | CENTER | RIGHT
    int      $xOffset = 0, // shift right (+N) or left (-N) cells
    int      $yOffset = 0  // shift down  (+N) or up   (-N) lines
): string

Corner positions

// Top-right corner
$veil->composite($fg, $bg, Position::TOP, Position::RIGHT);

// Bottom-left corner with offset
$veil->composite($fg, $bg, Position::BOTTOM, Position::LEFT, xOffset: 2, yOffset: -1);

Backdrop Dimming

Dim the background behind the overlay using withBackdrop(int $opacity) where opacity ranges from 0 (no dimming) to 100 (fully dimmed). Plain background lines are wrapped in a truecolor foreground blended from the approximated default white toward black by the opacity percentage; lines that already carry an escape introducer (SGR-styled output, OSC hyperlinks, …) are passed through unchanged, since rewriting their color runs would fight the producer's own palette. Because the dim ride lands as real SGR on the assembled frame, composite() diffs detect opacity-only changes between frames.

// Dim the background to 50% intensity
$veil = Veil::new()->withBackdrop(50);
$output = $veil->composite($fg, $bg, Position::CENTER, Position::CENTER);

Animations

Overlay transitions can be animated using withAnimation(AnimationKind). The animate() method accepts a float $progress parameter (0.0–1.0) to drive the transition. Animations use honey-bounce CubicBezier easing internally.

Available Animation Kinds

Kind Behavior
SLIDE Foreground enters from the anchor direction (no-op when anchored at CENTER on both axes — there is no edge to enter from)
FADE Foreground brightens in from black via a truecolor gray pen (needs a truecolor terminal)
SCALE Lines appear from the center outward
use SugarCraft\Veil\Veil;
use SugarCraft\Veil\Animation\AnimationKind;
use SugarCraft\Veil\Position;

$veil = Veil::new()
    ->withAnimation(AnimationKind::SLIDE)
    ->withBackdrop(30);

// Animate from 0% to 100% progress, sliding in from the top edge.
// (SLIDE needs at least one edge anchor: CENTER/CENTER would render the
// final position on every frame.)
for ($p = 0.0; $p <= 1.0; $p += 0.1) {
    $output = $veil->animate($fg, $bg, Position::TOP, Position::CENTER, progress: $p);
    // render $output ...
}

The animate() method composes the overlay with the animation applied at the given progress value. For SLIDE, the foreground starts displaced off-screen toward the anchored edge and eases into its final position — anchored at CENTER on an axis that axis contributes no displacement. For SCALE, lines are revealed from the center outward. For FADE, terminals cannot alpha-blend, so the overlay is drawn in a truecolor gray pen blended toward black by the eased opacity (Fade::opacity()), the same blend the backdrop dim uses: nothing is painted at opacity 0, every glyph is gray while the fade runs (the overlay's own foreground colours are suppressed; background colours and attributes are kept), and the original bytes return at opacity 100.

Z-Index Stacking

Use withZIndex(int $zIndex) to control the stacking order when rendering multiple overlays. Veils with higher z-index values render on top of those with lower values.

use SugarCraft\Veil\Veil;
use SugarCraft\Veil\Position;

$veil = Veil::new()
    ->withZIndex(10);  // renders above veils with zIndex < 10

$output = $veil->composite($fg, $bg, Position::CENTER, Position::CENTER);

Accessor: zIndex(): int

VeilStack (Multi-Veil Rendering)

VeilStack manages multiple veils ordered by z-index. When compositing, it sorts veils ascending by z-index and composites each onto the result of the previous one, so higher z-index veils appear on top of lower ones. Each layer composites its own withContent(string) foreground onto the accumulated canvas; a layer with no content contributes only its backdrop dim.

use SugarCraft\Veil\Veil;
use SugarCraft\Veil\VeilStack;
use SugarCraft\Veil\Position;

$stack = VeilStack::new()
    ->add(
        Veil::new()
            ->withZIndex(0)
            ->withBackdrop(30)                                          // base dim layer (no content)
    )
    ->add(
        Veil::new()
            ->withZIndex(10)
            ->withContent("╔════════╗\n║ MODAL  ║\n╚════════╝")  // modal box on top
    );

$output = $stack->composite($background, Position::CENTER, Position::CENTER);

VeilStack API

Method Description
add(Veil $veil): self Add a veil (returns new stack)
clear(): self Remove all veils
removeWhere(\Closure $pred): self Remove veils matching predicate
filter(\Closure $pred): self Keep only veils matching predicate
composite($background, $v, $h, $xOff, $yOff): string Composite all with shared position
compositeAll($background): string Composite all with their own positions
sorted(): list<Veil> Veils sorted by z-index ascending
all(): list<Veil> All veils in insertion order
maxZIndex(): ?int Highest z-index in stack (null if empty — 0 is a real z-index)
minZIndex(): ?int Lowest z-index in stack (null if empty)
isEmpty(): bool True if stack has no veils
count(): int Number of veils in stack

Auto-Size

Use withAutoSize(bool $enabled = true) to compute veil dimensions from content rather than from fixed width/height. When combined with withBorder(), the border chrome is applied to the content before dimension computation.

use SugarCraft\Veil\Veil;
use SugarCraft\Sprinkles\Border;
use SugarCraft\Veil\Position;

$veil = Veil::new()
    ->withAutoSize()
    ->withBorder(Border::new()->style(Border::STYLE_ROUND));

$output = $veil->composite($content, $bg, Position::CENTER, Position::CENTER);

Accessor: autoSize(): bool

Border Chrome

Use withBorder(Border $border) to wrap veil content in a terminal border rendered by candy-sprinkles Style. The applyBorderChrome(string $content) method applies the border to arbitrary content.

use SugarCraft\Veil\Veil;
use SugarCraft\Sprinkles\Border;
use SugarCraft\Sprinkles\Style;

$veil = Veil::new()
    ->withBorder(Border::new()->style(Border::STYLE_ROUND));

// Apply border to arbitrary content
$bordered = $veil->applyBorderChrome("Hello, World!");

Accessors: border(): ?Border

Click-Outside Dismiss

Use withClickOutsideDismiss(bool $enabled = true) to flag a veil for dismissal when a mouse click falls outside its rendered zone. The self-contained Scanner (from candy-mouse) handles hit testing locally — call scan($rendered) after rendering to register the veil zone, then use isClickOutside() or hit() to query it.

use SugarCraft\Veil\Veil;

$veil = Veil::new()
    ->withClickOutsideDismiss();

// ... after rendering the veil output ...
$veiled = $veil->scan($renderedOutput);

// Check if a mouse message is outside the veil's zone
$outside = $veiled->isClickOutside($mouseMsg);
if ($outside) {
    // dismiss the veil
}

Accessors: clickOutsideDismiss(): bool

The isClickOutside(MouseMsg $mouse): bool method returns true when clickOutsideDismiss is enabled, a rendered output has been scanned, and the click falls outside all tracked zones. Returns false when clickOutsideDismiss is disabled. When dismissal is enabled but scan() has not run yet, it throws a RuntimeException rather than silently answering "inside" — a dismiss handler built on an unscanned veil would never fire, so the miss is surfaced loudly.

The scanned zones travel with the veil: a with*() call made after scan() (say withContent() because the dialog body changed between the render and the click) keeps the scanned hit-test state, so the click is still judged against the frame that was on screen. Only the next scan() replaces it.

Buffer diffing

The composite() method maintains a ?Buffer $previousFrame across calls. On each call it builds the current Buffer, computes current->diff(previous) (from candy-buffer), and emits only the delta ANSI ops via DiffEncoder::encode($ops). The current frame then replaces previousFrame for the next render.

Frames are modeled as candy-buffer cell grids with terminal-faithful layout: wide graphemes (CJK, emoji) occupy two cells with a continuation tail, SGR sequences set a per-cell style pen, OSC 8 hyperlinks attach real Hyperlink metadata, and the invisible candy-mouse zone sentinels consume no columns. Style-only, link-only and backdrop-opacity-only frame changes therefore produce correct deltas instead of empty ones.

SSH bandwidth + flicker win: a one-character change in an 80×24 viewport produces ~8 bytes of delta ops instead of ~1 940 bytes for a full repaint. Over an SSH session this means far less per-frame data on the wire and eliminates the full-screen flicker of rewrite-based terminals. The first render after startup or a resize still emits a full Buffer (no diff possible), so behaviour is always correct.

A background with no cells (the empty string, or rows of zero width) is no canvas to clip the overlay against, so composite() returns the overlay itself as the frame — mirroring upstream's bg == "" early return — and records that frame in the session like any other. A frame with no cells at all resets the session, so the next non-empty composite is emitted in full rather than diffed against a frame the terminal no longer shows.

Truecolor SGR components outside 0–255 (38;2;300;0;0) and 256-colour palette indices outside 0–255 (38;5;300) are clamped to 255 / 0 when the diff pen is built, so a delta repaints a cell in the colour the terminal showed for the full frame rather than a bit-masked wrap-around or an out-of-range pen.

Shared foundations

Mouse hit-testing is self-contained via candy-mouse. The Scanner class handles zone registration and hit testing locally via scan() / hit().

License

MIT