Files
2026-10-17 23:56:32 +02:00

966 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 `<span class="mrmth-token-bracket ${cls}">${escapeHtml(t.value)}</span>`;
}
return `<span class="mrmth-token-${t.type}">${escapeHtml(t.value)}</span>`;
}).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 = `
<div class="mrmth-tt-title">${escapeHtml(meta.signature)}</div>
<div class="mrmth-tt-desc">${escapeHtml(meta.description)}</div>
${meta.details ? `<div class="mrmth-tt-details">${escapeHtml(meta.details)}</div>` : ""}
${params.map(([name, desc]) => `<div class="mrmth-tt-param"><span class="mrmth-param-name">${escapeHtml(name)}</span>: ${escapeHtml(desc)}</div>`).join("")}
<div class="mrmth-tt-args">${argsText}</div>
${meta.aliases.length ? `<div class="mrmth-tt-args">Also: ${escapeHtml(meta.aliases.join(", "))}</div>` : ""}
`;
tooltipEl.style.display = "block";
const rect = tooltipEl.getBoundingClientRect();
const flip = rect.right > window.innerWidth;
layoutEl.style.flexDirection = flip ? "row-reverse" : "row";
tooltipEl.style.marginLeft = flip ? "0" : "8px";
tooltipEl.style.marginRight = flip ? "8px" : "0";
}
const close = () => {
open = false;
layoutEl.hidden = true;
listEl.innerHTML = "";
tooltipEl.style.display = "none";
items = [];
selectedIndex = 0;
prefixInfo = null;
};
const openWith = (nextItems, nextPrefix) => {
items = nextItems;
selectedIndex = 0;
navigated = false;
prefixInfo = nextPrefix;
open = true;
layoutEl.hidden = false;
hideHint();
render();
};
const scrollSelectedIntoView = () => {
const selected = listEl.children[selectedIndex];
if (!selected) return;
const itemTop = selected.offsetTop;
const itemBottom = itemTop + selected.offsetHeight;
const pad = 2;
if (itemTop < listEl.scrollTop + pad) {
listEl.scrollTop = Math.max(0, itemTop - pad);
} else if (itemBottom > listEl.scrollTop + listEl.clientHeight - pad) {
listEl.scrollTop = itemBottom - listEl.clientHeight + pad;
}
};
const render = () => {
listEl.innerHTML = "";
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = `mrmth-autocomplete-item is-${item.kind}${index === selectedIndex ? " is-selected" : ""}`;
const name = document.createElement("span");
name.className = "mrmth-ac-name";
name.textContent = item.name;
const detail = document.createElement("span");
detail.className = "mrmth-ac-hint";
detail.textContent = item.detail;
const kind = document.createElement("span");
kind.className = "mrmth-ac-kind";
kind.textContent = item.kind;
li.append(name, detail, kind);
li.addEventListener("mousedown", (e) => {
e.preventDefault();
selectedIndex = index;
applySelected();
});
li.addEventListener("mouseenter", () => {
selectedIndex = index;
for (const el of listEl.children) el.classList.toggle("is-selected", el === li);
updateTooltip(item);
});
listEl.appendChild(li);
});
scrollSelectedIntoView();
updateTooltip(items[selectedIndex]);
positionDropdown();
};
const positionDropdown = () => {
const caret = getCaretScreenPosition(textarea, textarea.selectionStart);
const textareaRect = textarea.getBoundingClientRect();
const lineHeight = getViewportLineHeight(textarea, caret.scale);
const gap = 6;
const viewportPad = 8;
const preferredMax = 220;
const minListHeight = 48;
const dropdownWidth = 260;
const lineTop = caret.top;
const lineBottom = lineTop + lineHeight;
const spaceBelow = window.innerHeight - lineBottom - gap - viewportPad;
const spaceAbove = lineTop - gap - viewportPad;
const spaceRight = window.innerWidth - textareaRect.right - gap - viewportPad;
let placement = "below";
if (spaceBelow < minListHeight) {
if (spaceRight >= 160) {
placement = "right";
} else if (spaceAbove > spaceBelow + 40) {
placement = "above";
}
}
let maxHeight;
let top;
let left = caret.left;
if (placement === "below") {
maxHeight = Math.min(preferredMax, Math.max(minListHeight, spaceBelow));
top = lineBottom + gap;
} else if (placement === "above") {
maxHeight = Math.min(preferredMax, Math.max(minListHeight, spaceAbove));
top = lineTop - gap - maxHeight;
if (top < viewportPad) {
maxHeight = Math.max(minListHeight, lineTop - gap - viewportPad);
top = lineTop - gap - maxHeight;
}
} else {
maxHeight = Math.min(preferredMax, window.innerHeight - viewportPad * 2);
top = Math.max(viewportPad, Math.min(lineTop, window.innerHeight - maxHeight - viewportPad));
left = textareaRect.right + gap;
}
listEl.style.maxHeight = `${maxHeight}px`;
layoutEl.style.top = `${top}px`;
layoutEl.style.left = `${Math.max(viewportPad, Math.min(left, window.innerWidth - dropdownWidth - viewportPad))}px`;
};
const applySelected = () => {
const item = items[selectedIndex];
if (!item) return;
const end = textarea.selectionStart;
const isCall = item.kind === "function";
const hasParen = textarea.value[end] === "(";
let caret = prefixInfo.start + item.name.length;
if (isCall && hasParen) {
insertText(textarea, prefixInfo.start, end, item.name);
caret += 1;
} else if (isCall) {
insertText(textarea, prefixInfo.start, end, `${item.name}()`);
caret += 1;
} else {
insertText(textarea, prefixInfo.start, end, item.name);
}
textarea.setSelectionRange(caret, caret);
close();
updateHint();
};
const maybeOpen = (force) => {
const text = textarea.value;
const cursor = textarea.selectionStart;
if (cursor !== textarea.selectionEnd || isCursorInStringOrComment(text, cursor)) {
close();
return;
}
const info = getWordPrefix(text, cursor);
if (!info || (!force && !open && info.word.length < 2)) {
close();
return;
}
const suggestions = buildSuggestions(info.word, collectUserSymbols(text, info.start));
// Nothing to offer once the word is already complete.
if (!suggestions.length || (!force && suggestions.length === 1 && suggestions[0].name === info.word)) {
close();
return;
}
openWith(suggestions, info);
};
function hideHint() {
hintEl.hidden = true;
}
function updateHint() {
const cursor = textarea.selectionStart;
if (open || !enabled(SIGNATURE_HINTS_SETTING) || document.activeElement !== textarea || cursor !== textarea.selectionEnd) {
hideHint();
return;
}
const ctx = findCallContext(textarea.value, cursor);
const meta = ctx && FUNCTION_META[ctx.name];
if (!meta) {
hideHint();
return;
}
const form = pickForm(meta.forms, Math.max(ctx.argCount, ctx.argIndex + 1));
// A variadic arg (`...`) absorbs every argument from its position on.
const variadicAt = form.findIndex((a) => a.includes("..."));
let active = ctx.argIndex;
if (variadicAt !== -1 && active >= variadicAt) active = variadicAt;
const args = form
.map((a, i) => (i === active ? `<span class="mrmth-sig-active">${escapeHtml(a)}</span>` : escapeHtml(a)))
.join(", ");
// Doc names drop the optional/variadic markup: "[dims]" and "[g" -> "dims", "g".
const activeName = form[active]?.replace(/[[\].]/g, "").trim();
const paramDesc = enabled(PARAMETER_DOCS_SETTING) ? meta.params[activeName] : undefined;
hintEl.innerHTML = `
<div class="mrmth-sig-call">${escapeHtml(ctx.name)}(${args})</div>
${paramDesc ? `<div class="mrmth-sig-param"><span class="mrmth-param-name">${escapeHtml(activeName)}</span>: ${escapeHtml(paramDesc)}</div>` : ""}
${meta.description ? `<div class="mrmth-tt-desc">${escapeHtml(meta.description)}</div>` : ""}
`;
hintEl.hidden = false;
const caret = getCaretScreenPosition(textarea, cursor);
const lineHeight = getViewportLineHeight(textarea, caret.scale);
const hintRect = hintEl.getBoundingClientRect();
const viewportPad = 8;
let top = caret.top - hintRect.height - 4;
if (top < viewportPad) top = caret.top + lineHeight + 6;
hintEl.style.top = `${top}px`;
hintEl.style.left = `${Math.max(viewportPad, Math.min(caret.left, window.innerWidth - hintRect.width - viewportPad))}px`;
}
const onInput = () => {
maybeOpen(false);
updateHint();
};
const onKeyDown = (e) => {
if (e.ctrlKey && e.key === " ") {
e.preventDefault();
maybeOpen(true);
return;
}
if (!open) {
if (e.key === "Escape") hideHint();
return;
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const step = e.key === "ArrowDown" ? 1 : -1;
selectedIndex = (selectedIndex + step + items.length) % items.length;
navigated = true;
render();
return;
}
// Enter only accepts after the list was navigated, otherwise it would
// swallow newlines after words like `return` that happen to match.
if (e.key === "Tab" || (e.key === "Enter" && navigated)) {
e.preventDefault();
applySelected();
return;
}
if (e.key === "Enter" || e.key === "Escape") {
if (e.key === "Escape") e.preventDefault();
close();
}
};
const onSelectionChange = () => {
if (document.activeElement === textarea) updateHint();
};
const onBlur = () => {
setTimeout(close, 120);
hideHint();
};
const onReposition = () => {
if (open) positionDropdown();
if (!hintEl.hidden) updateHint();
};
textarea.addEventListener("input", onInput);
textarea.addEventListener("keydown", onKeyDown);
textarea.addEventListener("blur", onBlur);
textarea.addEventListener("scroll", onReposition);
document.addEventListener("selectionchange", onSelectionChange);
window.addEventListener("scroll", onReposition, true);
window.addEventListener("resize", onReposition);
return {
close,
destroy() {
close();
textarea.removeEventListener("input", onInput);
textarea.removeEventListener("keydown", onKeyDown);
textarea.removeEventListener("blur", onBlur);
textarea.removeEventListener("scroll", onReposition);
document.removeEventListener("selectionchange", onSelectionChange);
window.removeEventListener("scroll", onReposition, true);
window.removeEventListener("resize", onReposition);
layoutEl.remove();
hintEl.remove();
},
};
}
export { buildSuggestions, getWordPrefix, isCursorInStringOrComment, formatArgHint };