From 7ff7e8852bd41b1eb524da84fd73840dcd7cff1c Mon Sep 17 00:00:00 2001 From: drdozer Date: Thu, 11 Sep 2025 18:42:23 +0100 Subject: [PATCH] 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 59ca8adf2..c1c7bb14e 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) =>