Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187import { Box } from "./geo.mjs";import { radians, p2 } from "./num.mjs";const { round } = Math;
const spinnerDelayMax = 8; // Skip a few frames of the spinner.let spinnerDelay = 0;
let TYPEFACE_UI; // A default `Typeface` instance carried over from `disk`.
// Strip \color\ codes from text to get visible length// Format: \r,g,b\text\r,g,b\ -> textfunction stripColorCodes(text) { if (!text || typeof text !== 'string') return text; // Remove all \...\ patterns (color codes) return text.replace(/\\[^\\]*\\/g, '');}
function spinnerReset() { spinnerDelay = 0;}
function defaultRolloverScheme($, small = false) { const mode = $?.dark ? $?.theme?.dark : $?.theme?.light; const bg = mode?.buttonHover || mode?.buttonBg || ($?.dark ? [24, 32, 42] : [220, 245, 255]); const outline = mode?.buttonHoverOutline || mode?.buttonOutline || [255, 220, 0]; const text = mode?.buttonHoverText || mode?.buttonText || ($?.dark ? 255 : 0); return small ? [bg, outline, text, bg] : [bg, outline, text, 255];}
// Loading icon.function spinner(ctx, timePassed) { if (spinnerDelay < spinnerDelayMax) { spinnerDelay += 1; return; } const gap = 20, s = 8; ctx.save(); const scaledWidth = ctx.canvas.width / window.devicePixelRatio; ctx.translate(scaledWidth - (s + gap), s + gap); ctx.rotate(radians(timePassed % 360) * 1); ctx.beginPath(); // \ of the X ctx.moveTo(-s, -s); // top left ctx.lineTo(s, s); // bottom right // / of the X //ctx.moveTo(-s, s); // bottom left //ctx.lineTo(s, -s); // top right ctx.strokeStyle = "rgb(255, 255, 0)"; ctx.lineWidth = 4; ctx.lineCap = "round"; ctx.stroke(); ctx.restore();}
// Paused icon.function cached(ctx) { const gap = 4, s = 20;
ctx.save(); ctx.translate(round(gap / 2) + 6, round(gap / 2) + 4);
// Bottom Left option. ctx.translate( 0, round(ctx.canvas.height / window.devicePixelRatio - s * 2 + 2), );
ctx.beginPath();
ctx.moveTo(gap, gap); // left ctx.lineTo(gap, s); ctx.moveTo(gap * 3.5, gap); // right ctx.lineTo(gap * 3.5, s);
ctx.strokeStyle = "rgb(0, 255, 255)"; ctx.lineWidth = 4; ctx.lineCap = "round"; ctx.stroke();
ctx.restore();}
// Global tracking for drag-between-buttons behaviorconst activeButtons = new Set(); // Track which buttons are currently activeconst hoveredButtons = new Set(); // Shared cursor state across all ui.Button instances.
function updateHoverCursor(event) { for (const button of hoveredButtons) if (!button.over) hoveredButtons.delete(button); event?.cursor?.(hoveredButtons.size ? "active" : "precise");}
// Debug: Track recent rollout events to detect retap-after-rollout issuesconst recentRollouts = new Map(); // buttonId -> timestampconst ROLLOUT_MEMORY_TIME = 2000; // Remember rollouts for 2 seconds
function trackRollout(buttonId) { recentRollouts.set(buttonId, performance.now()); // Clean up old entries for (const [id, timestamp] of recentRollouts.entries()) { if (performance.now() - timestamp > ROLLOUT_MEMORY_TIME) { recentRollouts.delete(id); } }}
function wasRecentlyRolledOut(buttonId) { const rolloutTime = recentRollouts.get(buttonId); return rolloutTime && (performance.now() - rolloutTime < ROLLOUT_MEMORY_TIME);}function addActiveButton(btn, reason = "unknown", netLog = null) { activeButtons.add(btn); const logData = { buttonId: btn.id || "unnamed", reason, totalActive: activeButtons.size, down: btn.down, downPointer: btn.downPointer }; // console.log("➕ Button added to activeButtons:", logData); // Send to remote debugging if available if (netLog) { netLog.info("🔘 Button activated:", logData); } // Emergency cleanup if we have too many active buttons if (activeButtons.size > 5) { if (netLog) netLog.warn("🚨 Emergency cleanup: too many active buttons:", activeButtons.size); emergencyButtonCleanup("too many active buttons detected: " + activeButtons.size, netLog); }}
function removeActiveButton(btn, reason = "unknown", netLog = null) { const wasPresent = activeButtons.has(btn); activeButtons.delete(btn); if (wasPresent) { const logData = { buttonId: btn.id || "unnamed", reason, totalActive: activeButtons.size, down: btn.down, downPointer: btn.downPointer }; // console.log("➖ Button removed from activeButtons:", logData); if (netLog) { netLog.info("🔘 Button deactivated:", logData); } } else { const logData = { buttonId: btn.id || "unnamed", reason }; console.warn("⚠️ Tried to remove button not in activeButtons:", logData); if (netLog) { netLog.warn("⚠️ Button removal attempted but not found:", logData); } }}
// Debug: Track button state inconsistencieslet debugButtonStateChecks = 0;function debugActiveButtonsState() { debugButtonStateChecks++; if (debugButtonStateChecks % 60 === 0) { // Log every 60 calls (roughly once per second at 60fps) const activeButtonsArray = Array.from(activeButtons); const stuckButtons = activeButtonsArray.filter(btn => !btn.down); if (stuckButtons.length > 0) { console.warn("🐛 INCONSISTENT BUTTON STATE DETECTED:", { totalActive: activeButtonsArray.length, stuckButtons: stuckButtons.map(btn => ({ id: btn.id || "unnamed", down: btn.down, over: btn.over, downPointer: btn.downPointer })), allButtons: activeButtonsArray.map(btn => ({ id: btn.id || "unnamed", down: btn.down, over: btn.over, downPointer: btn.downPointer })) }); // Clean up stuck buttons - MUST call cancel callback to stop sounds stuckButtons.forEach(btn => { console.log("🧹 Cleaning up stuck button:", btn.id || "unnamed"); // Call cancel callback to properly clean up (e.g., stop sounds in notepat) btn.actions?.cancel?.(btn); btn.over = false; btn.downPointer = undefined; activeButtons.delete(btn); }); } }}
// Manual button state reset function (for emergency use)function resetAllButtons(netLog = null) { console.log("🔄 MANUAL BUTTON RESET - clearing all active buttons"); if (netLog) { netLog.info("🔄 Manual button reset initiated"); } const buttonsToReset = Array.from(activeButtons); buttonsToReset.forEach(btn => { console.log("🔄 Resetting button:", btn.id || "unnamed"); btn.down = false; btn.over = false; btn.downPointer = undefined; removeActiveButton(btn, "manual reset", netLog); });}
// Emergency cleanup function for when we detect stuck buttonsfunction emergencyButtonCleanup(reason = "unknown", netLog = null) { console.warn("🚨 EMERGENCY BUTTON CLEANUP:", reason); if (netLog) { netLog.error("🚨 Emergency button cleanup triggered:", { reason }); } const activeButtonsArray = Array.from(activeButtons); // Log the state before cleanup const buttonStates = activeButtonsArray.map(btn => ({ id: btn.id || "unnamed", down: btn.down, over: btn.over, downPointer: btn.downPointer })); console.log("📊 Active buttons before cleanup:", buttonStates); if (netLog) { netLog.info("📊 Emergency cleanup button states:", buttonStates); } // Reset all buttons - MUST call cancel callback to stop sounds activeButtonsArray.forEach(btn => { // Call cancel callback to properly clean up (e.g., stop sounds in notepat) btn.actions?.cancel?.(btn); btn.down = false; btn.over = false; btn.downPointer = undefined; activeButtons.delete(btn); }); console.log("🧹 Emergency cleanup complete - all buttons reset"); if (netLog) { netLog.info("🧹 Emergency cleanup completed:", { buttonsReset: activeButtonsArray.length }); }}
// Expose functions globally for debuggingif (typeof window !== 'undefined') { window.resetAllButtons = resetAllButtons; window.emergencyButtonCleanup = emergencyButtonCleanup;}
// An interactive button model.class Button { btn; box; down = false; disabled = false; icon; dom = false; over = false; // Keep track of rollover state. multitouch = true; // Toggle to false to make a single touch button2. downPointer; // Keep track of what original pointer downed the button. actions; // A held list of callbacks for virtually triggering events. noEdgeDetection = false; // Set to true to opt out of global edge detection cancellation noRolloverActivation = false; // Set to true to prevent activation via rollover from other buttons stickyScrubbing = false; // Set to true to prevent rollover activation when scrubbing from another button offScreenScrubbing = false; // Set to true to allow scrubbing to continue off-screen but still allow horizontal rollover
get up() { return !this.down; }
set up(value) { this.down = !value; }
// (x, y, width, height) or Box constructor() { if (arguments.length === 1) { // Assume we are passing in a box {x,y,w,h} object. this.box = Box.from(arguments[0]); } else this.box = new Box(...arguments); // Otherwise: x, y, w, h for a box. this.btn = this; }
publishToDom({ send }, label, message) { // The only use case for this right now is the Clipboard API. 23.06.16.15.40 // 📓 Where `message` is used as text to be copied. send({ type: "button:hitbox:add", content: { box: this.box, label, message }, }); }
removeFromDom({ send }, label) { send({ type: "button:hitbox:remove", content: label }); }
// For using in a piece's `act` function. Contains callbacks for // events that take place inside the button. // Usage: act(e, () => {}); // For 'push' callback only. // act(e, {push: () => {}, down: () => {}, cancel: () => {}, draw() => {}}); // You can optionally pass in an array of `pens` {x, y} for multi-touch support. act(e, callbacks = () => {}, pens = []) { const btn = this.btn; if (btn.disabled) { if (btn.over) { btn.over = false; hoveredButtons.delete(btn); updateHoverCursor(e); } return; }
// Extract net.log for remote debugging const netLog = e.net?.log;
// Debug: Check for inconsistent button states debugActiveButtonsState();
// If only a single function is sent, then assume it's a button push callback. if (typeof callbacks === "function") callbacks = { push: callbacks }; btn.actions = callbacks;
// Handle global edge detection cancellation if (e.is("ui:cancel-interactions") && !btn.noEdgeDetection) { // Only cancel if the button is still down (not already cancelled by other means) // For offScreenScrubbing buttons, don't cancel if cursor is horizontally within bounds // For stickyScrubbing buttons, always cancel when mouse leaves screen to prevent sounds getting stuck const shouldCancel = btn.down && (!btn.offScreenScrubbing || (btn.offScreenScrubbing && (e.x < btn.box.x || e.x >= btn.box.x + btn.box.w))); if (shouldCancel) { console.log("🚫 Global edge detection - button cancelled:", { buttonId: btn.id || "unnamed", timestamp: performance.now(), reason: "ui:cancel-interactions event", wasDown: btn.down, offScreenScrubbing: btn.offScreenScrubbing, cursorX: e.x, buttonBounds: { x: btn.box.x, w: btn.box.w } }); btn.down = false; btn.over = false; hoveredButtons.delete(btn); updateHoverCursor(e); btn.downPointer = undefined; removeActiveButton(btn, "global edge detection", netLog); // Remove from activeButtons Set callbacks.cancel?.(btn); } // ADDITIONAL SAFETY: Even if shouldCancel is false, if this button is in activeButtons // but shouldn't be, clean it up to prevent corruption if (!shouldCancel && activeButtons.has(btn) && !btn.down) { console.warn("🧹 Edge detection cleanup - removing orphaned button:", { buttonId: btn.id || "unnamed", reason: "button in activeButtons but not down during edge detection" }); removeActiveButton(btn, "edge detection orphan cleanup", netLog); btn.over = false; btn.downPointer = undefined; } return; }
const t = this.multitouch ? "any" : "1";
// 🏷️ Block piece button interactions when HUD button is active // This prevents buttons underneath the prompt label from receiving touch events if (e.hudButtonActive && !btn.noEdgeDetection) { // This is a piece button (has edge detection), and the HUD button is active // Don't process any touch/down events for this button return; }
// 1. Down: Enable the button if we touched over it. (Repeatable) if (e.is(`touch:${t}`) && btn.box.contains(e) && !btn.down) { const wasRecentRollout = wasRecentlyRolledOut(btn.id || "unnamed"); // buttonId: btn.id || "unnamed", // timestamp: performance.now(), // pointer: e.pointer, // eventType: "touch", // wasInActiveButtons: activeButtons.has(btn), // currentDownPointer: btn.downPointer, // over: btn.over, // wasRecentlyRolledOut: wasRecentRollout, // suspiciousRetap: wasRecentRollout && activeButtons.has(btn) // }); // SAFETY: If this button was somehow still in activeButtons but not down, clean it up first if (activeButtons.has(btn) && !btn.down) { console.warn("⚠️ CORRUPTED STATE DETECTED - button in activeButtons but not down:", { buttonId: btn.id || "unnamed", reason: "cleaning up before new touch", wasRecentlyRolledOut: wasRecentRollout }); removeActiveButton(btn, "corrupted state cleanup before touch", netLog); btn.over = false; btn.downPointer = undefined; } // EXTRA SAFETY: If this is a retap after recent rollout, ensure clean state if (wasRecentRollout) { console.log("🔄 Retap after recent rollout - ensuring clean state:", { buttonId: btn.id || "unnamed" }); btn.over = false; btn.downPointer = undefined; if (activeButtons.has(btn)) { removeActiveButton(btn, "retap after rollout cleanup", netLog); } } const downed = callbacks.down?.(btn); btn.down = downed || downed === undefined ? true : false; // console.log("🔍 downPointer state before assignment:", { // buttonId: btn.id || "unnamed", // eventPointer: e.pointer, // currentDownPointer: btn.downPointer, // btnDown: btn.down, // willSetDownPointer: btn.down && btn.downPointer === undefined // }); if (btn.down && btn.downPointer === undefined) { // console.log("🔧 Setting downPointer on initial touch:", { // buttonId: btn.id || "unnamed", // eventPointer: e.pointer, // beforeDownPointer: btn.downPointer, // afterDownPointer: e.pointer || 0 // }); btn.downPointer = e.pointer || 0; } else if (btn.down && btn.downPointer !== undefined && btn.downPointer !== e.pointer) { // In multitouch mode, allow downPointer to be transferred to the new pointer // This prevents buttons from getting stuck when multiple pointers interact with them if (this.multitouch) { console.log("🔄 Transferring downPointer ownership:", { buttonId: btn.id || "unnamed", fromPointer: btn.downPointer, toPointer: e.pointer, reason: "multitouch pointer transfer" }); btn.downPointer = e.pointer || 0; } else { console.warn("⚠️ Button already has downPointer - NOT updating:", { buttonId: btn.id || "unnamed", eventPointer: e.pointer, existingDownPointer: btn.downPointer, reason: "downPointer was not cleaned up properly" }); } } btn.over = btn.down; // Add to active buttons set if (btn.down) { addActiveButton(btn, "touch down", netLog); } }
// 3. Push: Trigger the button if we push it. // Only process lift events from the controlling pointer const isControllingLiftPointer = !this.multitouch || btn.downPointer === e.pointer || btn.downPointer === undefined; if (e.is(`lift:${t}`) && btn.down && isControllingLiftPointer) { // buttonId: btn.id || "unnamed", // timestamp: performance.now(), // pointer: e.pointer, // downPointer: btn.downPointer, // eventType: "lift" // }); function up() { const up = callbacks.up?.(btn); if (up === false) { btn.down = true; btn.over = true; console.log("🔄 Button kept down by callback:", { buttonId: btn.id || "unnamed", reason: "up() returned false" }); } else { btn.down = false; btn.over = false; btn.downPointer = undefined; // Only remove if still in activeButtons (avoid double removal) if (activeButtons.has(btn)) { removeActiveButton(btn, "button up callback", netLog); } // console.log("🏁 Button fully released:", { // buttonId: btn.id || "unnamed", // activeButtonsCount: activeButtons.size // }); } }
// In multitouch mode, only respond to lift events from the controlling pointer const isControllingPointer = !this.multitouch || btn.downPointer === e.pointer || btn.downPointer === undefined;
// console.log("🔍 Pointer control analysis:", { // buttonId: btn.id || "unnamed", // multitouch: this.multitouch, // eventPointer: e.pointer, // buttonDownPointer: btn.downPointer, // isControllingPointer, // pensCount: pens?.length || 0 // });
// Check if this is a valid button push or should be cancelled const isValidPush = isControllingPointer && btn.box.contains(e); // The lift event position (e.x, e.y) contains the position where the pointer was lifted. // If it's inside the button box, it's a valid push regardless of where other pointers are.
// console.log("📤 Button lift condition check:", { // buttonId: btn.id || "unnamed", // isControllingPointer, // isValidPush, // multitouch: this.multitouch, // pensLength: pens?.length || 0, // containsE: btn.box.contains(e), // containsNonePens: btn.box.containsNone(pens || []), // pointerMatch: btn.downPointer === e.pointer // });
if (isValidPush) { // console.log("🔴 Button lift - IMMEDIATE (valid push):", { // buttonId: btn.id || "unnamed", // timestamp: performance.now(), // pointer: e.pointer // }); btn.down = false; btn.over = false; // Only remove if still in activeButtons (avoid double removal from force cleanup) if (activeButtons.has(btn)) { removeActiveButton(btn, "valid push", netLog); } callbacks.push?.(btn); up(); } else if (isControllingPointer && !btn.box.contains(e)) { // Lift event happened outside the button - cancel the button btn.down = false; btn.over = false; // Only remove if still in activeButtons (avoid double removal from force cleanup) if (activeButtons.has(btn)) { removeActiveButton(btn, "cancelled - lift outside", netLog); } callbacks.cancel?.(btn); up(); //console.log("Button up (cancel):", btn, pens); } else { // Check for stuck button scenario: button is down but controlling pointer doesn't match // Force cleanup if pointer mismatch - phantom pointers need to be cleaned up aggressively const isStuckButton = btn.down && !isControllingPointer; if (isStuckButton) { // Force cleanup the stuck button btn.down = false; btn.over = false; btn.downPointer = undefined; removeActiveButton(btn, "force cleanup - stuck button", netLog); callbacks.cancel?.(btn); } else { // This button is down but neither valid push nor cancel condition was met console.log("⚠️ Button down but lift ignored:", { buttonId: btn.id || "unnamed", timestamp: performance.now(), pointer: e.pointer, downPointer: btn.downPointer, isControllingPointer, containsE: btn.box.contains(e), pensLength: pens?.length || 0, reason: "lift event ignored - waiting for controlling pointer" }); } } }
// Note: Each piece may use the below to implement custom rolling behavior, // which often differs among use cases such as pianos or general GUIs.
// 4. Rollover: Run a rollover event if dragged on. // Hover handling on pointer move: trigger hover/leave callbacks immediately. if (e.is && e.is("move")) { try { const containsNow = btn.box.contains(e); if (containsNow && !btn.over && !btn.down) { // Enter hover (passive - no button pressed) callbacks.hover?.(btn); btn.over = true; hoveredButtons.add(btn); updateHoverCursor(e); } else if (!containsNow && btn.over && !btn.down) { // Leave hover (passive - no button pressed) callbacks.leave?.(btn); btn.over = false; hoveredButtons.delete(btn); updateHoverCursor(e); } } catch (err) { // ignore } updateHoverCursor(e); }
// if (e.is("draw:any") && !this.down && this.box.contains(e)) {
// For offScreenScrubbing, check if cursor is horizontally within bounds const horizontallyWithin = btn.offScreenScrubbing && e.x >= btn.box.x && e.x < btn.box.x + btn.box.w;
if ( e.is(`draw:${t}`) && !btn.over && (btn.box.contains(e) || horizontallyWithin) ) { // Check if we're dragging from another button // Don't just check activeButtons.size since buttons can be removed during rollout but still logically down // Instead, check if ANY button is down (indicating an active drag operation) const anyButtonDown = Array.from(activeButtons).some(activeBtn => activeBtn.down) || // Also check for buttons that might be down but not in activeButtons due to rollout (e.drag && (e.drag.x !== e.x || e.drag.y !== e.y)); // Has actual drag movement const isDraggingFromOtherButton = anyButtonDown && !btn.down;
// Check if any active button has sticky scrubbing enabled const hasStickyButton = Array.from(activeButtons).some( (activeBtn) => activeBtn.stickyScrubbing, );
// Only prevent rollover activation if: // 1. This button doesn't allow rollover activation AND we're dragging from another button, OR // 2. We're dragging from another button AND that button has sticky scrubbing enabled const shouldPreventRollover = (btn.noRolloverActivation && isDraggingFromOtherButton) || (isDraggingFromOtherButton && hasStickyButton && btn.noRolloverActivation);
if ( isDraggingFromOtherButton && !shouldPreventRollover && (btn.box.contains(e) || horizontallyWithin) ) { // Only allow rollover activation if conditions are met // In multitouch mode, only deactivate buttons that don't have their own active pointer for (const otherBtn of activeButtons) { if (otherBtn !== btn && !otherBtn.stickyScrubbing) { // In multitouch mode, only deactivate if this is the same pointer or if the other button doesn't have a specific pointer if (!this.multitouch || otherBtn.downPointer === e.pointer || otherBtn.downPointer === undefined) { otherBtn.down = false; otherBtn.over = false; otherBtn.actions?.up?.(otherBtn); removeActiveButton(otherBtn, "rollover deactivation", netLog); } } }
// Only activate this button if no sticky button conflicts if (!hasStickyButton || btn.stickyScrubbing) { btn.down = true; btn.downPointer = e.pointer || 0; addActiveButton(btn, "rollover activation", netLog); callbacks.down?.(btn); } }
// Always allow rollover callbacks for visual feedback if ( !shouldPreventRollover || horizontallyWithin ) { if (callbacks.rollover) { callbacks.rollover(btn); } else { callbacks.over?.(btn); } btn.over = true; } }
// Scrub condition: allow if drag is within bounds OR if button is active from rollover // For sticky scrubbing, allow scrubbing even when dragging outside bounds // For offScreenScrubbing, allow scrubbing when horizontally within bounds even if vertically off-screen const containsDrag = btn.box.contains(e.drag); const inActiveButtons = activeButtons.has(btn); const horizontallyWithinForOffScreen = btn.offScreenScrubbing && e.drag && e.drag.x >= btn.box.x && e.drag.x < btn.box.x + btn.box.w; const allowScrub = containsDrag || (inActiveButtons && !btn.stickyScrubbing) || (btn.stickyScrubbing && btn.down) || (btn.offScreenScrubbing && btn.down && horizontallyWithinForOffScreen);
if (e.is(`draw:${t}`) && btn.down && allowScrub) { // In multitouch mode, only allow scrubbing from the controlling pointer unless special cases apply const isControllingPointer = !this.multitouch || btn.downPointer === e.pointer || btn.downPointer === undefined; // Allow scrubbing if: // 1. This is the controlling pointer (main case), OR // 2. Button is active from rollover AND it's the same pointer that's dragging (preserves rollover but prevents cross-pointer contamination), OR // 3. Sticky scrubbing is enabled, OR // 4. OffScreen scrubbing with horizontal bounds check if ( isControllingPointer || (inActiveButtons && !btn.stickyScrubbing && btn.downPointer === e.pointer) || // Rollover scrubbing with pointer match (btn.stickyScrubbing && btn.down) || (btn.offScreenScrubbing && btn.down && horizontallyWithinForOffScreen) ) { callbacks.scrub?.(btn); } }
// 5. Rollout: Run a rollout event if dragged off. if ( e.is(`draw:${t}`) && btn.over && !btn.box.contains(e) && btn.box.containsNone(pens) ) { // For stickyScrubbing buttons, never trigger rollout - they stay active until pointer is released // For offScreenScrubbing buttons, only trigger rollout if we're also outside horizontal bounds const shouldRollout = !btn.stickyScrubbing && (!btn.offScreenScrubbing || (btn.offScreenScrubbing && (e.x < btn.box.x || e.x >= btn.box.x + btn.box.w)));
// In multitouch mode, only trigger rollout if this is the pointer that controls this button const isControllingPointer = !this.multitouch || btn.downPointer === e.pointer || btn.downPointer === undefined;
if (shouldRollout && isControllingPointer) { // Track this rollout for detecting retap issues trackRollout(btn.id || "unnamed"); // Call the rollout/out callbacks first if (callbacks.rollout) { callbacks.rollout(btn); } else { callbacks.out?.(btn); } // Ensure button state is properly cleaned up after rollout btn.over = false; // Always remove from activeButtons on rollout, regardless of down state // This ensures we don't get stuck buttons in the activeButtons set if (activeButtons.has(btn)) { removeActiveButton(btn, "rollout - always remove from active", netLog); } // If the button is still marked as down after the callback, clean up remaining state if (btn.down) { btn.down = false; btn.downPointer = undefined; } } } // Final safety check: if button is not down but still in activeButtons, remove it // This catches any edge cases where callbacks modify button state but don't sync with activeButtons if (!btn.down && activeButtons.has(btn)) { removeActiveButton(btn, "safety cleanup - button not down", netLog); } }
// Draws a callback if the button is not disabled. paint(fn) { if (!this.disabled) fn(this); }
enableIf(flag) { this.disabled = !flag; }}
// Slider UI control built on Button scrubbing.class Slider { box; button; min = 0; max = 1; value = 0; step = 0; dragging = false;
constructor() { let opts = {}; if (arguments.length >= 4) { if (typeof arguments[4] === "object") opts = arguments[4]; this.box = new Box(arguments[0], arguments[1], arguments[2], arguments[3]); } else { this.box = Box.from(arguments[0]); if (typeof arguments[1] === "object") opts = arguments[1]; }
this.min = Number.isFinite(opts.min) ? opts.min : 0; this.max = Number.isFinite(opts.max) ? opts.max : 1; this.step = Number.isFinite(opts.step) ? opts.step : 0; this.value = Number.isFinite(opts.value) ? opts.value : this.min;
this.button = new Button(this.box); this.button.stickyScrubbing = true; this.button.offScreenScrubbing = true; this.button.noRolloverActivation = true; }
get normalized() { const span = this.max - this.min || 1; return (this.value - this.min) / span; }
setValue(value) { let v = Math.max(this.min, Math.min(this.max, value)); if (this.step > 0) { const steps = Math.round((v - this.min) / this.step); v = this.min + steps * this.step; } this.value = v; }
setNormalized(t) { const clamped = Math.max(0, Math.min(1, t)); this.setValue(this.min + clamped * (this.max - this.min)); }
_updateFromEvent(e) { const t = (e.x - this.box.x) / this.box.w; this.setNormalized(t); }
act(e, callbacks = {}) { const onChange = callbacks.change; this.button.act(e, { down: () => { this.dragging = true; this._updateFromEvent(e); callbacks.down?.(this); onChange?.(this); }, scrub: () => { this._updateFromEvent(e); onChange?.(this); }, up: () => { this.dragging = false; callbacks.up?.(this); }, cancel: () => { this.dragging = false; callbacks.cancel?.(this); }, over: () => callbacks.over?.(this), out: () => callbacks.out?.(this), }); }
paint(draw) { draw?.(this); }}
class TextButton { txt; btn;
#gap = 4; #cw = 6; // Character width in pixels. Set from `typeface`. #g2 = this.#gap * 2; #h = 12 + this.#g2; // 19; // #offset = { x: this.#gap, y: this.#gap }; #typeface = null;
constructor(text = "Button", pos = { x: 0, y: 0 }, typeface = TYPEFACE_UI, gap = null) { // Allow custom gap/padding if provided if (gap !== null) { this.#gap = gap; this.#g2 = this.#gap * 2; this.#offset = { x: this.#gap, y: this.#gap }; }
this.#typeface = typeface; this.#cw = typeface.blockWidth; this.#h = typeface.blockHeight + this.#gap * 2;
this.txt = text; this.btn = new Button(this.#computePosition(text, { ...pos })); }
get act() { return this.btn.act; }
set disabled(d) { return (this.btn.disabled = d); }
get disabled() { return this.btn.disabled; }
get down() { return this.btn.down; }
set down(d) { return (this.btn.down = d); }
get noEdgeDetection() { return this.btn.noEdgeDetection; }
set noEdgeDetection(value) { this.btn.noEdgeDetection = value; }
get stickyScrubbing() { return this.btn.stickyScrubbing; }
set stickyScrubbing(value) { this.btn.stickyScrubbing = value; }
#measureTextWidth(text) { const visibleText = stripColorCodes(text); if (this.#typeface?.getAdvance) { let w = 0; for (const ch of visibleText) w += this.#typeface.getAdvance(ch); return w; } return visibleText.length * this.#cw; }
get width() { return this.#measureTextWidth(this.txt) + this.#gap * 2; }
get height() { return this.#h; }
// Compute position for box. // pos: {x, y} or { top, left } for positioning. // pos: {bottom, right} for bottom right... // { center: "xy", screen } for screen centering. #computePosition(text, pos = { x: 0, y: 0 }) { pos = { ...pos }; // Make a shallow copy of pos because we will mutate it. let x, y; const w = this.#measureTextWidth(text) + this.#g2; const h = this.#h;
if (pos.screen) { pos.screen.x = pos.screen.x || 0; pos.screen.y = pos.screen.y || 0; }
if (pos.center === "xy") { return { x: pos.screen.x + (pos.x || 0) + pos.screen.width / 2 - w / 2, y: pos.screen.y + (pos.y || 0) + pos.screen.height / 2 - h / 2, w, h, }; }
if (pos.center === "x") { x = (pos.screen?.x || 0) + (pos.screen?.width || 0) / 2 - w / 2; } else { x = (pos.screen?.x || 0) + (pos.x || 0); if (pos.right !== undefined) { x += pos.screen.width - pos.right - w; } else { x += pos.left || 0; } }
y = (pos.screen?.y || 0) + (pos.y || 0); if (pos.bottom !== undefined) { y += pos.screen.height - pos.bottom - this.#h; } else { y += pos.top || 0; }
return { x, y, w, h }; }
// Update just the label text (keeps the same box when visible length matches). replaceLabel(txt) { this.txt = txt; }
reposition(pos, txt) { if (txt) this.txt = txt; this.btn.box = Box.from(this.#computePosition(this.txt, pos)); }
paint( $, // scheme = [ // [0, 100, 0], // [0, 255, 0, 150], // [0, 200, 0], // [0, 50, 0], // ], scheme = [0, 255, 255, 0], hoverScheme = [255, 0, 0, 255], disabledScheme = [64, 127, 127, 64], rolloverScheme = undefined, // Defaults to the active light/dark theme. ) { if (rolloverScheme === undefined) rolloverScheme = defaultRolloverScheme($); // Choose scheme based on state: disabled, pressed (down), rollover (hover), default let s; if (this.btn.disabled) { s = disabledScheme; } else if (this.btn.down && hoverScheme) { s = hoverScheme; } else if (this.btn.over && rolloverScheme) { s = rolloverScheme; } else { s = scheme; }
// Normalize scheme parts. Many callers pass nested arrays like // [fillColor, outlineColor, textColor, textAlpha] or sometimes numbers. const fillColor = s[0] !== undefined ? s[0] : [0, 0, 0]; const outlineColor = s[1] !== undefined ? s[1] : fillColor; const textColor = s[2] !== undefined ? s[2] : outlineColor; const textAlpha = typeof s[3] === "number" ? s[3] : undefined;
// Paint fill, outline, then text. Use textAlpha only if numeric. $.ink(fillColor).box(this.btn.box, "fill"); $.ink(outlineColor).box(this.btn.box, "outline"); if (textAlpha !== undefined) { $.ink(textColor).write(this.txt, p2.add(this.btn.box, this.#offset), undefined, textAlpha); } else { $.ink(textColor).write(this.txt, p2.add(this.btn.box, this.#offset)); } }}
// A smaller text button using MatrixChunky8 font (4px char width, 7px height)class TextButtonSmall { txt; btn; // MatrixChunky8: 4px char width, 7px char height #cw = 4; #ch = 7; #padL = 2; // Left padding #padR = 2; // Right padding #padY = 2; // Vertical padding #offset;
constructor(text = "Button", pos = { x: 0, y: 0 }) { this.txt = text; this.#offset = { x: this.#padL, y: this.#padY }; this.btn = new Button(this.#computePosition(text, { ...pos })); } get act() { return this.btn.act; } set disabled(d) { return (this.btn.disabled = d); } get disabled() { return this.btn.disabled; } get down() { return this.btn.down; } set down(d) { return (this.btn.down = d); } get stickyScrubbing() { return this.btn.stickyScrubbing; } set stickyScrubbing(value) { this.btn.stickyScrubbing = value; } get width() { const visibleText = stripColorCodes(this.txt); return visibleText.length * this.#cw + this.#padL + this.#padR; }
get height() { return this.#ch + this.#padY * 2; }
#computePosition(text, pos = { x: 0, y: 0 }) { const visibleText = stripColorCodes(text); const w = visibleText.length * this.#cw + this.#padL + this.#padR; const h = this.#ch + this.#padY * 2; let x = pos.x || 0; let y = pos.y || 0; // Handle screen-relative positioning if (pos.screen) { const sx = pos.screen.x || 0; const sy = pos.screen.y || 0; const sw = pos.screen.width || 0; const sh = pos.screen.height || 0; if (pos.center === "xy") { return { x: sx + sw / 2 - w / 2, y: sy + sh / 2 - h / 2, w, h }; } if (pos.center === "x") { x = sx + sw / 2 - w / 2; } if (pos.center !== "x") { if (pos.right !== undefined) { x = sx + sw - pos.right - w; } else { x = sx + (pos.left || pos.x || 0); } } if (pos.bottom !== undefined) { y = sy + sh - pos.bottom - h; } else { y = sy + (pos.top || pos.y || 0); } } return { x, y, w, h }; } reposition(pos, txt) { if (txt) this.txt = txt; this.btn.box = Box.from(this.#computePosition(this.txt, pos)); } paint( $, scheme = [[0, 64, 0], [0, 140, 0], 255, [0, 64, 0]], // [fill, outline, text, fill] hoverScheme = [[0, 100, 0], [0, 180, 0], 255, [0, 100, 0]], // hover/down disabledScheme = [[32, 32, 32], [64, 64, 64], 80, [32, 32, 32]], rolloverScheme = undefined, // Defaults to the active light/dark theme. ) { if (rolloverScheme === undefined) rolloverScheme = defaultRolloverScheme($, true); let s; if (this.btn.disabled) { s = disabledScheme; } else if (this.btn.down) { s = hoverScheme; } else if (this.btn.over && rolloverScheme) { s = rolloverScheme; } else { s = scheme; } $.ink(s[0]) .box(this.btn.box, "fill") .ink(s[1]) .box(this.btn.box, "outline") .ink(s[2]) .write(this.txt, p2.add(this.btn.box, this.#offset), undefined, undefined, false, "MatrixChunky8"); }}
function setTypeface(tf) { TYPEFACE_UI = tf;}
export { spinner, spinnerReset, cached, Button, Slider, TextButton, TextButtonSmall, setTypeface };