From cb011c8bb0b4187938aa03f264fccc4569ddffdc Mon Sep 17 00:00:00 2001 From: Matthew Pocock Date: Mon, 15 Sep 2025 18:44:30 +0100 Subject: [PATCH] Add autocomplete for mode function - Added modePreColonHandler for autocomplete of mode values (below, above, duck, root) - Added modeAfterColonHandler for pitch name completion after colon in mode:anchor syntax - Follows same pattern as sound/bank/scale handlers for complex expressions - Supports expressions like '' with proper fragment matching - Uses regex pattern (?:[\s\[\{\()<])([\w:]*)$ to handle delimited expressions --- packages/codemirror/autocomplete.mjs | 57 ++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index b5fb2f1f..71b371e6 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -97,6 +97,14 @@ try { 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' }, +]; + export const getSynonymDoc = (doc, synonym) => { const synonyms = doc.synonyms || []; const docLabel = getDocLabel(doc); @@ -248,6 +256,53 @@ function bankHandler(context) { }; } +function modePreColonHandler(context) { + // First check for mode context without quotes - block with empty completions + let modeNoQuotesContext = context.matchBefore(/mode\(\s*$/); + if (modeNoQuotesContext) { + return { + from: modeNoQuotesContext.to, + options: [], + }; + } + + // Then check for mode context with quotes - provide completions + let modeContext = context.matchBefore(/mode\(\s*['"][^'"]*$/); + 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(/(?:[\s\[\{\(<])([\w:]*)$/); + 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, + }; +} + +function modeAfterColonHandler(context) { + let modeContext = context.matchBefore(/mode\(\s*['"][^'"]*:[^'"]*$/); + if (!modeContext) return null; + + const text = modeContext.text; + const colonIdx = text.lastIndexOf(':'); + if (colonIdx === -1) return null; + 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 = modeContext.from + colonIdx + 1; + return { + from, + options, + }; +} + function scaleAfterColonHandler(context) { let scaleContext = context.matchBefore(/scale\(\s*['"][^'"]*:[^'"]*$/); if (!scaleContext) return null; @@ -284,7 +339,9 @@ const handlers = [ scalePreColonHandler, soundHandler, bankHandler, + modePreColonHandler, scaleAfterColonHandler, + modeAfterColonHandler, // this handler *must* be last fallbackHandler, ];