Search by

sugarcraft / candy-layout

detain

Constraint-based layout solver (Cassowary simplex + greedy fallback)

Package info

github.com/sugarcraft/candy-layout

Documentation

pkg:composer/sugarcraft/candy-layout

Statistics

Installs: 5 721

Dependents: 5

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-10-08 22:37 UTC

This package is auto-updated.

Last update: 2026-10-08 23:01:18 UTC


README

CI codecov Packagist Version License PHP

Constraint-based layout solver for terminal grid layouts.

GreedySolver is the default and only supported solver. It is deterministic and fully implements every constraint type (Length/Min/Max/Fill/Percentage/Ratio).

CassowarySolver is deprecated — its Big-M simplex never converged (it hit the 1000-pivot cap on every call, including trivial layouts, and silently returned wrong state; its Ratio path returned 0). Rather than rewrite the simplex, CassowarySolver::solve() now emits E_USER_DEPRECATED and delegates wholly to GreedySolver. The class is retained only for backward compatibility; new code should use GreedySolver directly.

Install

composer require sugarcraft/candy-layout

Quickstart

use SugarCraft\Layout\{Constraint, Direction, GreedySolver, Region};

$solver = GreedySolver::new();
$region = Region::fromSize(100, 24);

$rects = $solver->solve($region, Direction::Horizontal, [
    Constraint::length(20),      // exactly 20 cells
    Constraint::min(10),         // at least 10, takes more if available
    Constraint::fill(1),         // fills remaining space (weight 1)
    Constraint::percentage(30),  // 30% of total
    Constraint::ratio(1, 3),     // 1/3 of the TOTAL span (reserved like a fixed)
    Constraint::max(50),        // ceiling — greedy but clamped
]);

Solvers

Solver Use case Status
GreedySolver Deterministic, fast, no deps; all constraint types Supported
CassowarySolver Deprecated; solve() delegates to GreedySolver + warns Deprecated

Constraint types

  • Constraint::length(int) — fixed cell count
  • Constraint::min(int) — floor, grows if slack available
  • Constraint::max(int) — ceiling, greedy, clamped
  • Constraint::fill(int $weight = 1) — proportional remainder
  • Constraint::percentage(int 0-100) — % of total
  • Constraint::ratio(int $num, int $denom) — num/denom of the total span, reserved like a fixed

Rounding and overflow. Sizes are whole cells, so floor() loses fractions; the solver hands them back so the regions tile the span: the Fill/Max remainder rides the first Fill (else Max), Min-only slack residue rides the first Min, and a Percentage/Ratio shortfall of at most N−1 cells (N = their count — the most N floored shares can lose) is reclaimed earliest-first; a larger gap is slack the constraints never asked for and stays open. When fixed demand exceeds the span, regions shrink proportionally and the floor loss is returned one cell per shrunk region — silently, because a terminal resized below its fixed panes is a normal runtime state, not an error. withoutOverflowTruncation() keeps full base sizes instead (the layout runs off-grid).

Shared foundations

candy-layout is a foundation package consumed by candy-sprinkles (step-10) and sugar-bits/candy-forms (step-14/15). The LayoutSolver interface — solve() alone; construction lives on the concrete classes — is the only public contract (Expression, a leftover of the retired simplex, is @internal). Use GreedySolver; CassowarySolver is a deprecated shim that delegates to it.

Region here is deliberately distinct from SugarCraft\Buffer\Region in candy-buffer (a leaf-package name collision that keeps candy-layout dependency-free); candy-sprinkles' RegionBridge converts between them.

Dock layout

SugarCraft\Layout\Dock\* is an original SugarCraft primitive (no upstream mirror) for the classic IDE/TUI dock: a primary center pane with optional left and right stacks, each stack holding zero or more pane slots split vertically by rational weights. Everything is immutable — mutators return new instances via a hand-rolled mutate() (the package is dependency-free, so no candy-core Mutable trait).

use SugarCraft\Layout\Dock\{DockLayout, Side};
use SugarCraft\Layout\Region;

$dock = DockLayout::new('chat')                 // center id; empty sides, 1/3 column shares
    ->withSlotAdded(Side::Left, 'files')
    ->withSlotAdded(Side::Left, 'git')
    ->withSlotAdded(Side::Right, 'term')
    ->withStackWeight(Side::Left, 0, 2, 1);     // files takes twice the rows of git

$geometry = $dock->resolve(Region::fromSize(100, 30));
$geometry->regionFor('files');                  // Region(0, 0, 32, 19)
$geometry->regionFor('git');                    // Region(0, 20, 32, 10)
$geometry->regionFor('chat');                   // Region(33, 0, 34, 30)
$geometry->dividerColumns();                    // [['x' => 32, 'side' => Side::Left], ...]

Degradation ladder. resolve() never throws on small frames: a side column is raised to sideMinCols only while the center keeps centerMinCols; if no plan satisfies both floors, the side with fewer slots drops (ties drop Left), then the remaining side, and finally the center takes the whole frame. Degenerate (zero-width/height) frames resolve to empty geometry — the host decides the fallback. The center region rides inside $geometry->regions under its pane id. A side whose share floors to 0 columns (raise refused while the center still meets its floor) intentionally keeps its divider and a width-0 region — the frame stays exactly tiled; hide a side by removing all of its slots.

Shares. Column shares are rational [num, denom] pairs (like sugar-crush's Tui/SplitLayout, referenced but not depended on). withColumnShare() clamps — never throws — so left+right stay ≤ 1/2 of usable width; chat is the primary surface. The ceiling is a mutator policy: fromArray() restores persisted shares verbatim so round-trips are lossless. Stack heights are split in exact integer arithmetic over the weights' common denominator (no float ever decides a boundary row), so a stack weight set whose scaled sum would exceed PHP_INT_MAX is refused with InvalidArgumentException at the mutator or manifest that introduces it — resolve() itself never throws.

Persistence. toArray()/fromArray() ship an exact versioned shape: ['version' => 1, 'center' => 'chat', 'sides' => ['left' => [['id' => …, 'weight' => [n, d]], …], 'right' => […]], 'columnShare' => ['left' => [1, 3], 'right' => [1, 3]], 'minimums' => [24, 20]]. Malformed manifests throw InvalidArgumentException naming the failed key — including a slot id that collides with the center pane's (a pane has exactly one home; withSlotAdded() refuses the same collision). dividerCols has no public mutator and is not persisted.

Credits & inspiration

Originally inspired by the ratatui layout constraint system and Badros & Borning 2001, "The Cassowary Linear Arithmetic Constraint Solving Algorithm"; SugarCraft is developed as a native PHP project.