mirror of
https://codeberg.org/uzu/strudel
synced 2026-10-15 12:33:14 -04:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be3422c5e6 | |||
| 382d5df933 | |||
| 2e47716d23 | |||
| 0ffeb14c58 | |||
| 73b3982b69 | |||
| a18c2b6b80 | |||
| c9b32ec38b | |||
| d3325a2f82 | |||
| a2af0cd5cc | |||
| 56b279d723 | |||
| 32cccda215 | |||
| d7b4c6e498 | |||
| dd8d04dc5c | |||
| 1a52eb4515 | |||
| 196115a9da | |||
| 3d6f4cf32b | |||
| 2ebf9f7103 | |||
| a3302cf167 | |||
| cd6d364067 | |||
| ff80bae275 | |||
| 8247179a4d | |||
| c398620439 | |||
| 2cc9bf2568 | |||
| 39b6466a91 | |||
| d5dc506dc4 | |||
| c5fd80902b | |||
| 58dfe9a55c | |||
| 4671d343c4 |
-14
@@ -45,20 +45,6 @@ tidal-drum-machines
|
||||
webaudiofontdata
|
||||
src-tauri/target
|
||||
|
||||
|
||||
|
||||
### BEGIN Visual Studio Code ###
|
||||
# Blanket, recursive exclude for .vscode directory and files
|
||||
.vscode/**/*
|
||||
# Unexclude specific files and directories within .vscode
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
### END Visual Studio Code ###
|
||||
|
||||
|
||||
|
||||
# BEGIN JetBrains -> END JetBrains
|
||||
|
||||
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import jsdoc from '../../doc.json';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { h } from './html';
|
||||
//TODO: fix tonal scale import
|
||||
// import { Scale } from '@tonaljs/tonal';
|
||||
// import { soundMap } from '@strudel/webaudio';
|
||||
let soundMap = undefined;
|
||||
import { complex } from '@strudel/tonal';
|
||||
|
||||
const escapeHtml = (str) => {
|
||||
const div = document.createElement('div');
|
||||
@@ -80,53 +75,6 @@ const isValidDoc = (doc) => {
|
||||
const hasExcludedTags = (doc) =>
|
||||
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
|
||||
|
||||
export function bankCompletions() {
|
||||
// TODO: FIX IMPORT
|
||||
const soundDict = soundMap?.get() ?? {};
|
||||
|
||||
const banks = new Set();
|
||||
for (const key of Object.keys(soundDict)) {
|
||||
const [bank, suffix] = key.split('_');
|
||||
if (suffix && bank) banks.add(bank);
|
||||
}
|
||||
return Array.from(banks)
|
||||
.sort()
|
||||
.map((name) => ({ label: name, type: 'bank' }));
|
||||
}
|
||||
|
||||
// Attempt to get all scale names from Tonal TODO: FIX IMPORT
|
||||
let scaleCompletions = [];
|
||||
// try {
|
||||
// scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
|
||||
// } catch (e) {
|
||||
// console.warn('[autocomplete] Could not load scale names from Tonal:', e);
|
||||
// }
|
||||
|
||||
// Valid mode values for voicing
|
||||
const modeCompletions = [
|
||||
{ label: 'below', type: 'mode' },
|
||||
{ label: 'above', type: 'mode' },
|
||||
{ label: 'duck', type: 'mode' },
|
||||
{ label: 'root', type: 'mode' },
|
||||
];
|
||||
|
||||
// Valid chord symbols from ireal dictionary plus empty string for major triads
|
||||
const chordSymbols = ['', ...Object.keys(complex)].sort();
|
||||
const chordSymbolCompletions = chordSymbols.map((symbol) => {
|
||||
if (symbol === '') {
|
||||
return {
|
||||
label: 'major',
|
||||
apply: '',
|
||||
type: 'chord-symbol',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: symbol,
|
||||
apply: symbol,
|
||||
type: 'chord-symbol',
|
||||
};
|
||||
});
|
||||
|
||||
export const getSynonymDoc = (doc, synonym) => {
|
||||
const synonyms = doc.synonyms || [];
|
||||
const docLabel = getDocLabel(doc);
|
||||
@@ -165,299 +113,19 @@ const jsdocCompletions = (() => {
|
||||
return completions;
|
||||
})();
|
||||
|
||||
// --- Handler functions for each context ---
|
||||
const pitchNames = [
|
||||
'C',
|
||||
'C#',
|
||||
'Db',
|
||||
'D',
|
||||
'D#',
|
||||
'Eb',
|
||||
'E',
|
||||
'E#',
|
||||
'Fb',
|
||||
'F',
|
||||
'F#',
|
||||
'Gb',
|
||||
'G',
|
||||
'G#',
|
||||
'Ab',
|
||||
'A',
|
||||
'A#',
|
||||
'Bb',
|
||||
'B',
|
||||
'B#',
|
||||
'Cb',
|
||||
];
|
||||
|
||||
// Cached regex patterns for scaleHandler
|
||||
const SCALE_NO_QUOTES_REGEX = /scale\(\s*$/;
|
||||
const SCALE_AFTER_COLON_REGEX = /scale\(\s*['"][^'"]*:[^'"]*$/;
|
||||
const SCALE_PRE_COLON_REGEX = /scale\(\s*['"][^'"]*$/;
|
||||
const SCALE_PITCH_MATCH_REGEX = /([A-Ga-g][#b]*)?$/;
|
||||
const SCALE_SPACES_TO_COLON_REGEX = /\s+/g;
|
||||
|
||||
function scaleHandler(context) {
|
||||
// First check for scale context without quotes - block with empty completions
|
||||
let scaleNoQuotesContext = context.matchBefore(SCALE_NO_QUOTES_REGEX);
|
||||
if (scaleNoQuotesContext) {
|
||||
return {
|
||||
from: scaleNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for after-colon context first (more specific)
|
||||
let scaleAfterColonContext = context.matchBefore(SCALE_AFTER_COLON_REGEX);
|
||||
if (scaleAfterColonContext) {
|
||||
const text = scaleAfterColonContext.text;
|
||||
const colonIdx = text.lastIndexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const fragment = text.slice(colonIdx + 1);
|
||||
const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment));
|
||||
const options = filteredScales.map((s) => ({
|
||||
...s,
|
||||
apply: s.label.replace(SCALE_SPACES_TO_COLON_REGEX, ':'),
|
||||
}));
|
||||
const from = scaleAfterColonContext.from + colonIdx + 1;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for pre-colon context
|
||||
let scalePreColonContext = context.matchBefore(SCALE_PRE_COLON_REGEX);
|
||||
if (scalePreColonContext) {
|
||||
if (!scalePreColonContext.text.includes(':')) {
|
||||
if (context.explicit) {
|
||||
const text = scalePreColonContext.text;
|
||||
const match = text.match(SCALE_PITCH_MATCH_REGEX);
|
||||
const fragment = match ? match[0] : '';
|
||||
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const from = scalePreColonContext.to - fragment.length;
|
||||
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
|
||||
return { from, options };
|
||||
} else {
|
||||
return { from: scalePreColonContext.to, options: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cached regex patterns for soundHandler
|
||||
const SOUND_NO_QUOTES_REGEX = /(s|sound)\(\s*$/;
|
||||
const SOUND_WITH_QUOTES_REGEX = /(s|sound)\(\s*['"][^'"]*$/;
|
||||
const SOUND_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w]*)$/;
|
||||
|
||||
function soundHandler(context) {
|
||||
// First check for sound context without quotes - block with empty completions
|
||||
let soundNoQuotesContext = context.matchBefore(SOUND_NO_QUOTES_REGEX);
|
||||
if (soundNoQuotesContext) {
|
||||
return {
|
||||
from: soundNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for sound context with quotes - provide completions
|
||||
let soundContext = context.matchBefore(SOUND_WITH_QUOTES_REGEX);
|
||||
if (!soundContext) return null;
|
||||
|
||||
const text = soundContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
|
||||
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
|
||||
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
|
||||
const from = soundContext.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for bankHandler
|
||||
const BANK_NO_QUOTES_REGEX = /bank\(\s*$/;
|
||||
const BANK_WITH_QUOTES_REGEX = /bank\(\s*['"][^'"]*$/;
|
||||
|
||||
function bankHandler(context) {
|
||||
// First check for bank context without quotes - block with empty completions
|
||||
let bankNoQuotesContext = context.matchBefore(BANK_NO_QUOTES_REGEX);
|
||||
if (bankNoQuotesContext) {
|
||||
return {
|
||||
from: bankNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for bank context with quotes - provide completions
|
||||
let bankMatch = context.matchBefore(BANK_WITH_QUOTES_REGEX);
|
||||
if (!bankMatch) return null;
|
||||
|
||||
const text = bankMatch.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragment = inside;
|
||||
let banks = bankCompletions();
|
||||
const filteredBanks = banks.filter((b) => b.label.startsWith(fragment));
|
||||
const from = bankMatch.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options: filteredBanks,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for modeHandler
|
||||
const MODE_NO_QUOTES_REGEX = /mode\(\s*$/;
|
||||
const MODE_AFTER_COLON_REGEX = /mode\(\s*['"][^'"]*:[^'"]*$/;
|
||||
const MODE_PRE_COLON_REGEX = /mode\(\s*['"][^'"]*$/;
|
||||
const MODE_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w:]*)$/;
|
||||
|
||||
function modeHandler(context) {
|
||||
// First check for mode context without quotes - block with empty completions
|
||||
let modeNoQuotesContext = context.matchBefore(MODE_NO_QUOTES_REGEX);
|
||||
if (modeNoQuotesContext) {
|
||||
return {
|
||||
from: modeNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for after-colon context first (more specific)
|
||||
let modeAfterColonContext = context.matchBefore(MODE_AFTER_COLON_REGEX);
|
||||
if (modeAfterColonContext) {
|
||||
const text = modeAfterColonContext.text;
|
||||
const colonIdx = text.lastIndexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const fragment = text.slice(colonIdx + 1);
|
||||
// For anchor after colon, we can suggest pitch names
|
||||
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
|
||||
const from = modeAfterColonContext.from + colonIdx + 1;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for pre-colon context
|
||||
let modeContext = context.matchBefore(MODE_PRE_COLON_REGEX);
|
||||
if (!modeContext) return null;
|
||||
|
||||
const text = modeContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragMatch = inside.match(MODE_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
const filteredModes = modeCompletions.filter((m) => m.label.startsWith(fragment));
|
||||
const from = modeContext.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options: filteredModes,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for chordHandler
|
||||
const CHORD_NO_QUOTES_REGEX = /chord\(\s*$/;
|
||||
const CHORD_WITH_QUOTES_REGEX = /chord\(\s*['"][^'"]*$/;
|
||||
const CHORD_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w#b+^:-]*)$/;
|
||||
|
||||
function chordHandler(context) {
|
||||
// First check for chord context without quotes - block with empty completions
|
||||
let chordNoQuotesContext = context.matchBefore(CHORD_NO_QUOTES_REGEX);
|
||||
if (chordNoQuotesContext) {
|
||||
return {
|
||||
from: chordNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for chord context with quotes - provide completions
|
||||
let chordContext = context.matchBefore(CHORD_WITH_QUOTES_REGEX);
|
||||
if (!chordContext) return null;
|
||||
|
||||
const text = chordContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
|
||||
// Use same fragment matching as sound/mode for expressions like "<G Am>"
|
||||
const fragMatch = inside.match(CHORD_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
|
||||
// Check if fragment contains any pitch name at start (for root + symbol)
|
||||
let rootMatch = null;
|
||||
let symbolFragment = fragment;
|
||||
for (const pitch of pitchNames) {
|
||||
if (fragment.toLowerCase().startsWith(pitch.toLowerCase())) {
|
||||
rootMatch = pitch;
|
||||
symbolFragment = fragment.slice(pitch.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootMatch) {
|
||||
// We have a root, now complete chord symbols
|
||||
const filteredSymbols = chordSymbolCompletions.filter((s) =>
|
||||
s.label.toLowerCase().startsWith(symbolFragment.toLowerCase()),
|
||||
);
|
||||
|
||||
// Create completions that replace the entire chord, not just the symbol part
|
||||
const options = filteredSymbols;
|
||||
|
||||
const from = chordContext.to - symbolFragment.length;
|
||||
return { from, options };
|
||||
} else {
|
||||
// No root yet, complete with pitch names
|
||||
const filteredPitches = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const options = filteredPitches.map((p) => ({ label: p, type: 'pitch' }));
|
||||
const from = chordContext.to - fragment.length;
|
||||
return { from, options };
|
||||
}
|
||||
}
|
||||
|
||||
// Cached regex patterns for fallbackHandler
|
||||
const FALLBACK_WORD_REGEX = /\w*/;
|
||||
|
||||
function fallbackHandler(context) {
|
||||
const word = context.matchBefore(FALLBACK_WORD_REGEX);
|
||||
if (word && word.from === word.to && !context.explicit) return null;
|
||||
if (word) {
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlers = [
|
||||
soundHandler,
|
||||
bankHandler,
|
||||
chordHandler,
|
||||
scaleHandler,
|
||||
modeHandler,
|
||||
// this handler *must* be last
|
||||
fallbackHandler,
|
||||
];
|
||||
|
||||
export const strudelAutocomplete = (context) => {
|
||||
for (const handler of handlers) {
|
||||
const result = handler(context);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (word.from === word.to && !context.explicit) return null;
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
/* options: [
|
||||
{ label: 'match', type: 'keyword' },
|
||||
{ label: 'hello', type: 'variable', info: '(World)' },
|
||||
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
|
||||
], */
|
||||
};
|
||||
};
|
||||
|
||||
export const isAutoCompletionEnabled = (enabled) =>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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.evaluateBlock(block, true, { range });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
import { indentWithTab, toggleLineComment } from '@codemirror/commands';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
@@ -12,19 +12,19 @@ import {
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { logger, registerControl, repl } from '@strudel/core';
|
||||
import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw';
|
||||
import { registerControl, repl } from '@strudel/core';
|
||||
import { cleanupDraw, Drawer } from '@strudel/draw';
|
||||
|
||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||
import { basicSetup } from './basicSetup.mjs';
|
||||
import { evalBlock } from './block_utilities.mjs';
|
||||
import { flash, isFlashEnabled } from './flash.mjs';
|
||||
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
|
||||
import { keybindings } from './keybindings.mjs';
|
||||
import { jumpToCharacter } from './labelJump.mjs';
|
||||
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||
import { activateTheme, initTheme, theme } from './themes.mjs';
|
||||
import { isTooltipEnabled } from './tooltip.mjs';
|
||||
import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
import { jumpToCharacter } from './labelJump.mjs';
|
||||
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
@@ -64,7 +64,6 @@ export const defaultSettings = {
|
||||
isLineWrappingEnabled: false,
|
||||
isTabIndentationEnabled: false,
|
||||
isMultiCursorEnabled: false,
|
||||
isBlockBasedEvalEnabled: false,
|
||||
theme: 'strudelTheme',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
@@ -76,7 +75,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
|
||||
});
|
||||
|
||||
// https://codemirror.net/docs/guide/
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
|
||||
const settings = codemirrorSettings.get();
|
||||
const initialSettings = Object.keys(compartments).map((key) =>
|
||||
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
|
||||
@@ -106,26 +105,11 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Ctrl-Enter',
|
||||
run: () => {
|
||||
// issue with referencing settings, this works more reliably
|
||||
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||
evalBlock(strudelMirror);
|
||||
return true;
|
||||
} else {
|
||||
return onEvaluate?.();
|
||||
}
|
||||
},
|
||||
run: () => onEvaluate?.(),
|
||||
},
|
||||
{
|
||||
key: 'Alt-Enter',
|
||||
run: () => {
|
||||
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||
evalBlock(strudelMirror);
|
||||
return true;
|
||||
} else {
|
||||
return onEvaluate?.();
|
||||
}
|
||||
},
|
||||
run: () => onEvaluate?.(),
|
||||
},
|
||||
{
|
||||
key: 'Ctrl-.',
|
||||
@@ -187,7 +171,6 @@ 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));
|
||||
@@ -218,12 +201,8 @@ export class StrudelMirror {
|
||||
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);
|
||||
}
|
||||
beforeEval: async () => {
|
||||
cleanupDraw(true, id);
|
||||
await this.prebaked;
|
||||
await replOptions?.beforeEval?.();
|
||||
},
|
||||
@@ -233,13 +212,10 @@ export class StrudelMirror {
|
||||
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');
|
||||
// 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);
|
||||
updateWidgets(this.editor, widgets);
|
||||
updateMiniLocations(this.editor, this.miniLocations);
|
||||
replOptions?.afterEval?.(options);
|
||||
// if no painters are set (.onPaint was not called), then we only need
|
||||
// the present moment (for highlighting)
|
||||
@@ -247,14 +223,8 @@ 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,
|
||||
@@ -267,9 +237,7 @@ 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';
|
||||
@@ -289,39 +257,6 @@ export class StrudelMirror {
|
||||
}
|
||||
};
|
||||
document.addEventListener('start-repl', this.onStartRepl);
|
||||
|
||||
// Handle global evaluation requests (e.g., from Vim :w)
|
||||
this.onEvaluateRequest = (e) => {
|
||||
try {
|
||||
if (e.detail.view !== this.editor) {
|
||||
return; // ignore events from other editors
|
||||
}
|
||||
// Evaluate current editor on repl-evaluate
|
||||
logger('[repl] evaluate via event');
|
||||
this.evaluate();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-evaluate event', err);
|
||||
}
|
||||
};
|
||||
document.addEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||
document.addEventListener('repl-stop', this.onStopRequest);
|
||||
|
||||
// Toggle comments requested from Vim (gc)
|
||||
this.onToggleComment = (e) => {
|
||||
try {
|
||||
if (e.detail.view !== this.editor) {
|
||||
return; // ignore events from other editors
|
||||
}
|
||||
// Honor selections; toggleLineComment handles both selections and
|
||||
// single line
|
||||
toggleLineComment(this.editor);
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-toggle-comment event', err);
|
||||
}
|
||||
};
|
||||
document.addEventListener('repl-toggle-comment', this.onToggleComment);
|
||||
}
|
||||
draw(haps, time, painters) {
|
||||
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
|
||||
@@ -345,23 +280,10 @@ export class StrudelMirror {
|
||||
this.flash();
|
||||
await this.repl.evaluate(this.code, autostart);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.repl.stop();
|
||||
this.repl.scheduler.stop();
|
||||
}
|
||||
|
||||
// Listen for global stop requests (e.g., from Vim :q)
|
||||
onStopRequest = (e) => {
|
||||
try {
|
||||
if (e.detail.view !== this.editor) {
|
||||
return; // ignore events from other editors
|
||||
}
|
||||
this.stop();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-stop event', err);
|
||||
}
|
||||
};
|
||||
async toggle() {
|
||||
if (this.repl.scheduler.started) {
|
||||
this.repl.stop();
|
||||
@@ -369,8 +291,8 @@ export class StrudelMirror {
|
||||
this.evaluate();
|
||||
}
|
||||
}
|
||||
flash(ms, range) {
|
||||
flash(this.editor, ms, range);
|
||||
flash(ms) {
|
||||
flash(this.editor, ms);
|
||||
}
|
||||
highlight(haps, time) {
|
||||
highlightMiniLocations(this.editor, time, haps);
|
||||
@@ -402,10 +324,6 @@ export class StrudelMirror {
|
||||
setLineWrappingEnabled(enabled) {
|
||||
this.reconfigureExtension('isLineWrappingEnabled', enabled);
|
||||
}
|
||||
|
||||
setBlockBasedEvalEnabled(enabled) {
|
||||
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
|
||||
}
|
||||
setBracketMatchingEnabled(enabled) {
|
||||
this.reconfigureExtension('isBracketMatchingEnabled', enabled);
|
||||
}
|
||||
@@ -427,10 +345,6 @@ 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);
|
||||
}
|
||||
@@ -454,21 +368,8 @@ export class StrudelMirror {
|
||||
setCode(code) {
|
||||
this.replaceCode(code, 0, this.editor.state.doc.length);
|
||||
}
|
||||
// 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);
|
||||
this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest);
|
||||
this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment);
|
||||
}
|
||||
getCursorLocation() {
|
||||
return this.editor.state.selection.main.head;
|
||||
|
||||
@@ -14,8 +14,7 @@ export const flashField = StateField.define({
|
||||
const mark = Decoration.mark({
|
||||
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
|
||||
});
|
||||
const range = e.value.range || { from: 0, to: tr.newDoc.length };
|
||||
flash = Decoration.set([mark.range(range.from, range.to)]);
|
||||
flash = Decoration.set([mark.range(0, tr.newDoc.length)]);
|
||||
} else {
|
||||
flash = Decoration.set([]);
|
||||
}
|
||||
@@ -30,9 +29,8 @@ export const flashField = StateField.define({
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
export const flash = (view, ms = 200, range) => {
|
||||
const flashData = range ? { range } : true;
|
||||
view.dispatch({ effects: setFlash.of(flashData) });
|
||||
export const flash = (view, ms = 200) => {
|
||||
view.dispatch({ effects: setFlash.of(true) });
|
||||
setTimeout(() => {
|
||||
view.dispatch({ effects: setFlash.of(false) });
|
||||
}, ms);
|
||||
|
||||
@@ -3,9 +3,8 @@ import { Decoration, EditorView } from '@codemirror/view';
|
||||
|
||||
export const setMiniLocations = StateEffect.define();
|
||||
export const showMiniLocations = StateEffect.define();
|
||||
export const displayMiniLocations = StateEffect.define();
|
||||
export const updateMiniLocations = (view, locations, range = null) => {
|
||||
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
|
||||
export const updateMiniLocations = (view, locations) => {
|
||||
view.dispatch({ effects: setMiniLocations.of(locations) });
|
||||
};
|
||||
export const highlightMiniLocations = (view, atTime, haps) => {
|
||||
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
|
||||
@@ -22,54 +21,23 @@ const miniLocations = StateField.define({
|
||||
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(setMiniLocations)) {
|
||||
//block-based eval case
|
||||
if (e.value.range) {
|
||||
const stateMiniLocations = getMiniLocationsFromDecorations(locations);
|
||||
|
||||
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(':');
|
||||
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(...range); // -> Decoration
|
||||
});
|
||||
|
||||
const previousMarks = stateMiniLocations
|
||||
.filter(({ id }) => !newIds.has(id))
|
||||
.map(({ from, to, id }) =>
|
||||
// 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,
|
||||
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(from, to),
|
||||
);
|
||||
}).range(...range), // -> Decoration
|
||||
);
|
||||
|
||||
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>
|
||||
}
|
||||
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,83 +75,12 @@ const visibleMiniLocations = StateField.define({
|
||||
},
|
||||
});
|
||||
|
||||
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 miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => {
|
||||
const iterator = state.field(miniLocations).iter();
|
||||
const { haps } = state.field(visibleMiniLocations);
|
||||
const builder = new RangeSetBuilder();
|
||||
|
||||
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,
|
||||
@@ -192,48 +89,50 @@ const getMiniLocationsFromDecorations = (decorations) => {
|
||||
spec: { id },
|
||||
},
|
||||
} = iterator;
|
||||
miniLocationsArray.push({
|
||||
from,
|
||||
to,
|
||||
id,
|
||||
});
|
||||
|
||||
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 miniLocationsArray;
|
||||
};
|
||||
|
||||
export const getMiniLocations = (state) => {
|
||||
const decorations = state.field(miniLocations);
|
||||
return getMiniLocationsFromDecorations(decorations);
|
||||
};
|
||||
return builder.finish();
|
||||
});
|
||||
|
||||
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 highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights];
|
||||
|
||||
export const isPatternHighlightingEnabled = (on, config) => {
|
||||
// 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);
|
||||
on &&
|
||||
config &&
|
||||
setTimeout(() => {
|
||||
updateMiniLocations(config.editor, config.miniLocations);
|
||||
}, 100);
|
||||
return on ? Prec.highest(highlightExtension) : [];
|
||||
};
|
||||
|
||||
@@ -3,11 +3,9 @@ import { Prec, EditorState } from '@codemirror/state';
|
||||
import { keymap, ViewPlugin } from '@codemirror/view';
|
||||
// import { searchKeymap } from '@codemirror/search';
|
||||
import { emacs } from '@replit/codemirror-emacs';
|
||||
import { vim, Vim } from '@replit/codemirror-vim';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
// import { vim } from './vim_test.mjs';
|
||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||
import { helix, commands } from 'codemirror-helix';
|
||||
import { logger } from '@strudel/core';
|
||||
|
||||
const vscodePlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
@@ -21,166 +19,11 @@ const vscodePlugin = ViewPlugin.fromClass(
|
||||
);
|
||||
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
|
||||
|
||||
function replEval(view) {
|
||||
try {
|
||||
// Dispatch a dedicated evaluate event first
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-evaluate event', e);
|
||||
}
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
// Try Ctrl+Enter first if not handled by custom event
|
||||
const ctrlEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(ctrlEnter);
|
||||
// If not handled (no handler called preventDefault), try Alt+Enter as
|
||||
// fallback
|
||||
if (!ctrlEnter.defaultPrevented) {
|
||||
const altEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altEnter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl evaluation event', e);
|
||||
}
|
||||
}
|
||||
|
||||
function replStop(view) {
|
||||
try {
|
||||
// First try dispatching our custom stop event, then fallback to Alt+.
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false;
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-stop event', e);
|
||||
}
|
||||
if (!handled) {
|
||||
const altDot = new KeyboardEvent('keydown', {
|
||||
key: '.',
|
||||
code: 'Period',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altDot);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl stop event', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
|
||||
// event 'repl-evaluate' that the editor listens for, and also simulate
|
||||
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
|
||||
// appears in the Console panel.
|
||||
try {
|
||||
if (Vim && typeof Vim.defineEx === 'function') {
|
||||
// Map gc to toggle line comments by dispatching a custom event that our
|
||||
// CodeMirror integration listens to. This avoids depending on Vim's
|
||||
// internal actions and works with current selections/visual mode.
|
||||
try {
|
||||
Vim.defineAction('strudelToggleComment', (cm) => {
|
||||
const view = cm.cm6;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
|
||||
document.dispatchEvent(ev);
|
||||
} catch (e) {
|
||||
console.error('strudelToggleComment dispatch failed', e);
|
||||
}
|
||||
});
|
||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' });
|
||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' });
|
||||
} catch (e) {
|
||||
console.error('Vim gc mapping failed', e);
|
||||
}
|
||||
|
||||
// :q to pause/stop
|
||||
Vim.defineEx('quit', 'q', (cm) => {
|
||||
const view = cm.cm6;
|
||||
logger('[vim] :q — stopping repl');
|
||||
replStop(view);
|
||||
});
|
||||
|
||||
// :w to evaluate
|
||||
Vim.defineEx('write', 'w', (cm) => {
|
||||
const view = cm.cm6;
|
||||
try {
|
||||
view.focus?.();
|
||||
// Let the app know this came from Vim :w
|
||||
try {
|
||||
logger('[vim] :w — evaluating code');
|
||||
} catch (e) {
|
||||
console.error('Error logging Vim :w evaluation', e);
|
||||
}
|
||||
replEval(view);
|
||||
} catch (e) {
|
||||
console.error('Error dispatching :w evaluation event', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
|
||||
}
|
||||
|
||||
// Map Helix :w to trigger the same action as evaluation. We dispatch a custom
|
||||
// event 'repl-evaluate' that the editor listens for, and also simulate
|
||||
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
|
||||
// appears in the Console panel.
|
||||
const helixCommands = commands.of([
|
||||
{
|
||||
// :w to evaluate
|
||||
name: 'write',
|
||||
aliases: ['w'],
|
||||
help: 'Repl-eval',
|
||||
handler(view, args) {
|
||||
try {
|
||||
view?.focus?.(); // Let the app know this came from Helix :w
|
||||
logger('[helix] :w — evaluating code');
|
||||
replEval(view);
|
||||
} catch (e) {
|
||||
console.error('Error dispatching helix :w evaluation event', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// :q to pause/stop
|
||||
name: 'quit',
|
||||
aliases: ['q'],
|
||||
help: 'Repl-stop',
|
||||
handler(view, args) {
|
||||
try {
|
||||
view?.focus?.(); // Let the app know this came from Helix :q
|
||||
logger('[helix] :q — stopping repl');
|
||||
replStop(view);
|
||||
} catch (e) {
|
||||
console.error('Error dispatching helix :q stop event', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const keymaps = {
|
||||
vim,
|
||||
emacs,
|
||||
codemirror: () => keymap.of(defaultKeymap),
|
||||
vscode: vscodeExtension,
|
||||
helix: () => [helix(), helixCommands],
|
||||
};
|
||||
|
||||
export { Vim } from '@replit/codemirror-vim';
|
||||
|
||||
@@ -44,14 +44,10 @@
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"codemirror-helix": "^0.5.0",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
"@tonaljs/tonal": "^4.10.0",
|
||||
"nanostores": "^0.11.3",
|
||||
"superdough": "workspace:*"
|
||||
"nanostores": "^0.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
|
||||
+17
-106
@@ -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, id) {
|
||||
constructor(value, min, max, from, to, step, view) {
|
||||
super();
|
||||
this.value = value;
|
||||
this.min = min;
|
||||
@@ -16,21 +16,10 @@ 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(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
|
||||
);
|
||||
eq() {
|
||||
return false;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
@@ -49,7 +38,6 @@ 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) => {
|
||||
@@ -61,7 +49,7 @@ export class SliderWidget extends WidgetType {
|
||||
slider.originalValue = insert;
|
||||
slider.value = insert;
|
||||
this.view.dispatch({ changes: change });
|
||||
const id = slider.id; // Use range-based ID
|
||||
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler
|
||||
window.postMessage({ type: 'cm-slider', value: Number(next), id });
|
||||
});
|
||||
return wrap;
|
||||
@@ -74,60 +62,19 @@ export class SliderWidget extends WidgetType {
|
||||
|
||||
export const setSliderWidgets = StateEffect.define();
|
||||
|
||||
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) });
|
||||
}
|
||||
export const updateSliderWidgets = (view, widgets) => {
|
||||
view.dispatch({ effects: setSliderWidgets.of(widgets) });
|
||||
};
|
||||
|
||||
function getSliders(widgetConfigs, view) {
|
||||
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;
|
||||
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 */);
|
||||
});
|
||||
}
|
||||
|
||||
export const sliderPlugin = ViewPlugin.fromClass(
|
||||
@@ -154,42 +101,7 @@ export const sliderPlugin = ViewPlugin.fromClass(
|
||||
}
|
||||
}
|
||||
for (let e of tr.effects) {
|
||||
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)) {
|
||||
if (e.is(setSliderWidgets)) {
|
||||
this.decorations = Decoration.set(getSliders(e.value, update.view));
|
||||
}
|
||||
}
|
||||
@@ -220,7 +132,6 @@ 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) => {
|
||||
@@ -229,7 +140,7 @@ if (typeof window !== 'undefined') {
|
||||
// update state when slider is moved
|
||||
sliderValues[e.data.id] = e.data.value;
|
||||
} else {
|
||||
console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+45
-193
@@ -1,111 +1,55 @@
|
||||
import { StateEffect, StateField } from '@codemirror/state';
|
||||
import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
|
||||
import { Pattern } from '@strudel/core';
|
||||
|
||||
export const setWidgets = StateEffect.define();
|
||||
export const addWidget = StateEffect.define({
|
||||
map: ({ from, to }, change) => {
|
||||
return { from: change.mapPos(from), to: change.mapPos(to) };
|
||||
},
|
||||
});
|
||||
|
||||
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) });
|
||||
}
|
||||
export const updateWidgets = (view, widgets) => {
|
||||
view.dispatch({ effects: addWidget.of(widgets) });
|
||||
};
|
||||
|
||||
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 {
|
||||
function getWidgets(widgetConfigs) {
|
||||
return (
|
||||
widgetConfigs
|
||||
// codemirror throws an error if we don't sort
|
||||
.sort((a, b) => a.to - b.to)
|
||||
.map((widgetConfig) => {
|
||||
return Decoration.widget({
|
||||
widget: new BlockWidget(widgetConfig, view),
|
||||
widget: new BlockWidget(widgetConfig),
|
||||
side: 0,
|
||||
}).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
|
||||
block: true,
|
||||
}).range(widgetConfig.to);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
const widgetField = StateField.define(
|
||||
/* <DecorationSet> */ {
|
||||
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),
|
||||
});
|
||||
|
||||
// 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));
|
||||
} catch (error) {
|
||||
console.log('err', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
}
|
||||
return widgets;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -116,116 +60,24 @@ export function setWidget(id, el) {
|
||||
}
|
||||
|
||||
export class BlockWidget extends WidgetType {
|
||||
constructor(widgetConfig, view) {
|
||||
constructor(widgetConfig) {
|
||||
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(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
|
||||
);
|
||||
eq() {
|
||||
return true;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
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;
|
||||
const id = getWidgetID(this.widgetConfig);
|
||||
const el = widgetElements[id];
|
||||
return el;
|
||||
}
|
||||
|
||||
ignoreEvent(e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
export const widgetPlugin = [widgetField];
|
||||
|
||||
// widget implementer API to create a new widget type
|
||||
export function registerWidget(type, fn) {
|
||||
|
||||
@@ -5,34 +5,6 @@ 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);
|
||||
|
||||
+35
-295
@@ -45,7 +45,6 @@ export function repl({
|
||||
pattern: undefined,
|
||||
miniLocations: [],
|
||||
widgets: [],
|
||||
sliders: [],
|
||||
pending: false,
|
||||
started: false,
|
||||
};
|
||||
@@ -87,132 +86,11 @@ 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 cleanupConflictingRanges(codeBlocks, currentKey, newRange) {
|
||||
for (const [existingKey, existingBlock] of Object.entries(codeBlocks)) {
|
||||
if (existingKey === currentKey) continue;
|
||||
if (!existingBlock.range) continue;
|
||||
|
||||
const [existingStart, existingEnd] = existingBlock.range;
|
||||
const [newStart, newEnd] = newRange;
|
||||
|
||||
// If ranges overlap (not just touch), remove the stale block
|
||||
if (!(newEnd <= existingStart || newStart >= existingEnd)) {
|
||||
delete codeBlocks[existingKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// helper
|
||||
function handleSingleLabelBlock(label, code, options, meta) {
|
||||
// Detect if this block contains a non-inline widget
|
||||
// The activeVisualizer is now provided by the transpiler for all labels
|
||||
const activeVisualizer = label.activeVisualizer || null;
|
||||
|
||||
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 (including declaration blocks)
|
||||
cleanupConflictingRanges(codeBlocks, label.name, options.range);
|
||||
}
|
||||
|
||||
// helper
|
||||
// These blocks return silence but may contain mini notation strings that need highlighting
|
||||
function handleDeclarationBlock(code, options, meta) {
|
||||
const range = options.range || [];
|
||||
if (range.length < 2) return;
|
||||
|
||||
const blockKey = `_decl:${range[0]}:${range[1]}`;
|
||||
|
||||
codeBlocks[blockKey] = {
|
||||
code: code,
|
||||
range: range,
|
||||
labels: [],
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeVisualizer: null,
|
||||
};
|
||||
|
||||
// Clean up any overlapping declaration blocks
|
||||
cleanupConflictingRanges(codeBlocks, blockKey, range);
|
||||
}
|
||||
|
||||
const hush = function () {
|
||||
pPatterns = {};
|
||||
anonymousIndex = 0;
|
||||
allTransform = undefined;
|
||||
eachTransform = undefined;
|
||||
codeBlocks = {};
|
||||
blockPatterns.clear();
|
||||
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||
return silence;
|
||||
};
|
||||
|
||||
@@ -233,60 +111,7 @@ export function repl({
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
setClockTime(() => scheduler.getTime()); // TODO: refactor?
|
||||
|
||||
// Helper function to apply pattern transformations (solo, each, all)
|
||||
// this should be abstracted more
|
||||
function applyPatternTransforms(pattern) {
|
||||
const allPatterns = Object.values(pPatterns);
|
||||
|
||||
if (allPatterns.length) {
|
||||
let patterns = [];
|
||||
let soloActive = false;
|
||||
for (const [key, value] of Object.entries(pPatterns)) {
|
||||
// handle soloed patterns ex: S$: s("bd!4")
|
||||
const isSolod = key.length > 1 && key.startsWith('S');
|
||||
if (isSolod && soloActive === false) {
|
||||
// first time we see a soloed pattern, clear existing patterns
|
||||
patterns = [];
|
||||
soloActive = true;
|
||||
}
|
||||
if (!soloActive || (soloActive && isSolod)) {
|
||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||
patterns.push(valWithState);
|
||||
}
|
||||
}
|
||||
if (eachTransform) {
|
||||
// Explicit lambda so only element (not index and array) are passed
|
||||
patterns = patterns.map((x) => eachTransform(x));
|
||||
}
|
||||
pattern = stack(...patterns);
|
||||
} else if (eachTransform) {
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransforms.length) {
|
||||
for (const transform of allTransforms) {
|
||||
pattern = transform(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPattern(pattern)) {
|
||||
pattern = silence;
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
codeBlocks = {};
|
||||
blockPatterns.clear();
|
||||
pPatterns = {};
|
||||
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||
updateState({
|
||||
miniLocations: [],
|
||||
widgets: [],
|
||||
sliders: [],
|
||||
});
|
||||
scheduler.stop();
|
||||
};
|
||||
const stop = () => scheduler.stop();
|
||||
const start = () => scheduler.start();
|
||||
const pause = () => scheduler.pause();
|
||||
const toggle = () => scheduler.toggle();
|
||||
@@ -407,7 +232,7 @@ export function repl({
|
||||
});
|
||||
};
|
||||
|
||||
const evaluate = async (code, autostart = true) => {
|
||||
const evaluate = async (code, autostart = true, shouldHush = true) => {
|
||||
if (!code) {
|
||||
throw new Error('no code to evaluate');
|
||||
}
|
||||
@@ -416,132 +241,47 @@ export function repl({
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
setClockTime(() => scheduler.getTime()); // TODO: refactor?
|
||||
await beforeEval?.({ code, blockBased: false });
|
||||
await beforeEval?.({ code });
|
||||
allTransforms = []; // reset all transforms
|
||||
|
||||
codeBlocks = {};
|
||||
hush();
|
||||
shouldHush && hush();
|
||||
|
||||
if (mondo) {
|
||||
code = `mondolang\`${code}\``;
|
||||
}
|
||||
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||
|
||||
pattern = applyPatternTransforms(pattern);
|
||||
|
||||
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, range: undefined, widgetRemoved: false });
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
logger(`[eval] error: ${err.message}`, 'error');
|
||||
console.error(err);
|
||||
updateState({ evalError: err, pending: false });
|
||||
onEvalError?.(err);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateBlock = async (code, autostart = true, options = {}) => {
|
||||
if (!code) {
|
||||
throw new Error('no code to evaluate');
|
||||
}
|
||||
try {
|
||||
updateState({ code, pending: true });
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
setClockTime(() => scheduler.getTime()); // TODO: refactor?
|
||||
await beforeEval?.({ code, blockBased: true });
|
||||
allTransforms = []; // reset all transforms
|
||||
|
||||
const transpilerOptionsWithBlock = {
|
||||
...transpilerOptions,
|
||||
blockBased: true,
|
||||
range: options.range || [],
|
||||
};
|
||||
|
||||
if (mondo) {
|
||||
code = `mondolang\`${code}\``;
|
||||
}
|
||||
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptionsWithBlock);
|
||||
|
||||
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||
let widgetRemoved = false;
|
||||
|
||||
const labels = meta.labels || [];
|
||||
|
||||
// Check for anonymous labels (labels starting with '$')
|
||||
const hasAnonymousLabel = labels.some((label) => label.name.startsWith('$'));
|
||||
|
||||
// Store code blocks in dictionary using labels as keys
|
||||
if (hasAnonymousLabel) {
|
||||
// variable/function declarations that don't return patterns 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)',
|
||||
);
|
||||
} else if (labels.length > 0) {
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
// 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 {
|
||||
// Declaration block (variable/function that returns silence)
|
||||
// Store it so its miniLocations are preserved for highlighting patterns stored in variables
|
||||
handleDeclarationBlock(code, options, meta);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// this is the hackiest bit
|
||||
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;
|
||||
if (Object.keys(pPatterns).length) {
|
||||
let patterns = [];
|
||||
let soloActive = false;
|
||||
for (const [key, value] of Object.entries(pPatterns)) {
|
||||
// handle soloed patterns ex: S$: s("bd!4")
|
||||
const isSolod = key.length > 1 && key.startsWith('S');
|
||||
if (isSolod && soloActive === false) {
|
||||
// first time we see a soloed pattern, clear existing patterns
|
||||
patterns = [];
|
||||
soloActive = true;
|
||||
}
|
||||
if (!soloActive || (soloActive && isSolod)) {
|
||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||
patterns.push(valWithState);
|
||||
}
|
||||
}
|
||||
if (eachTransform) {
|
||||
// Explicit lambda so only element (not index and array) are passed
|
||||
patterns = patterns.map((x) => eachTransform(x));
|
||||
}
|
||||
pattern = stack(...patterns);
|
||||
} else if (eachTransform) {
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransforms.length) {
|
||||
for (const transform of allTransforms) {
|
||||
pattern = transform(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
pPatterns = Object.fromEntries(
|
||||
Object.entries(pPatterns).filter(([key]) => {
|
||||
return Object.keys(codeBlocks).includes(key);
|
||||
}),
|
||||
);
|
||||
|
||||
pattern = applyPatternTransforms(pattern);
|
||||
if (!isPattern(pattern)) {
|
||||
pattern = silence;
|
||||
}
|
||||
|
||||
logger(`[eval] code updated`);
|
||||
pattern = await setPattern(pattern, autostart);
|
||||
@@ -556,7 +296,7 @@ export function repl({
|
||||
pending: false,
|
||||
});
|
||||
|
||||
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
|
||||
afterEval?.({ code, pattern, meta });
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
logger(`[eval] error: ${err.message}`, 'error');
|
||||
@@ -567,7 +307,7 @@ export function repl({
|
||||
};
|
||||
|
||||
const setCode = (code) => updateState({ code });
|
||||
return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||
}
|
||||
|
||||
export const getSchedule =
|
||||
|
||||
@@ -1143,3 +1143,26 @@ export const perx = new Pattern(function (state) {
|
||||
const n = Fraction(1).div(state.span.duration);
|
||||
return [new Hap(undefined, state.span, Math.log(n) / Math.log(2) + 1)];
|
||||
});
|
||||
|
||||
/**
|
||||
* A signal that goes from `0` to `1` over one cycle, and then stays at `1` forever.
|
||||
* Mainly useful in combination with other functions that reset the cycle. See also
|
||||
* `close`.
|
||||
* @tags temporal
|
||||
* @example
|
||||
* sound("bd sd [- bd] sd".fast(2))
|
||||
* .lpf(open.slow(4).range(300,3000)) // close envelope over 4 cycles
|
||||
* .restart("x/4") // restart every 4 cycles
|
||||
*/
|
||||
export const open = signal((t) => t.min(1));
|
||||
|
||||
/**
|
||||
* The same as `open`, but goes from `1` to `0` over one cycle, and then
|
||||
* stays on `0` forever.
|
||||
* @tags temporal
|
||||
* @example
|
||||
* sound("bd sd [- bd] sd".fast(2))
|
||||
* .lpf(close.slow(4).range(300,3000)) // close envelope over 4 cycles
|
||||
* .restart("x/4") // restart every 4 cycles
|
||||
*/
|
||||
export const close = signal((t) => Fraction(1).sub(t).max(0));
|
||||
|
||||
@@ -4,11 +4,13 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { register, noteToMidi } from '@strudel/core';
|
||||
import { register } from '@strudel/core';
|
||||
import { Dough, doughsamples } from 'dough-synth';
|
||||
import { getAudioContext, ensureMinimalOutput } from '@strudel/webaudio';
|
||||
import wasm from 'dough-synth/dough.wasm?url';
|
||||
import workletCode from 'dough-synth/dough.js?raw';
|
||||
import { hap2dough } from './lib.mjs';
|
||||
export * from './lib.mjs';
|
||||
|
||||
Object.assign(globalThis, { doughsamples });
|
||||
|
||||
@@ -42,16 +44,8 @@ export async function doughOutput(hap, targetTime, cps = 1) {
|
||||
if (!offset) {
|
||||
return; // not ready
|
||||
}
|
||||
hap.ensureObjectValue();
|
||||
const event = {
|
||||
dough: 'play',
|
||||
...hap.value,
|
||||
time: targetTime - offset,
|
||||
duration: hap.duration / cps,
|
||||
};
|
||||
if (typeof event.note === 'string') {
|
||||
event.note = noteToMidi(event.note);
|
||||
}
|
||||
const t = targetTime - offset;
|
||||
const event = hap2dough(hap, t, cps);
|
||||
D.evaluate(event);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { register, registerControl, stack, Pattern, noteToMidi } from '@strudel/core';
|
||||
|
||||
export const { group } = registerControl('group');
|
||||
export const { voices } = registerControl('voices');
|
||||
export const { glide } = registerControl('glide');
|
||||
|
||||
export const groupmod = register('groupmod', function (modulator, pat) {
|
||||
const modpat = modulator
|
||||
.withValue((vm) => pat.withValue(({ group }) => ({ ...vm, group })))
|
||||
.outerJoin()
|
||||
.set({ dough: 'update_group' });
|
||||
return stack(pat.set(modulator), modpat);
|
||||
});
|
||||
Pattern.prototype.gmod = Pattern.prototype.groupmod;
|
||||
|
||||
export function hap2dough(hap, time, cps) {
|
||||
hap.ensureObjectValue();
|
||||
const event = {
|
||||
dough: 'play',
|
||||
...hap.value,
|
||||
time,
|
||||
duration: hap.duration / cps,
|
||||
};
|
||||
if (typeof event.note === 'string') {
|
||||
event.note = noteToMidi(event.note);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*",
|
||||
"dough-synth": "0.2.4"
|
||||
"dough-synth": "0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
|
||||
@@ -78,16 +78,6 @@ 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) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import './tonal.mjs';
|
||||
import './voicings.mjs';
|
||||
import './ireal.mjs';
|
||||
|
||||
export * from './tonal.mjs';
|
||||
export * from './voicings.mjs';
|
||||
export * from './ireal.mjs';
|
||||
|
||||
import './ireal.mjs';
|
||||
|
||||
export const packageName = '@strudel/tonal';
|
||||
|
||||
@@ -39,15 +39,11 @@ const sliderTranspilerPlugin = {
|
||||
if (!isSliderFunction(node)) return;
|
||||
context.widgets ??= [];
|
||||
context.sliders ??= [];
|
||||
const { options, widgets, sliders, nodeOffset } = context;
|
||||
const { options, widgets, sliders } = context;
|
||||
const { emitWidgets } = options;
|
||||
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,
|
||||
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,
|
||||
@@ -56,7 +52,7 @@ const sliderTranspilerPlugin = {
|
||||
};
|
||||
emitWidgets && widgets.push(sliderConfig);
|
||||
sliders.push(sliderConfig);
|
||||
return this.replace(sliderWithLocation(node, nodeOffset));
|
||||
return this.replace(sliderWithLocation(node));
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -73,13 +69,8 @@ function isWidgetMethod(node) {
|
||||
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
||||
}
|
||||
|
||||
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}`;
|
||||
function sliderWithLocation(node) {
|
||||
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
|
||||
|
||||
// add loc as identifier to first argument
|
||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||
@@ -95,19 +86,10 @@ function sliderWithLocation(node, nodeOffset = 0) {
|
||||
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
|
||||
// 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}`;
|
||||
// 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
|
||||
}
|
||||
|
||||
function widgetWithLocation(node, widgetConfig) {
|
||||
|
||||
@@ -35,12 +35,10 @@ export function transpiler(input, options = {}) {
|
||||
addReturn: true,
|
||||
emitMiniLocations: true,
|
||||
emitWidgets: true,
|
||||
blockBased: false,
|
||||
range: [],
|
||||
...options,
|
||||
};
|
||||
|
||||
const { wrapAsync, addReturn, emitMiniLocations, emitWidgets, blockBased, range } = options;
|
||||
const { wrapAsync, addReturn, emitMiniLocations, emitWidgets } = options;
|
||||
|
||||
const comments = [];
|
||||
let ast = parse(input, {
|
||||
@@ -52,67 +50,21 @@ export function transpiler(input, options = {}) {
|
||||
|
||||
let miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
||||
|
||||
// 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 = [];
|
||||
|
||||
let labels = [];
|
||||
|
||||
const context = { options, input, nodeOffset, miniDisableRanges, labels };
|
||||
const context = { options, input, miniDisableRanges, labels };
|
||||
const plugins = getPlugins().map((plugin) => plugin.walk?.(context));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin?.enter?.call(this, node, parent, prop, index)) continue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLabelStatement(node)) {
|
||||
// Collect label info for block-based evaluation
|
||||
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
|
||||
if (blockBased) {
|
||||
labels.push({
|
||||
name: node.label.name,
|
||||
index: node.start - nodeOffset,
|
||||
end: node.label.end - nodeOffset,
|
||||
fullMatch: input.slice(node.start - nodeOffset, node.label.end - nodeOffset),
|
||||
activeVisualizer: findVisualizerInSubtree(node.body),
|
||||
});
|
||||
}
|
||||
return this.replace(labelToP(node));
|
||||
}
|
||||
// Detect all() calls as special labels for block management
|
||||
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
|
||||
if (blockBased && isAllCall(node)) {
|
||||
labels.push({
|
||||
name: 'all',
|
||||
index: node.start - nodeOffset,
|
||||
end: node.end - nodeOffset,
|
||||
fullMatch: input.slice(node.start - nodeOffset, node.end - nodeOffset),
|
||||
activeVisualizer: node.arguments[0] ? findVisualizerInSubtree(node.arguments[0]) : null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
leave(node, parent, prop, index) {
|
||||
@@ -141,14 +93,6 @@ export function transpiler(input, options = {}) {
|
||||
body.push(silenceExpression);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (addReturn) {
|
||||
const { expression } = body[body.length - 1];
|
||||
@@ -166,7 +110,7 @@ export function transpiler(input, options = {}) {
|
||||
}
|
||||
|
||||
let pluginContext;
|
||||
({ options, input, miniDisableRanges, nodeOffset, ...pluginContext } = context);
|
||||
({ options, input, miniDisableRanges, ...pluginContext } = context);
|
||||
|
||||
return { output, ...pluginContext };
|
||||
}
|
||||
|
||||
Generated
+24
-34
@@ -242,24 +242,12 @@ importers:
|
||||
'@strudel/draw':
|
||||
specifier: workspace:*
|
||||
version: link:../draw
|
||||
'@strudel/tonal':
|
||||
specifier: workspace:*
|
||||
version: link:../tonal
|
||||
'@strudel/transpiler':
|
||||
specifier: workspace:*
|
||||
version: link:../transpiler
|
||||
'@tonaljs/tonal':
|
||||
specifier: ^4.10.0
|
||||
version: 4.10.0
|
||||
codemirror-helix:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
|
||||
nanostores:
|
||||
specifier: ^0.11.3
|
||||
version: 0.11.3
|
||||
superdough:
|
||||
specifier: workspace:*
|
||||
version: link:../superdough
|
||||
devDependencies:
|
||||
vite:
|
||||
specifier: ^6.0.11
|
||||
@@ -318,8 +306,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../webaudio
|
||||
dough-synth:
|
||||
specifier: 0.2.4
|
||||
version: 0.2.4
|
||||
specifier: 0.4.1
|
||||
version: 0.4.1
|
||||
devDependencies:
|
||||
vite:
|
||||
specifier: ^6.0.11
|
||||
@@ -3660,15 +3648,6 @@ packages:
|
||||
resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
codemirror-helix@0.5.0:
|
||||
resolution: {integrity: sha512-hI56hf9VGz53H1YvL6H1GC7HtP6te8vX+MsIHaE9J7Q3PQ6KFapKtIRg6lqSH898ikHWpMCPu42r6HJN0IfVLA==}
|
||||
peerDependencies:
|
||||
'@codemirror/commands': ^6.0.0
|
||||
'@codemirror/language': ^6.0.0
|
||||
'@codemirror/search': ^6.0.0
|
||||
'@codemirror/state': ^6.0.0
|
||||
'@codemirror/view': ^6.0.0
|
||||
|
||||
collapse-white-space@2.1.0:
|
||||
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
||||
|
||||
@@ -4078,8 +4057,8 @@ packages:
|
||||
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dough-synth@0.2.4:
|
||||
resolution: {integrity: sha512-qMnMKVj1B2ivUgobNCwqJqNzZHOMN+5339OmCOk07zIDEK7iCYdufa+ZDE2BEgmKrWDyhGS7oTNFjHtTwzEv9w==}
|
||||
dough-synth@0.4.1:
|
||||
resolution: {integrity: sha512-hxBsfOBwTIlJMhSB/Pk2xNNc7YTAVTgexn5Y7GRv/Kn2qBkMxwLxHJNvhsUhklQhVvzny/aCihxb0EOq4tv5Bw==}
|
||||
hasBin: true
|
||||
|
||||
dset@3.1.4:
|
||||
@@ -8030,6 +8009,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.22.0:
|
||||
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xmlcreate@2.0.4:
|
||||
resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==}
|
||||
|
||||
@@ -11411,14 +11402,6 @@ snapshots:
|
||||
|
||||
cmd-shim@6.0.3: {}
|
||||
|
||||
codemirror-helix@0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2):
|
||||
dependencies:
|
||||
'@codemirror/commands': 6.8.0
|
||||
'@codemirror/language': 6.10.8
|
||||
'@codemirror/search': 6.5.8
|
||||
'@codemirror/state': 6.5.1
|
||||
'@codemirror/view': 6.36.2
|
||||
|
||||
collapse-white-space@2.1.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
@@ -11800,7 +11783,12 @@ snapshots:
|
||||
|
||||
dotenv@16.4.7: {}
|
||||
|
||||
dough-synth@0.2.4: {}
|
||||
dough-synth@0.4.1:
|
||||
dependencies:
|
||||
ws: 8.22.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
dset@3.1.4: {}
|
||||
|
||||
@@ -16698,6 +16686,8 @@ snapshots:
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
ws@8.22.0: {}
|
||||
|
||||
xmlcreate@2.0.4: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
@@ -2440,6 +2440,43 @@ exports[`runs examples > example "clip" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "close" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | s:bd cutoff:3000 ]",
|
||||
"[ 1/8 → 1/4 | s:sd cutoff:2915.625 ]",
|
||||
"[ 5/16 → 3/8 | s:bd cutoff:2789.0625 ]",
|
||||
"[ 3/8 → 1/2 | s:sd cutoff:2746.875 ]",
|
||||
"[ 1/2 → 5/8 | s:bd cutoff:2662.5 ]",
|
||||
"[ 5/8 → 3/4 | s:sd cutoff:2578.125 ]",
|
||||
"[ 13/16 → 7/8 | s:bd cutoff:2451.5625 ]",
|
||||
"[ 7/8 → 1/1 | s:sd cutoff:2409.375 ]",
|
||||
"[ 1/1 → 9/8 | s:bd cutoff:2325 ]",
|
||||
"[ 9/8 → 5/4 | s:sd cutoff:2240.625 ]",
|
||||
"[ 21/16 → 11/8 | s:bd cutoff:2114.0625 ]",
|
||||
"[ 11/8 → 3/2 | s:sd cutoff:2071.875 ]",
|
||||
"[ 3/2 → 13/8 | s:bd cutoff:1987.5 ]",
|
||||
"[ 13/8 → 7/4 | s:sd cutoff:1903.125 ]",
|
||||
"[ 29/16 → 15/8 | s:bd cutoff:1776.5625 ]",
|
||||
"[ 15/8 → 2/1 | s:sd cutoff:1734.375 ]",
|
||||
"[ 2/1 → 17/8 | s:bd cutoff:1650 ]",
|
||||
"[ 17/8 → 9/4 | s:sd cutoff:1565.625 ]",
|
||||
"[ 37/16 → 19/8 | s:bd cutoff:1439.0625 ]",
|
||||
"[ 19/8 → 5/2 | s:sd cutoff:1396.875 ]",
|
||||
"[ 5/2 → 21/8 | s:bd cutoff:1312.5 ]",
|
||||
"[ 21/8 → 11/4 | s:sd cutoff:1228.125 ]",
|
||||
"[ 45/16 → 23/8 | s:bd cutoff:1101.5625 ]",
|
||||
"[ 23/8 → 3/1 | s:sd cutoff:1059.375 ]",
|
||||
"[ 3/1 → 25/8 | s:bd cutoff:975 ]",
|
||||
"[ 25/8 → 13/4 | s:sd cutoff:890.625 ]",
|
||||
"[ 53/16 → 27/8 | s:bd cutoff:764.0625 ]",
|
||||
"[ 27/8 → 7/2 | s:sd cutoff:721.875 ]",
|
||||
"[ 7/2 → 29/8 | s:bd cutoff:637.5 ]",
|
||||
"[ 29/8 → 15/4 | s:sd cutoff:553.125 ]",
|
||||
"[ 61/16 → 31/8 | s:bd cutoff:426.5625 ]",
|
||||
"[ 31/8 → 4/1 | s:sd cutoff:384.375 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "coarse" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | s:hh coarse:1 ]",
|
||||
@@ -8764,6 +8801,43 @@ exports[`runs examples > example "onTriggerTime" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "open" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | s:bd cutoff:300 ]",
|
||||
"[ 1/8 → 1/4 | s:sd cutoff:384.375 ]",
|
||||
"[ 5/16 → 3/8 | s:bd cutoff:510.9375 ]",
|
||||
"[ 3/8 → 1/2 | s:sd cutoff:553.125 ]",
|
||||
"[ 1/2 → 5/8 | s:bd cutoff:637.5 ]",
|
||||
"[ 5/8 → 3/4 | s:sd cutoff:721.875 ]",
|
||||
"[ 13/16 → 7/8 | s:bd cutoff:848.4375 ]",
|
||||
"[ 7/8 → 1/1 | s:sd cutoff:890.625 ]",
|
||||
"[ 1/1 → 9/8 | s:bd cutoff:975 ]",
|
||||
"[ 9/8 → 5/4 | s:sd cutoff:1059.375 ]",
|
||||
"[ 21/16 → 11/8 | s:bd cutoff:1185.9375 ]",
|
||||
"[ 11/8 → 3/2 | s:sd cutoff:1228.125 ]",
|
||||
"[ 3/2 → 13/8 | s:bd cutoff:1312.5 ]",
|
||||
"[ 13/8 → 7/4 | s:sd cutoff:1396.875 ]",
|
||||
"[ 29/16 → 15/8 | s:bd cutoff:1523.4375 ]",
|
||||
"[ 15/8 → 2/1 | s:sd cutoff:1565.625 ]",
|
||||
"[ 2/1 → 17/8 | s:bd cutoff:1650 ]",
|
||||
"[ 17/8 → 9/4 | s:sd cutoff:1734.375 ]",
|
||||
"[ 37/16 → 19/8 | s:bd cutoff:1860.9375 ]",
|
||||
"[ 19/8 → 5/2 | s:sd cutoff:1903.125 ]",
|
||||
"[ 5/2 → 21/8 | s:bd cutoff:1987.5 ]",
|
||||
"[ 21/8 → 11/4 | s:sd cutoff:2071.875 ]",
|
||||
"[ 45/16 → 23/8 | s:bd cutoff:2198.4375 ]",
|
||||
"[ 23/8 → 3/1 | s:sd cutoff:2240.625 ]",
|
||||
"[ 3/1 → 25/8 | s:bd cutoff:2325 ]",
|
||||
"[ 25/8 → 13/4 | s:sd cutoff:2409.375 ]",
|
||||
"[ 53/16 → 27/8 | s:bd cutoff:2535.9375 ]",
|
||||
"[ 27/8 → 7/2 | s:sd cutoff:2578.125 ]",
|
||||
"[ 7/2 → 29/8 | s:bd cutoff:2662.5 ]",
|
||||
"[ 29/8 → 15/4 | s:sd cutoff:2746.875 ]",
|
||||
"[ 61/16 → 31/8 | s:bd cutoff:2873.4375 ]",
|
||||
"[ 31/8 → 4/1 | s:sd cutoff:2915.625 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "orbit" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/6 | s:hh delay:0.5 delaytime:0.25 orbit:1 ]",
|
||||
|
||||
@@ -20,7 +20,6 @@ const skippedExamples = [
|
||||
'accelerationX',
|
||||
'defaultmidimap',
|
||||
'midimaps',
|
||||
'clearScope',
|
||||
'bmod',
|
||||
];
|
||||
|
||||
|
||||
@@ -10,11 +10,8 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import AstroPWA from '@vite-pwa/astro';
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
const site = process.env.SITE_URL || `https://strudel.cc/`; // root url without a path
|
||||
const base = process.env.BASE_PATH || ''; // base path of the strudel site
|
||||
|
||||
const site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path
|
||||
const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site
|
||||
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
|
||||
// this rehype plugin fixes relative links
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
title: Helix Keybindings
|
||||
layout: ../../layouts/MainLayout.astro
|
||||
---
|
||||
|
||||
# Helix Keybindings in the REPL
|
||||
|
||||
The Strudel REPL supports [Helix editor](https://helix-editor.com/) keybindings through the `codemirror-helix` extension. Helix is a post-modern modal text editor with a focus on selection-first editing and multiple cursors.
|
||||
|
||||
## Enabling Helix Mode
|
||||
|
||||
1. Open the Settings panel in the REPL (click the settings icon or use the keyboard shortcut)
|
||||
2. Find the "Keybindings" section
|
||||
3. Select "Helix" from the available options
|
||||
|
||||
## Key Differences from Vim
|
||||
|
||||
Helix uses a selection-first approach, which means:
|
||||
|
||||
- **Selection → Action** (Helix) vs **Action → Motion** (Vim)
|
||||
- For example, to delete a word in Helix: `w` (select word) → `d` (delete)
|
||||
- In Vim, it would be: `dw` (delete word)
|
||||
|
||||
## Common Helix Commands
|
||||
|
||||
### Normal Mode
|
||||
|
||||
- `i` — Enter insert mode before selection
|
||||
- `a` — Enter insert mode after selection
|
||||
- `v` — Enter visual/select mode
|
||||
- `w` — Select next word
|
||||
- `e` — Select to end of word
|
||||
- `b` — Select previous word
|
||||
- `x` — Select/extend line
|
||||
- `d` — Delete selection
|
||||
- `c` — Change selection (delete and enter insert mode)
|
||||
- `y` — Yank (copy) selection
|
||||
- `p` — Paste after selection
|
||||
- `u` — Undo
|
||||
- `U` — Redo
|
||||
- `/` — Search
|
||||
- `n` — Select next search match
|
||||
- `N` — Select previous search match
|
||||
|
||||
### Movement
|
||||
|
||||
- `h, j, k, l` — Move left, down, up, right
|
||||
- `gg` — Go to start of document
|
||||
- `ge` — Go to end of document
|
||||
- `{` / `}` — Move to previous/next paragraph
|
||||
- `%` — Match bracket
|
||||
|
||||
### Multi-cursor
|
||||
|
||||
- `C` — Duplicate cursor to line below
|
||||
- `Alt-C` — Duplicate cursor to line above
|
||||
- `,` — Remove primary cursor
|
||||
- `Alt-,` — Remove all secondary cursors
|
||||
|
||||
## Strudel-Specific Features
|
||||
|
||||
Currently, Helix mode in Strudel provides standard Helix keybindings. Unlike Vim mode, there are no custom Strudel-specific commands (like `:w` to evaluate) yet.
|
||||
|
||||
To evaluate code while in Helix mode, use the standard shortcuts:
|
||||
|
||||
- `Ctrl+Enter` or `Alt+Enter` — Evaluate code
|
||||
- `Alt+.` — Stop playback
|
||||
|
||||
## Resources
|
||||
|
||||
- [Helix Documentation](https://docs.helix-editor.com/)
|
||||
- [Helix Keymap](https://docs.helix-editor.com/keymap.html)
|
||||
- [codemirror-helix on npm](https://www.npmjs.com/package/codemirror-helix)
|
||||
- [codemirror-helix on GitLab](https://gitlab.com/_rvidal/codemirror-helix)
|
||||
|
||||
## Notes
|
||||
|
||||
- The `codemirror-helix` extension is described as an "initial version" and may not implement all Helix features
|
||||
- Behavior may differ slightly from the native Helix editor
|
||||
- If you encounter issues, you can switch back to other keybinding modes in Settings
|
||||
- The keybinding preference is saved in your browser's local storage
|
||||
|
||||
## Contributing
|
||||
|
||||
If you'd like to add Strudel-specific Helix commands (similar to Vim's `:w` for evaluate), contributions are welcome! See the implementation in `/packages/codemirror/keybindings.mjs` for reference.
|
||||
@@ -117,10 +117,6 @@ Here's an example AST for `c3 [e3 g3]`
|
||||
|
||||
which translates to `seq(c3, seq(e3, g3))`
|
||||
|
||||
## Vim Keybindings
|
||||
|
||||
See the separate page on Vim shortcuts for a quick reference: [/technical-manual/vim](/technical-manual/vim)
|
||||
|
||||
## Scheduling Events
|
||||
|
||||
After an instance of `Pattern` is obtained from the user code,
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
title: Vim Shortcuts
|
||||
layout: ../../layouts/MainLayout.astro
|
||||
---
|
||||
|
||||
# Vim Shortcuts in the REPL
|
||||
|
||||
When the REPL editor (CodeMirror) is configured to use Vim keybindings, the following commands are available:
|
||||
|
||||
- :w — Evaluate the current code
|
||||
|
||||
- Triggers the same evaluation as Ctrl+Enter / Alt+Enter
|
||||
- You'll see messages in the Console panel such as:
|
||||
- [vim] :w — evaluating code
|
||||
- [repl] evaluate via event
|
||||
- [eval] code updated
|
||||
|
||||
- :q — Stop/pause playback
|
||||
|
||||
- Triggers the same stop action as Alt+.
|
||||
- Useful to quickly stop scheduling without leaving Vim mode
|
||||
|
||||
- gc — Toggle line comments for the current selection(s)
|
||||
|
||||
- Works in normal and visual mode
|
||||
- If there's a selection, all selected lines are toggled
|
||||
|
||||
Notes
|
||||
|
||||
- Behavior respects the current language mode in the editor for comment syntax.
|
||||
- If multiple REPL editors are open, commands target the active editor. The implementation dispatches custom events handled by the editor.
|
||||
- If you don't see the Console panel, open the right panel in the REPL UI.
|
||||
|
||||
Troubleshooting
|
||||
|
||||
- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback.
|
||||
- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.
|
||||
|
||||
## Adding custom keybindings
|
||||
|
||||
To add custom keybindings the `Vim` object can be used (either from within a pattern or in a prebake script)
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
// Map 'jk' to Escape in normal mode
|
||||
Vim.map('jk', '<Esc>', 'insert');
|
||||
// Map 'U' to :w (Evaluate the current code)
|
||||
Vim.map('U', ':w<CR>', 'normal');
|
||||
// Map 'Q' to :q — Stop/pause playback
|
||||
Vim.map('Q', ':q<CR>', 'normal');
|
||||
// Map 'J' to find next '$' (jump to next label)
|
||||
Vim.map('J', '/\\$<CR>', 'normal');
|
||||
// Map 'K' to find previous '$' (jump to previous label)
|
||||
Vim.map('K', '?\\$<CR>', 'normal');
|
||||
```
|
||||
|
||||
For more information on how to use the `Vim` object see [CodeMirror Vim](https://github.com/replit/codemirror-vim)
|
||||
@@ -9,7 +9,7 @@ import { confirmDialog } from '../../util.mjs';
|
||||
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio';
|
||||
import { ActionButton } from '../button/action-button.jsx';
|
||||
import { exportScript, ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import cx from '@src/cx.mjs';
|
||||
|
||||
const inputClass =
|
||||
@@ -147,7 +147,6 @@ function MainSettingsContent({ started }) {
|
||||
isTabIndentationEnabled,
|
||||
isMultiCursorEnabled,
|
||||
patternAutoStart,
|
||||
isBlockBasedEvalEnabled,
|
||||
} = useSettings();
|
||||
const shouldAlwaysSync = isUdels();
|
||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||
@@ -235,7 +234,7 @@ function MainSettingsContent({ started }) {
|
||||
<ButtonGroup
|
||||
value={keybindings}
|
||||
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
|
||||
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', helix: 'Helix', vscode: 'VSCode' }}
|
||||
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
|
||||
></ButtonGroup>
|
||||
</FormItem>
|
||||
<FormItem label="Panel Position">
|
||||
@@ -296,11 +295,6 @@ function MainSettingsContent({ started }) {
|
||||
onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)}
|
||||
value={isMultiCursorEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Enable Block-based Evaluation (EXPERIMENTAL)"
|
||||
onChange={(cbEvent) => settingsMap.setKey('isBlockBasedEvalEnabled', cbEvent.target.checked)}
|
||||
value={isBlockBasedEvalEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Enable flashing on evaluation"
|
||||
onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)}
|
||||
@@ -356,11 +350,6 @@ function MainSettingsContent({ started }) {
|
||||
function PrebakeSettingsContent() {
|
||||
const { fontFamily, includePrebakeScriptInShare, prebakeScript } = useSettings();
|
||||
const editorRef = useRef();
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
editorRef.current?.cleanup();
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full text-foreground w-full overflow-auto" style={{ fontFamily }}>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toggleLineComment } from '@codemirror/commands';
|
||||
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
@@ -63,28 +62,6 @@ export class PrebakeCodeMirror {
|
||||
state,
|
||||
parent: container,
|
||||
});
|
||||
|
||||
const handleSaveEvent = async (e) => {
|
||||
if (e.detail.view !== this.view) {
|
||||
return; // ignore events from other editors
|
||||
}
|
||||
await this.savePrebake();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
};
|
||||
const handleToggleComment = (e) => {
|
||||
if (e.detail.view !== this.view) {
|
||||
return; // ignore events from other editors
|
||||
}
|
||||
this.toggleComment();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
};
|
||||
|
||||
document.addEventListener('repl-evaluate', handleSaveEvent);
|
||||
document.addEventListener('repl-toggle-comment', handleToggleComment);
|
||||
this.cleanup = () => {
|
||||
document.removeEventListener('prebake-evaluate', handleSaveEvent);
|
||||
document.removeEventListener('prebake-toggle-comment', handleToggleComment);
|
||||
};
|
||||
}
|
||||
|
||||
async savePrebake() {
|
||||
@@ -94,16 +71,6 @@ export class PrebakeCodeMirror {
|
||||
logger('[prebake] prebake saved');
|
||||
}
|
||||
|
||||
toggleComment() {
|
||||
try {
|
||||
// Honor selections; toggleLineComment handles both selections and
|
||||
// single line
|
||||
toggleLineComment(this.view);
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-toggle-comment event', err);
|
||||
}
|
||||
}
|
||||
|
||||
setCode(code) {
|
||||
const changes = {
|
||||
from: 0,
|
||||
|
||||
@@ -107,24 +107,21 @@ export function useReplContext() {
|
||||
//post to iframe parent (like Udels) if it exists...
|
||||
window.parent?.postMessage(code);
|
||||
|
||||
// Get the full buffer content from the editor instead of just the evaluated block
|
||||
const fullBufferCode = editorRef.current?.code || code;
|
||||
setLatestCode(fullBufferCode);
|
||||
|
||||
setLatestCode(code);
|
||||
try {
|
||||
window.location.hash = '#' + code2hash(fullBufferCode);
|
||||
window.location.hash = '#' + code2hash(code);
|
||||
} catch (e) {
|
||||
console.warn('[useReplContext] Failed to update hash:', e.message);
|
||||
}
|
||||
setDocumentTitle(fullBufferCode);
|
||||
setDocumentTitle(code);
|
||||
const viewingPatternData = getViewingPatternData();
|
||||
setVersionDefaultsFrom(fullBufferCode);
|
||||
const data = { ...viewingPatternData, code: fullBufferCode };
|
||||
setVersionDefaultsFrom(code);
|
||||
const data = { ...viewingPatternData, code };
|
||||
let id = data.id;
|
||||
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
|
||||
|
||||
if (isExamplePattern) {
|
||||
const codeHasChanged = fullBufferCode !== viewingPatternData.code;
|
||||
const codeHasChanged = code !== viewingPatternData.code;
|
||||
if (codeHasChanged) {
|
||||
// fork example
|
||||
const newPattern = userPattern.duplicate(data);
|
||||
@@ -176,8 +173,9 @@ export function useReplContext() {
|
||||
useEffect(() => {
|
||||
let editorSettings = {};
|
||||
Object.keys(defaultSettings).forEach((key) => {
|
||||
// Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties
|
||||
editorSettings[key] = _settings[key];
|
||||
if (Object.prototype.hasOwnProperty.call(_settings, key)) {
|
||||
editorSettings[key] = _settings[key];
|
||||
}
|
||||
});
|
||||
editorRef.current?.updateSettings(editorSettings);
|
||||
}, [_settings]);
|
||||
|
||||
@@ -47,7 +47,6 @@ export const defaultSettings = {
|
||||
isPatternHighlightingEnabled: true,
|
||||
isTabIndentationEnabled: false,
|
||||
isMultiCursorEnabled: false,
|
||||
isBlockBasedEvalEnabled: false,
|
||||
theme: 'strudelTheme',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
@@ -105,7 +104,6 @@ export const $settings = computed(settingsMap, (state) => {
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user