Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ below was added since 2.1.4, the notebook runtime of 2.2.0b1 included.

### Added

- Editor: code completion from the SDK itself. `scad.` (and `scad.ql.`, `scad.std.gear.`, `ql.`)
lists the real functions with their signatures and docstrings; accepting one, or typing `(`
after a known name, writes the call with every required parameter as `name=` and Tab moves
between them; inside a call the parameters not given yet complete as `name=` (required first).
The names come from the running package (`GET /api/sdk`), so they cannot drift from the code.
- Modeling: `scale`, affine `transform`, signed `offset`, `draft`, tapered and
until-limited extrusions and multisection sweeps are recorded features, and
boolean results can keep every output of a set operation; graph nodes keep
Expand Down
2 changes: 2 additions & 0 deletions editor-web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions editor-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.13.1",
"@codemirror/state": "^6.7.6",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.14",
"@lezer/highlight": "^1.2.5",
Expand Down
15 changes: 11 additions & 4 deletions editor-web/src/components/CellEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { python } from "@codemirror/lang-python";
import { EditorView } from "@codemirror/view";
Expand All @@ -20,6 +20,7 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl";
import { cadTheme } from "@/lib/cm-theme";
import { sdkCompletion } from "@/lib/sdk-complete";
import { cn } from "@/lib/utils";

