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 527

Dependents: 5

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-09-22 21:14 UTC

This package is auto-updated.

Last update: 2026-09-25 20:40:01 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 remaining after 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) — fractional proportion

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 is the only public contract. 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.

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.

References

  • Mirrors ratatui/ratatui layout constraint system
  • Based on Badros & Borning 2001 "The Cassowary Linear Arithmetic Constraint Solving Algorithm"