diff --git a/AGENTS.md b/AGENTS.md index 4a9ae95..257b466 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ |---|---|---| | 框架 | Astro | ^5.18.1 | | UI 组件 | Svelte | ^5.55.2 | -| 编辑器 | CodeMirror 6 | codemirror ^6.0.2 | +| 编辑器 | CodeMirror 6 | codemirror ^6.0.2, @codemirror/autocomplete ^6.20.3 | | 样式 | Tailwind CSS v4 + DaisyUI v5 | @tailwindcss/vite ^4.2.2, daisyui ^5.5.19 | | 语言 | TypeScript | ^5.9.3 | | 高精度数值 | decimal.js | ^10.6.0 | @@ -56,6 +56,7 @@ calcuko/ │ ├── language/ # 表达式语言 tokenizer、AST、Pratt parser、源码位置类型及测试 │ ├── types.ts # 共享类型定义(LineResult) │ ├── constants.ts # 示例公式和帮助弹窗元数据 + │ ├── autocomplete.ts # CodeMirror 智能补全候选与 Tab 接受逻辑 │ ├── evaluator.ts # ⭐ 内置注册、逐行求值和统一 formatter │ ├── resultFormatting.ts # 行级结果格式选项、精度与显示转换 │ ├── editorExtensions.ts # CodeMirror 高亮、错误/hover 行与高度 spacer @@ -112,6 +113,7 @@ calcuko/ - **数值模型**:整数为任意精度 BigInt,小数使用 34 位有效数字且 half-even 舍入的 Decimal,`a$b` 为自动约分的精确 Rational;混合运算按 BigInt → Rational → Decimal 提升 - **语法高亮**:`src/lib/highlight.ts` 与 CodeMirror 装饰层直接消费求值语言 tokenizer 的 token,支持注释、字符串、数值(含 SI 与进制)、运算符(含逗号等标点)、括号和变量,并以不同颜色区分内置函数与自定义函数;高亮模式按行恢复词法错误,以错误样式显示未知字符和未闭合字符串并继续着色,求值模式仍严格报错 - **括号匹配**:光标定位时高亮配对括号 `()[]{}` +- **智能补全**:输入标识符时以浮动框提示当前行上方已进入作用域的变量、自定义函数及全部内置函数;内置函数候选显示签名与简要功能说明,Tab 仅在补全激活时接受候选 - **帮助弹窗**:Header 中的「帮助」按钮展示基本语法、函数和常量元数据,支持 Escape 关闭、关闭按钮标签及打开/关闭焦点恢复 - **编辑器标题栏操作**:载入示例和清空均先确认、写入同一持久化 key,并提供单步撤销 - **变量快照复制**:变量快照以 button 形式展示 `name = value`,点击通过 `navigator.clipboard.writeText()` 复制值,并显示 2 秒自动消失的「已复制」Toast @@ -137,6 +139,7 @@ calcuko/ - 内置函数 `#a78bfa`(淡紫)、自定义函数 `#22d3ee`(青色) - **编辑器选区**:文本选中背景使用半透明主题主色(玫瑰粉),保持高亮叠层文字可见 - **错误行标识**:编辑器以半透明错误色背景和左侧红色边线标记求值失败的整行,未知字符使用红色波浪下划线显示 +- **智能补全浮层**:使用主题 base-200 背景、base-300 边框与圆角阴影,列表与浮层四边保持等量内边距;选中项使用半透明主色,函数和变量图标分别沿用函数紫与变量蓝 - **Tailwind v4 语法**:使用 `@import "tailwindcss"` 和 `@plugin "daisyui"` 而非旧版 `@tailwind` 指令 - **字体大小**:在 `@theme` 块中自定义了 `--text-xs` 到 `--text-6xl` diff --git a/e2e/calculator.spec.ts b/e2e/calculator.spec.ts index 711d323..d063d50 100644 --- a/e2e/calculator.spec.ts +++ b/e2e/calculator.spec.ts @@ -40,6 +40,29 @@ test("shows a visible themed editor cursor", async ({ page }) => { await expect(cursor).toHaveCSS("border-left-color", "rgb(251, 113, 133)"); }); +test("autocompletes earlier symbols and built-ins with Tab without consuming a normal Tab", async ({ page }) => { + await page.goto("./"); + await setSource(page, "total = 42\nfn triple(value)=value*3\ntot"); + await expect(page.locator(".cm-tooltip-autocomplete")).toBeVisible(); + await expect(page.locator(".cm-completionLabel", { hasText: "total" })).toBeVisible(); + await expect(page.locator(".cm-tooltip-autocomplete")).toHaveCSS("border-top-width", "1px"); + await expect(page.locator(".cm-tooltip-autocomplete")).toHaveCSS("border-radius", "16px"); + const variableIconColor = await page.locator(".cm-completionIcon-variable").first().evaluate((icon) => getComputedStyle(icon, "::after").color); + await expect(page.locator(".cm-tooltip-autocomplete")).toHaveCSS("padding", "8px"); + expect(variableIconColor).toBe("rgb(14, 165, 233)"); + await editor(page).press("Tab"); + await expect(page.locator(".cm-line").nth(2)).toHaveText("total"); + + await editor(page).press("Enter"); + await editor(page).pressSequentially("sq"); + await expect(page.locator(".cm-completionLabel", { hasText: "sqrt" })).toBeVisible(); + await expect(page.locator(".cm-completionLabel", { hasText: "sqrt" }).locator("..").locator(".cm-completionDetail")).toContainText("平方根"); + await editor(page).press("Escape"); + const before = await editor(page).textContent(); + await editor(page).press("Tab"); + await expect(editor(page)).toHaveText(before ?? ""); +}); + test("shows matrices, errors, and color previews", async ({ page }) => { await page.goto("./"); await setSource(page, "missing\nmatrix([[1,2],[3,4]])\nrgb(255,0,0)"); diff --git a/package-lock.json b/package-lock.json index 03ba77e..ed80395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.0", "dependencies": { "@astrojs/svelte": "^7.2.5", + "@codemirror/autocomplete": "^6.20.3", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", "@tailwindcss/vite": "^4.2.2", diff --git a/package.json b/package.json index 9ece7e3..13e121c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@astrojs/svelte": "^7.2.5", + "@codemirror/autocomplete": "^6.20.3", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", "@tailwindcss/vite": "^4.2.2", diff --git a/src/components/FormulaCalculator.svelte b/src/components/FormulaCalculator.svelte index 7fb1598..e77a0a1 100644 --- a/src/components/FormulaCalculator.svelte +++ b/src/components/FormulaCalculator.svelte @@ -10,6 +10,7 @@ import { isMatrix } from "../lib/language/matrix"; import { formatOptions, formatResult, type FormatOption, type ResultFormat, type ResultValueKind } from "../lib/resultFormatting"; import { editorUiField, setEditorUi, syntaxDecorations } from "../lib/editorExtensions"; + import { calcukoAutocomplete } from "../lib/autocomplete"; const BASE_URL = import.meta.env.BASE_URL.replace(/\/?$/, ""); const APP_VERSION = import.meta.env.PUBLIC_APP_VERSION?.trim() || "dev"; @@ -116,7 +117,7 @@ } catch { localStorage.removeItem(formatStorageKey); } editorView = new EditorView({ parent: editorHost, - state: EditorState.create({ doc: source, extensions: [basicSetup, editorUiField, syntaxDecorations, EditorView.theme({ "&": { height: "100%" }, ".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }, ".cm-line": { minHeight: "24px", lineHeight: "24px", padding: "0 16px" }, ".cm-content": { padding: "16px 0" }, ".cm-gutters": { backgroundColor: "color-mix(in oklab, var(--color-base-200) 40%, transparent)", borderRight: "1px solid var(--color-base-300)" }, ".cm-activeLine": { backgroundColor: "color-mix(in oklab, var(--color-primary) 10%, transparent)" }, ".cm-activeLineGutter": { backgroundColor: "color-mix(in oklab, var(--color-primary) 12%, transparent)" }, ".cm-result-hover-line": { backgroundColor: "color-mix(in oklab, var(--color-primary) 22%, transparent) !important", boxShadow: "inset 3px 0 var(--color-primary)" }, ".cm-error-line": { backgroundColor: "color-mix(in oklab, var(--color-error) 18%, transparent)", boxShadow: "inset 3px 0 var(--color-error)" } }) , EditorView.updateListener.of((update) => { + state: EditorState.create({ doc: source, extensions: [basicSetup, calcukoAutocomplete, editorUiField, syntaxDecorations, EditorView.theme({ "&": { height: "100%" }, ".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }, ".cm-line": { minHeight: "24px", lineHeight: "24px", padding: "0 16px" }, ".cm-content": { padding: "16px 0" }, ".cm-gutters": { backgroundColor: "color-mix(in oklab, var(--color-base-200) 40%, transparent)", borderRight: "1px solid var(--color-base-300)" }, ".cm-activeLine": { backgroundColor: "color-mix(in oklab, var(--color-primary) 10%, transparent)" }, ".cm-activeLineGutter": { backgroundColor: "color-mix(in oklab, var(--color-primary) 12%, transparent)" }, ".cm-result-hover-line": { backgroundColor: "color-mix(in oklab, var(--color-primary) 22%, transparent) !important", boxShadow: "inset 3px 0 var(--color-primary)" }, ".cm-error-line": { backgroundColor: "color-mix(in oklab, var(--color-error) 18%, transparent)", boxShadow: "inset 3px 0 var(--color-error)" } }) , EditorView.updateListener.of((update) => { if (update.docChanged) { const next = update.state.doc.toString(); reconcileFormats(source, next); source = next; localStorage.setItem(storageKey, source); persistFormats(); } if (update.docChanged || update.selectionSet) activeLine = update.state.doc.lineAt(update.state.selection.main.head).number; if (update.selectionSet) queueMicrotask(dispatchEditorUi); @@ -421,6 +422,15 @@ :global(.cm-line-spacer-active) { background: color-mix(in oklab, var(--color-primary) 10%, transparent); } :global(.cm-line-spacer-hover) { background: color-mix(in oklab, var(--color-primary) 22%, transparent); box-shadow: inset 3px 0 var(--color-primary); } :global(.cm-line-spacer-error) { background: color-mix(in oklab, var(--color-error) 18%, transparent); box-shadow: inset 3px 0 var(--color-error); } + :global(.cm-tooltip.cm-tooltip-autocomplete) { box-sizing: border-box; padding: 0.5rem; background: var(--color-base-200); color: var(--color-base-content); border: 1px solid var(--color-base-300); border-radius: var(--radius-box); box-shadow: 0 16px 40px rgb(0 0 0 / 0.45), 0 0 0 1px color-mix(in oklab, var(--color-primary) 8%, transparent); overflow: hidden; } + :global(.cm-tooltip-autocomplete > ul) { box-sizing: border-box; min-width: 15rem; max-height: 15rem; height: auto; padding: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + :global(.cm-tooltip-autocomplete > ul > li) { min-height: 2rem; padding: 0.3rem 0.625rem; border-radius: calc(var(--radius-field) / 2); color: color-mix(in oklab, var(--color-base-content) 82%, transparent); } + :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) { background: color-mix(in oklab, var(--color-primary) 22%, var(--color-base-200)) !important; color: var(--color-base-content) !important; } + :global(.cm-completionLabel) { font-weight: 600; } + :global(.cm-completionMatchedText) { color: var(--color-primary); font-weight: 800; text-decoration: none; } + :global(.cm-completionDetail) { color: color-mix(in oklab, var(--color-base-content) 48%, transparent); font-style: normal; margin-left: 1rem; } + :global(.cm-completionIcon-function::after) { color: #a78bfa; } + :global(.cm-completionIcon-variable::after) { color: #0ea5e9; } div::-webkit-scrollbar { display: none; diff --git a/src/lib/autocomplete.test.ts b/src/lib/autocomplete.test.ts new file mode 100644 index 0000000..9096f6f --- /dev/null +++ b/src/lib/autocomplete.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { completionOptions } from "./autocomplete"; + +describe("completionOptions", () => { + it("includes earlier variables, custom functions, and built-in functions", () => { + const source = "金额 = 12\nfn double(value)=value*2\ndou"; + const options = completionOptions(source, source.length); + + expect(options).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: "金额", type: "variable", detail: "上方变量" }), + expect.objectContaining({ label: "double", type: "function", detail: "自定义函数 (value)" }), + expect.objectContaining({ label: "sqrt", type: "function", detail: "Math.sqrt(x) — 平方根" }), + ])); + }); + + it("does not suggest declarations from the current or following lines", () => { + const source = "first = 1\ncurrent = fir\nlater = 3"; + const labels = completionOptions(source, source.indexOf("fir", source.indexOf("current")) + 3).map(({ label }) => label); + + expect(labels).toContain("first"); + expect(labels).not.toContain("current"); + expect(labels).not.toContain("later"); + }); + + it("keeps a shadowing user function instead of its built-in namesake", () => { + const source = "fn sqrt(value)=value\nsq"; + const sqrt = completionOptions(source, source.length).find(({ label }) => label === "sqrt"); + + expect(sqrt).toMatchObject({ detail: "自定义函数 (value)", boost: 2 }); + }); +}); diff --git a/src/lib/autocomplete.ts b/src/lib/autocomplete.ts new file mode 100644 index 0000000..b11debe --- /dev/null +++ b/src/lib/autocomplete.ts @@ -0,0 +1,55 @@ +import { acceptCompletion, autocompletion, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete"; +import { Prec } from "@codemirror/state"; +import { keymap } from "@codemirror/view"; +import { mathContext } from "./evaluator"; +import { mathFunctions } from "./constants"; +import { parse } from "./language/parser"; + +const identifierBeforeCursor = /(?:[\p{ID_Start}$]|\p{Extended_Pictographic})(?:[\p{ID_Continue}$]|\p{Extended_Pictographic})*$/u; + +const builtinCompletions: Completion[] = Object.entries(mathContext) + .filter(([, value]) => typeof value === "function") + .map(([label]) => ({ label, type: "function", detail: mathFunctions[label] ?? "内置函数", boost: 0 })) + .sort((left, right) => left.label.localeCompare(right.label)); + +export function completionOptions(source: string, cursor: number): Completion[] { + const lineStart = source.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; + const options = new Map(); + + for (const line of source.slice(0, lineStart).split("\n")) { + try { + const statement = parse(line); + if (statement.kind === "functionDefinition") { + options.set(statement.name, { label: statement.name, type: "function", detail: `自定义函数 (${statement.parameters.join(", ")})`, boost: 2 }); + } else if (statement.kind === "assignment") { + const parameters = statement.value.kind === "lambda" ? statement.value.parameters : undefined; + options.set(statement.name, parameters + ? { label: statement.name, type: "function", detail: `自定义函数 (${parameters.join(", ")})`, boost: 2 } + : { label: statement.name, type: "variable", detail: "上方变量", boost: 1 }); + } else if (statement.kind === "destructuringAssignment") { + for (const label of statement.names) options.set(label, { label, type: "variable", detail: "上方变量", boost: 1 }); + } + } catch { + // 正在编辑或语法错误的行不会向后方作用域贡献候选。 + } + } + for (const option of builtinCompletions) { + if (!options.has(option.label)) options.set(option.label, option); + } + return [...options.values()]; +} + +export function calcukoCompletionSource(context: CompletionContext): CompletionResult | null { + const word = context.matchBefore(identifierBeforeCursor); + if (!word && !context.explicit) return null; + return { + from: word?.from ?? context.pos, + options: completionOptions(context.state.doc.toString(), context.pos), + validFor: identifierBeforeCursor, + }; +} + +export const calcukoAutocomplete = [ + autocompletion({ override: [calcukoCompletionSource], activateOnTyping: true }), + Prec.highest(keymap.of([{ key: "Tab", run: acceptCompletion }])), +]; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9389c7f..1d279e5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -24,12 +24,27 @@ export const mathFunctions: Record = { decimal: "decimal(x) — 转 34 位高精度小数", rat: "rat(a, b) — 构造精确分数 a$b", range: "range(start, stop, step?) — 生成不含终点的数值范围", + len: "len(array) — 获取数组长度", + reverse: "reverse(array) — 反转数组顺序", sum: "sum(array) — 数组求和", ave: "ave(array) — 数组平均值", + minArray: "minArray(array) — 获取数组最小值", + maxArray: "maxArray(array) — 获取数组最大值", map: "map(array, fn) — 映射数组", + aggregate: "aggregate(array, fn) — 逐项归并数组", filter: "filter(array, fn) — 过滤数组", sort: "sort(array, fn?) — 排序数组", unique: "unique(array) — 数组去重", + count1: "count1(width, value) — 统计置位比特数", + rotateL: "rotateL(width, value, n?) — 循环左移", + rotateR: "rotateR(width, value, n?) — 循环右移", + reverseBits: "reverseBits(width, value) — 翻转比特顺序", + reverseBytes: "reverseBytes(width, value) — 翻转字节顺序", + swapNib: "swapNib(width, value) — 交换半字节", + evenParity: "evenParity(width, value) — 计算偶校验位", + oddParity: "oddParity(width, value) — 计算奇校验位", + pack: "pack(bytes, values) — 将整数数组打包", + unpack: "unpack(bytes, count, value) — 将整数拆分为数组", matrix: "matrix(rows) — 从二维数组构造矩阵", row: "row(values...) — 构造单行矩阵", col: "col(values...) — 构造单列矩阵",