export type NotebookCell = {
Expand Down Expand Up @@ -49,7 +50,9 @@ export type { ParamMeta, ParamValue };
type SocketLike = { send: (message: Record<string, unknown>) => void } | null;

const AUTOSAVE_MS = 700;
const EXTENSIONS = [python(), EditorView.lineWrapping];
const EXTENSIONS = [python(), EditorView.lineWrapping, sdkCompletion];
/** a constant: a new object each render makes the editor wrapper rebuild its configuration, which drops a snippet in progress */
const BASIC_SETUP = { lineNumbers: false, foldGutter: false, highlightActiveLine: false };

function loadCollapsed(notebook: string | null): Set<string> {
try {
Expand Down Expand Up @@ -317,6 +320,10 @@ function CellBlock({
onDelete: () => void;
onEdit: (value: string) => void;
}) {
// the editor takes this once: a new function each render would rebuild its configuration
const onEditRef = useRef(onEdit);
onEditRef.current = onEdit;
const handleChange = useCallback((value: string) => onEditRef.current(value), []);
const failed = cell.status === "exception" || cell.status === "error" || (cell.errors?.length ?? 0) > 0;
const stale = cell.status === "stale";
const dirty = draft !== undefined && draft !== cell.code;
Expand Down Expand Up @@ -411,8 +418,8 @@ function CellBlock({
extensions={EXTENSIONS}
theme={cadTheme}
editable={!cell.readonly}
basicSetup={{ lineNumbers: false, foldGutter: false, highlightActiveLine: false }}
onChange={onEdit}
basicSetup={BASIC_SETUP}
onChange={handleChange}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
Expand Down
16 changes: 12 additions & 4 deletions editor-web/src/components/ModuleView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { python } from "@codemirror/lang-python";
import { EditorView } from "@codemirror/view";
Expand All @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ParamControl, type ParamMeta, type ParamValue } from "@/components/ParamControl";
import { cadTheme } from "@/lib/cm-theme";
import { sdkCompletion } from "@/lib/sdk-complete";

/** A local module of the workspace (shared dimensions, helpers): what the
* server sends as the `module` envelope. */
Expand All @@ -21,7 +22,9 @@ export type ModuleState = {
};

const AUTOSAVE_MS = 700;
const EXTENSIONS = [python(), EditorView.lineWrapping];
const EXTENSIONS = [python(), EditorView.lineWrapping, sdkCompletion];
/** a constant: a new object each render makes the editor wrapper rebuild its configuration, which drops a snippet in progress */
const BASIC_SETUP = { lineNumbers: true, foldGutter: false, highlightActiveLine: false };

/** A module open next to the notebooks: its dataclass fields as controls, its
* source below. Every edit is written to the file at once, and every notebook
Expand Down Expand Up @@ -60,6 +63,11 @@ export function ModuleView({
}, AUTOSAVE_MS);
};

// the editor takes this once: a new function each render would rebuild its configuration
const editRef = useRef(edit);
editRef.current = edit;
const handleChange = useCallback((value: string) => editRef.current(value), []);

const forward = (id: string, value: ParamValue) => {
pending.current.set(id, value);
if (paramTimer.current !== null) return;
Expand Down Expand Up @@ -124,8 +132,8 @@ export function ModuleView({
height="auto"
extensions={EXTENSIONS}
theme={cadTheme}
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: false }}
onChange={edit}
basicSetup={BASIC_SETUP}
onChange={handleChange}
/>
</section>
</div>
Expand Down
19 changes: 19 additions & 0 deletions editor-web/src/lib/cm-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,29 @@ const theme = EditorView.theme(
color: "var(--popover-foreground)",
border: "2px solid var(--border)",
},
".cm-tooltip-autocomplete": { fontSize: "12px" },
".cm-tooltip-autocomplete > ul": { fontFamily: "var(--mono)", maxHeight: "16em" },
".cm-tooltip-autocomplete ul li": { padding: "2px 8px" },
".cm-tooltip-autocomplete ul li[aria-selected]": {
backgroundColor: "var(--primary)",
color: "var(--primary-foreground)",
},
".cm-completionDetail": { color: "var(--muted-foreground)", fontStyle: "normal", marginLeft: "1em" },
".cm-tooltip-autocomplete ul li[aria-selected] .cm-completionDetail": { color: "inherit", opacity: "0.75" },
".cm-completionMatchedText": { textDecoration: "none", color: "var(--focus)", fontWeight: "700" },
".cm-tooltip-autocomplete ul li[aria-selected] .cm-completionMatchedText": { color: "inherit" },
".cm-completionIcon": { opacity: "0.6" },
".cm-completionInfo": {
backgroundColor: "var(--popover)",
color: "var(--popover-foreground)",
border: "2px solid var(--border)",
padding: "6px 8px",
maxWidth: "420px",
},
".sca-api-signature": { color: "var(--name)", wordBreak: "break-word" },
".sca-api-doc": { marginTop: "4px", color: "var(--muted-foreground)" },
// the parameter to fill in next
".cm-snippetField": { backgroundColor: "var(--code-selection)", outline: "1px solid var(--focus)" },
},
{ dark: false },
);
Expand Down
208 changes: 208 additions & 0 deletions editor-web/src/lib/sdk-complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { acceptCompletion, snippet, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
import { pythonLanguage } from "@codemirror/lang-python";
import { Prec, type Extension } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";

/**
* Code completion from the SDK itself. The server describes the running package (GET /api/sdk:
* every public function and class with its parameters); from that:
*
* - `scad.` lists the functions (also `scad.ql.`, `scad.std.gear.`, and `ql.` / `Q.`);
* - accepting one, or typing `(` after a known name, writes the call with every required
* parameter as `name=` and moves between them with Tab;
* - inside a call, the parameters not given yet complete as `name=` (required ones first).
*/

interface Param {
n: string;
r: boolean;
/** "p" positional only, "a" the *args parameter, "w" the **kwargs one, "" an ordinary one */
k: "p" | "a" | "w" | "";
d?: string;
}
interface Entry {
n: string;
k: "f" | "c";
p: Param[];
d: string;
}
interface SdkIndex {
namespaces: Record<string, Entry[]>;
submodules: Record<string, string[]>;
}

let index: SdkIndex | null = null;
let loading: Promise<SdkIndex | null> | null = null;
const load = (): Promise<SdkIndex | null> =>
(loading ??= fetch("/api/sdk")
.then((response) => (response.ok ? (response.json() as Promise<SdkIndex>) : null))
.then((data) => (index = data))
.catch(() => null));

/** how a name written in a cell reaches a namespace of the index */
const HEADS: Record<string, string> = { scad: "scad", ql: "scad.ql", Q: "scad.ql" };
const namespaceOf = (path: string): string | null => {
const [head, ...rest] = path.split(".");
const base = HEADS[head];
return base ? [base, ...rest].join(".") : null;
};

/** `scad.ql.faces` before the cursor: the namespace path and the name being typed */
const MEMBER = /(?<![\w.])([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\.(\w*)$/;

const lookup = (path: string, name: string): Entry | null => {
const space = namespaceOf(path);
return (space && index?.namespaces[space]?.find((entry) => entry.n === name)) || null;
};

const escapeSnippet = (text: string) => text.replace(/[\\${}]/g, "\\$&");

/** `(width=${1}, height=${2})`: every parameter that has to be written, in order */
export const callTemplate = (entry: Entry): string => {
let slot = 0;
const args = entry.p
.filter((param) => param.k !== "w" && (param.r || param.k === "a"))
.map((param) => {
slot += 1;
// positional ones cannot be named: a hint the person overwrites
return param.k === "p" || param.k === "a" ? `\${${slot}:${escapeSnippet(param.n)}}` : `${param.n}=\${${slot}}`;
});
return `(${args.length ? args.join(", ") : "${}"})`;
};

const signature = (entry: Entry): string => {
const required = entry.p.filter((param) => param.r || param.k === "a").map((param) => (param.k === "a" ? `*${param.n}` : param.n));
const more = entry.p.some((param) => !param.r && param.k !== "a" && param.k !== "w");
return `(${required.join(", ")}${more ? (required.length ? ", …" : "…") : ""})`;
};

const info = (entry: Entry) => () => {
const root = document.createElement("div");
const call = document.createElement("div");
call.className = "sca-api-signature";
call.textContent =
entry.n +
"(" +
entry.p.map((param) => (param.k === "a" ? "*" : param.k === "w" ? "**" : "") + param.n + (param.d !== undefined ? `=${param.d}` : "")).join(", ") +
")";
root.append(call);
if (entry.d) {
const doc = document.createElement("div");
doc.className = "sca-api-doc";
doc.textContent = entry.d;
root.append(doc);
}
return root;
};

const entryCompletion = (entry: Entry): Completion => ({
label: entry.n,
type: entry.k === "c" ? "class" : "function",
detail: signature(entry),
info: info(entry),
apply: (view, completion, from, to) => {
// editing an existing call: only the name
if (view.state.sliceDoc(to, to + 1) === "(") {
view.dispatch({ changes: { from, to, insert: entry.n }, selection: { anchor: from + entry.n.length } });
return;
}
snippet(`${escapeSnippet(entry.n)}${callTemplate(entry)}`)(view, completion, from, to);
},
});

/** `scad.` + the typed start of a name */
const memberSource = async (context: CompletionContext): Promise<CompletionResult | null> => {
const line = context.state.doc.lineAt(context.pos);
const match = MEMBER.exec(line.text.slice(0, context.pos - line.from));
if (!match) return null;
const space = namespaceOf(match[1]);
if (!space) return null;
const data = index ?? (await load());
if (!data || (!data.namespaces[space] && !data.submodules[space])) return null;
const options: Completion[] = [
...(data.namespaces[space] ?? []).map(entryCompletion),
...(data.submodules[space] ?? []).map((name): Completion => ({ label: name, type: "namespace", detail: "module" })),
];
return { from: context.pos - match[2].length, options, validFor: /^\w*$/ };
};

/** the innermost call the cursor is in: its function, and what is written between the parentheses */
const enclosingCall = (text: string, pos: number): { path: string; name: string; args: string } | null => {
let depth = 0;
for (let i = pos - 1; i >= Math.max(0, pos - 4000); i--) {
const ch = text[i];
if (ch === ")" || ch === "]" || ch === "}") depth++;
else if (ch === "(" || ch === "[" || ch === "{") {
if (depth > 0) depth--;
else if (ch !== "(") return null;
else {
const named = /(?<![\w.])([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\.([A-Za-z_]\w*)\s*$/.exec(text.slice(Math.max(0, i - 160), i));
return named ? { path: named[1], name: named[2], args: text.slice(i + 1, pos) } : null;
}
}
}
return null;
};

/** the top level of an argument list: nested brackets emptied out */
const topLevel = (args: string): string => {
let depth = 0;
let out = "";
for (const ch of args) {
if (ch === "(" || ch === "[" || ch === "{") depth++;
else if (ch === ")" || ch === "]" || ch === "}") depth = Math.max(0, depth - 1);
else if (depth === 0) out += ch;
}
return out;
};

/** inside `scad.f(`: the parameters not given yet, as `name=` */
const kwargSource = async (context: CompletionContext): Promise<CompletionResult | null> => {
const word = context.matchBefore(/\w*/);
if (!word || (word.from === word.to && !context.explicit)) return null;
const text = context.state.sliceDoc(0, context.pos);
const call = enclosingCall(text, context.pos);
if (!call) return null;
const data = index ?? (await load());
if (!data) return null;
const entry = lookup(call.path, call.name);
if (!entry) return null;
const top = topLevel(call.args);
const segments = top.split(",");
// only where an argument starts: not after `name=` and not inside an expression
if (!/^\s*\w*$/.test(segments[segments.length - 1])) return null;
const given = new Set([...top.matchAll(/(?<![=!<>])\b([A-Za-z_]\w*)\s*=(?!=)/g)].map((m) => m[1]));
const positional = segments.slice(0, -1).filter((segment) => segment.trim() && !/(?<![=!<>])=(?!=)/.test(segment)).length;
const free = entry.p.filter((param) => param.k !== "a" && param.k !== "w");
const options: Completion[] = free
.filter((param, k) => !given.has(param.n) && k >= positional && param.k !== "p")
.map((param) => ({
label: `${param.n}=`,
type: "property",
detail: param.r ? "required" : param.d !== undefined ? `default ${param.d}` : "",
// above Python's own names (bool, ...): inside this call these are what is wanted
boost: param.r ? 99 : 50,
}));
return options.length ? { from: word.from, options, validFor: /^\w*$/ } : null;
};

/** `(` typed after a known function: write the call with its required parameters */
const parenthesis = EditorView.inputHandler.of((view, from, to, text) => {
if (text !== "(" || from !== to || !index) return false;
const line = view.state.doc.lineAt(from);
const match = MEMBER.exec(line.text.slice(0, from - line.from));
const entry = match && match[2] ? lookup(match[1], match[2]) : null;
if (!entry || view.state.sliceDoc(to, to + 1) === "(") return false;
snippet(callTemplate(entry))(view, null, from, to);
return true;
});

export const sdkCompletion: Extension = [
pythonLanguage.data.of({ autocomplete: memberSource }),
pythonLanguage.data.of({ autocomplete: kwargSource }),
Prec.high(parenthesis),
// Tab takes the highlighted suggestion (else it moves to the next parameter of a call)
Prec.highest(keymap.of([{ key: "Tab", run: acceptCompletion }])),
];

void load();
Loading
Loading