candycore / candy-zone
PHP port of lrstanley/bubblezone — mouse zone tracker for TUI apps.
Requires
- php: ^8.1
- sugarcraft/candy-core: @dev
Requires (Dev)
- phpunit/phpunit: ^10.5
This package is not auto-updated.
Last update: 2026-05-08 01:46:23 UTC
README
CandyZone
PHP port of lrstanley/bubblezone — mouse-zone tracker for TUI apps. Wrap rendered chunks with named markers, let CandyZone discover their bounding boxes, then ask zones whether a {@see \SugarCraft\Core\Msg\MouseMsg} fell inside them.
composer require sugarcraft/candy-zone
use SugarCraft\Zone\Manager; use SugarCraft\Sprinkles\Style; $z = Manager::newGlobal(); // Build a frame $btnOk = $z->mark('btn:ok', Style::new()->padding(0, 2)->render('OK')); $btnCancel = $z->mark('btn:cancel', Style::new()->padding(0, 2)->render('Cancel')); $frame = $btnOk . ' ' . $btnCancel; // Scan once before printing — Manager records marker positions and strips them. $displayable = $z->scan($frame); echo $displayable; // Later, when a MouseMsg arrives: if ($z->get('btn:ok')?->inBounds($mouseMsg)) { // ... }
Markers are APC escape sequences (ESC _ ... ESC \) — terminals ignore them,
so they don't affect layout. {@see Manager::scan()} computes each zone's
bounding box in 1-based terminal cells, accounting for ANSI styling and
Unicode width.
Manager API
Beyond mark() / scan() / get():
setEnabled(bool)/isEnabled()— flip marker emission off in non-interactive contexts (CI logs, file dumps). When off,mark()returns content verbatim andscan()is identity.Manager::newPrefix(?string)— namespace every id with a prefix so two CandyZone-aware components don't collide on'item-0'. Auto- generates a monotonic prefix when called bare.prefix()— read-only accessor for the prefix string.get($id)/all()/clear(?$id)— single-zone lookup, every zone, and targeted-or-wipe-all clear.close()— drop every zone + flip the manager into pass-through mode. Idempotent. PHP synchronous-scan has no worker to stop, so this is purely a state cleanup.
Package-level facade
SugarCraft\Zone\Zones mirrors bubblezone's package-level surface
(bubblezone.DefaultManager + Mark / Scan / Clear / Get /
Close / SetEnabled / Enabled / NewPrefix / AnyInBounds*)
as static methods over a single shared Manager:
use SugarCraft\Zone\Zones; $marked = Zones::mark('header', $header); $cleaned = Zones::scan($marked); if (Zones::get('header')?->inBounds($mouse)) { /* … */ }
Zones::setDefaultManager(?Manager) swaps in a custom manager —
useful in tests (Zones::setDefaultManager(null) flushes state) or
when you want every package-level call routed through a prefixed
manager.
Tips
- Each id should be unique within a
Manager. UseManager::newPrefix()per UI sub-tree so two child models don't shadow each other's ids. - Run
scan()once on the full root frame, not per sub-tree — nested zone bounds depend on the outer layout. lipgloss.Width()(CandySprinkles) and CandyZone interact cleanly:scan()strips markers before measurement.Zone::isZero()distinguishes "never rendered" from "rendered but empty bounding box".- Organic shapes (ASCII art) report a rectangular bounding box — the marker pair only carries 4 corners' worth of information.
- The PHP port has a synchronous
scan()(no background worker), soclose()is purely a state reset / disable rather than a thread join.
Test
cd candy-zone && composer install && vendor/bin/phpunit