block based eval

This commit is contained in:
Dsm0 2025-12-23 01:47:06 -08:00
parent 5a51b4ec71
commit fd1e3d248a
13 changed files with 1106 additions and 200 deletions

View file

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

View file

@ -13,7 +13,7 @@ import {
} from '@codemirror/view'; } from '@codemirror/view';
import { persistentAtom } from '@nanostores/persistent'; import { persistentAtom } from '@nanostores/persistent';
import { logger, registerControl, repl } from '@strudel/core'; 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 { isAutoCompletionEnabled } from './autocomplete.mjs';
import { basicSetup } from './basicSetup.mjs'; import { basicSetup } from './basicSetup.mjs';
@ -21,9 +21,13 @@ import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs'; import { keybindings } from './keybindings.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { widgetPlugin, updateWidgets } from './widget.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.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'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
@ -63,6 +67,7 @@ export const defaultSettings = {
isLineWrappingEnabled: false, isLineWrappingEnabled: false,
isTabIndentationEnabled: false, isTabIndentationEnabled: false,
isMultiCursorEnabled: false, isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme', theme: 'strudelTheme',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 18, fontSize: 18,
@ -74,7 +79,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
}); });
// https://codemirror.net/docs/guide/ // 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 settings = codemirrorSettings.get();
const initialSettings = Object.keys(compartments).map((key) => const initialSettings = Object.keys(compartments).map((key) =>
compartments[key].of(extensions[key](parseBooleans(settings[key]))), compartments[key].of(extensions[key](parseBooleans(settings[key]))),
@ -104,11 +109,26 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
keymap.of([ keymap.of([
{ {
key: 'Ctrl-Enter', 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', key: 'Alt-Enter',
run: () => onEvaluate?.(), run: () => {
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
}, },
{ {
key: 'Ctrl-.', key: 'Ctrl-.',
@ -162,6 +182,7 @@ export class StrudelMirror {
this.onDraw = onDraw || this.draw; this.onDraw = onDraw || this.draw;
this.id = id || s4(); this.id = id || s4();
this.solo = solo; this.solo = solo;
this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings()
this.drawer = new Drawer((haps, time, _, painters) => { this.drawer = new Drawer((haps, time, _, painters) => {
const currentFrame = haps.filter((hap) => hap.isActive(time)); const currentFrame = haps.filter((hap) => hap.isActive(time));
@ -169,6 +190,7 @@ export class StrudelMirror {
this.onDraw(haps, time, painters); this.onDraw(haps, time, painters);
}, drawTime); }, drawTime);
this.prebaked = prebake(); this.prebaked = prebake();
autodraw && this.drawFirstFrame(); autodraw && this.drawFirstFrame();
this.repl = repl({ this.repl = repl({
@ -192,20 +214,28 @@ export class StrudelMirror {
cleanupDraw(true, id); cleanupDraw(true, id);
} }
}, },
beforeEval: async () => { beforeEval: async ({ blockBased } = {}) => {
cleanupDraw(true, id); // 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 this.prebaked;
await replOptions?.beforeEval?.(); await replOptions?.beforeEval?.();
}, },
afterEval: (options) => { afterEval: (options) => {
// remember for when highlighting is toggled on // remember for when highlighting is toggled on
this.miniLocations = options.meta?.miniLocations; this.miniLocations = options.meta?.miniLocations || [];
this.widgets = options.meta?.widgets; this.widgets = options.meta?.widgets;
const sliders = this.widgets.filter((w) => w.type === 'slider'); const sliders = this.widgets.filter((w) => w.type === 'slider');
updateSliderWidgets(this.editor, sliders);
const widgets = this.widgets.filter((w) => w.type !== 'slider'); const widgets = this.widgets.filter((w) => w.type !== 'slider');
updateWidgets(this.editor, widgets); // range-aware update for block-based evaluation
updateMiniLocations(this.editor, this.miniLocations); 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); replOptions?.afterEval?.(options);
// if no painters are set (.onPaint was not called), then we only need // if no painters are set (.onPaint was not called), then we only need
// the present moment (for highlighting) // the present moment (for highlighting)
@ -213,8 +243,14 @@ export class StrudelMirror {
this.drawer.setDrawTime(drawTime); this.drawer.setDrawTime(drawTime);
// invalidate drawer after we've set the appropriate drawTime // invalidate drawer after we've set the appropriate drawTime
this.drawer.invalidate(this.repl.scheduler); 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({ this.editor = initEditor({
root, root,
initialCode, initialCode,
@ -227,7 +263,9 @@ export class StrudelMirror {
onEvaluate: () => this.evaluate(), onEvaluate: () => this.evaluate(),
onStop: () => this.stop(), onStop: () => this.stop(),
mondo: replOptions.mondo, mondo: replOptions.mondo,
strudelMirror: this,
}); });
const cmEditor = this.root.querySelector('.cm-editor'); const cmEditor = this.root.querySelector('.cm-editor');
if (cmEditor) { if (cmEditor) {
this.root.style.display = 'block'; this.root.style.display = 'block';
@ -297,8 +335,9 @@ export class StrudelMirror {
this.flash(); this.flash();
await this.repl.evaluate(this.code, autostart); await this.repl.evaluate(this.code, autostart);
} }
async stop() { async stop() {
this.repl.scheduler.stop(); this.repl.stop();
} }
// Listen for global stop requests (e.g., from Vim :q) // Listen for global stop requests (e.g., from Vim :q)
@ -317,8 +356,8 @@ export class StrudelMirror {
this.evaluate(); this.evaluate();
} }
} }
flash(ms) { flash(ms, range) {
flash(this.editor, ms); flash(this.editor, ms, range);
} }
highlight(haps, time) { highlight(haps, time) {
highlightMiniLocations(this.editor, time, haps); highlightMiniLocations(this.editor, time, haps);
@ -350,6 +389,10 @@ export class StrudelMirror {
setLineWrappingEnabled(enabled) { setLineWrappingEnabled(enabled) {
this.reconfigureExtension('isLineWrappingEnabled', enabled); this.reconfigureExtension('isLineWrappingEnabled', enabled);
} }
setBlockBasedEvalEnabled(enabled) {
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
}
setBracketMatchingEnabled(enabled) { setBracketMatchingEnabled(enabled) {
this.reconfigureExtension('isBracketMatchingEnabled', enabled); this.reconfigureExtension('isBracketMatchingEnabled', enabled);
} }
@ -371,6 +414,10 @@ export class StrudelMirror {
for (let key in extensions) { for (let key in extensions) {
this.reconfigureExtension(key, settings[key]); 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 }; const updated = { ...codemirrorSettings.get(), ...settings };
codemirrorSettings.set(updated); codemirrorSettings.set(updated);
} }
@ -392,6 +439,16 @@ export class StrudelMirror {
}; };
this.editor.dispatch({ changes }); 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() { clear() {
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);

View file

@ -14,7 +14,8 @@ export const flashField = StateField.define({
const mark = Decoration.mark({ const mark = Decoration.mark({
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` }, 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 { } else {
flash = Decoration.set([]); flash = Decoration.set([]);
} }
@ -29,8 +30,9 @@ export const flashField = StateField.define({
provide: (f) => EditorView.decorations.from(f), provide: (f) => EditorView.decorations.from(f),
}); });
export const flash = (view, ms = 200) => { export const flash = (view, ms = 200, range) => {
view.dispatch({ effects: setFlash.of(true) }); const flashData = range ? { range } : true;
view.dispatch({ effects: setFlash.of(flashData) });
setTimeout(() => { setTimeout(() => {
view.dispatch({ effects: setFlash.of(false) }); view.dispatch({ effects: setFlash.of(false) });
}, ms); }, ms);

View file

@ -3,8 +3,9 @@ import { Decoration, EditorView } from '@codemirror/view';
export const setMiniLocations = StateEffect.define(); export const setMiniLocations = StateEffect.define();
export const showMiniLocations = StateEffect.define(); export const showMiniLocations = StateEffect.define();
export const updateMiniLocations = (view, locations) => { export const displayMiniLocations = StateEffect.define();
view.dispatch({ effects: setMiniLocations.of(locations) }); export const updateMiniLocations = (view, locations, range = null) => {
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
}; };
export const highlightMiniLocations = (view, atTime, haps) => { export const highlightMiniLocations = (view, atTime, haps) => {
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
@ -21,23 +22,56 @@ const miniLocations = StateField.define({
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(setMiniLocations)) { if (e.is(setMiniLocations)) {
// this is called on eval, with the mini locations obtained from the transpiler //block-based eval case
// codemirror will automatically remap the marks when the document is edited if (e.value.range) {
// create a mark for each mini location, adding the range to the spec to find it later const stateMiniLocations = getMiniLocationsFromDecorations(locations);
const marks = e.value const existingById = new Map(stateMiniLocations.map(({ id, from, to }) => [id, [from, to]]));
.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<Decoration> 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<Decoration>
} 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<Decoration>
}
} }
} }
@ -75,12 +109,83 @@ const visibleMiniLocations = StateField.define({
}, },
}); });
// // Derive the set of decorations from the miniLocations and visibleLocations const displayMiniLocationsState = StateField.define({
const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => { create() {
const iterator = state.field(miniLocations).iter(); return true; // default to showing miniLocations
const { haps } = state.field(visibleMiniLocations); },
const builder = new RangeSetBuilder(); 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) { while (iterator.value) {
const { const {
from, from,
@ -89,50 +194,48 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi
spec: { id }, spec: { id },
}, },
} = iterator; } = iterator;
miniLocationsArray.push({
if (haps.has(id)) { from,
const hap = haps.get(id); to,
const color = hap.value?.color ?? 'var(--foreground)'; id,
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(); 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) => { export const isPatternHighlightingEnabled = (on, config) => {
on && // NOTE:
config && // Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed.
setTimeout(() => { // This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly.
updateMiniLocations(config.editor, config.miniLocations); // The setTimeout was also removed because it conflicted with the range-specific updates required by
}, 100); // block based evaluation.
return on ? Prec.highest(highlightExtension) : []; // 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);
}; };

View file

@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core';
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view'; import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
import { StateEffect } from '@codemirror/state'; import { StateEffect } from '@codemirror/state';
// Global state storage for all widget types
export let sliderValues = {}; export let sliderValues = {};
const getSliderID = (from) => `slider_${from}`;
export class SliderWidget extends WidgetType { export class SliderWidget extends WidgetType {
constructor(value, min, max, from, to, step, view) { constructor(value, min, max, from, to, step, view, id) {
super(); super();
this.value = value; this.value = value;
this.min = min; this.min = min;
@ -16,10 +16,21 @@ export class SliderWidget extends WidgetType {
this.to = to; this.to = to;
this.step = step; this.step = step;
this.view = view; this.view = view;
this.id = id || `${from}:${to}`; // Range-based ID for stability
} }
eq() { eq(other) {
return false; 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() { toDOM() {
@ -38,6 +49,7 @@ export class SliderWidget extends WidgetType {
slider.from = this.from; slider.from = this.from;
slider.originalFrom = this.originalFrom; slider.originalFrom = this.originalFrom;
slider.to = this.to; 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)'; slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
this.slider = slider; this.slider = slider;
slider.addEventListener('input', (e) => { slider.addEventListener('input', (e) => {
@ -49,7 +61,7 @@ export class SliderWidget extends WidgetType {
slider.originalValue = insert; slider.originalValue = insert;
slider.value = insert; slider.value = insert;
this.view.dispatch({ changes: change }); 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 }); window.postMessage({ type: 'cm-slider', value: Number(next), id });
}); });
return wrap; return wrap;
@ -62,19 +74,60 @@ export class SliderWidget extends WidgetType {
export const setSliderWidgets = StateEffect.define(); export const setSliderWidgets = StateEffect.define();
export const updateSliderWidgets = (view, widgets) => { export const setSliderWidgetsInRange = StateEffect.define();
view.dispatch({ effects: setSliderWidgets.of(widgets) });
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) { function getSliders(widgetConfigs, view) {
return widgetConfigs return (
.filter((w) => w.type === 'slider') widgetConfigs
.map(({ from, to, value, min, max, step }) => { .filter((w) => w.type === 'slider')
return Decoration.widget({ // Deduplicate sliders that might appear multiple times (e.g., during paste operations)
widget: new SliderWidget(value, min, max, from, to, step, view), .filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to))
side: 0, .sort((a, b) => a.from - b.from)
}).range(from /* , to */); .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( export const sliderPlugin = ViewPlugin.fromClass(
@ -101,7 +154,42 @@ export const sliderPlugin = ViewPlugin.fromClass(
} }
} }
for (let e of tr.effects) { 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)); 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) sliderValues[id] = value; // sync state at eval time (code -> state)
return ref(() => sliderValues[id]); // use state at query time return ref(() => sliderValues[id]); // use state at query time
}; };
// update state when sliders are moved // update state when sliders are moved
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
@ -139,7 +228,7 @@ if (typeof window !== 'undefined') {
// update state when slider is moved // update state when slider is moved
sliderValues[e.data.id] = e.data.value; sliderValues[e.data.id] = e.data.value;
} else { } 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)}`);
} }
} }
}); });

View file

@ -1,55 +1,111 @@
import { StateEffect, StateField } from '@codemirror/state'; 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 { getWidgetID, registerWidgetType } from '@strudel/transpiler';
import { Pattern } from '@strudel/core'; import { Pattern } from '@strudel/core';
export const addWidget = StateEffect.define({ export const setWidgets = StateEffect.define();
map: ({ from, to }, change) => {
return { from: change.mapPos(from), to: change.mapPos(to) };
},
});
export const updateWidgets = (view, widgets) => { export const setWidgetsInRange = StateEffect.define();
view.dispatch({ effects: addWidget.of(widgets) });
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) { function getWidgets(widgetConfigs, view) {
return ( const filtered = widgetConfigs
widgetConfigs // Filter to widget configs only (exclude sliders)
// codemirror throws an error if we don't sort .filter((w) => w && w.type && w.type !== 'slider')
.sort((a, b) => a.to - b.to) // Deduplicate widgets by ID, matching slider behavior for stable widget identity
.map((widgetConfig) => { .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({ return Decoration.widget({
widget: new BlockWidget(widgetConfig), widget: new BlockWidget(widgetConfig, view),
side: 0, side: 0,
block: true, }).range(widgetConfig.to || widgetConfig.from || 0);
}).range(widgetConfig.to); } catch (error) {
}) console.error('error creating widget', error);
); return null;
}
})
.filter(Boolean); // Remove any null results from failed creations
} }
const widgetField = StateField.define( export const widgetPlugin = ViewPlugin.fromClass(
/* <DecorationSet> */ { class {
create() { decorations; //: DecorationSet
return Decoration.none;
}, constructor(view /* : EditorView */) {
update(widgets, tr) { this.decorations = Decoration.set([]);
widgets = widgets.map(tr.changes); }
for (let e of tr.effects) {
if (e.is(addWidget)) { update(update /* : ViewUpdate */) {
try { update.transactions.forEach((tr) => {
widgets = widgets.update({ if (tr.docChanged) {
filter: () => false, this.decorations = this.decorations.map(tr.changes);
add: getWidgets(e.value), const iterator = this.decorations.iter();
}); // Apply changes to iterator.from and iterator.to if docChanged
} catch (error) { while (iterator.value) {
console.log('err', error); // 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();
} }
} }
} for (let e of tr.effects) {
return widgets; if (e.is(setWidgetsInRange)) {
}, // Block-aware widget update logic
provide: (f) => EditorView.decorations.from(f), 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 { export class BlockWidget extends WidgetType {
constructor(widgetConfig) { constructor(widgetConfig, view) {
super(); 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; 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() { toDOM() {
const id = getWidgetID(this.widgetConfig); let wrap = document.createElement('span');
const el = widgetElements[id]; wrap.setAttribute('aria-hidden', 'true');
return el; 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) { ignoreEvent(e) {
return true; 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 // widget implementer API to create a new widget type
export function registerWidget(type, fn) { export function registerWidget(type, fn) {

View file

@ -5,6 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
export const strudelScope = {}; 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) => { export const evalScope = async (...args) => {
const results = await Promise.allSettled(args); const results = await Promise.allSettled(args);

View file

@ -32,6 +32,7 @@ export function repl({
pattern: undefined, pattern: undefined,
miniLocations: [], miniLocations: [],
widgets: [], widgets: [],
sliders: [],
pending: false, pending: false,
started: false, started: false,
}; };
@ -70,11 +71,172 @@ export function repl({
let allTransform; let allTransform;
let eachTransform; 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 () { const hush = function () {
pPatterns = {}; pPatterns = {};
anonymousIndex = 0; anonymousIndex = 0;
allTransform = undefined; allTransform = undefined;
eachTransform = undefined; eachTransform = undefined;
codeBlocks = {};
blockPatterns.clear();
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
return silence; return silence;
}; };
@ -93,7 +255,18 @@ export function repl({
}; };
setTime(() => scheduler.now()); // TODO: refactor? 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 start = () => scheduler.start();
const pause = () => scheduler.pause(); const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle(); 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) { if (!code) {
throw new Error('no code to evaluate'); throw new Error('no code to evaluate');
} }
@ -209,15 +382,92 @@ export function repl({
updateState({ code, pending: true }); updateState({ code, pending: true });
await injectPatternMethods(); await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor? setTime(() => scheduler.now()); // TODO: refactor?
await beforeEval?.({ code }); await beforeEval?.({ code, blockBased });
allTransforms = []; // reset all transforms allTransforms = []; // reset all transforms
shouldHush && hush();
const transpilerOptionsWithBlock = {
...transpilerOptions,
blockBased,
range: options.range || [],
};
if (!blockBased) {
codeBlocks = {};
shouldHush && hush();
}
if (mondo) { if (mondo) {
code = `mondolang\`${code}\``; 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 patterns = [];
let soloActive = false; let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) { for (const [key, value] of Object.entries(pPatterns)) {
@ -250,18 +500,22 @@ export function repl({
if (!isPattern(pattern)) { if (!isPattern(pattern)) {
pattern = silence; pattern = silence;
} }
logger(`[eval] code updated`); logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart); pattern = await setPattern(pattern, autostart);
updateState({ updateState({
miniLocations: meta?.miniLocations || [], miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [], widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeCode: code, activeCode: code,
pattern, pattern,
evalError: undefined, evalError: undefined,
schedulerError: undefined, schedulerError: undefined,
pending: false, pending: false,
}); });
afterEval?.({ code, pattern, meta });
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
return pattern; return pattern;
} catch (err) { } catch (err) {
logger(`[eval] error: ${err.message}`, 'error'); logger(`[eval] error: ${err.message}`, 'error');
@ -276,18 +530,18 @@ export function repl({
export const getTrigger = export const getTrigger =
({ getTime, defaultOutput }) => ({ getTime, defaultOutput }) =>
async (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) // ^ 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 // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
try { try {
if (!hap.context.onTrigger || !hap.context.dominantTrigger) { if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await defaultOutput(hap, deadline, duration, cps, t); 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');
}
};

View file

@ -78,6 +78,16 @@ export const cleanupDraw = (clearScreen = true, id) => {
stopAllAnimations(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) { Pattern.prototype.onPaint = function (painter) {
return this.withState((state) => { return this.withState((state) => {
if (!state.controls.painters) { if (!state.controls.painters) {

View file

@ -19,7 +19,14 @@ export function registerLanguage(type, config) {
} }
export function transpiler(input, options = {}) { 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, { let ast = parse(input, {
ecmaVersion: 2022, ecmaVersion: 2022,
@ -28,6 +35,13 @@ export function transpiler(input, options = {}) {
}); });
let miniLocations = []; 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 collectMiniLocations = (value, node) => {
const minilang = languages.get('minilang'); const minilang = languages.get('minilang');
if (minilang) { if (minilang) {
@ -40,9 +54,27 @@ export function transpiler(input, options = {}) {
} }
}; };
let widgets = []; let widgets = [];
let sliders = [];
walk(ast, { walk(ast, {
enter(node, parent /* , prop, index */) { 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)) { if (isLanguageLiteral(node)) {
const { name } = node.tag; const { name } = node.tag;
const language = languages.get(name); const language = languages.get(name);
@ -79,22 +111,29 @@ export function transpiler(input, options = {}) {
return this.replace(miniWithLocation(value, node)); return this.replace(miniWithLocation(value, node));
} }
if (isSliderFunction(node)) { if (isSliderFunction(node)) {
emitWidgets && const from = node.arguments[0].start + nodeOffset;
widgets.push({ const to = node.arguments[0].end + nodeOffset;
from: node.arguments[0].start, const id = `${from}:${to}`; // Range-based ID for stability
to: node.arguments[0].end,
value: node.arguments[0].raw, // don't use value! const sliderConfig = {
min: node.arguments[1]?.value ?? 0, from,
max: node.arguments[2]?.value ?? 1, to,
step: node.arguments[3]?.value, id,
type: 'slider', value: node.arguments[0].raw, // don't use value!
}); min: node.arguments[1]?.value ?? 0,
return this.replace(sliderWithLocation(node)); 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)) { if (isWidgetMethod(node)) {
const type = node.callee.property.name; const type = node.callee.property.name;
const index = widgets.filter((w) => w.type === type).length; const index = widgets.filter((w) => w.type === type).length;
const widgetConfig = { const widgetConfig = {
from: node.start,
to: node.end, to: node.end,
index, index,
type, type,
@ -115,17 +154,33 @@ export function transpiler(input, options = {}) {
let { body } = ast; let { body } = ast;
const silenceExpression = {
type: 'ExpressionStatement',
expression: {
type: 'Identifier',
name: 'silence',
},
};
if (!body.length) { if (!body.length) {
console.warn('empty body -> fallback to silence'); console.warn('empty body -> fallback to silence');
body.push({ body.push(silenceExpression);
type: 'ExpressionStatement',
expression: {
type: 'Identifier',
name: 'silence',
},
});
} else if (!body?.[body.length - 1]?.expression) { } 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 // add return to last statement
@ -143,7 +198,7 @@ export function transpiler(input, options = {}) {
if (!emitMiniLocations) { if (!emitMiniLocations) {
return { output }; return { output };
} }
return { output, miniLocations, widgets }; return { output, miniLocations, widgets, sliders };
} }
function isStringWithDoubleQuotes(node, locations, code) { function isStringWithDoubleQuotes(node, locations, code) {
@ -190,8 +245,14 @@ function isWidgetMethod(node) {
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name); return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
} }
function sliderWithLocation(node) { function sliderWithLocation(node, nodeOffset = 0) {
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id // 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 // add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({ node.arguments.unshift({
@ -206,14 +267,26 @@ function sliderWithLocation(node) {
export function getWidgetID(widgetConfig) { export function getWidgetID(widgetConfig) {
// the widget id is used as id for the dom element + as key for eventual resources // 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 // 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 // Update: use range-based ID generation for better stability during block evaluation
// return `widget_${widgetConfig.to}`; // more gargabe // When we have both from and to, use them together for stability
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage // Otherwise fall back to position-based ID for backward compatibility
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage 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) { function widgetWithLocation(node, widgetConfig) {
const id = getWidgetID(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 // add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({ node.arguments.unshift({
@ -327,3 +400,85 @@ function languageWithLocation(name, value, offset) {
optional: false, 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,
},
},
},
];
}

View file

@ -116,6 +116,7 @@ export function SettingsTab({ started }) {
isMultiCursorEnabled, isMultiCursorEnabled,
patternAutoStart, patternAutoStart,
includePrebakeScriptInShare, includePrebakeScriptInShare,
isBlockBasedEvalEnabled,
} = useSettings(); } = useSettings();
const shouldAlwaysSync = isUdels(); const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
@ -288,6 +289,11 @@ export function SettingsTab({ started }) {
onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)} onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)}
value={isMultiCursorEnabled} value={isMultiCursorEnabled}
/> />
<Checkbox
label="Enable Block-based Evaluation (EXPERIMENTAL)"
onChange={(cbEvent) => settingsMap.setKey('isBlockBasedEvalEnabled', cbEvent.target.checked)}
value={isBlockBasedEvalEnabled}
/>
<Checkbox <Checkbox
label="Enable flashing on evaluation" label="Enable flashing on evaluation"
onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)} onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)}

View file

@ -106,17 +106,19 @@ export function useReplContext() {
//post to iframe parent (like Udels) if it exists... //post to iframe parent (like Udels) if it exists...
window.parent?.postMessage(code); window.parent?.postMessage(code);
setLatestCode(code); // Get the full buffer content from the editor instead of just the evaluated block
window.location.hash = '#' + code2hash(code); const fullBufferCode = editorRef.current?.code || code;
setDocumentTitle(code); setLatestCode(fullBufferCode);
window.location.hash = '#' + code2hash(fullBufferCode);
setDocumentTitle(fullBufferCode);
const viewingPatternData = getViewingPatternData(); const viewingPatternData = getViewingPatternData();
setVersionDefaultsFrom(code); setVersionDefaultsFrom(fullBufferCode);
const data = { ...viewingPatternData, code }; const data = { ...viewingPatternData, code: fullBufferCode };
let id = data.id; let id = data.id;
const isExamplePattern = viewingPatternData.collection !== userPattern.collection; const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
if (isExamplePattern) { if (isExamplePattern) {
const codeHasChanged = code !== viewingPatternData.code; const codeHasChanged = fullBufferCode !== viewingPatternData.code;
if (codeHasChanged) { if (codeHasChanged) {
// fork example // fork example
const newPattern = userPattern.duplicate(data); const newPattern = userPattern.duplicate(data);
@ -168,9 +170,8 @@ export function useReplContext() {
useEffect(() => { useEffect(() => {
let editorSettings = {}; let editorSettings = {};
Object.keys(defaultSettings).forEach((key) => { Object.keys(defaultSettings).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(_settings, key)) { // Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties
editorSettings[key] = _settings[key]; editorSettings[key] = _settings[key];
}
}); });
editorRef.current?.updateSettings(editorSettings); editorRef.current?.updateSettings(editorSettings);
}, [_settings]); }, [_settings]);

View file

@ -32,6 +32,7 @@ export const defaultSettings = {
isPatternHighlightingEnabled: true, isPatternHighlightingEnabled: true,
isTabIndentationEnabled: false, isTabIndentationEnabled: false,
isMultiCursorEnabled: false, isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme', theme: 'strudelTheme',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 18, fontSize: 18,
@ -92,6 +93,7 @@ export function useSettings() {
isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled), isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled),
isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled), isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled),
isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled), isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled),
isBlockBasedEvalEnabled: parseBoolean(state.isBlockBasedEvalEnabled),
fontSize: Number(state.fontSize), fontSize: Number(state.fontSize),
panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is! panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
isPanelPinned: parseBoolean(state.isPanelPinned), isPanelPinned: parseBoolean(state.isPanelPinned),