Improve autocompletion behavior

- Refactored handlers to use consistent early return pattern for better readability
- Added blocking behavior for unquoted contexts (sound(, scale(, bank()) to guide users toward correct syntax instead of showing irrelevant fallback completions
- Fixed soundHandler to show all sounds when fragment is empty (e.g., sound(""))
- Simplified bankHandler by removing complex quote handling logic in favor of consistent blocking pattern
- Made scale handler regex patterns consistent (removed leading dots)
- All handlers now follow same structure: block unquoted contexts, provide completions for quoted contexts
This commit is contained in:
drdozer 2025-09-15 14:27:59 +01:00
parent 978616833a
commit c8875b29c1

View file

@ -1,12 +1,9 @@
import jsdoc from '../../doc.json'; import jsdoc from '../../doc.json';
import { autocompletion } from '@codemirror/autocomplete'; import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html'; import { h } from './html';
import { Scale } from '@tonaljs/tonal'; import { Scale } from '@tonaljs/tonal';
import { soundMap } from 'superdough'; import { soundMap } from 'superdough';
const escapeHtml = (str) => { const escapeHtml = (str) => {
const div = document.createElement('div'); const div = document.createElement('div');
div.innerText = str; div.innerText = str;
@ -80,7 +77,6 @@ const isValidDoc = (doc) => {
const hasExcludedTags = (doc) => const hasExcludedTags = (doc) =>
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)); ['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
export function bankCompletions() { export function bankCompletions() {
const soundDict = soundMap.get(); const soundDict = soundMap.get();
const banks = new Set(); const banks = new Set();
@ -88,10 +84,11 @@ export function bankCompletions() {
const [bank, suffix] = key.split('_'); const [bank, suffix] = key.split('_');
if (suffix && bank) banks.add(bank); if (suffix && bank) banks.add(bank);
} }
return Array.from(banks).sort().map((name) => ({ label: name, type: 'bank' })); return Array.from(banks)
.sort()
.map((name) => ({ label: name, type: 'bank' }));
} }
// Attempt to get all scale names from Tonal // Attempt to get all scale names from Tonal
let scaleCompletions = []; let scaleCompletions = [];
try { try {
@ -138,15 +135,43 @@ const jsdocCompletions = (() => {
return completions; return completions;
})(); })();
// --- Handler functions for each context --- // --- Handler functions for each context ---
const pitchNames = [ const pitchNames = [
'C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'E#', 'Fb', 'F', 'F#', 'Gb', 'C',
'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B', 'B#', 'Cb' 'C#',
'Db',
'D',
'D#',
'Eb',
'E',
'E#',
'Fb',
'F',
'F#',
'Gb',
'G',
'G#',
'Ab',
'A',
'A#',
'Bb',
'B',
'B#',
'Cb',
]; ];
function scalePreColonHandler(context) { function scalePreColonHandler(context) {
let scalePreColonContext = context.matchBefore(/\.scale\(\s*['"][^'"]*$/); // First check for scale context without quotes - block with empty completions
let scaleNoQuotesContext = context.matchBefore(/scale\(\s*$/);
if (scaleNoQuotesContext) {
return {
from: scaleNoQuotesContext.to,
options: [],
};
}
// Then check for scale context with quotes - provide completions
let scalePreColonContext = context.matchBefore(/scale\(\s*['"][^'"]*$/);
if (scalePreColonContext) { if (scalePreColonContext) {
if (!scalePreColonContext.text.includes(':')) { if (!scalePreColonContext.text.includes(':')) {
if (context.explicit) { if (context.explicit) {
@ -161,89 +186,86 @@ function scalePreColonHandler(context) {
return { from: scalePreColonContext.to, options: [] }; return { from: scalePreColonContext.to, options: [] };
} }
} }
if (!scalePreColonContext.text.includes(':')) {
return { from: scalePreColonContext.to, options: [] };
}
} }
return null; return null;
} }
function soundHandler(context) { function soundHandler(context) {
let soundContext = context.matchBefore(/(s|sound)\(\s*['"][^'"]*$/); // First check for sound context without quotes - block with empty completions
if (soundContext) { let soundNoQuotesContext = context.matchBefore(/(s|sound)\(\s*$/);
const text = soundContext.text; if (soundNoQuotesContext) {
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.includes(fragment));
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
const from = soundContext.to - fragment.length;
return { return {
from, from: soundNoQuotesContext.to,
options, options: [],
}; };
} }
return null;
// Then check for sound context with quotes - provide completions
let soundContext = context.matchBefore(/(s|sound)\(\s*['"][^'"]*$/);
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(/(?:[\s\[\{\(<])([\w]*)$/);
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,
};
} }
function bankHandler(context) { function bankHandler(context) {
let bankMatch = context.matchBefore(/bank\(\s*(['"])?([\w]*)$/); // First check for bank context without quotes - block with empty completions
if (bankMatch) { let bankNoQuotesContext = context.matchBefore(/bank\(\s*$/);
let banks = bankCompletions(); if (bankNoQuotesContext) {
// 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 { return {
from, from: bankNoQuotesContext.to,
options, options: [],
}; };
} }
return null;
// Then check for bank context with quotes - provide completions
let bankMatch = context.matchBefore(/bank\(\s*['"][^'"]*$/);
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,
};
} }
function scaleAfterColonHandler(context) { function scaleAfterColonHandler(context) {
let scaleContext = context.matchBefore(/\.scale\(\s*['"][^'"]*:[^'"]*$/); let scaleContext = context.matchBefore(/scale\(\s*['"][^'"]*:[^'"]*$/);
if (scaleContext) { if (!scaleContext) return null;
const text = scaleContext.text;
const colonIdx = text.lastIndexOf(':'); const text = scaleContext.text;
if (colonIdx === -1) return null; const colonIdx = text.lastIndexOf(':');
const fragment = text.slice(colonIdx + 1); if (colonIdx === -1) return null;
const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment)); const fragment = text.slice(colonIdx + 1);
const options = filteredScales.map((s) => ({ const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment));
...s, const options = filteredScales.map((s) => ({
apply: s.label.replace(/\s+/g, ':') ...s,
})); apply: s.label.replace(/\s+/g, ':'),
const from = scaleContext.from + colonIdx + 1; }));
return { const from = scaleContext.from + colonIdx + 1;
from, return {
options, from,
}; options,
} };
return null;
} }
function fallbackHandler(context) { function fallbackHandler(context) {
@ -264,7 +286,7 @@ const handlers = [
bankHandler, bankHandler,
scaleAfterColonHandler, scaleAfterColonHandler,
// this handler *must* be last // this handler *must* be last
fallbackHandler fallbackHandler,
]; ];
export const strudelAutocomplete = (context) => { export const strudelAutocomplete = (context) => {