โ† All 41 libraries

CandyMouse

๐ŸŽฏ CandyMouse

Self-contained Mark/Scan/Get mouse hit-testing

port of lrstanley/bubblezone mouse hit-testing zone-click-track

CandyMouse 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/candy-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 upstream bubblezone's 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 candy-core so wide East-Asian characters account for 2 cell columns each.
ZoneClickTracker improves on bubblezone issue #10Press+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

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

Mouse-zone bounding boxes around rendered buttons.