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-zoneuse 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.
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.
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.
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
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
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
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.
- 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.
| 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 |
cd candy-zone && composer install && vendor/bin/phpunit