UNPKG

lazy-widgets

Version:

Typescript retained mode GUI for the HTML canvas API

488 lines 21 kB
// Need to use unsafe code which abuses the any type /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { TextHelper } from '../helpers/TextHelper.js'; import { BaseTheme } from '../theme/BaseTheme.js'; import { Widget } from '../widgets/Widget.js'; import { Msg } from './Strings.js'; import { Root } from './Root.js'; import { DebuggableCanvasViewport } from './DebuggableCanvasViewport.js'; const features = new Map(); /** * Check if a debug feature is enabled. * * @param debugFeature - The debug feature name, for example, "watchflag.Widget._layoutDirty" * @returns Returns true if the debug feature is enabled. If the feature doesn't exist or ins't enabled, returns false. * * @category Debug */ export function isDebugFeatureEnabled(debugFeature) { const featureConfig = features.get(debugFeature); if (featureConfig === undefined) { console.warn(`Unknown debug feature "${debugFeature}"; defaulting to not enabled`); return false; } return featureConfig[0]; } /** * Enable or disable a debug feature. * * @param debugFeature - The debug feature name, for example, "watchflag.Widget._layoutDirty" * @param enabled - Should the feature be enabled or disabled? If undefined, toggles the feature * * @category Debug */ export function toggleDebugFeature(debugFeature, enabled) { const featureConfig = features.get(debugFeature); if (featureConfig === undefined) { console.warn(`Unknown debug feature "${debugFeature}"; ignored`); return; } const [wasEnabled, _description] = featureConfig; if (enabled === undefined) { enabled = !wasEnabled; } if (wasEnabled !== enabled) { featureConfig[0] = enabled; console.info(`[lazy-widgets] ${enabled ? 'En' : 'Dis'}abled "${debugFeature}" debug feature`); if (featureConfig.length > 2) { featureConfig[2](enabled); } } } /** * List all debug features in the console. * * @category Debug */ export function listDebugFeatures() { for (const [feature, featureConfig] of features) { console.info(`[lazy-widgets] "${feature}" (${featureConfig[0] ? 'en' : 'dis'}abled): ${featureConfig[1]}`); } } /** * Inject code for a new debug feature that watches when a class' property is * set to true and prints to the console. * * @param classObj - The class. Widget for example * @param flagKey - The key of the property to watch. "_layoutDirty" for example * * @category Debug */ export function injectWatchflagFeature(classObj, flagKey) { const propertyPath = `${extractWidgetName(classObj)}.${flagKey}`; const featureName = `watchflag.${propertyPath}`; if (features.has(featureName)) { console.warn(`[lazy-widgets] Already injected debug feature with name ${featureName}; ignored`); return; } const featureNameStrace = `watchflag.${propertyPath}.strace`; if (features.has(featureNameStrace)) { console.warn(`[lazy-widgets] Already injected debug feature with name ${featureNameStrace}; ignored`); return; } const valueMap = new WeakMap(); Object.defineProperty(classObj.prototype, flagKey, { get() { return valueMap.get(this); }, set(newValue) { if (isDebugFeatureEnabled(featureName)) { const oldVal = valueMap.get(this); if (!oldVal && newValue) { const msg = `[lazy-widgets ${featureName}] ${extractWidgetName(this.constructor)}.${flagKey} set to true`; if (isDebugFeatureEnabled(featureNameStrace)) { console.groupCollapsed(msg); console.trace(); console.groupEnd(); } else { console.debug(msg); } } } valueMap.set(this, newValue); }, }); features.set(featureName, [false, `Show when ${propertyPath} is set to true`]); features.set(featureNameStrace, [false, `Print stack trace when ${featureName} shows that a flag has been set`]); } /** * Inject code for a new debug feature that traces when a class' method is * called, if the class calls the same method for other objects (prints tree) * and how long each call took in milliseconds. * * @param classObj - The class. Widget for example * @param methodKey - The key of the property to watch. "paint" for example * @param messageGenerator - A function that returns a string with extra information about the function call. For example, a function which returns " (forced)" if Widget.paint is called with forced set to true * * @category Debug */ export function injectTraceFeature(classObj, methodKey, messageGenerator = null) { const methodPath = `${extractWidgetName(classObj)}.${methodKey}`; const featureName = `trace.${methodPath}`; if (features.has(featureName)) { console.warn(`[lazy-widgets] Already injected debug feature with name ${featureName}; ignored`); return; } const msgStack = []; const msgIndices = new Map(); const methodOrig = classObj.prototype[methodKey]; let traceLevel = 0; function logMsgStack() { if (traceLevel === 0) { if (isDebugFeatureEnabled(featureName)) { console.debug(`[lazy-widgets ${featureName}] Trace:\n${msgStack.join('\n')}`); } traceLevel = 0; msgStack.length = 0; msgIndices.clear(); } } classObj.prototype[methodKey] = function (...args) { traceLevel++; let msgIndex = msgIndices.get(this); if (msgIndex === undefined) { msgIndex = msgStack.length; msgIndices.set(this, msgIndex); let prefix; if (traceLevel > 1) { prefix = ' '.repeat(traceLevel - 2) + '> '; } else { prefix = ''; } msgStack.push(`${prefix}${extractWidgetName(this.constructor)}`); } else { msgStack[msgIndex] += ', recall'; } if (messageGenerator !== null) { msgStack[msgIndex] += messageGenerator.apply(this, args); } const startTime = (new Date()).getTime(); try { const returnVal = methodOrig.apply(this, args); msgStack[msgIndex] += ` <${(new Date()).getTime() - startTime} ms>`; return returnVal; } catch (e) { msgStack[msgIndex] += ' <exception thrown>'; throw e; } finally { traceLevel--; logMsgStack(); } }; features.set(featureName, [false, `Trace ${methodPath} method calls`]); } /** * Inject code for a new debug feature that returns a random fill colour in a * given property when enabled. * * EPILEPSY WARNING: This debug feature may trigger epileptic seizures when * enabled, especially for widgets that frequently update. * * @param classObj - The class. BaseTheme for example * @param themePropertyKey - The key of the property to override. "canvasFill" for example * * @category Debug */ export function injectRandomFillFeature(classObj, themePropertyKey) { const propertyPath = `${extractWidgetName(classObj)}.${themePropertyKey}`; const featureName = `randomfill.${propertyPath}`; if (features.has(featureName)) { console.warn(`[lazy-widgets] Already injected debug feature with name ${featureName}; ignored`); return; } const propertyOrig = Object.getOwnPropertyDescriptor(classObj.prototype, themePropertyKey); Object.defineProperty(classObj.prototype, themePropertyKey, { get() { if (isDebugFeatureEnabled(featureName)) { return '#' + Math.floor(Math.random() * 0xffffff).toString(16); } else if ((propertyOrig === null || propertyOrig === void 0 ? void 0 : propertyOrig.get) !== undefined) { return propertyOrig.get.apply(this); } }, set(newValue) { if ((propertyOrig === null || propertyOrig === void 0 ? void 0 : propertyOrig.set) !== undefined) { propertyOrig.set.apply(this, [newValue]); } }, }); features.set(featureName, [false, `(EPILEPSY WARNING) Override the ${propertyPath} theme property with a new random colour every time the theme property's value is fetched. Useful for visualising widget painting`]); } /** * Inject code for a new debug feature that calls console.trace when a specific * method is called and this feature is enabled. * * @param classObj - The class. Widget for example * @param methodKey - The key of the property to watch. "paint" for example * * @category Debug */ export function injectStackTraceFeature(classObj, methodKey) { const methodPath = `${extractWidgetName(classObj)}.${methodKey}`; const featureName = `stacktrace.${methodPath}`; if (features.has(featureName)) { console.warn(`[lazy-widgets] Already injected debug feature with name ${featureName}; ignored`); return; } const methodOrig = classObj.prototype[methodKey]; classObj.prototype[methodKey] = function (...args) { if (isDebugFeatureEnabled(featureName)) { console.groupCollapsed(`[lazy-widgets ${featureName}] ${extractWidgetName(this.constructor)}.${methodKey} called`); console.trace(); console.groupEnd(); } return methodOrig.apply(this, args); }; features.set(featureName, [false, `Print stack trace when ${methodPath} is called`]); } /** * Check if a given number is whole, given a minimum distance from the nearest * whole number. If sensitivity is 0, then the number must be an integer. If * not, the it can be near an integer and still count as whole. * * @internal */ function isWhole(val, sensitivity) { const clamped = Math.abs(val) % 1; if (clamped < sensitivity) { return true; } else if (clamped > 1 - sensitivity) { return true; } else { return false; } } let injected = false; /** * Inject all default debug code. Call this before doing anything if you want to * enable debugging. Has no effect when called more than once. * * @category Debug */ export function injectDebugCode() { if (injected) { console.warn('[lazy-widgets] Already injected debug code; ignored'); return; } injected = true; // trace.Widget.paint injectTraceFeature(Widget, 'paint', (forced) => { return forced ? ' (forced)' : ''; }); // trace.Widget.resolveDimensions injectTraceFeature(Widget, 'resolveDimensions', (minWidth, maxWidth, minHeight, maxHeight) => { return ` (${minWidth}, ${maxWidth}, ${minHeight}, ${maxHeight})`; }); // trace.Widget.resolvePosition injectTraceFeature(Widget, 'resolvePosition', (x, y) => { return ` (${x}, ${y})`; }); // trace.Widget.dispatchEvent injectTraceFeature(Widget, 'dispatchEvent', (event) => { return ` (${event.type})`; }); // stacktrace.Root.resolveLayout injectStackTraceFeature(Root, 'resolveLayout'); // stacktrace.Root.paint injectStackTraceFeature(Root, 'paint'); // stacktrace.Root.dispatchEvent injectStackTraceFeature(Root, 'dispatchEvent'); // stacktrace.Root.preLayoutUpdate injectStackTraceFeature(Root, 'preLayoutUpdate'); // stacktrace.Root.postLayoutUpdate injectStackTraceFeature(Root, 'postLayoutUpdate'); // stacktrace.Root.requestPointerStyle injectStackTraceFeature(Root, 'requestPointerStyle'); // stacktrace.Root.clearPointerStyle injectStackTraceFeature(Root, 'clearPointerStyle'); // stacktrace.Root.clearPointerStylesFromWidget injectStackTraceFeature(Root, 'clearPointerStylesFromWidget'); // stacktrace.Root.requestFocus injectStackTraceFeature(Root, 'requestFocus'); // stacktrace.Root.dropFocus injectStackTraceFeature(Root, 'dropFocus'); // stacktrace.Root.clearFocus injectStackTraceFeature(Root, 'clearFocus'); // stacktrace.Root.getFocus injectStackTraceFeature(Root, 'getFocus'); // stacktrace.Root.getFocusCapturer injectStackTraceFeature(Root, 'getFocusCapturer'); // stacktrace.Root.registerDriver injectStackTraceFeature(Root, 'registerDriver'); // stacktrace.Root.unregisterDriver injectStackTraceFeature(Root, 'unregisterDriver'); // stacktrace.Root.clearDrivers injectStackTraceFeature(Root, 'clearDrivers'); // stacktrace.Root.getTextInput injectStackTraceFeature(Root, 'getTextInput'); // stacktrace.Widget.dispatchEvent injectStackTraceFeature(Widget, 'dispatchEvent'); // stacktrace.Widget.preLayoutUpdate injectStackTraceFeature(Widget, 'preLayoutUpdate'); // stacktrace.Widget.resolveDimensions injectStackTraceFeature(Widget, 'resolveDimensions'); // stacktrace.Widget.resolvePosition injectStackTraceFeature(Widget, 'resolvePosition'); // stacktrace.Widget.postLayoutUpdate injectStackTraceFeature(Widget, 'postLayoutUpdate'); // stacktrace.Widget.paint injectStackTraceFeature(Widget, 'paint'); // stacktrace.Widget.propagateDirtyRect injectStackTraceFeature(Widget, 'propagateDirtyRect'); // randomfill.BaseTheme.canvasFill injectRandomFillFeature(BaseTheme, 'canvasFill'); // randomfill.BaseTheme.primaryFill injectRandomFillFeature(BaseTheme, 'primaryFill'); // randomfill.BaseTheme.accentFill injectRandomFillFeature(BaseTheme, 'accentFill'); // randomfill.BaseTheme.backgroundFill injectRandomFillFeature(BaseTheme, 'backgroundFill'); // randomfill.BaseTheme.backgroundGlowFill injectRandomFillFeature(BaseTheme, 'backgroundGlowFill'); // randomfill.BaseTheme.bodyTextFill injectRandomFillFeature(BaseTheme, 'bodyTextFill'); // randomfill.BaseTheme.inputBackgroundFill injectRandomFillFeature(BaseTheme, 'inputBackgroundFill'); // randomfill.BaseTheme.inputSelectBackgroundFill injectRandomFillFeature(BaseTheme, 'inputSelectBackgroundFill'); // randomfill.BaseTheme.inputTextFill injectRandomFillFeature(BaseTheme, 'inputTextFill'); // randomfill.BaseTheme.inputTextFillDisabled injectRandomFillFeature(BaseTheme, 'inputTextFillDisabled'); // randomfill.BaseTheme.inputTextFillInvalid injectRandomFillFeature(BaseTheme, 'inputTextFillInvalid'); // textrendergroups; special debug feature for TextRenderGroup features.set('textrendergroups', [ false, `Draw text render groups in a TextHelper with alternating background colours (green and red). Width overriding groups have a blue background and zero-width groups have a black background. Throws an exception on negative width groups`, ]); const textHelperAlternate = new Map(); const textHelperPaintOrig = TextHelper.prototype.paint; TextHelper.prototype.paint = function (ctx, fillStyle, x, y) { textHelperAlternate.set(this, false); textHelperPaintOrig.apply(this, [ctx, fillStyle, x, y]); }; const textHelperPaintGroupOrig = TextHelper.prototype.paintGroup; TextHelper.prototype.paintGroup = function (ctx, group, left, x, y) { if (isDebugFeatureEnabled('textrendergroups')) { const origFillStyle = ctx.fillStyle; const height = this.actualLineHeight; const fullHeight = this.fullLineHeight; if (!group.overridesWidth && group.right > left) { const alternate = textHelperAlternate.get(this); ctx.fillStyle = alternate ? 'rgba(255, 0, 0, 0.5)' : 'rgba(0, 255, 0, 0.5)'; ctx.fillRect(x, y - height, group.right - left, fullHeight); textHelperAlternate.set(this, !alternate); } else { let debugWidth = group.right - left; ctx.fillStyle = debugWidth > 0 ? 'rgba(0, 0, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'; if (debugWidth == 0) { debugWidth = 4; } else if (debugWidth < 0) { throw new Error(Msg.NEGATIVE_TEXT_GROUP); } ctx.fillRect(x, y - height, debugWidth, fullHeight); } ctx.fillStyle = origFillStyle; } textHelperPaintGroupOrig.apply(this, [ctx, group, left, x, y]); }; // warnsubpixels; special debug feature for Widget features.set('warnsubpixels', [ false, `Print a console warning whenever a Widget is detected to have non-integer width, height, x or y. Only warned once per Widget type`, ]); const warnedSubX = new Set(); const warnedSubY = new Set(); const warnedSubWidth = new Set(); const warnedSubHeight = new Set(); const msgLeft = '[lazy-widgets warnsubpixels] Widget type "'; const msgMid = '" has a non-integer '; const msgRight = ', which will create clipping issues due to subpixels. This message won\'t be shown again for this widget type'; const finalizeBoundsOrig = Widget.prototype.finalizeBounds; Widget.prototype.finalizeBounds = function () { finalizeBoundsOrig.apply(this); const typeName = extractWidgetName(this.constructor); if (isDebugFeatureEnabled('warnsubpixels')) { const [scaleX, scaleY] = this.root.effectiveScale; const [x, y] = this.position; if (!isWhole(x * scaleX, 1e-10) && !warnedSubX.has(typeName)) { warnedSubX.add(typeName); console.warn(`${msgLeft}${typeName}${msgMid}X coordinate (${x})${msgRight}`); } if (!isWhole(y * scaleY, 1e-10) && !warnedSubY.has(typeName)) { warnedSubY.add(typeName); console.warn(`${msgLeft}${typeName}${msgMid}Y coordinate (${y})${msgRight}`); } const [width, height] = this.dimensions; if (!isWhole(width * scaleX, 1e-10) && !warnedSubWidth.has(typeName)) { warnedSubWidth.add(typeName); console.warn(`${msgLeft}${typeName}${msgMid}width (${width})${msgRight}`); } if (!isWhole(height * scaleY, 1e-10) && !warnedSubHeight.has(typeName)) { warnedSubHeight.add(typeName); console.warn(`${msgLeft}${typeName}${msgMid}height (${height})${msgRight}`); } } }; // flashdamage; special debug feature for CanvasViewport const viewports = []; features.set('flashdamage', [ false, 'Debug damage regions; momentarily flash rectangles that are marked as dirty for 1 second. Pushed dirty rectangles are painted in red, while merged (effective) dirty rectangles are painted in blue', (enabled) => { for (const viewport of viewports) { viewport.overlayEnabled = enabled; } } ]); Root.makeViewport = function (child, properties) { const viewport = new DebuggableCanvasViewport(child, properties === null || properties === void 0 ? void 0 : properties.resolution, properties === null || properties === void 0 ? void 0 : properties.preventBleeding, properties === null || properties === void 0 ? void 0 : properties.preventAtlasBleeding, properties === null || properties === void 0 ? void 0 : properties.canvasStartingWidth, properties === null || properties === void 0 ? void 0 : properties.canvasStartingHeight); viewports.push(viewport); if (isDebugFeatureEnabled('flashdamage')) { viewport.overlayEnabled = true; } return viewport; }; Object.defineProperty(Root.prototype, 'canvas', { get() { return this.viewport.outputCanvas; }, }); // Make debug functions available in global scope // eslint-disable-next-line @typescript-eslint/no-explicit-any window.canvasDebug = { enabled: isDebugFeatureEnabled, toggle: toggleDebugFeature, list: listDebugFeatures, }; console.info('[lazy-widgets] Injected debug code; the library will be slower'); console.info('[lazy-widgets] Check if a debug feature is enabled in the console with canvasDebug.enabled(debugFeature: string)'); console.info('[lazy-widgets] Enable a debug feature in the console with canvasDebug.toggle(debugFeature: string, enabled?: boolean)'); console.info('[lazy-widgets] List debug features in the console with canvasDebug.list()'); } /** * Extract a human-readable name from a Widget class * @internal */ function extractWidgetName(classObj) { if (classObj.autoXML && ('name' in classObj.autoXML)) { return classObj.autoXML.name; } else { return classObj.name; } } //# sourceMappingURL=DebugInjector.js.map