From fd1e3d248aa3b2d429456c73a1de17cd19253e5c Mon Sep 17 00:00:00 2001 From: Dsm0 Date: Tue, 23 Dec 2025 01:47:06 -0800 Subject: [PATCH] block based eval --- packages/codemirror/block_utilities.mjs | 51 +++ packages/codemirror/codemirror.mjs | 85 ++++- packages/codemirror/flash.mjs | 8 +- packages/codemirror/highlight.mjs | 229 ++++++++++---- packages/codemirror/slider.mjs | 123 +++++++- packages/codemirror/widget.mjs | 240 +++++++++++--- packages/core/evaluate.mjs | 28 ++ packages/core/repl.mjs | 296 ++++++++++++++++-- packages/draw/draw.mjs | 10 + packages/transpiler/transpiler.mjs | 209 +++++++++++-- .../src/repl/components/panel/SettingsTab.jsx | 6 + website/src/repl/useReplContext.jsx | 19 +- website/src/settings.mjs | 2 + 13 files changed, 1106 insertions(+), 200 deletions(-) create mode 100644 packages/codemirror/block_utilities.mjs diff --git a/packages/codemirror/block_utilities.mjs b/packages/codemirror/block_utilities.mjs new file mode 100644 index 000000000..4b53755dc --- /dev/null +++ b/packages/codemirror/block_utilities.mjs @@ -0,0 +1,51 @@ +// Block-based evaluation utilities + +export function getBlockRegions(code) { + const chars = code.split(''); + let i = 0, + blanks = [], + blockStart = 0, + regions = []; + while (i < chars.length) { + const isBlank = chars[i] === '\n'; + if (isBlank) { + blanks.push(i); + } else if (chars[i].trim() !== '') { + if (blanks.length > 1) { + regions.push([blockStart, blanks[0]]); + blockStart = i; + } + blanks = []; + } + i++; + } + regions.push([blockStart, blanks.length ? blanks[0] : i]); + return regions; +} + +export function getBlockAt(code, cursor) { + const regions = getBlockRegions(code); + for (const [start, end] of regions) { + if (cursor >= start && cursor <= end) { + return [start, end]; + } + } + return null; +} + +export const evalBlock = (strudelMirror) => { + const { state } = strudelMirror.editor; + const code = state.doc.toString(); + const cursor = state.selection.main.head; + const range = getBlockAt(code, cursor); + if (range) { + const [a, b] = range; + const block = code.slice(a, b); + if (block) { + // Flash the block being evaluated + strudelMirror.flash(200, { from: a, to: b }); + strudelMirror.repl.evaluate(block, true, true, true, { range }); + } + } + return true; +}; diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index a612ab610..71986ba4a 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -13,7 +13,7 @@ import { } from '@codemirror/view'; import { persistentAtom } from '@nanostores/persistent'; import { logger, registerControl, repl } from '@strudel/core'; -import { cleanupDraw, Drawer } from '@strudel/draw'; +import { cleanupDraw, Drawer, cleanupDrawContext } from '@strudel/draw'; import { isAutoCompletionEnabled } from './autocomplete.mjs'; import { basicSetup } from './basicSetup.mjs'; @@ -21,9 +21,13 @@ import { flash, isFlashEnabled } from './flash.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { keybindings } from './keybindings.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; +import { widgetPlugin, updateWidgets } from './widget.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs'; import { isTooltipEnabled } from './tooltip.mjs'; -import { updateWidgets, widgetPlugin } from './widget.mjs'; +import { getActiveWidgets } from './widget.mjs'; +import { getSliderWidgets } from './slider.mjs'; + +import { evalBlock } from './block_utilities.mjs'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; @@ -63,6 +67,7 @@ export const defaultSettings = { isLineWrappingEnabled: false, isTabIndentationEnabled: false, isMultiCursorEnabled: false, + isBlockBasedEvalEnabled: false, theme: 'strudelTheme', fontFamily: 'monospace', fontSize: 18, @@ -74,7 +79,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS }); // https://codemirror.net/docs/guide/ -export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) { +export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) { const settings = codemirrorSettings.get(); const initialSettings = Object.keys(compartments).map((key) => compartments[key].of(extensions[key](parseBooleans(settings[key]))), @@ -104,11 +109,26 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo keymap.of([ { key: 'Ctrl-Enter', - run: () => onEvaluate?.(), + run: () => { + // issue with referencing settings, this works more reliably + if (strudelMirror?.isBlockBasedEvalEnabled) { + evalBlock(strudelMirror); + return true; + } else { + return onEvaluate?.(); + } + }, }, { key: 'Alt-Enter', - run: () => onEvaluate?.(), + run: () => { + if (strudelMirror?.isBlockBasedEvalEnabled) { + evalBlock(strudelMirror); + return true; + } else { + return onEvaluate?.(); + } + }, }, { key: 'Ctrl-.', @@ -162,6 +182,7 @@ export class StrudelMirror { this.onDraw = onDraw || this.draw; this.id = id || s4(); this.solo = solo; + this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings() this.drawer = new Drawer((haps, time, _, painters) => { const currentFrame = haps.filter((hap) => hap.isActive(time)); @@ -169,6 +190,7 @@ export class StrudelMirror { this.onDraw(haps, time, painters); }, drawTime); + this.prebaked = prebake(); autodraw && this.drawFirstFrame(); this.repl = repl({ @@ -192,20 +214,28 @@ export class StrudelMirror { cleanupDraw(true, id); } }, - beforeEval: async () => { - cleanupDraw(true, id); + beforeEval: async ({ blockBased } = {}) => { + // Only clean up all drawings for full evaluation + // Block-based eval should preserve animations (like .scope()) from other blocks + if (!blockBased) { + cleanupDraw(true, id); + } await this.prebaked; await replOptions?.beforeEval?.(); }, afterEval: (options) => { // remember for when highlighting is toggled on - this.miniLocations = options.meta?.miniLocations; + this.miniLocations = options.meta?.miniLocations || []; this.widgets = options.meta?.widgets; + const sliders = this.widgets.filter((w) => w.type === 'slider'); - updateSliderWidgets(this.editor, sliders); const widgets = this.widgets.filter((w) => w.type !== 'slider'); - updateWidgets(this.editor, widgets); - updateMiniLocations(this.editor, this.miniLocations); + // range-aware update for block-based evaluation + const range = options.range && options.range.length >= 2 ? options.range : null; + + updateSliderWidgets(this.editor, sliders, range); + updateWidgets(this.editor, widgets, range); + updateMiniLocations(this.editor, this.miniLocations, range); replOptions?.afterEval?.(options); // if no painters are set (.onPaint was not called), then we only need // the present moment (for highlighting) @@ -213,8 +243,14 @@ export class StrudelMirror { this.drawer.setDrawTime(drawTime); // invalidate drawer after we've set the appropriate drawTime this.drawer.invalidate(this.repl.scheduler); + + // Clean up draw context if a non-inline widget was removed + if (options.widgetRemoved) { + cleanupDrawContext(id); + } }, }); + this.cleanupDrawContext = () => cleanupDrawContext(id); this.editor = initEditor({ root, initialCode, @@ -227,7 +263,9 @@ export class StrudelMirror { onEvaluate: () => this.evaluate(), onStop: () => this.stop(), mondo: replOptions.mondo, + strudelMirror: this, }); + const cmEditor = this.root.querySelector('.cm-editor'); if (cmEditor) { this.root.style.display = 'block'; @@ -297,8 +335,9 @@ export class StrudelMirror { this.flash(); await this.repl.evaluate(this.code, autostart); } + async stop() { - this.repl.scheduler.stop(); + this.repl.stop(); } // Listen for global stop requests (e.g., from Vim :q) @@ -317,8 +356,8 @@ export class StrudelMirror { this.evaluate(); } } - flash(ms) { - flash(this.editor, ms); + flash(ms, range) { + flash(this.editor, ms, range); } highlight(haps, time) { highlightMiniLocations(this.editor, time, haps); @@ -350,6 +389,10 @@ export class StrudelMirror { setLineWrappingEnabled(enabled) { this.reconfigureExtension('isLineWrappingEnabled', enabled); } + + setBlockBasedEvalEnabled(enabled) { + this.reconfigureExtension('isBlockBasedEvalEnabled', enabled); + } setBracketMatchingEnabled(enabled) { this.reconfigureExtension('isBracketMatchingEnabled', enabled); } @@ -371,6 +414,10 @@ export class StrudelMirror { for (let key in extensions) { this.reconfigureExtension(key, settings[key]); } + // Update block-based eval setting on the instance + if (settings.isBlockBasedEvalEnabled !== undefined) { + this.isBlockBasedEvalEnabled = parseBooleans(settings.isBlockBasedEvalEnabled); + } const updated = { ...codemirrorSettings.get(), ...settings }; codemirrorSettings.set(updated); } @@ -392,6 +439,16 @@ export class StrudelMirror { }; this.editor.dispatch({ changes }); } + // used for debugging but could serve other purposes + getActiveWidgets() { + return getActiveWidgets(this.editor); + } + getSliderWidgets() { + return getSliderWidgets(this.editor); + } + getMiniLocations() { + return this.miniLocations; + } clear() { this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); diff --git a/packages/codemirror/flash.mjs b/packages/codemirror/flash.mjs index 243500bff..7b2473506 100644 --- a/packages/codemirror/flash.mjs +++ b/packages/codemirror/flash.mjs @@ -14,7 +14,8 @@ export const flashField = StateField.define({ const mark = Decoration.mark({ attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` }, }); - flash = Decoration.set([mark.range(0, tr.newDoc.length)]); + const range = e.value.range || { from: 0, to: tr.newDoc.length }; + flash = Decoration.set([mark.range(range.from, range.to)]); } else { flash = Decoration.set([]); } @@ -29,8 +30,9 @@ export const flashField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); -export const flash = (view, ms = 200) => { - view.dispatch({ effects: setFlash.of(true) }); +export const flash = (view, ms = 200, range) => { + const flashData = range ? { range } : true; + view.dispatch({ effects: setFlash.of(flashData) }); setTimeout(() => { view.dispatch({ effects: setFlash.of(false) }); }, ms); diff --git a/packages/codemirror/highlight.mjs b/packages/codemirror/highlight.mjs index f9f977c6b..5cc6d3a11 100644 --- a/packages/codemirror/highlight.mjs +++ b/packages/codemirror/highlight.mjs @@ -3,8 +3,9 @@ import { Decoration, EditorView } from '@codemirror/view'; export const setMiniLocations = StateEffect.define(); export const showMiniLocations = StateEffect.define(); -export const updateMiniLocations = (view, locations) => { - view.dispatch({ effects: setMiniLocations.of(locations) }); +export const displayMiniLocations = StateEffect.define(); +export const updateMiniLocations = (view, locations, range = null) => { + view.dispatch({ effects: setMiniLocations.of({ locations, range }) }); }; export const highlightMiniLocations = (view, atTime, haps) => { view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); @@ -21,23 +22,56 @@ const miniLocations = StateField.define({ for (let e of tr.effects) { if (e.is(setMiniLocations)) { - // this is called on eval, with the mini locations obtained from the transpiler - // codemirror will automatically remap the marks when the document is edited - // create a mark for each mini location, adding the range to the spec to find it later - const marks = e.value - .filter(([from]) => from < tr.newDoc.length) - .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]) - .map( - (range) => - Decoration.mark({ - id: range.join(':'), - // this green is only to verify that the decoration moves when the document is edited - // it will be removed later, so the mark is not visible by default - attributes: { style: `background-color: #00CA2880` }, - }).range(...range), // -> Decoration - ); + //block-based eval case + if (e.value.range) { + const stateMiniLocations = getMiniLocationsFromDecorations(locations); + const existingById = new Map(stateMiniLocations.map(({ id, from, to }) => [id, [from, to]])); - locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet + const normalized = e.value.locations + .filter(([from]) => from < tr.newDoc.length) + .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]); + + const newIds = new Set(normalized.map((r) => r.join(':'))); + + const marks = normalized.map((range) => { + const id = range.join(':'); + const useRange = existingById.get(id) || range; + return Decoration.mark({ + id, + // this green is only to verify that the decoration moves when the document is edited + // it will be removed later, so the mark is not visible by default + attributes: { style: `background-color: #00CA2880` }, + }).range(...useRange); // -> Decoration + }); + + const previousMarks = stateMiniLocations + .filter(({ id }) => !newIds.has(id)) + .map(({ from, to, id }) => + Decoration.mark({ + id, + attributes: { style: `background-color: #00CA2880` }, + }).range(from, to), + ); + + locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet + } else { + // this is called on eval, with the mini locations obtained from the transpiler + // codemirror will automatically remap the marks when the document is edited + // create a mark for each mini location, adding the range to the spec to find it later + const marks = e.value.locations + .filter(([from]) => from < tr.newDoc.length) + .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]) + .map( + (range) => + Decoration.mark({ + id: range.join(':'), + // this green is only to verify that the decoration moves when the document is edited + // it will be removed later, so the mark is not visible by default + attributes: { style: `background-color: #00CA2880` }, + }).range(...range), // -> Decoration + ); + locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet + } } } @@ -75,12 +109,83 @@ const visibleMiniLocations = StateField.define({ }, }); -// // Derive the set of decorations from the miniLocations and visibleLocations -const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => { - const iterator = state.field(miniLocations).iter(); - const { haps } = state.field(visibleMiniLocations); - const builder = new RangeSetBuilder(); +const displayMiniLocationsState = StateField.define({ + create() { + return true; // default to showing miniLocations + }, + update(display, tr) { + for (let e of tr.effects) { + if (e.is(displayMiniLocations)) { + display = e.value; + } + } + return display; + }, +}); +// // Derive the set of decorations from the miniLocations and visibleLocations +const miniLocationHighlights = EditorView.decorations.compute( + [miniLocations, visibleMiniLocations, displayMiniLocationsState], + (state) => { + // Check if miniLocations display is disabled + const shouldDisplay = state.field(displayMiniLocationsState); + if (!shouldDisplay) { + return Decoration.none; // Return empty decorations if display is disabled + } + + const iterator = state.field(miniLocations).iter(); + const { haps } = state.field(visibleMiniLocations); + const builder = new RangeSetBuilder(); + + while (iterator.value) { + const { + from, + to, + value: { + spec: { id }, + }, + } = iterator; + + if (haps.has(id)) { + const hap = haps.get(id); + const color = hap.value?.color ?? 'var(--foreground)'; + const style = hap.value?.markcss || `outline: solid 2px ${color}`; + // Get explicit channels for color values + /* + const swatch = document.createElement('div'); + swatch.style.color = color; + document.body.appendChild(swatch); + let channels = getComputedStyle(swatch) + .color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/) + .slice(1) + .map((c) => parseFloat(c || 1)); + document.body.removeChild(swatch); + + // Get percentage of event + const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration; + channels[3] *= percent; + */ + + builder.add( + from, + to, + Decoration.mark({ + // attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` }, + attributes: { style }, + }), + ); + } + + iterator.next(); + } + + return builder.finish(); + }, +); + +const getMiniLocationsFromDecorations = (decorations) => { + const iterator = decorations.iter(); + const miniLocationsArray = []; while (iterator.value) { const { from, @@ -89,50 +194,48 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi spec: { id }, }, } = iterator; - - if (haps.has(id)) { - const hap = haps.get(id); - const color = hap.value?.color ?? 'var(--foreground)'; - const style = hap.value?.markcss || `outline: solid 2px ${color}`; - // Get explicit channels for color values - /* - const swatch = document.createElement('div'); - swatch.style.color = color; - document.body.appendChild(swatch); - let channels = getComputedStyle(swatch) - .color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/) - .slice(1) - .map((c) => parseFloat(c || 1)); - document.body.removeChild(swatch); - - // Get percentage of event - const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration; - channels[3] *= percent; - */ - - builder.add( - from, - to, - Decoration.mark({ - // attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` }, - attributes: { style }, - }), - ); - } - + miniLocationsArray.push({ + from, + to, + id, + }); iterator.next(); } + return miniLocationsArray; +}; - return builder.finish(); -}); +export const getMiniLocations = (state) => { + const decorations = state.field(miniLocations); + return getMiniLocationsFromDecorations(decorations); +}; -export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; +export const getActiveMiniLocations = (state) => { + const miniLocations = getMiniLocations(state); + const { haps } = state.field(visibleMiniLocations); + + const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id)); + return activeMiniLocations; +}; + +export const highlightExtension = [ + miniLocations, + visibleMiniLocations, + displayMiniLocationsState, + miniLocationHighlights, +]; export const isPatternHighlightingEnabled = (on, config) => { - on && - config && - setTimeout(() => { - updateMiniLocations(config.editor, config.miniLocations); - }, 100); - return on ? Prec.highest(highlightExtension) : []; + // NOTE: + // Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed. + // This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly. + // The setTimeout was also removed because it conflicted with the range-specific updates required by + // block based evaluation. + // Not sure if this is the best approach, but for block based eval I can't think of a better way to do it. + + if (config) { + // Toggle the display state for miniLocations + config.editor.dispatch({ effects: displayMiniLocations.of(on) }); + } + + return Prec.highest(highlightExtension); }; diff --git a/packages/codemirror/slider.mjs b/packages/codemirror/slider.mjs index 72f951254..647779ba8 100644 --- a/packages/codemirror/slider.mjs +++ b/packages/codemirror/slider.mjs @@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core'; import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view'; import { StateEffect } from '@codemirror/state'; +// Global state storage for all widget types export let sliderValues = {}; -const getSliderID = (from) => `slider_${from}`; export class SliderWidget extends WidgetType { - constructor(value, min, max, from, to, step, view) { + constructor(value, min, max, from, to, step, view, id) { super(); this.value = value; this.min = min; @@ -16,10 +16,21 @@ export class SliderWidget extends WidgetType { this.to = to; this.step = step; this.view = view; + this.id = id || `${from}:${to}`; // Range-based ID for stability } - eq() { - return false; + eq(other) { + if (!(other instanceof SliderWidget)) { + return false; + } + return ( + this.id === other.id && + this.from === other.from && + this.to === other.to && + this.value === other.value && + this.min === other.min && + this.max === other.max + ); } toDOM() { @@ -38,6 +49,7 @@ export class SliderWidget extends WidgetType { slider.from = this.from; slider.originalFrom = this.originalFrom; slider.to = this.to; + slider.id = this.id; // Store range-based ID in DOM element slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)'; this.slider = slider; slider.addEventListener('input', (e) => { @@ -49,7 +61,7 @@ export class SliderWidget extends WidgetType { slider.originalValue = insert; slider.value = insert; this.view.dispatch({ changes: change }); - const id = getSliderID(slider.originalFrom); // matches id generated in transpiler + const id = slider.id; // Use range-based ID window.postMessage({ type: 'cm-slider', value: Number(next), id }); }); return wrap; @@ -62,19 +74,60 @@ export class SliderWidget extends WidgetType { export const setSliderWidgets = StateEffect.define(); -export const updateSliderWidgets = (view, widgets) => { - view.dispatch({ effects: setSliderWidgets.of(widgets) }); +export const setSliderWidgetsInRange = StateEffect.define(); + +export const updateSliderWidgets = (view, widgets, range = null) => { + if (range) { + // range argument passed for block-based evaluation + view.dispatch({ effects: setSliderWidgetsInRange.of({ widgets, range }) }); + } else { + view.dispatch({ effects: setSliderWidgets.of(widgets) }); + } }; function getSliders(widgetConfigs, view) { - return widgetConfigs - .filter((w) => w.type === 'slider') - .map(({ from, to, value, min, max, step }) => { - return Decoration.widget({ - widget: new SliderWidget(value, min, max, from, to, step, view), - side: 0, - }).range(from /* , to */); - }); + return ( + widgetConfigs + .filter((w) => w.type === 'slider') + // Deduplicate sliders that might appear multiple times (e.g., during paste operations) + .filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to)) + .sort((a, b) => a.from - b.from) + .map(({ from, to, value, min, max, step, id }) => { + return Decoration.widget({ + widget: new SliderWidget(value, min, max, from, to, step, view, id), + side: 0, + }).range(from /* , to */); + }) + ); +} + +export function getSliderWidgets(view) { + if (!view || !view.state) { + return []; + } + + const sliderPluginInstance = view.plugin(sliderPlugin); + if (!sliderPluginInstance || !sliderPluginInstance.decorations) { + return []; + } + + const sliderWidgets = []; + + sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => { + if (decoration.widget instanceof SliderWidget) { + sliderWidgets.push({ + type: 'slider', + from: decoration.widget.from, + to: decoration.widget.to, + value: decoration.widget.value, + min: decoration.widget.min, + max: decoration.widget.max, + step: decoration.widget.step, + }); + } + }); + + return sliderWidgets; } export const sliderPlugin = ViewPlugin.fromClass( @@ -101,7 +154,42 @@ export const sliderPlugin = ViewPlugin.fromClass( } } for (let e of tr.effects) { - if (e.is(setSliderWidgets)) { + if (e.is(setSliderWidgetsInRange)) { + // Block-aware slider update logic + const { widgets, range } = e.value; + const [rangeStart, rangeEnd] = range; + + // Get existing slider widgets that should be preserved + const existingSliders = []; + this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => { + if (decoration.widget instanceof SliderWidget) { + // Preserve sliders outside the evaluation range + // Use strict > for rangeEnd because when code is deleted, slider positions + // map to the deletion boundary (rangeEnd), and those should be removed, not preserved + if (from < rangeStart || from > rangeEnd) { + existingSliders.push({ + from, + to, + value: decoration.widget.value, + min: decoration.widget.min, + max: decoration.widget.max, + step: decoration.widget.step, + id: decoration.widget.id || `${from}:${to}`, + type: 'slider', + }); + } + } + }); + + // Merge preserved sliders with new widgets + const mergedWidgets = [...existingSliders, ...widgets] + .filter( + (slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id), + ) + .sort((a, b) => a.from - b.from); + + this.decorations = Decoration.set(getSliders(mergedWidgets, update.view)); + } else if (e.is(setSliderWidgets)) { this.decorations = Decoration.set(getSliders(e.value, update.view)); } } @@ -131,6 +219,7 @@ export let sliderWithID = (id, value, min, max) => { sliderValues[id] = value; // sync state at eval time (code -> state) return ref(() => sliderValues[id]); // use state at query time }; + // update state when sliders are moved if (typeof window !== 'undefined') { window.addEventListener('message', (e) => { @@ -139,7 +228,7 @@ if (typeof window !== 'undefined') { // update state when slider is moved sliderValues[e.data.id] = e.data.value; } else { - console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`); + console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`); } } }); diff --git a/packages/codemirror/widget.mjs b/packages/codemirror/widget.mjs index 42d3b1512..6c40d6733 100644 --- a/packages/codemirror/widget.mjs +++ b/packages/codemirror/widget.mjs @@ -1,55 +1,111 @@ import { StateEffect, StateField } from '@codemirror/state'; -import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view'; import { getWidgetID, registerWidgetType } from '@strudel/transpiler'; import { Pattern } from '@strudel/core'; -export const addWidget = StateEffect.define({ - map: ({ from, to }, change) => { - return { from: change.mapPos(from), to: change.mapPos(to) }; - }, -}); +export const setWidgets = StateEffect.define(); -export const updateWidgets = (view, widgets) => { - view.dispatch({ effects: addWidget.of(widgets) }); +export const setWidgetsInRange = StateEffect.define(); + +export const updateWidgets = (view, widgets, range = null) => { + if (range) { + // range argument passed for block-based evaluation + view.dispatch({ effects: setWidgetsInRange.of({ widgets, range }) }); + } else { + view.dispatch({ effects: setWidgets.of(widgets) }); + } }; -function getWidgets(widgetConfigs) { - return ( - widgetConfigs - // codemirror throws an error if we don't sort - .sort((a, b) => a.to - b.to) - .map((widgetConfig) => { +function getWidgets(widgetConfigs, view) { + const filtered = widgetConfigs + // Filter to widget configs only (exclude sliders) + .filter((w) => w && w.type && w.type !== 'slider') + // Deduplicate widgets by ID, matching slider behavior for stable widget identity + .filter((widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id)); + + // Filter out widgets whose range is encompassed by another widget + // const nonEncompassed = filterEncompassedWidgets(filtered); + + return filtered + .sort((a, b) => (a.to || 0) - (b.to || 0)) + .map((widgetConfig) => { + try { return Decoration.widget({ - widget: new BlockWidget(widgetConfig), + widget: new BlockWidget(widgetConfig, view), side: 0, - block: true, - }).range(widgetConfig.to); - }) - ); + }).range(widgetConfig.to || widgetConfig.from || 0); + } catch (error) { + console.error('error creating widget', error); + return null; + } + }) + .filter(Boolean); // Remove any null results from failed creations } -const widgetField = StateField.define( - /* */ { - create() { - return Decoration.none; - }, - update(widgets, tr) { - widgets = widgets.map(tr.changes); - for (let e of tr.effects) { - if (e.is(addWidget)) { - try { - widgets = widgets.update({ - filter: () => false, - add: getWidgets(e.value), - }); - } catch (error) { - console.log('err', error); +export const widgetPlugin = ViewPlugin.fromClass( + class { + decorations; //: DecorationSet + + constructor(view /* : EditorView */) { + this.decorations = Decoration.set([]); + } + + update(update /* : ViewUpdate */) { + update.transactions.forEach((tr) => { + if (tr.docChanged) { + this.decorations = this.decorations.map(tr.changes); + const iterator = this.decorations.iter(); + // Apply changes to iterator.from and iterator.to if docChanged + while (iterator.value) { + // when the widgets are moved, we need to tell the dom node the current position + // this is important because the widget functions have to work with the dom node + if (iterator.value?.widget instanceof BlockWidget) { + iterator.value.widget.from = iterator.from; + iterator.value.widget.to = iterator.to; + } + iterator.next(); } } - } - return widgets; - }, - provide: (f) => EditorView.decorations.from(f), + for (let e of tr.effects) { + if (e.is(setWidgetsInRange)) { + // Block-aware widget update logic + const { widgets, range } = e.value; + const [rangeStart, rangeEnd] = range; + + // Get existing widget widgets that should be preserved + const existingWidgets = []; + this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => { + if (decoration.widget instanceof BlockWidget) { + // Preserve widgets outside the evaluation range + // Use strict > for rangeEnd because when code is deleted, widget positions + // map to the deletion boundary (rangeEnd), and those should be removed, not preserved + if (from < rangeStart || from > rangeEnd) { + existingWidgets.push({ + from: decoration.widget.from, + to: decoration.widget.to, + type: decoration.widget.type, + index: decoration.widget.index, + id: decoration.widget.id, + }); + } + } + }); + + // Merge preserved widgets with new widgets, deduplicating by ID + const mergedWidgets = [...existingWidgets, ...widgets].filter( + (widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id), + ); + + this.decorations = Decoration.set(getWidgets(mergedWidgets, update.view)); + } else if (e.is(setWidgets)) { + this.decorations = Decoration.set(getWidgets(e.value, update.view)); + } + } + }); + } + }, + { + decorations: (v) => v.decorations, }, ); @@ -60,24 +116,116 @@ export function setWidget(id, el) { } export class BlockWidget extends WidgetType { - constructor(widgetConfig) { + constructor(widgetConfig, view) { super(); + + // Graceful handling of invalid configs like sliders + if (!widgetConfig || typeof widgetConfig !== 'object') { + widgetConfig = { type: 'unknown', from: 0, to: 0 }; + } + + this.from = widgetConfig.from || 0; + this.originalFrom = widgetConfig.from || 0; + this.to = widgetConfig.to || this.from; + this.originalTo = widgetConfig.to || this.from; + this.type = widgetConfig.type || 'unknown'; + this.index = widgetConfig.index || 0; + this.view = view; + + // Use range-based ID for stability, similar to sliders + this.id = widgetConfig.id || getWidgetID?.(widgetConfig); this.widgetConfig = widgetConfig; } - eq() { - return true; + + eq(other) { + if (!(other instanceof BlockWidget)) { + return false; + } + return ( + this.id === other.id && + this.from === other.from && + this.to === other.to && + this.type === other.type && + this.index === other.index + ); } + toDOM() { - const id = getWidgetID(this.widgetConfig); - const el = widgetElements[id]; - return el; + let wrap = document.createElement('span'); + wrap.setAttribute('aria-hidden', 'true'); + wrap.className = 'cm-widget-container'; + + let el = widgetElements[this.id]; + if (el) { + // Ensure the element has the correct ID + el.id = this.id; + wrap.appendChild(el); + } else { + // Create a placeholder element if the widget element doesn't exist + // This prevents CodeMirror errors when widget is missing + const placeholder = document.createElement('span'); + placeholder.setAttribute('aria-hidden', 'true'); + placeholder.className = 'cm-widget-placeholder'; + placeholder.style.cssText = 'display: none;'; // Hide placeholder + placeholder.id = this.id; + wrap.appendChild(placeholder); + } + + return wrap; } + ignoreEvent(e) { return true; } } -export const widgetPlugin = [widgetField]; +export function getActiveWidgets(view) { + if (!view || !view.state) { + return []; + } + + const widgetPluginInstance = view.plugin(widgetPlugin); + if (!widgetPluginInstance || !widgetPluginInstance.decorations) { + return []; + } + + const widgets = []; + + widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => { + if (decoration.widget instanceof BlockWidget) { + widgets.push({ + type: decoration.widget.type, + from: decoration.widget.from, + to: decoration.widget.to, + index: decoration.widget.index, + id: decoration.widget.id, + }); + } + }); + + return widgets; +} + +export function getAllWidgetIds(view) { + if (!view || !view.state) { + return []; + } + + const widgetPluginInstance = view.plugin(widgetPlugin); + if (!widgetPluginInstance || !widgetPluginInstance.decorations) { + return []; + } + + const widgetIds = []; + + widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => { + if (decoration.widget instanceof BlockWidget) { + widgetIds.push(decoration.widget.id); + } + }); + + return widgetIds; +} // widget implementer API to create a new widget type export function registerWidget(type, fn) { diff --git a/packages/core/evaluate.mjs b/packages/core/evaluate.mjs index 0599d8662..bb7b46648 100644 --- a/packages/core/evaluate.mjs +++ b/packages/core/evaluate.mjs @@ -5,6 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th */ export const strudelScope = {}; +// Make strudelScope available globally so transpiled code can access it +globalThis.strudelScope = strudelScope; + +// Track user-defined keys (from block-based eval) so we can clear them without removing strudel functions +export const userDefinedKeys = new Set(); +globalThis.userDefinedKeys = userDefinedKeys; + +/** + * Clears all user-defined variables and functions from the scope. + * This removes variables created during block-based evaluation. + * @name clearScope + * @example + * // After defining variables in blocks: + * // let myVar = 5 + * // function myFunc() { return 10; } + * clearScope() // removes myVar and myFunc from scope + */ +export const clearScope = () => { + for (const key of userDefinedKeys) { + delete strudelScope[key]; + delete globalThis[key]; + } + userDefinedKeys.clear(); + // Return silence if available (for use in pattern expressions), otherwise undefined + return globalThis.silence; +}; +// Make clearScope available globally +globalThis.clearScope = clearScope; export const evalScope = async (...args) => { const results = await Promise.allSettled(args); diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 470be47bc..4e0af7ab7 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -32,6 +32,7 @@ export function repl({ pattern: undefined, miniLocations: [], widgets: [], + sliders: [], pending: false, started: false, }; @@ -70,11 +71,172 @@ export function repl({ let allTransform; let eachTransform; + // Block-based evaluation state + let codeBlocks = {}; + let lastActiveVisualizerLabel = null; + // Track which patterns belong to which blocks: { blockRange: [patternKeys] } + let blockPatterns = new Map(); + + // Helper function to collect properties from all code blocks (handles both labeled and anonymous blocks) + function collectFromBlocks(property) { + return Object.entries(codeBlocks).flatMap(([key, block]) => { + if (key === '$') { + // Anonymous blocks are stored as an array of block objects + return Array.isArray(block) ? block.flatMap((b) => b[property] || []) : []; + } + // Labeled blocks are stored as single block objects + return block[property] || []; + }); + } + + // Helper function to process a single labeled block + function processLabeledBlock(labels, i, code, options, meta) { + const label = labels[i]; + const nextLabel = labels[i + 1] || { index: code.length, end: code.length }; + + const labelCode = code.slice(label.index, nextLabel.index); + const labelRange = [label.index + options.range[0], label.end + options.range[0]]; + + // Calculate the full block range (from label start to next label start) + const blockStart = label.index + options.range[0]; + const blockEnd = nextLabel.index + options.range[0]; + + const blockWidgets = (meta?.widgets || []).filter((widget) => { + const widgetPos = widget.from ?? widget.index ?? 0; + return widgetPos >= blockStart && widgetPos < blockEnd; + }); + + const blockSliders = (meta?.sliders || []).filter((slider) => { + const sliderPos = slider.from ?? slider.index ?? 0; + return sliderPos >= blockStart && sliderPos < blockEnd; + }); + + const blockMiniLocations = (meta?.miniLocations || []).filter((loc) => { + // const locStart = loc.start ?? loc.from ?? 0; + // mini locations can be either [start, end] arrays or objects with start/from + const locStart = Array.isArray(loc) ? loc[0] : (loc.start ?? loc.from ?? 0); + return locStart >= blockStart && locStart < blockEnd; + }); + + handleSingleLabelBlock( + label, + labelCode, + { ...options, range: labelRange }, + { widgets: blockWidgets, sliders: blockSliders, miniLocations: blockMiniLocations }, + ); + } + + // // Helper function to filter meta (widgets, sliders, miniLocations) by block range + // function splitCodeByRange(meta, blockStart, blockEnd) { + // } + + // Helper function to extract labels from code with their positions + function extractLabelsFromCode(code) { + const labels = []; + // Regex to find label patterns like "d1:" or "myLabel:" + const labelRegex = /^(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:)/gm; + let match; + while ((match = labelRegex.exec(code)) !== null) { + labels.push({ + name: match[2], + index: match.index, + end: match.index + match[1].length, + fullMatch: match[1], + }); + } + + // Also check for 'all()' and treat it as a special label + // just for management purposes + const allRegex = /all\s*\(\s*([^)]+)\s*\)/; + const allMatch = allRegex.exec(code); + if (allMatch) { + // Check if the argument contains a widget call + const argCode = allMatch[1]; + const activeVisualizer = detectActiveVisualizer(argCode); + + labels.push({ + name: 'all', + index: allMatch.index, + end: allMatch.index + allMatch[0].length, + fullMatch: allMatch[0], + activeVisualizer: activeVisualizer, + }); + } + + return labels; + } + + // Helper function to detect non-inline widget calls in code + function detectActiveVisualizer(code) { + // List of non-inline widgets that need cleanup + // These are Pattern.prototype methods that create persistent visualizations + // I don't like this approach, feels hacky, but since these methods don't create ids that + // can be read through the transpiler, I'm not sure how best to detect them. + // Would probably be better to register these methods through some function that would + // tag them better + const nonInlineWidgets = ['punchcard', 'spiral', 'scope', 'pitchwheel', 'spectrum', 'pianoroll', 'wordfall',]; + + for (const widget of nonInlineWidgets) { + const widgetRegex = new RegExp(`\\.${widget}\\s*\\(`); + if (widgetRegex.test(code)) { + return widget; + } + } + return null; + } + + // Helper function to handle single label code block storage + function handleSingleLabelBlock(label, code, options, meta) { + + // Detect if this block contains a non-inline widget + // For 'all' label, widget info is already on the label object from extractLabelsFromCode + + // As mentioned in detectActiveVisualizer, this is a bad approach to + // managing non-inline widgets (or widgets that don't have ids) + // and a proper solution would give them widgets in the transpiler, or at least + // track where they are so we don't have to futz around with regexes + const activeVisualizer = label.activeVisualizer !== undefined + ? label.activeVisualizer + : detectActiveVisualizer(code); + + if (activeVisualizer !== null) { + lastActiveVisualizerLabel = label.name; + } + + // Store the entire code block under the label name + codeBlocks[label.name] = { + code: code, + range: options.range, + labels: [label.name], + miniLocations: meta?.miniLocations || [], + widgets: meta?.widgets || [], + sliders: meta?.sliders || [], + activeVisualizer: activeVisualizer, // Store the widget type if present, null otherwise + }; + + + // Clean up any blocks with conflicting ranges + for (const [existingKey, existingBlock] of Object.entries(codeBlocks)) { + if (existingKey !== label.name && existingBlock.range && options.range) { + const [existingStart, existingEnd] = existingBlock.range; + const [newStart, newEnd] = options.range; + + // If ranges overlap (not just touch), remove the stale block + if (!(newEnd <= existingStart || newStart >= existingEnd)) { + delete codeBlocks[existingKey]; + } + } + } + } + const hush = function () { pPatterns = {}; anonymousIndex = 0; allTransform = undefined; eachTransform = undefined; + codeBlocks = {}; + blockPatterns.clear(); + lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking return silence; }; @@ -93,7 +255,18 @@ export function repl({ }; setTime(() => scheduler.now()); // TODO: refactor? - const stop = () => scheduler.stop(); + const stop = () => { + codeBlocks = {}; + blockPatterns.clear(); + pPatterns = {}; + lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking + updateState({ + miniLocations: [], + widgets: [], + sliders: [], + }); + scheduler.stop(); + }; const start = () => scheduler.start(); const pause = () => scheduler.pause(); const toggle = () => scheduler.toggle(); @@ -201,7 +374,7 @@ export function repl({ }); }; - const evaluate = async (code, autostart = true, shouldHush = true) => { + const evaluate = async (code, autostart = true, shouldHush = true, blockBased = false, options = {}) => { if (!code) { throw new Error('no code to evaluate'); } @@ -209,15 +382,92 @@ export function repl({ updateState({ code, pending: true }); await injectPatternMethods(); setTime(() => scheduler.now()); // TODO: refactor? - await beforeEval?.({ code }); + await beforeEval?.({ code, blockBased }); allTransforms = []; // reset all transforms - shouldHush && hush(); + + const transpilerOptionsWithBlock = { + ...transpilerOptions, + blockBased, + range: options.range || [], + }; + + if (!blockBased) { + codeBlocks = {}; + shouldHush && hush(); + } if (mondo) { code = `mondolang\`${code}\``; } - let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions); - if (Object.keys(pPatterns).length) { + + let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptionsWithBlock); + + // Track activeVisualizer cleanup: check if any block's visualizer was removed + let widgetRemoved = false; + + if (blockBased) { + const labels = extractLabelsFromCode(code); + + // Store code blocks in dictionary using labels as keys + if (labels.length > 0) { + for (let i = 0; i < labels.length; i++) { + // processLabeledBlock(labels, i, code, options, meta); + // processing transpiler output instead of code is simply to avoid + // extra regex in detecting whether or not an inline widget has been commented out + processLabeledBlock(labels, i, meta.output, options, meta); + } + } else if (pattern !== silence) { + // variable/function declarations that return silence are allowed, + // but anonymous pattern blocks pose an issue for block-based evaluation + // if an anonymous pattern is evaluated multiple times it will just stack and get louder and louder + + // it's very common for users to write code prefixed with '$' + // but to modify and override existing patterns, the patterns must be labeled, + // otherwise we'll have no idea of which pattern is being overridden + + // (we probably need to update the docs on this) + + // we could easily enable it, but it would confuse a lot of people + throw new Error( + 'anonymous labels disabled for block based evaluation (see https://strudel.cc/blog/#label-notation)', + ); + } + + // For block-based evaluation, collect from all blocks + // The highlight/widget/slider systems will handle selective updates based on the range + meta.miniLocations = collectFromBlocks('miniLocations'); + meta.widgets = collectFromBlocks('widgets'); + meta.sliders = collectFromBlocks('sliders'); + + // Track activeVisualizer cleanup: check if any block's visualizer was removed + const blocksToUpdate = labels.map((label) => label.name); + + for (const [key, block] of Object.entries(codeBlocks)) { + if (blocksToUpdate.includes(key)) { + // This block was just updated + if (block.activeVisualizer !== null) { + // Block now has a visualizer, update tracking + lastActiveVisualizerLabel = key; + } else if (lastActiveVisualizerLabel === key) { + // This block lost its visualizer, trigger cleanup + widgetRemoved = true; + lastActiveVisualizerLabel = null; + } + } + } + } + + const allPatterns = Object.values(pPatterns); + + if (blockBased) { + pPatterns = Object.fromEntries( + Object.entries(pPatterns).filter(([key]) => { + return Object.keys(codeBlocks).includes(key); + }), + ); + } + + if (allPatterns.length) { let patterns = []; let soloActive = false; for (const [key, value] of Object.entries(pPatterns)) { @@ -250,18 +500,22 @@ export function repl({ if (!isPattern(pattern)) { pattern = silence; } + logger(`[eval] code updated`); pattern = await setPattern(pattern, autostart); updateState({ miniLocations: meta?.miniLocations || [], widgets: meta?.widgets || [], + sliders: meta?.sliders || [], activeCode: code, pattern, evalError: undefined, schedulerError: undefined, pending: false, }); - afterEval?.({ code, pattern, meta }); + + + afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved }); return pattern; } catch (err) { logger(`[eval] error: ${err.message}`, 'error'); @@ -276,18 +530,18 @@ export function repl({ export const getTrigger = ({ getTime, defaultOutput }) => - async (hap, deadline, duration, cps, t) => { - // ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger) - // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004 - try { - if (!hap.context.onTrigger || !hap.context.dominantTrigger) { - await defaultOutput(hap, deadline, duration, cps, t); + async (hap, deadline, duration, cps, t) => { + // ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger) + // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004 + try { + if (!hap.context.onTrigger || !hap.context.dominantTrigger) { + await defaultOutput(hap, deadline, duration, cps, t); + } + if (hap.context.onTrigger) { + // call signature of output / onTrigger is different... + await hap.context.onTrigger(hap, getTime(), cps, t); + } + } catch (err) { + errorLogger(err, 'getTrigger'); } - if (hap.context.onTrigger) { - // call signature of output / onTrigger is different... - await hap.context.onTrigger(hap, getTime(), cps, t); - } - } catch (err) { - errorLogger(err, 'getTrigger'); - } - }; + }; diff --git a/packages/draw/draw.mjs b/packages/draw/draw.mjs index 95f3aed50..514215f10 100644 --- a/packages/draw/draw.mjs +++ b/packages/draw/draw.mjs @@ -78,6 +78,16 @@ export const cleanupDraw = (clearScreen = true, id) => { stopAllAnimations(id); }; +export const cleanupDrawContext = (replID) => { + const ctx = getDrawContext(); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + // clear the big canvas context, ignore inline widgets + Object.keys(animationFrames).forEach((id) => + (!replID || id.startsWith(replID)) && !id.startsWith('_') && stopAnimationFrame(id) + ); +}; + Pattern.prototype.onPaint = function (painter) { return this.withState((state) => { if (!state.controls.painters) { diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index fea6bad4d..467e6c53f 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -19,7 +19,14 @@ export function registerLanguage(type, config) { } export function transpiler(input, options = {}) { - const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options; + const { + wrapAsync = false, + addReturn = true, + emitMiniLocations = true, + emitWidgets = true, + blockBased = false, + range = [] + } = options; let ast = parse(input, { ecmaVersion: 2022, @@ -28,6 +35,13 @@ export function transpiler(input, options = {}) { }); let miniLocations = []; + + // Position offset for block-based evaluation + let nodeOffset = range && range.length > 0 ? range[0] : 0; + + // Track declarations to add to strudelScope for block-based eval + let scopeDeclarations = []; + const collectMiniLocations = (value, node) => { const minilang = languages.get('minilang'); if (minilang) { @@ -40,9 +54,27 @@ export function transpiler(input, options = {}) { } }; let widgets = []; + let sliders = []; walk(ast, { enter(node, parent /* , prop, index */) { + // Apply position offset for block-based evaluation + if (blockBased && node.start !== undefined) { + node.start = node.start + nodeOffset; + node.end = node.end + nodeOffset; + } + // Collect variable and function declarations for strudelScope (block-based eval) + if (blockBased && parent?.type === 'Program') { + if (node.type === 'VariableDeclaration') { + for (const declarator of node.declarations) { + if (declarator.id?.name) { + scopeDeclarations.push(declarator.id.name); + } + } + } else if (node.type === 'FunctionDeclaration' && node.id?.name) { + scopeDeclarations.push(node.id.name); + } + } if (isLanguageLiteral(node)) { const { name } = node.tag; const language = languages.get(name); @@ -79,22 +111,29 @@ export function transpiler(input, options = {}) { return this.replace(miniWithLocation(value, node)); } if (isSliderFunction(node)) { - emitWidgets && - widgets.push({ - from: node.arguments[0].start, - to: node.arguments[0].end, - value: node.arguments[0].raw, // don't use value! - min: node.arguments[1]?.value ?? 0, - max: node.arguments[2]?.value ?? 1, - step: node.arguments[3]?.value, - type: 'slider', - }); - return this.replace(sliderWithLocation(node)); + const from = node.arguments[0].start + nodeOffset; + const to = node.arguments[0].end + nodeOffset; + const id = `${from}:${to}`; // Range-based ID for stability + + const sliderConfig = { + from, + to, + id, + value: node.arguments[0].raw, // don't use value! + min: node.arguments[1]?.value ?? 0, + max: node.arguments[2]?.value ?? 1, + step: node.arguments[3]?.value, + type: 'slider', + }; + emitWidgets && widgets.push(sliderConfig); + sliders.push(sliderConfig); + return this.replace(sliderWithLocation(node, nodeOffset)); } if (isWidgetMethod(node)) { const type = node.callee.property.name; const index = widgets.filter((w) => w.type === type).length; const widgetConfig = { + from: node.start, to: node.end, index, type, @@ -115,17 +154,33 @@ export function transpiler(input, options = {}) { let { body } = ast; + const silenceExpression = { + type: 'ExpressionStatement', + expression: { + type: 'Identifier', + name: 'silence', + }, + }; + if (!body.length) { console.warn('empty body -> fallback to silence'); - body.push({ - type: 'ExpressionStatement', - expression: { - type: 'Identifier', - name: 'silence', - }, - }); + body.push(silenceExpression); } else if (!body?.[body.length - 1]?.expression) { - throw new Error('unexpected ast format without body expression'); + // Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration) + if (blockBased) { + // For block-based eval, add silence as the return value when block ends with declaration + body.push(silenceExpression); + } else { + throw new Error('unexpected ast format without body expression'); + } + } + + // For block-based eval, add scope assignments before the return statement + // This allows variables/functions defined in one block to be used in other blocks + if (blockBased && scopeDeclarations.length > 0) { + const scopeAssignments = scopeDeclarations.flatMap((name) => createScopeAssignment(name)); + // Insert scope assignments before the last statement (which will become the return) + body.splice(body.length - 1, 0, ...scopeAssignments); } // add return to last statement @@ -143,7 +198,7 @@ export function transpiler(input, options = {}) { if (!emitMiniLocations) { return { output }; } - return { output, miniLocations, widgets }; + return { output, miniLocations, widgets, sliders }; } function isStringWithDoubleQuotes(node, locations, code) { @@ -190,8 +245,14 @@ function isWidgetMethod(node) { return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name); } -function sliderWithLocation(node) { - const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id +function sliderWithLocation(node, nodeOffset = 0) { + // Apply nodeOffset for block-based evaluation to generate correct range + const from = node.arguments[0].start + nodeOffset; + const to = node.arguments[0].end + nodeOffset; + + // Use range-based ID for stability during block evaluation + const id = `${from}:${to}`; + // add loc as identifier to first argument // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) node.arguments.unshift({ @@ -206,14 +267,26 @@ function sliderWithLocation(node) { export function getWidgetID(widgetConfig) { // the widget id is used as id for the dom element + as key for eventual resources // for example, for each scope widget, a new analyser + buffer (large) is created - // that means, if we use the index index of line position as id, less garbage is generated - // return `widget_${widgetConfig.to}`; // more gargabe - //return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage - return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage + // Update: use range-based ID generation for better stability during block evaluation + // When we have both from and to, use them together for stability + // Otherwise fall back to position-based ID for backward compatibility + let uniqueIdentifier; + if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) { + // Use range for more stable identification + uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`; + } else { + // Fallback to single position (for backward compatibility) + uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0; + } + const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`; + return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`; } function widgetWithLocation(node, widgetConfig) { const id = getWidgetID(widgetConfig); + // Store the unique ID back into the config so it's available for widget management + // This is critical for block-based evaluation to match existing widgets with new ones + widgetConfig.id = id; // add loc as identifier to first argument // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) node.arguments.unshift({ @@ -327,3 +400,85 @@ function languageWithLocation(name, value, offset) { optional: false, }; } + +// Creates AST nodes for: userDefinedKeys.add('name'); strudelScope.name = name; globalThis.name = name; +// Used in block-based evaluation to persist variables/functions across blocks +// We add to both strudelScope (for internal lookups) and globalThis (for direct access) +// We also track the key in userDefinedKeys so clearScope() can remove it later +function createScopeAssignment(name) { + return [ + // userDefinedKeys.add('name'); + { + type: 'ExpressionStatement', + expression: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'userDefinedKeys', + }, + property: { + type: 'Identifier', + name: 'add', + }, + computed: false, + }, + arguments: [ + { + type: 'Literal', + value: name, + }, + ], + }, + }, + // strudelScope.name = name; + { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'strudelScope', + }, + property: { + type: 'Identifier', + name: name, + }, + computed: false, + }, + right: { + type: 'Identifier', + name: name, + }, + }, + }, + // globalThis.name = name; + { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'globalThis', + }, + property: { + type: 'Identifier', + name: name, + }, + computed: false, + }, + right: { + type: 'Identifier', + name: name, + }, + }, + }, + ]; +} diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index f46fa661c..4e81aa00e 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -116,6 +116,7 @@ export function SettingsTab({ started }) { isMultiCursorEnabled, patternAutoStart, includePrebakeScriptInShare, + isBlockBasedEvalEnabled, } = useSettings(); const shouldAlwaysSync = isUdels(); const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; @@ -288,6 +289,11 @@ export function SettingsTab({ started }) { onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)} value={isMultiCursorEnabled} /> + settingsMap.setKey('isBlockBasedEvalEnabled', cbEvent.target.checked)} + value={isBlockBasedEvalEnabled} + /> settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)} diff --git a/website/src/repl/useReplContext.jsx b/website/src/repl/useReplContext.jsx index 0fbd89e38..0d44eedfe 100644 --- a/website/src/repl/useReplContext.jsx +++ b/website/src/repl/useReplContext.jsx @@ -106,17 +106,19 @@ export function useReplContext() { //post to iframe parent (like Udels) if it exists... window.parent?.postMessage(code); - setLatestCode(code); - window.location.hash = '#' + code2hash(code); - setDocumentTitle(code); + // Get the full buffer content from the editor instead of just the evaluated block + const fullBufferCode = editorRef.current?.code || code; + setLatestCode(fullBufferCode); + window.location.hash = '#' + code2hash(fullBufferCode); + setDocumentTitle(fullBufferCode); const viewingPatternData = getViewingPatternData(); - setVersionDefaultsFrom(code); - const data = { ...viewingPatternData, code }; + setVersionDefaultsFrom(fullBufferCode); + const data = { ...viewingPatternData, code: fullBufferCode }; let id = data.id; const isExamplePattern = viewingPatternData.collection !== userPattern.collection; if (isExamplePattern) { - const codeHasChanged = code !== viewingPatternData.code; + const codeHasChanged = fullBufferCode !== viewingPatternData.code; if (codeHasChanged) { // fork example const newPattern = userPattern.duplicate(data); @@ -168,9 +170,8 @@ export function useReplContext() { useEffect(() => { let editorSettings = {}; Object.keys(defaultSettings).forEach((key) => { - if (Object.prototype.hasOwnProperty.call(_settings, key)) { - editorSettings[key] = _settings[key]; - } + // Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties + editorSettings[key] = _settings[key]; }); editorRef.current?.updateSettings(editorSettings); }, [_settings]); diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 8ec7d455a..9094cba9a 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -32,6 +32,7 @@ export const defaultSettings = { isPatternHighlightingEnabled: true, isTabIndentationEnabled: false, isMultiCursorEnabled: false, + isBlockBasedEvalEnabled: false, theme: 'strudelTheme', fontFamily: 'monospace', fontSize: 18, @@ -92,6 +93,7 @@ export function useSettings() { isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled), isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled), isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled), + isBlockBasedEvalEnabled: parseBoolean(state.isBlockBasedEvalEnabled), fontSize: Number(state.fontSize), panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is! isPanelPinned: parseBoolean(state.isPanelPinned),