import { FUNCTIONS, KEYWORDS, CONSTANTS, FUNCTION_META } from "./inbuilt_symbols.js"; const AUTOCOMPLETE_STYLE_ID = "mrmth-script-autocomplete-styles"; function ensureAutocompleteStyles() { if (document.getElementById(AUTOCOMPLETE_STYLE_ID)) return; const style = document.createElement("style"); style.id = AUTOCOMPLETE_STYLE_ID; style.textContent = ` .mrmth-autocomplete-layout { display: flex; flex-direction: row; align-items: flex-start; position: fixed; z-index: 10000; pointer-events: none; } .mrmth-autocomplete-layout[hidden], .mrmth-signature-hint[hidden] { display: none; } .mrmth-autocomplete { width: 260px; max-height: 220px; overflow-y: auto; overscroll-behavior: contain; pointer-events: auto; margin: 0; padding: 4px 0; list-style: none; border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 8px; background: rgba(18, 20, 24, 0.97); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); font-family: monospace; font-size: 12px; } .mrmth-autocomplete-item { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 5px 10px; cursor: pointer; color: #ecf0f1; } .mrmth-autocomplete-item.is-selected { background: rgba(142, 68, 173, 0.35); } .mrmth-autocomplete-item .mrmth-ac-kind { flex-shrink: 0; font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: #9aa0a6; } .mrmth-autocomplete-item .mrmth-ac-name { color: #c39bd3; font-weight: 600; } .mrmth-autocomplete-item.is-variable .mrmth-ac-name { color: #ecf0f1; } .mrmth-autocomplete-tooltip, .mrmth-signature-hint { max-width: 320px; padding: 8px 10px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; background: rgba(24, 28, 36, 0.98); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); font-family: monospace; font-size: 11px; color: #b3b9c1; line-height: 1.5; } .mrmth-autocomplete-tooltip { min-width: 200px; margin-left: 8px; pointer-events: auto; } .mrmth-signature-hint { position: fixed; z-index: 10000; pointer-events: none; } .mrmth-tt-title, .mrmth-sig-call { color: #c39bd3; font-weight: 600; } .mrmth-tt-param, .mrmth-sig-param { margin-top: 2px; color: #d5d9de; } .mrmth-sig-param { padding-bottom: 4px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); margin-bottom: 4px; } .mrmth-param-name { color: #ffd700; } .mrmth-tt-details { margin-top: 4px; white-space: pre-wrap; color: #9aa0a6; } .mrmth-sig-call .mrmth-sig-active { color: #ffd700; text-decoration: underline; } `; document.head.appendChild(style); } export function escapeHtml(value) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } const BRACKET_PAIRS = { "(": ")", "[": "]", "{": "}" }; const OPENING_BRACKETS = new Set(Object.keys(BRACKET_PAIRS)); const CLOSING_BRACKETS = new Set(Object.values(BRACKET_PAIRS)); const QUOTES = new Set(['"', "'"]); const INDENT = " "; // Mirrors the MathExpr.g4 lexer closely enough for highlighting. Unterminated // block comments and strings run to the end of the text/line so they show up. const TOKEN_PATTERN = /#.*|\/\*[\s\S]*?(?:\*\/|$)|"(?:\\.|[^"\\\r\n])*"?|'(?:\\.|[^'\\\r\n])*'?|\b\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\B\.\d+(?:[eE][+-]?\d+)?|==|!=|>=|<=|<<|>>|->|\+=|-=|\*=|\/=|%=|[+\-*/%^=<>|?:,;()\[\]{}]|[a-zA-Z_][a-zA-Z_0-9]*|\s+|./g; /** * Splits script text into tokens. Bracket tokens get `depth` (per bracket * type, for rainbow colouring) and `match` (index of the paired bracket token, * or -1 when unmatched). */ export function tokenize(text) { const tokens = []; const openStack = []; const depthByType = { "(": 0, "[": 0, "{": 0 }; TOKEN_PATTERN.lastIndex = 0; let m; while ((m = TOKEN_PATTERN.exec(text)) !== null) { const value = m[0]; const c = value[0]; const token = { type: "text", value, start: m.index, depth: 0, match: -1 }; if (c === "#" || value.startsWith("/*")) { token.type = "comment"; } else if (c === '"' || c === "'") { token.type = "string"; } else if (OPENING_BRACKETS.has(c)) { token.type = "bracket"; token.depth = depthByType[c]++; openStack.push(tokens.length); } else if (CLOSING_BRACKETS.has(c)) { token.type = "bracket"; const openIndex = openStack[openStack.length - 1]; const open = tokens[openIndex]; if (open && BRACKET_PAIRS[open.value] === c) { openStack.pop(); token.depth = --depthByType[open.value]; token.match = openIndex; open.match = tokens.length; } } else if (/^(?:[+\-*/%^=<>|?:,;]|!=)/.test(value)) { token.type = "operator"; } else if (/^\.?\d/.test(value)) { token.type = "number"; } else if (/^[a-zA-Z_]/.test(c)) { if (KEYWORDS.has(value)) token.type = "keyword"; else if (CONSTANTS.has(value)) token.type = "constant"; else if (FUNCTIONS.has(value)) token.type = "function"; else token.type = "variable"; } tokens.push(token); } return tokens; } function isBlank(token) { return token.type === "text" && !token.value.trim(); } /** * Insert text through the browser's editing pipeline so it lands on the native * undo stack (plain `textarea.value = ...` assignments wipe Ctrl+Z history). * An empty `text` deletes the range. */ export function insertText(textarea, start, end, text) { textarea.focus(); textarea.setSelectionRange(start, end); const ok = text ? document.execCommand("insertText", false, text) : document.execCommand("delete"); if (!ok) { textarea.setRangeText(text, start, end, "end"); textarea.dispatchEvent(new Event("input", { bubbles: true })); } } export function renderHighlight(text, cursor) { const tokens = tokenize(text); // Bracket next to the cursor (left side preferred) and its partner. const active = new Set(); const isPairedAt = (pos) => (t) => t.type === "bracket" && t.start === pos && t.match !== -1; let nearIndex = tokens.findIndex(isPairedAt(cursor - 1)); if (nearIndex === -1) nearIndex = tokens.findIndex(isPairedAt(cursor)); if (nearIndex !== -1) { active.add(nearIndex); active.add(tokens[nearIndex].match); } return tokens.map((t, i) => { if (t.type === "text") return escapeHtml(t.value); if (t.type === "bracket") { let cls = t.match === -1 ? "mrmth-bracket-unmatched" : `mrmth-bracket-${t.depth % 6}`; if (active.has(i)) cls += " mrmth-bracket-active"; return `${escapeHtml(t.value)}`; } return `${escapeHtml(t.value)}`; }).join(""); } /** * Editor key behaviour: bracket/quote pairing, Backspace pair delete, auto * indent on Enter, Tab / Shift+Tab indent. Returns true when the key was * handled and the caller should preventDefault. Keys already consumed by the * autocomplete popup arrive with `defaultPrevented` and are ignored. */ export function handleEditingKey(textarea, e) { if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return false; const { selectionStart: start, selectionEnd: end, value } = textarea; const prev = value[start - 1] ?? ""; const next = value[end] ?? ""; const lineStart = value.lastIndexOf("\n", start - 1) + 1; const key = e.key; // Type over an already present closing bracket/quote. if (start === end && next === key && (CLOSING_BRACKETS.has(key) || QUOTES.has(key))) { textarea.setSelectionRange(start + 1, start + 1); return true; } if (Object.hasOwn(BRACKET_PAIRS, key) || QUOTES.has(key)) { const closing = BRACKET_PAIRS[key] ?? key; if (start !== end) { insertText(textarea, start, end, key + value.slice(start, end) + closing); textarea.setSelectionRange(start + 1, end + 1); return true; } if (/\w/.test(next) || (QUOTES.has(key) && /\w/.test(prev))) return false; insertText(textarea, start, end, key + closing); textarea.setSelectionRange(start + 1, start + 1); return true; } // `}` typed on a whitespace-only line closes one indent level. if (key === "}" && start === end && /^[ \t]*$/.test(value.slice(lineStart, start)) && value.slice(lineStart, start).endsWith(INDENT)) { insertText(textarea, start - INDENT.length, start, "}"); return true; } if (key === "Backspace" && start === end && start > 0 && (BRACKET_PAIRS[prev] === next || (QUOTES.has(prev) && prev === next))) { insertText(textarea, start - 1, start + 1, ""); return true; } if (key === "Enter" && !e.shiftKey) { const before = value.slice(lineStart, start); const lead = before.match(/^[ \t]*/)[0]; if (before.trimEnd().endsWith("{")) { const inner = lead + INDENT; const text = next === "}" ? `\n${inner}\n${lead}` : `\n${inner}`; insertText(textarea, start, end, text); const caret = start + 1 + inner.length; textarea.setSelectionRange(caret, caret); } else { insertText(textarea, start, end, `\n${lead}`); } return true; } if (key === "Tab") { if (!e.shiftKey && start === end) { insertText(textarea, start, end, INDENT); return true; } // Indent / unindent every line touched by the selection. const blockEnd = end > start && value[end - 1] === "\n" ? end - 1 : end; const block = value.slice(lineStart, blockEnd); const lines = block.split("\n"); const newLines = e.shiftKey ? lines.map((l) => l.replace(/^(?: {1,4}|\t)/, "")) : lines.map((l) => INDENT + l); const newBlock = newLines.join("\n"); if (newBlock === block) return true; insertText(textarea, lineStart, blockEnd, newBlock); const firstDelta = newLines[0].length - lines[0].length; const totalDelta = newBlock.length - block.length; textarea.setSelectionRange(Math.max(lineStart, start + firstDelta), Math.max(lineStart, end + totalDelta)); return true; } return false; } function isCursorInStringOrComment(text, cursor) { let inString = false; let stringChar = ""; let inLineComment = false; let inBlockComment = false; for (let i = 0; i < cursor; i++) { const ch = text[i]; const next = text[i + 1]; if (inLineComment) { if (ch === "\n") inLineComment = false; continue; } if (inBlockComment) { if (ch === "*" && next === "/") { inBlockComment = false; i++; } continue; } if (inString) { if (ch === "\\") { i++; continue; } if (ch === stringChar || ch === "\n") inString = false; continue; } if (ch === "#") { inLineComment = true; continue; } if (ch === "/" && next === "*") { inBlockComment = true; i++; continue; } if (ch === '"' || ch === "'") { inString = true; stringChar = ch; } } return inString || inLineComment || inBlockComment; } function getWordPrefix(text, cursor) { const before = text.slice(0, cursor); const match = before.match(/[a-zA-Z_][a-zA-Z0-9_]*$/); if (!match) return null; return { word: match[0], start: cursor - match[0].length }; } function formatArgHint(meta) { if (!meta) return ""; if (meta.maxArgs === null) { if (meta.minArgs <= 1) return "(…)"; return `(${meta.minArgs}+ args)`; } if (meta.minArgs === meta.maxArgs) { if (meta.minArgs === 0) return "()"; if (meta.minArgs === 1) return "(x)"; return `(${meta.minArgs} args)`; } return `(${meta.minArgs}–${meta.maxArgs} args)`; } // Names defined or used in the script itself; `name(` counts as a function. function collectUserSymbols(text, skipStart) { const tokens = tokenize(text); const symbols = new Map(); tokens.forEach((t, i) => { if (t.type !== "variable" || t.start === skipStart) return; let j = i + 1; while (tokens[j] && isBlank(tokens[j])) j++; if (tokens[j]?.value === "(") symbols.set(t.value, "function"); else if (!symbols.has(t.value)) symbols.set(t.value, "variable"); }); return symbols; } const KIND_ORDER = { variable: 0, function: 1, constant: 2, keyword: 3 }; function buildSuggestions(prefix, userSymbols = new Map()) { const lower = prefix.toLowerCase(); const items = []; const matches = (name) => name.toLowerCase().startsWith(lower); for (const [name, kind] of userSymbols) { if (matches(name)) items.push({ name, kind, detail: kind === "function" ? "user" : "" }); } for (const name of FUNCTIONS) { if (matches(name)) items.push({ name, kind: "function", detail: formatArgHint(FUNCTION_META[name]) }); } for (const name of CONSTANTS) { if (matches(name)) items.push({ name, kind: "constant", detail: "" }); } for (const name of KEYWORDS) { if (matches(name)) items.push({ name, kind: "keyword", detail: "" }); } items.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)); return items.slice(0, 40); } // Innermost built-in call whose argument list contains the cursor. function findCallContext(text, cursor) { const tokens = tokenize(text.slice(0, cursor)); let depth = 0; let argIndex = 0; for (let i = tokens.length - 1; i >= 0; i--) { const { type, value } = tokens[i]; if (value === "," && type === "operator") { if (depth === 0) argIndex++; continue; } if (type !== "bracket") continue; if (CLOSING_BRACKETS.has(value)) { depth++; continue; } if (depth > 0) { depth--; continue; } // Inside a list or dict literal: it is one argument of the enclosing call. if (value !== "(") { argIndex = 0; continue; } let j = i - 1; while (j >= 0 && isBlank(tokens[j])) j--; const name = tokens[j]?.type === "function" ? tokens[j].value : null; return name ? { name, argIndex, argCount: countCallArgs(text, tokens[i].start) } : null; } return null; } // Number of arguments in the call whose "(" is at openPos, including those after the cursor. function countCallArgs(text, openPos) { let depth = 0; let commas = 0; let hasArg = false; for (const t of tokenize(text.slice(openPos))) { if (t.type === "bracket") { if (OPENING_BRACKETS.has(t.value)) { if (++depth === 1) continue; } else if (--depth === 0) { break; } } if (depth === 1 && t.type === "operator" && t.value === ",") commas++; else if (!isBlank(t) && t.type !== "comment") hasArg = true; } return hasArg || commas ? commas + 1 : 0; } // Arity of one documented call form, e.g. ["x", "[dims]"] or ["r", "[g", "b]"]. function formArity(args) { let depth = 0; let min = 0; let max = 0; for (const a of args) { const optional = depth > 0 || a.startsWith("["); depth += (a.match(/\[/g) ?? []).length - (a.match(/\]/g) ?? []).length; if (a.includes("...")) return { min, max: Infinity }; max++; if (!optional) min++; } return { min, max }; } // First documented form that accepts argCount arguments (e.g. rgb_to_hsv(rgb) vs rgb_to_hsv(r, g, b)). function pickForm(forms, argCount) { return forms.find((form) => { const { min, max } = formArity(form); return argCount >= min && argCount <= max; }) ?? forms[0]; } function getCaretCoordinates(textarea, position) { const style = window.getComputedStyle(textarea); const div = document.createElement("div"); const props = [ "fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "textTransform", "wordSpacing", "textIndent", "whiteSpace", "wordBreak", "overflowWrap", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "boxSizing", ]; div.style.position = "absolute"; div.style.visibility = "hidden"; div.style.whiteSpace = style.whiteSpace === "none" ? "pre" : "pre-wrap"; div.style.overflowWrap = style.overflowWrap; div.style.wordBreak = style.wordBreak; div.style.width = `${textarea.clientWidth}px`; for (const prop of props) { div.style[prop] = style[prop]; } const span = document.createElement("span"); span.textContent = textarea.value.substring(0, position); div.appendChild(span); const marker = document.createElement("span"); marker.textContent = ""; div.appendChild(marker); document.body.appendChild(div); const top = marker.offsetTop; const left = marker.offsetLeft; div.remove(); return { top, left }; } // The node canvas may scale the textarea with a CSS transform. function getTextareaViewportScale(textarea) { const layoutHeight = textarea.offsetHeight; if (layoutHeight <= 0) return 1; return textarea.getBoundingClientRect().height / layoutHeight; } function getViewportLineHeight(textarea, scale) { const lh = parseFloat(window.getComputedStyle(textarea).lineHeight); if (Number.isFinite(lh) && lh > 0) return lh * scale; return 18 * scale; } function getCaretScreenPosition(textarea, position) { const coords = getCaretCoordinates(textarea, position); const rect = textarea.getBoundingClientRect(); const scale = getTextareaViewportScale(textarea); const x = coords.left - textarea.scrollLeft; const y = coords.top - textarea.scrollTop - 6; return { left: rect.left + x * scale, top: rect.top + y * scale, scale, }; } export const SIGNATURE_HINTS_SETTING = "mrmth.ScriptEditor.SignatureHints"; export const PARAMETER_DOCS_SETTING = "mrmth.ScriptEditor.ParameterDescriptions"; export const EDITOR_SETTINGS = [ { id: SIGNATURE_HINTS_SETTING, category: ["More math", "Script editor", "Signature hints"], name: "Show signature hints", tooltip: "Show the function signature and description while typing its arguments.", type: "boolean", defaultValue: true, }, { id: PARAMETER_DOCS_SETTING, category: ["More math", "Script editor", "Parameter descriptions"], name: "Show parameter descriptions", tooltip: "Describe the argument being typed in the signature hint, and list the arguments in the completion tooltip.", type: "boolean", defaultValue: true, }, ]; /** * Attach identifier autocomplete and built-in signature hints to a script * textarea. The popups are portaled to document.body. Must be attached before * other keydown handlers so they can skip keys it consumed (`defaultPrevented`). * @param {HTMLTextAreaElement} textarea * @param {(id: string) => unknown} [getSetting] - reads one of EDITOR_SETTINGS; features stay on unless it returns false * @returns {{ destroy: () => void, close: () => void }} */ export function attachAutocomplete(textarea, getSetting = () => undefined) { ensureAutocompleteStyles(); const enabled = (id) => getSetting(id) !== false; const layoutEl = document.createElement("div"); layoutEl.className = "mrmth-autocomplete-layout"; layoutEl.hidden = true; const listEl = document.createElement("ul"); listEl.className = "mrmth-autocomplete"; layoutEl.appendChild(listEl); const tooltipEl = document.createElement("div"); tooltipEl.className = "mrmth-autocomplete-tooltip"; tooltipEl.style.display = "none"; layoutEl.appendChild(tooltipEl); const hintEl = document.createElement("div"); hintEl.className = "mrmth-signature-hint"; hintEl.hidden = true; document.body.appendChild(layoutEl); document.body.appendChild(hintEl); let items = []; let selectedIndex = 0; let navigated = false; let prefixInfo = null; let open = false; listEl.addEventListener("wheel", (e) => { e.stopPropagation(); const maxScroll = Math.max(0, listEl.scrollHeight - listEl.clientHeight); if ((e.deltaY < 0 && listEl.scrollTop <= 0) || (e.deltaY > 0 && listEl.scrollTop >= maxScroll)) { e.preventDefault(); } }, { passive: false }); function updateTooltip(item) { const meta = item.kind === "function" ? FUNCTION_META[item.name] : null; if (!meta?.description) { tooltipEl.style.display = "none"; return; } let argsText = `Arguments: min ${meta.minArgs}`; argsText += meta.maxArgs === null ? "+ (variadic)" : `, max ${meta.maxArgs}`; const params = enabled(PARAMETER_DOCS_SETTING) ? Object.entries(meta.params) : []; tooltipEl.innerHTML = `