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
This commit is contained in:
drdozer
2025-09-11 18:42:23 +01:00
parent a46617f3f0
commit d87ce960d2
+155 -11
View File
@@ -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) =>