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: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -137,6 +139,7 @@ calcuko/
- 内置函数 `#a78bfa`(淡紫)、自定义函数 `#22d3ee`(青色)
- **编辑器选区**:文本选中背景使用半透明主题主色(玫瑰粉),保持高亮叠层文字可见
- **错误行标识**:编辑器以半透明错误色背景和左侧红色边线标记求值失败的整行,未知字符使用红色波浪下划线显示
- **智能补全浮层**:使用主题 base-200 背景、base-300 边框与圆角阴影,列表与浮层四边保持等量内边距;选中项使用半透明主色,函数和变量图标分别沿用函数紫与变量蓝
- **Tailwind v4 语法**:使用 `@import "tailwindcss"` 和 `@plugin "daisyui"` 而非旧版 `@tailwind` 指令
- **字体大小**:在 `@theme` 块中自定义了 `--text-xs` 到 `--text-6xl`

Expand Down
23 changes: 23 additions & 0 deletions e2e/calculator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion src/components/FormulaCalculator.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/lib/autocomplete.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
55 changes: 55 additions & 0 deletions src/lib/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -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<string, Completion>();

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 }])),
];
15 changes: 15 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,27 @@ export const mathFunctions: Record<string, string> = {
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...) — 构造单列矩阵",
Expand Down
Loading