From 7ff7e8852bd41b1eb524da84fd73840dcd7cff1c Mon Sep 17 00:00:00 2001 From: drdozer Date: Thu, 11 Sep 2025 18:42:23 +0100 Subject: [PATCH 1/7] feat: implement context-aware autocomplete Adds context-sensitive autocompletion for: - Sound names in s("...") and sound("...") after the opening quote or after whitespace/bracket - Bank names in bank("...") with quote handling and live filtering - Scale types in .scale("...") after the colon, inserting colon-form for multi-word names - Pitch names (C, C#, Db, etc.) for scale keys on explicit completion (Ctrl+Space) before the colon - Blocks fallback completions in scale context before colon to avoid function list cross-talk --- packages/codemirror/autocomplete.mjs | 166 +++++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 11 deletions(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index 59ca8adf..c1c7bb14 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -1,6 +1,11 @@ + import jsdoc from '../../doc.json'; import { autocompletion } from '@codemirror/autocomplete'; import { h } from './html'; +import { Scale } from '@tonaljs/tonal'; +import { soundMap } from 'superdough'; + + const escapeHtml = (str) => { const div = document.createElement('div'); @@ -74,6 +79,31 @@ const isValidDoc = (doc) => { const hasExcludedTags = (doc) => ['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)); + + +export function bankCompletions() { + 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' })); +} + +// Placeholder: Replace with actual logic to get live sound names +const soundCompletions = [ + // Example: { label: 'clap', type: 'sound' }, ... +]; + +// Attempt to get all scale names from Tonal +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); +} + const jsdocCompletions = jsdoc.docs .filter((doc) => isValidDoc(doc) && !hasExcludedTags(doc)) // https://codemirror.net/docs/ref/#autocomplete.Completion @@ -85,18 +115,132 @@ const jsdocCompletions = jsdoc.docs })); export const strudelAutocomplete = (context) => { - const word = context.matchBefore(/\w*/); - if (word.from === word.to && !context.explicit) return null; + // --- Pitch names for scale key completion --- + // Ideally, these should be imported from packages/tonal/tonleiter.mjs + // but are duplicated here as they are not exported. + const pitchNames = [ + 'C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'Fb', 'F', 'F#', 'Gb', + 'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B', 'Cb' + ]; + + // Block fallback completions inside .scale("..."), even before the colon + let scalePreColonContext = context.matchBefore(/\.scale\(\s*['"][^'"]*$/); + if (scalePreColonContext) { + // Only yield completions if a colon is present (handled below) + if (!scalePreColonContext.text.includes(':')) { + if (context.explicit) { + // Provide pitch completions on explicit request + // (Union of sharps and flats from tonleiter.mjs) + const text = scalePreColonContext.text; + const match = text.match(/([A-Ga-g][#b]*)?$/); + 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 { + // Block fallback completions + return { from: scalePreColonContext.to, options: [] }; + } + } - return { - from: word.from, - options: jsdocCompletions, - /* options: [ - { label: 'match', type: 'keyword' }, - { label: 'hello', type: 'variable', info: '(World)' }, - { label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' }, - ], */ - }; + // (second block removed, handled above) + if (!scalePreColonContext.text.includes(':')) { + return { from: scalePreColonContext.to, options: [] }; + } + } + + // 1. Check for sound context: s("..."), sound("...") + // Trigger after at least one letter is typed after whitespace or bracket inside the quotes + let soundContext = context.matchBefore(/(s|sound)\(\s*['"][^'"]*$/); + if (soundContext) { + 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(/(?:[\s\[\{\(<])([\w]*)$/); + const fragment = fragMatch ? fragMatch[1] : inside; + if (!fragment || fragment.length === 0) return null; + const soundNames = Object.keys(soundMap.get()).sort(); + const filteredSounds = soundNames.filter((name) => name.startsWith(fragment)); + let options = filteredSounds.map((name) => ({ label: name, type: 'sound' })); + const from = soundContext.to - fragment.length; + return { + from, + options, + }; + } + + let bankMatch = context.matchBefore(/bank\(\s*(['"])?([\w]*)$/); + if (bankMatch) { + let banks = bankCompletions(); + console.log('[autocomplete] Bank context detected:', bankMatch.text); + console.log('[autocomplete] soundMap keys:', Object.keys(soundMap.get())); + console.log('[autocomplete] bankCompletions:', banks); + // Extract quote and fragment using regex groups on match.text + const groups = bankMatch.text.match(/(['"])?([\w]*)$/); + const quote = groups ? groups[1] : undefined; + const fragment = groups ? groups[2] || '' : ''; + let from; + if (quote) { + from = bankMatch.from + bankMatch.text.indexOf(quote) + 1; + } else { + from = bankMatch.to - fragment.length; + } + const filteredBanks = banks.filter((b) => b.label.startsWith(fragment)); + let options; + if (!quote) { + options = filteredBanks.map((b) => ({ ...b, apply: '"' + b.label + '"' })); + } else { + const afterCursor = context.state.sliceDoc(bankMatch.to, bankMatch.to + 1); + options = filteredBanks.map((b) => { + if (afterCursor !== quote) { + return { ...b, apply: b.label + quote }; + } + return b; + }); + } + return { + from, + options, + }; + } + + // 3. Check for scale context: .scale("..."), only after colon + let scaleContext = context.matchBefore(/\.scale\(\s*['"][^'"]*:[^'"]*$/); + if (scaleContext) { + // Find the last colon in the text + const text = scaleContext.text; + const colonIdx = text.lastIndexOf(':'); + if (colonIdx === -1) return null; + // Get the fragment after the colon + const fragment = text.slice(colonIdx + 1); + // Filter scale completions by fragment + const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment)); + // Insert colon-form (replace spaces with colons) for completions + const options = filteredScales.map((s) => ({ + ...s, + apply: s.label.replace(/\s+/g, ':') + })); + const from = scaleContext.from + colonIdx + 1; + return { + from, + options, + }; + } + + // fallback: original logic + const word = context.matchBefore(/\w*/); + if (word && word.from === word.to && !context.explicit) return null; + + if (word) { + console.log('[autocomplete] Default context:', word.text); + return { + from: word.from, + options: jsdocCompletions, + }; + } + return null; }; export const isAutoCompletionEnabled = (enabled) => From 085e839bfe3f0e4f9404afecba943b70fe106c0f Mon Sep 17 00:00:00 2001 From: drdozer Date: Thu, 11 Sep 2025 18:59:26 +0100 Subject: [PATCH 2/7] refactor: modularize autocomplete context handlers using strategy array --- packages/codemirror/autocomplete.mjs | 68 ++++++++++++++++------------ 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index c1c7bb14..591552cc 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -114,23 +114,18 @@ const jsdocCompletions = jsdoc.docs type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type })); -export const strudelAutocomplete = (context) => { - // --- Pitch names for scale key completion --- - // Ideally, these should be imported from packages/tonal/tonleiter.mjs - // but are duplicated here as they are not exported. - const pitchNames = [ - 'C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'Fb', 'F', 'F#', 'Gb', - 'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B', 'Cb' - ]; - - // Block fallback completions inside .scale("..."), even before the colon + +// --- Handler functions for each context --- +const pitchNames = [ + 'C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'Fb', 'F', 'F#', 'Gb', + 'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B', 'Cb' +]; + +function scalePreColonHandler(context) { let scalePreColonContext = context.matchBefore(/\.scale\(\s*['"][^'"]*$/); if (scalePreColonContext) { - // Only yield completions if a colon is present (handled below) if (!scalePreColonContext.text.includes(':')) { if (context.explicit) { - // Provide pitch completions on explicit request - // (Union of sharps and flats from tonleiter.mjs) const text = scalePreColonContext.text; const match = text.match(/([A-Ga-g][#b]*)?$/); const fragment = match ? match[0] : ''; @@ -139,19 +134,17 @@ export const strudelAutocomplete = (context) => { const options = filtered.map((p) => ({ label: p, type: 'pitch' })); return { from, options }; } else { - // Block fallback completions return { from: scalePreColonContext.to, options: [] }; } } - - // (second block removed, handled above) if (!scalePreColonContext.text.includes(':')) { return { from: scalePreColonContext.to, options: [] }; } } - - // 1. Check for sound context: s("..."), sound("...") - // Trigger after at least one letter is typed after whitespace or bracket inside the quotes + return null; +} + +function soundHandler(context) { let soundContext = context.matchBefore(/(s|sound)\(\s*['"][^'"]*$/); if (soundContext) { const text = soundContext.text; @@ -170,13 +163,13 @@ export const strudelAutocomplete = (context) => { options, }; } + return null; +} +function bankHandler(context) { let bankMatch = context.matchBefore(/bank\(\s*(['"])?([\w]*)$/); if (bankMatch) { let banks = bankCompletions(); - console.log('[autocomplete] Bank context detected:', bankMatch.text); - console.log('[autocomplete] soundMap keys:', Object.keys(soundMap.get())); - console.log('[autocomplete] bankCompletions:', banks); // Extract quote and fragment using regex groups on match.text const groups = bankMatch.text.match(/(['"])?([\w]*)$/); const quote = groups ? groups[1] : undefined; @@ -205,19 +198,17 @@ export const strudelAutocomplete = (context) => { options, }; } + return null; +} - // 3. Check for scale context: .scale("..."), only after colon +function scaleAfterColonHandler(context) { let scaleContext = context.matchBefore(/\.scale\(\s*['"][^'"]*:[^'"]*$/); if (scaleContext) { - // Find the last colon in the text const text = scaleContext.text; const colonIdx = text.lastIndexOf(':'); if (colonIdx === -1) return null; - // Get the fragment after the colon const fragment = text.slice(colonIdx + 1); - // Filter scale completions by fragment const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment)); - // Insert colon-form (replace spaces with colons) for completions const options = filteredScales.map((s) => ({ ...s, apply: s.label.replace(/\s+/g, ':') @@ -228,19 +219,36 @@ export const strudelAutocomplete = (context) => { options, }; } + return null; +} - // fallback: original logic +function fallbackHandler(context) { const word = context.matchBefore(/\w*/); if (word && word.from === word.to && !context.explicit) return null; - if (word) { - console.log('[autocomplete] Default context:', word.text); return { from: word.from, options: jsdocCompletions, }; } return null; +} + +const handlers = [ + scalePreColonHandler, + soundHandler, + bankHandler, + scaleAfterColonHandler, + // 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; }; export const isAutoCompletionEnabled = (enabled) => From 9200a67a600471309162dabeeb8bce915f915c55 Mon Sep 17 00:00:00 2001 From: drdozer Date: Thu, 11 Sep 2025 19:00:05 +0100 Subject: [PATCH 4/7] feat: add E# and B# to pitchNames for complete enharmonic coverage --- packages/codemirror/autocomplete.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index 591552cc..5d6c7233 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -117,8 +117,8 @@ const jsdocCompletions = jsdoc.docs // --- Handler functions for each context --- const pitchNames = [ - 'C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'Fb', 'F', 'F#', 'Gb', - 'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B', 'Cb' + 'C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'E#', 'Fb', 'F', 'F#', 'Gb', + 'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B', 'B#', 'Cb' ]; function scalePreColonHandler(context) { From 2a00fcdf5d70a2b0789b11376cf71b9c60cdc3cf Mon Sep 17 00:00:00 2001 From: drdozer Date: Thu, 11 Sep 2025 19:01:22 +0100 Subject: [PATCH 5/7] Updated the codemirror packages to be able to access additional imports. --- packages/codemirror/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index 803b33f3..bb1059f1 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -47,6 +47,8 @@ "@strudel/core": "workspace:*", "@strudel/draw": "workspace:*", "@strudel/transpiler": "workspace:*", + "@tonaljs/tonal": "^4.10.0", + "superdough": "workspace:*", "nanostores": "^0.11.3" }, "devDependencies": { From 41d6f1b5da98e3929ae71d2e2adf5ea00768664b Mon Sep 17 00:00:00 2001 From: drdozer Date: Thu, 11 Sep 2025 21:37:35 +0100 Subject: [PATCH 6/7] feat: sound autocomplete matches anywhere in name using includes() --- packages/codemirror/autocomplete.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index 5d6c7233..110a9165 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -155,7 +155,7 @@ function soundHandler(context) { const fragment = fragMatch ? fragMatch[1] : inside; if (!fragment || fragment.length === 0) return null; const soundNames = Object.keys(soundMap.get()).sort(); - const filteredSounds = soundNames.filter((name) => name.startsWith(fragment)); + 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 7676641c9ef8a631a47fc7ef0d6877442eb5c509 Mon Sep 17 00:00:00 2001 From: drdozer Date: Fri, 12 Sep 2025 12:11:36 +0100 Subject: [PATCH 7/7] Commiting lockfile. --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4a24f61..4cf7ea60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,9 +215,15 @@ importers: '@strudel/transpiler': specifier: workspace:* version: link:../transpiler + '@tonaljs/tonal': + specifier: ^4.10.0 + version: 4.10.0 nanostores: specifier: ^0.11.3 version: 0.11.3 + superdough: + specifier: workspace:* + version: link:../superdough devDependencies: vite: specifier: ^6.0.11