Self-contained Mark/Scan/Get mouse hit-testing
Own your own Scanner instance โ no global Manager wiring required. Wrap rendered chunks with invisible Unicode-sentinel markers, scan to discover bounding boxes, then hit-test mouse events. ZoneClickTracker deduplicates Press/Release pairs so each logical click emits once.
composer require sugarcraft/candy-mouse
use SugarCraft\Mouse\Mark;
use SugarCraft\Mouse\Scanner;
use SugarCraft\Mouse\ZoneClickTracker;
use SugarCraft\Mouse\MouseEvent;
use SugarCraft\Mouse\MouseAction;
// 1. Wrap interactive content with invisible zone markers.
$rendered = Mark::zone('btn-ok', ' OK ')
. Mark::zone('btn-cancel', 'Cancel');
// 2. Scan after rendering to populate the zone registry.
$scanner = Scanner::new()->scan($rendered);
// 3. Reverse-lookup on mouse events.
$zone = $scanner->hit($mouseX, $mouseY); // ?Zone
// 4. Deduplicate clicks so each press+release pair emits one click.
$tracker = new ZoneClickTracker();
$result = $tracker->track(new MouseEvent(5, 1, 0, MouseAction::Release));
if ($result !== null) {
echo "Clicked zone: " . $result->zone->id;
}
Mark::zone($id, $content) wraps content in invisible U+E000/U+E001 codepoints safe from ANSI SGR clobbering. Mark::new() / Mark::disabled() build an instance for wrap(); Mark::isValidId() is the id predicate encoder and decoder share.Scan::parse() decodes exactly what Mark encodes and every tag byte is zero-width; a lone sentinel or malformed id skips only its 3 sentinel bytes, so a stray marker can never shift the zones after it. Orphan closes/unclosed opens are dropped, a zone that paints no cell yields no zone, and a duplicate id throws.\n and \r\n start a new row; a lone \r returns to column 1 like a terminal. A zone that painted nothing before a leading newline or \r starts where it first paints, instead of upstream bubblezone's inverted or over-wide boxes.extract() copies the selected text out of a frame.Scanner::new()->scan($rendered) parses sentinels; hit(col, row) reverse-lookup by coordinates; get(id) lookup by zone id.Zone readonly value object with 1-based start/end col/row coordinates matching terminal cell space.ZoneClickTracker state machine per button โ suppresses drag spurious presses and mismatched Press+Release on different zones.Width::string() from candy-core so wide East-Asian characters account for 2 cell columns each.| Class | Method | Description |
|---|---|---|
| Mark | ::zone(id, content) | Wrap content with invisible sentinel markers |
| Mark | ::new() / ::disabled() / ->withEnabled(bool) / ->wrap(id, content) | Instance form; a disabled Mark returns content unwrapped |
| Mark | ::isValidId(id) | Whether an id is one Mark encodes and Scan decodes (โค MAX_ID_BYTES, no spaces/escapes/non-ASCII) |
| Scan | ->parse(rendered, ?width) | Stateless, reentrant decoder behind Scanner; returns id => Zone |
| Scanner | ::new() | Create empty scanner |
| Scanner | ->scan(rendered) | Parse sentinels, build zone registry |
| Scanner | ->get(id) | Get Zone by id |
| Scanner | ->hit(col, row) | Reverse-lookup Zone at coordinates |
| Scanner | ->all() / ->prefixed(prefix) / ->clear() | Every zone, zones whose id starts with a prefix, reset |
| Zone | $id | Zone identifier |
| Zone | $startCol / $startRow / $endCol / $endRow | 1-based bounding box |
| MouseEvent | ::press(x, y, button) | Factory for press event |
| MouseEvent | ::release(x, y, button) | Factory for release event |
| MouseEvent | ::drag(x, y, button) / ::scroll(x, y, button) | Factories for drag and scroll events |
| MouseAction | Press / Release / Drag / Scroll | Mouse action enum |
| ZoneClickTracker | ->track(MouseEvent) | Feed event, receive ClickResult or null |
| ZoneClickTracker | ->setPressZone(Zone) | Set zone for pending press (call after track(Press)) |
| ClickResult | $zone / $button | Completed click result |
| Selection | ::new(rowFrom, rowTo, colFrom, colTo) / ->begin(row, col) / ->dragTo(row, col) / ->range() / ->clear() | Text-selection state machine bounded to a selectable region |
| SelectionRange | ::new(rowA, colA, rowB, colB) / ->spanOnRow(row) / ->cellContains(row, col) / ->cells() / ->extract(lines) | Normalised, inclusive selected range and its text |
VHS-recorded GIFs of every example shipped with the library. Regenerated automatically on every push that touches the source.