← All 40 libraries

SugarMouse

🎯 SugarMouse

Mark/Scan/Get mouse hit-testing plus the bubblezone-style Zones manager

inspired by lrstanley/bubblezone mouse hit-testing zones zone-click-track

SugarMouse code coverage

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.

Install

composer require sugarcraft/sugar-mouse

Quickstart

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;
}

What's in the box

Mark sentinel wrapMark::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.
Strict scan grammarScan::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.
Row accounting\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.
Selection / SelectionRangePress → drag → release text-selection state machine over a selectable region, plus a normalised inclusive stream range whose extract() copies the selected text out of a frame.
Scanner hit-testingScanner::new()->scan($rendered) parses sentinels; hit(col, row) reverse-lookup by coordinates; get(id) lookup by zone id.
Zone bounding boxZone readonly value object with 1-based start/end col/row coordinates matching terminal cell space.
ZoneClickTracker dedupZoneClickTracker state machine per button — suppresses drag spurious presses and mismatched Press+Release on different zones.
No external ManagerScanner is owned by each consumer — no global Manager wiring, no shared state across components.
ANSI-safe sentinelsPrivate-use Unicode codepoints U+E000/U+E001 never collide with CSI or OSC ANSI sequences.
CJK column accountingScanning uses Width::string() from sugar-core so wide East-Asian characters account for 2 cell columns each.
ZoneClickTracker improves on bubblezonePress+Release dedup per zone per button — bubblezone never shipped this fix.

Source & demos

Try the quickstart →

API

ClassMethodDescription
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$idZone identifier
Zone$startCol / $startRow / $endCol / $endRow1-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
MouseActionPress / Release / Drag / ScrollMouse 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 / $buttonCompleted 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

Zones (bubblezone Manager layer)

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)) {
    // ...
}
Marker injection$z->mark('btn:ok', $rendered) wraps the chunk in invisible sentinel tags via sugar-mouse Mark::zone().
Single-pass scan$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.
Bounds query$z->get('btn:ok')?->inBounds($mouseMsg) — clean predicate, no coordinate math.
ANSI-awareWidth calculation honours SGR + Unicode grapheme widths; emojis, CJK, combining marks all account correctly.
Manager isolationManager::newGlobal() for app-wide; or per-component managers for nested mouse handling.
Plays with SugarCraftDrop into your Model — every view() call rescans, every MouseMsg routes.
Hover trackingZoneHoverTracker emits ZoneEnterMsg / ZoneExitMsg on cursor boundary crossings.
Drag trackingDragTracker tracks press → move → release drags across zones; emits ZoneDragStartMsg / ZoneDragMoveMsg / ZoneDragEndMsg.
Click trackingClickCounter detects double / triple click streaks; emits DoubleClickMsg / TripleClickMsg within a configurable interval.
Motion tracking CSIManager::setMotionTracking(true) returns \x1b[?1003h to enable SGR mode 1003 (all-motion events).

Zones API

ClassMethodDescription
ManagernewGlobal()Create global manager
ManagernewPrefix(?prefix)Create prefixed manager for isolation
Managermark(id, content)Wrap output with zone marker
Managerscan(rendered)Record positions, strip markers (same tag rule as sugar-mouse Scan)
Managerall() / clear(?id) / close() / setEnabled(bool)Every zone, reset one or all, disable
ManageranyInBounds(mouseMsg)Return first zone under the mouse
Managerget(name)Get zone by name
ZoneinBounds(mouseMsg)Test if mouse is inside zone
ZoneHoverTrackernew(manager)Track hover state over a manager
ZoneHoverTrackerupdate(mouseMsg)Process mouse event, return enter/exit msg
ZoneHoverTrackercurrentZone()Get the hovered Zone or null
ZoneEnterMsgzoneZone the cursor just entered
ZoneExitMsgzoneZone the cursor just left
DragTrackernew(manager)Track drag sequences over a manager
DragTrackerupdate(mouseMsg)Process mouse event, return drag msg
DragTrackeroriginZone()Get the origin Zone or null
DragTrackercurrentZone()Get the current Zone or null
ZoneDragStartMsgoriginZone / currentZoneZone where drag started; zone at current cursor
ZoneDragMoveMsgoriginZone / currentZoneFixed origin zone; zone cursor just crossed into
ZoneDragEndMsgoriginZone / currentZoneZone drag started from; zone at release
ClickCounternew(manager, clickIntervalMs)Track double/triple click streaks
ClickCounterupdate(mouseMsg)Process press event, return double/triple msg
ClickCounterclickCount()Current streak count (0 = no streak)
DoubleClickMsgzoneZone of the second press
TripleClickMsgzoneZone of the third press
ManagersetMotionTracking(bool)Return CSI 1003 h/l escape sequence
Stylenew()Create style builder
Stylepadding(top, bottom)Set zone padding

Demos.

VHS-recorded GIFs of every example shipped with the library. Regenerated automatically on every push that touches the source.

Two clickable buttons

Two clickable buttons

Zone bounding boxes around rendered buttons via Manager::mark()/scan().
List with zones

List with zones

Wrapping every list row in a Zone for click targeting.
Nested components

Nested components

Zones composed across nested rendered components.