sugarcraft / 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 auto-updated.
Last update: 2026-05-19 10:49:53 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.
Hover tracking
ZoneHoverTracker wraps a Manager and tracks which zone the cursor
is in across MouseMsg events. It emits ZoneEnterMsg when the cursor
crosses into a zone and ZoneExitMsg when it leaves — ideal for
tooltips, highlights, or data-fetch-on-hover:
use SugarCraft\Zone\Manager; use SugarCraft\Zone\ZoneHoverTracker; use SugarCraft\Zone\Msg\ZoneEnterMsg; use SugarCraft\Zone\Msg\ZoneExitMsg; $tracker = new ZoneHoverTracker($manager); // $manager must already have run scan() to populate zone registry. [$tracker, $msg] = $tracker->update($mouseMsg); if ($msg instanceof ZoneEnterMsg) { // cursor entered $msg->zone } elseif ($msg instanceof ZoneExitMsg) { // cursor left $msg->zone }
Boundary crossing: moving directly from zone A to zone B produces
an exit for A first; call update() again to receive the enter for B.
This two-step pattern lets the Program animate the exit before routing
the enter.
State accessors:
currentZoneId()— id of the hovered zone, or nullcurrentZone()—Zoneobject, or nullwithManager(Manager)— rebind to a different manager (e.g. a prefixed manager in a sub-component)withCurrentZoneId(string)— restore from a serialized state
Drag tracking
DragTracker wraps a Manager and tracks press → move → release
drag sequences within and across zones. It emits a ZoneDragStartMsg on
button-down inside a zone, ZoneDragMoveMsg when the cursor crosses a
zone boundary while dragging, and ZoneDragEndMsg on button release:
use SugarCraft\Zone\Manager; use SugarCraft\Zone\DragTracker; use SugarCraft\Zone\Msg\ZoneDragStartMsg; use SugarCraft\Zone\Msg\ZoneDragMoveMsg; use SugarCraft\Zone\Msg\ZoneDragEndMsg; $tracker = new DragTracker($manager); // $manager must already have run scan() to populate zone registry. [$tracker, $msg] = $tracker->update($mouseMsg); if ($msg instanceof ZoneDragStartMsg) { // drag started in $msg->originZone } elseif ($msg instanceof ZoneDragMoveMsg) { // cursor crossed from $msg->originZone into $msg->currentZone } elseif ($msg instanceof ZoneDragEndMsg) { // drag ended; started at $msg->originZone, released at $msg->currentZone }
Origin vs. current zone: the origin zone is fixed for the entire drag and never changes. The current zone updates whenever the cursor crosses a zone boundary during the drag.
Boundary crossing: moving directly from zone A to zone B while
dragging produces a move message for A first; call update() again to
receive the move for B. This two-step pattern lets the Program animate
the transition before routing the next enter.
State accessors:
originZoneId()/originZone()— zone the drag started from, or nullcurrentZoneId()/currentZone()— zone the cursor is in, or nullwithManager(Manager)— rebind to a different managerwithZoneIds(?string $origin, ?string $current)— restore from a serialized state
Click tracking
ClickCounter wraps a Manager and tracks double/triple click
streaks inside zones. It emits DoubleClickMsg on the second press
and TripleClickMsg on the third press — all within a configurable
click interval (default 500 ms). The streak resets when the interval
expires or when the cursor moves to a different zone:
use SugarCraft\Zone\Manager; use SugarCraft\Zone\ClickCounter; use SugarCraft\Zone\Msg\DoubleClickMsg; use SugarCraft\Zone\Msg\TripleClickMsg; $counter = new ClickCounter($manager); // $manager must already have run scan() to populate zone registry. [$counter, $msg] = $counter->update($mouseMsg); if ($msg instanceof DoubleClickMsg) { // second press in same zone within interval } elseif ($msg instanceof TripleClickMsg) { // third press in same zone within interval }
State accessors:
clickCount()— current streak count (0 when no streak is active)withManager(Manager)— rebind to a different manager$counter->manager/$counter->clickIntervalMs— public constructor params for rebinding / tuning
Motion tracking escape sequences
Manager::setMotionTracking(bool $on) returns the terminal escape
sequence that enables (\x1b[?1003h) or disables (\x1b[?1003l)
SGR mouse mode 1003 (all motion events). Write the returned string to
the TTY to activate motion reporting before processing mouse move
events. This manager does not directly emit — it is a
text-processing component that produces the raw CSI sequence.
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.
API summary
| Class | Method | Description |
|---|---|---|
Manager |
newGlobal() |
Create global manager |
Manager |
newPrefix(?prefix) |
Create prefixed manager for isolation |
Manager |
mark(name, rendered) |
Wrap output with zone marker |
Manager |
scan(output) |
Record positions, strip markers |
Manager |
anyInBounds(mouseMsg) |
Return first zone under the mouse |
Manager |
get(name) |
Get zone by name |
Manager |
setMotionTracking(bool) |
Return CSI 1003 h/l escape sequence |
Zone |
inBounds(mouseMsg) |
Test if mouse is inside zone |
ZoneHoverTracker |
new(manager) |
Track hover state over a manager |
ZoneHoverTracker |
update(mouseMsg) |
Process mouse event, return enter/exit msg |
ZoneHoverTracker |
currentZone() |
Get the hovered Zone or null |
ZoneEnterMsg |
zone |
Zone the cursor just entered |
ZoneExitMsg |
zone |
Zone the cursor just left |
DragTracker |
new(manager) |
Track drag sequences over a manager |
DragTracker |
update(mouseMsg) |
Process mouse event, return drag msg |
DragTracker |
originZone() |
Get the origin Zone or null |
DragTracker |
currentZone() |
Get the current Zone or null |
ZoneDragStartMsg |
originZone / currentZone |
Zone where drag started; zone at current cursor |
ZoneDragMoveMsg |
originZone / currentZone |
Fixed origin zone; zone cursor just crossed into |
ZoneDragEndMsg |
originZone / currentZone |
Zone drag started from; zone at release |
ClickCounter |
new(manager, clickIntervalMs) |
Track double/triple click streaks |
ClickCounter |
update(mouseMsg) |
Process press event, return double/triple msg |
ClickCounter |
clickCount() |
Current streak count (0 = no streak) |
DoubleClickMsg |
zone |
Zone of the second press |
TripleClickMsg |
zone |
Zone of the third press |
Test
cd candy-zone && composer install && vendor/bin/phpunit