sugarcraft/candy-zone

PHP port of lrstanley/bubblezone — mouse zone tracker for TUI apps.

Maintainers

Package info

github.com/sugarcraft/candy-zone

Documentation

pkg:composer/sugarcraft/candy-zone

Statistics

Installs: 693

Dependents: 4

Suggesters: 0

Stars: 1

Open Issues: 0

v0.2.0 2026-05-07 02:01 UTC

This package is auto-updated.

Last update: 2026-05-19 10:49:53 UTC


README

candy-zone

CandyZone

CI codecov Packagist Version License PHP

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 and scan() 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 null
  • currentZone()Zone object, or null
  • withManager(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 null
  • currentZoneId() / currentZone() — zone the cursor is in, or null
  • withManager(Manager) — rebind to a different manager
  • withZoneIds(?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. Use Manager::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), so close() 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