Mark/Scan/Get mouse hit-testing plus the bubblezone-style Zones manager
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/sugar-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 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 sugar-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 |
Wrap rendered chunks with named markers, let SugarMouse discover their bounding boxes, then ask zones whether a MouseMsg fell inside them. Markers are sugar-mouse's invisible private-use sentinels (U+E000 / U+E001), and scan() strips them before the frame is printed, so they never affect layout.
use SugarCraft\Mouse\Zones\Manager;
use SugarCraft\Style\Style;
$z = Manager::newGlobal();
$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)) {
// ...
}
$z->mark('btn:ok', $rendered) wraps the chunk in invisible sentinel tags via sugar-mouse Mark::zone().$z->scan($frame) records every marker's 1-based cell bounding box and strips them in one pass. It strips exactly the tags sugar-mouse's Scan::parse() decodes (ids that pass Mark::isValidId()); any other stray sentinel loses only its own 3 bytes and the text after it is kept, so the printed frame matches the measured one.$z->get('btn:ok')?->inBounds($mouseMsg) — clean predicate, no coordinate math.view() call rescans, every MouseMsg routes.ZoneHoverTracker emits ZoneEnterMsg / ZoneExitMsg on cursor boundary crossings.DragTracker tracks press → move → release drags across zones; emits ZoneDragStartMsg / ZoneDragMoveMsg / ZoneDragEndMsg.ClickCounter detects double / triple click streaks; emits DoubleClickMsg / TripleClickMsg within a configurable interval.Manager::setMotionTracking(true) returns \x1b[?1003h to enable SGR mode 1003 (all-motion events).| Class | Method | Description |
|---|---|---|
| Manager | newGlobal() | Create global manager |
| Manager | newPrefix(?prefix) | Create prefixed manager for isolation |
| Manager | mark(id, content) | Wrap output with zone marker |
| Manager | scan(rendered) | Record positions, strip markers (same tag rule as sugar-mouse Scan) |
| Manager | all() / clear(?id) / close() / setEnabled(bool) | Every zone, reset one or all, disable |
| Manager | anyInBounds(mouseMsg) | Return first zone under the mouse |
| Manager | get(name) | Get zone by name |
| 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 |
| Manager | setMotionTracking(bool) | Return CSI 1003 h/l escape sequence |
| Style | new() | Create style builder |
| Style | padding(top, bottom) | Set zone padding |
VHS-recorded GIFs of every example shipped with the library. Regenerated automatically on every push that touches the source.