From 83adefdf5ec8d2372a24a329f9de24f992236184 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 16:15:59 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E7=9A=84=E8=BE=93=E5=85=A5=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=8E=92=E6=88=90=E5=B8=A6=E8=A1=A8=E5=A4=B4=E5=92=8C=E8=BE=B9?= =?UTF-8?q?=E6=A1=86=E7=9A=84=E8=A1=A8=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 18 ++++--- vscode/src/codeReader/contract.ts | 6 ++- .../codeReader/translators/zhTemplate.test.ts | 34 ++++++++++--- .../src/codeReader/translators/zhTemplate.ts | 8 ++- .../src/codeReader/PropertyGrid.tsx | 45 ++++++++++++++--- .../webview-ui/src/codeReader/codeReader.css | 50 +++++++++++++++++++ .../webview-ui/src/codeReader/subRows.test.ts | 36 +++++++++++++ vscode/webview-ui/src/codeReader/subRows.ts | 16 ++++++ vscode/webview-ui/src/codeReader/types.ts | 6 ++- vscode/webview-ui/src/codeReader/useReveal.ts | 2 +- 10 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/subRows.test.ts create mode 100644 vscode/webview-ui/src/codeReader/subRows.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index b905ccb..c498179 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -30,6 +30,7 @@ | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | +| 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 | | 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 | ## 用户可见行为 @@ -97,12 +98,13 @@ ### 属性表(函数、类、导入、常量、变量块) -卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`)。输入参数的子行分名称 / 类型 / 默认值三列对齐,前两列宽 150px / 220px,编辑区窄时按 28% / 38% 收,默认值列占剩下的;字典 / 对象的子行分键 / 值两列,键列同名称列。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和参数、导入项的子行一样)。 +卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。 +输入参数的表格(原型 `paramRows`):往右缩进 5px,第一行是表头「名称 | 类型 | 默认值」,下面每个参数一行。名称、类型两列宽 150px / 220px(编辑区窄时按 28% / 38% 收),默认值列占剩下的;格子左右留 8px、上下 2px,长类型、长默认值在格子里换行,不挤掉别的列。外框 1px、圆角 4px,格子之间、行之间 1px 分隔线,颜色都是主题的表格分隔色(`--vscode-tree-tableColumnsBorder`,主题没给时退到 `--vscode-editorWidget-border`);表头垫主题的表头底色(`--vscode-keybindingTable-headerBackground`,比编辑区深一档或浅一档),列名界面字体 12px、`descriptionForeground` 色。没有默认值的格子留空。折叠「输入参数」那一行时整张表收起;子行 id、跳到定义闪「它那个参数」、高亮名字的各处出现都和列表一样,闪的是表格里那一行(行边框和相邻行共用,改在行里面描一圈聚焦色)。哪些子行排成表格由翻译插件定:给了列名(`PropertyRow.columns`)的排成表格,没给的(导入项、字典 / 对象的各项)照旧是列表,webview 只看有没有列名。 | 种类 | 行 | | --- | --- | -| 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | +| 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,展开成表格,列名 名称 \| 类型 \| 默认值,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | | 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript / TSX 有,看 `abstract class`,TypeScript 的类都有这一行;JavaScript / JSX 没有)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开) | | 导入 | 来源(TypeScript 是字符串字面量原样,如 `'node:fs'`,字符串色;Python 是点分模块名,如 `fastapi`、`.models`,类型色)/ 仅类型(是 / 否,只有 TypeScript / TSX 有,看 `import type`;JavaScript / JSX 没有)/ 导入项(`N 个` 或「无」,每项一行:`name`、`name 作为 alias`;默认导入写「默认导出 作为 x」,`* as x` 写「全部 作为 x」,Python `from m import *` 写「全部」,Python `import a.b as c` 写「整个模块 作为 c」,一条 `import a, b` 导入多个模块时写模块名) | | 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」;是字典 / 对象字面量时见下面「字典 / 对象字面量」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | @@ -357,8 +359,11 @@ export interface Defines { defines?: SourceRange } /** 属性表里展开的一个子行:几列对齐的格子;items 是展开在它下面的下一层子行(嵌套的字典 / 对象),一层层可以一直往下套。 */ export interface PropertyItem extends Defines { cells: Inline[][], items?: PropertyItem[] } -/** 属性表的一行:属性名、着色的值或 Markdown 说明(函数、类的「说明」)、展开在下面的子行(输入参数、导入项、字典 / 对象的各项)。 */ -export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[] } +/** + * 属性表的一行:属性名、着色的值或 Markdown 说明(函数、类的「说明」)、展开在下面的子行(输入参数、导入项、字典 / 对象的各项)。 + * columns 是子行各列的列名(输入参数的名称 / 类型 / 默认值):给了的子行排成带表头的表格(只有一层),没给的排成列表。 + */ +export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[], columns?: string[] } /** 语言插件:语法树 → 代码块树。 */ export interface LanguageAdapter { @@ -574,7 +579,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P | hint | `--vscode-descriptionForeground` | 同左 | | plain | `--vscode-editor-foreground` | 同左 | -属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认、默认导出、全部、整个模块、匿名按 hint。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 +属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认导出、全部、整个模块、匿名按 hint;输入参数表格的列名是界面文字(`descriptionForeground`),不是着色的词。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框)。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 @@ -605,7 +610,7 @@ vscode/src/codeReader/ editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts) ``` ## 测试 @@ -616,6 +621,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则;字典 / 对象字面量的各项:Python 字符串键、名字键、嵌套字典、`**` 展开、空字典、类属性,TypeScript 名字键、字符串键、计算属性名、简写、展开、方法和 getter(头部到主体之前)、嵌套对象、空对象,不是字面量的值没有 entries。 - 字典 / 对象的属性表:项数、键 | 值两列、嵌套一层层展开、「展开」、方法头部、空的一行;键和值里的名字、字符串带下标(嵌套的也是);每项标的定义是键,展开项不标;跳到定义落在对象属性名上只闪那一项、连外层各层一起展开,只是显示着的名字闪整块、展开到它那一层。 - 推导式:Python 语言插件读出的结构(调用唯一参数的生成器带 sharedParentheses、几个参数之一的不带,列表带条件,字典分出键和值,集合的解构变量,几节 for / if 照源码顺序,async for,套着的各自认,字面量和 for 语句不算);中文直译「推导式」一节的样例表逐字一致,框的样式、框头、关键字着色,顿号连写、条件位置、另起一句、异步遍历、集合、框里的框、多个参数旁边的生成器、跨行带注释的生成器,属性表的默认值和字典项的值里也换;框里的名字带下标且指回源码原文,高亮各处出现、跳到定义都认框里的名字(只显示在框里的推导式变量 → 框所在的块);TypeScript 不认。 +- 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 - 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 11f0f94..173e190 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -236,11 +236,15 @@ export interface PropertyItem extends Defines { items?: PropertyItem[] } -/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项、字典 / 对象的各项)。 */ +/** + * 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项、字典 / 对象的各项)。 + * columns 是子行各列的列名(输入参数的名称 / 类型 / 默认值):给了列名的子行排成带表头和边框的表格,没给的排成列表;表格只有一层,子行不再往下套。 + */ export interface PropertyRow extends Defines { label: string value: PreviewText items?: PropertyItem[] + columns?: string[] } /** 语言插件:语法树 → 代码块树。 */ diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 52a57ff..aa87a74 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -433,7 +433,7 @@ describe('python 写在表达式里的遍历(推导式)', () => { 'CONFIG = {"ids": {k: v for k, v in pairs}}', ].join('\n') const rows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source)) - expect(rows[1]).toContain(' ids | 未标注 | 默认 ⟦列表|遍历 users 的每个 u,取 u.id⟧') + expect(rows[1]).toContain(' ids | 未标注 | ⟦列表|遍历 users 的每个 u,取 u.id⟧') expect(rows[2]).toEqual(['名称: CONFIG', '访问级别: 公共', '值: 字典 1 项', ' "ids" | ⟦字典|遍历 pairs 的每个 k, v,取 k: v⟧']) }) @@ -504,8 +504,8 @@ describe('属性表', () => { '装饰器: @router.get("/{user_id}", response_model=UserDetailResponse)', '输入参数: 3 个', ' user_id | Int4Path | ', - ' db | AsyncSession | 默认 Depends(get_db)', - ' current_user | User | 默认 Depends(get_current_user)', + ' db | AsyncSession | Depends(get_db)', + ' current_user | User | Depends(get_current_user)', '输出参数: UserDetailResponse', ]) expect(routes[111]).toContain(' file | Annotated[UploadFile, File(..., description="头像文件")] | ') @@ -518,7 +518,29 @@ describe('属性表', () => { expect(port[1]).toEqual(['名称: _default_port', '访问级别: 私有', '初始值: 如果 _role 等于 "beat" 则 5680 否则 5679']) }) - it('属性名是纯文字;值按词性着色,说明词(未标注、N 个、默认)用 hint', async () => { + it('输入参数的子行排成表格:列名是名称 / 类型 / 默认值,默认值格子只写译文、没有就留空;导入项、字典的子行没有列名', async () => { + const pySource = [ + 'from m import a, b', + 'def f(x: int = 1, y=None, *args): pass', + 'D = {"k": 1}', + ].join('\n') + const py1 = await describeAll(py, 'python', pythonZhTranslator, pySource) + const params = py1.get(2)?.find(row => row.label === '输入参数') + expect(params?.columns).toEqual(['名称', '类型', '默认值']) + expect(colors(params?.items?.map(item => item.cells))).toEqual([ + [[{ text: 'x', role: 'variable' }], [{ text: '整数', role: 'type' }], [{ text: '1', role: 'number' }]], + [[{ text: 'y', role: 'variable' }], [{ text: '未标注', role: 'hint' }], [{ text: '空', role: 'storage' }]], + [[{ text: '*', role: 'plain' }, { text: 'args', role: 'variable' }], [{ text: '未标注', role: 'hint' }], []], + ]) + expect([...py1.values()].flat().filter(row => row.columns).map(row => row.label)).toEqual(['输入参数']) + + const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, 'function g(a: string, b = 2) {}') + const tsParams = tsRows.get(1)?.find(row => row.label === '输入参数') + expect(tsParams?.columns).toEqual(['名称', '类型', '默认值']) + expect(tsParams?.items?.map(item => item.cells.map(cell => text(cell)))).toEqual([['a', '字符串', ''], ['b', '未标注', '2']]) + }) + + it('属性名是纯文字;值按词性着色,说明词(未标注、N 个)用 hint', async () => { const env = await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const rows = colors(env.get(18) ?? []) expect(rows.map(row => row.label)).toEqual(['名称', '说明', '访问级别', '异步', '输入参数', '输出参数']) @@ -547,7 +569,7 @@ describe('属性表', () => { expect(colors(valueOf(sample.get(3), '来源'))).toEqual([{ text: 'os', role: 'type' }]) expect(colors(sample.get(39)?.find(row => row.label === '输入参数')?.items)).toEqual([ { cells: [[{ text: 'self', role: 'variable' }], [{ text: '未标注', role: 'hint' }], []] }, - { cells: [[{ text: 'verbose', role: 'variable' }], [{ text: '布尔', role: 'type' }], [{ text: '默认 ', role: 'hint' }, { text: '假', role: 'storage' }]] }, + { cells: [[{ text: 'verbose', role: 'variable' }], [{ text: '布尔', role: 'type' }], [{ text: '假', role: 'storage' }]] }, ]) }) @@ -677,7 +699,7 @@ describe('属性表', () => { ].join('\n') expect(rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, source))).toEqual({ 1: ['名称: 匿名', '访问级别: 公共', '异步: 是', '输入参数: 无', '输出参数: 未标注'], - 2: ['名称: f', '访问级别: 私有', '异步: 否', '输入参数: 2 个', ' b? | 数值 | ', ' c | 未标注 | 默认 1', '输出参数: 无'], + 2: ['名称: f', '访问级别: 私有', '异步: 否', '输入参数: 2 个', ' b? | 数值 | ', ' c | 未标注 | 1', '输出参数: 无'], 3: ['名称: inner', '异步: 否', '输入参数: 无', '输出参数: 未标注'], 5: ['名称: K', '访问级别: 私有', '抽象: 否'], 6: ['名称: load', '访问级别: 受保护', '异步: 否', '装饰器: @memo()', '输入参数: 无', '输出参数: x is Foo'], diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index b7eb88f..48cc6e7 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -550,7 +550,10 @@ function aliased(item: Inline[], alias: Node | undefined, say: Phrases): Inline[ return [...item, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, { text: ' ', role: 'plain' }, ...say.translated(alias)] } -/** 函数:名称 / 说明 / 访问级别 / 异步(只有分这个的语言才有)/ 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ +/** + * 函数:名称 / 说明 / 访问级别 / 异步(只有分这个的语言才有)/ 装饰器 / 输入参数 / 输出参数。 + * 输入参数是一张表,列名是名称 / 类型 / 默认值,每个参数一行;没有默认值的格子留空。 + */ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { return [ { label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] }, @@ -561,11 +564,12 @@ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { { label: '输入参数', value: count(facts.parameters.length), + columns: ['名称', '类型', '默认值'], items: facts.parameters.map(param => ({ cells: [ param.optional ? [...say.translated(param.name), { text: '?', role: 'plain' }] : say.translated(param.name), param.type ? say.translated(param.type) : [hint('未标注')], - param.default ? [hint('默认 '), ...say.translated(param.default)] : [], + param.default ? say.translated(param.default) : [], ], ...defining(param.name), })), diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index d517986..cdd758a 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -4,13 +4,14 @@ import { ChevronDown, ChevronRight } from 'lucide-react' import { Fragment } from 'react' import { inlineText } from './segments' import { SegmentText } from './SegmentText' +import { subRowsLayout, tableCells } from './subRows' import { TextPreview } from './TextPreview' import { propertyItemId, propertyRowId } from './viewState' -/** 子行里除最后一列以外各列的宽度(参数的名称、类型,字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ +/** 子行里除最后一列以外各列的宽度(参数表格的名称、类型列,字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)'] -function columns(count: number): string { +function gridTemplate(count: number): string { const fixed = Array.from({ length: count - 1 }, (_, i) => CELL_WIDTHS[i] ?? CELL_WIDTHS[CELL_WIDTHS.length - 1]) return [...fixed, 'minmax(0, 1fr)'].join(' ') } @@ -74,7 +75,7 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items:
{item.cells.map((cell, i) => i === 0 && nested ? 0} folding={folding} /> @@ -93,7 +94,36 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items: ) } -/** 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行。 */ +/** + * 给了列名的子行(输入参数)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。 + * 列宽同列表的子行,最后一列占满剩下的,长了在格子里换行。 + */ +function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement { + const template = { gridTemplateColumns: gridTemplate(columns.length) } + return ( +
+
+
+ {columns.map((name, i) => {name})} +
+ {items.map((item, index) => ( +
+ {tableCells(item, columns).map((cell, i) => ( +

+ +

+ ))} +
+ ))} +
+
+ ) +} + +/** + * 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行, + * 翻译插件给了列名的(输入参数)排成表格,没给的排成列表。 + */ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { blockId: string rows: PropertyRow[] @@ -103,14 +133,14 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { return (
{rows.map((row, index) => { - const items = row.items ?? [] + const layout = subRowsLayout(row) const id = propertyRowId(blockId, index) const folded = collapsed.has(id) const Chevron = folded ? ChevronRight : ChevronDown return (
- {items.length > 0 + {layout ? (
- {items.length > 0 && !folded && } + {!folded && layout === 'table' && } + {!folded && layout === 'list' && }
) })} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 5e0e039..bb067fa 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -119,6 +119,22 @@ body.vscode-high-contrast-light { :is(.cr-block, .cr-prop-row, .cr-prop-item)[data-flash] { animation: cr-flash 500ms ease-in-out 3; } +/* 表格里的一行(参数):边框是和相邻行共用的分隔线,改用画在行里面的一圈聚焦色 */ +@keyframes cr-flash-row { + 50% { + background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent); + box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); + } +} +@keyframes cr-flash-row-still { + from, to { + background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent); + box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); + } +} +.cr-table-row[data-flash] { + animation: cr-flash-row 500ms ease-in-out 3; +} @media (prefers-reduced-motion: reduce) { .cr-block, @@ -128,6 +144,9 @@ body.vscode-high-contrast-light { :is(.cr-block, .cr-prop-row, .cr-prop-item)[data-flash] { animation: cr-flash-still 1500ms; } + .cr-table-row[data-flash] { + animation: cr-flash-row-still 1500ms; + } } .cr-badge { @@ -310,6 +329,37 @@ body.vscode-high-contrast-light { white-space: nowrap; } +/* + * 属性表里带列名的子行(输入参数)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线; + * 表头垫主题的表头底色(比编辑区深一档或浅一档)、列名用说明色 + */ +.cr-table { + --cr-table-border: var(--vscode-tree-tableColumnsBorder, var(--vscode-editorWidget-border, var(--vscode-contrastBorder))); + border: 1px solid var(--cr-table-border); + border-radius: 4px; + overflow: hidden; +} +.cr-table-head { + background: var(--vscode-keybindingTable-headerBackground, color-mix(in srgb, var(--vscode-foreground) 5%, transparent)); +} +.cr-table-row { + border-top: 1px solid var(--cr-table-border); +} +.cr-table .cr-table-cell { + padding: 2px 8px; +} +.cr-table .cr-table-cell + .cr-table-cell { + border-left: 1px solid var(--cr-table-border); +} +.cr-table-head .cr-table-cell { + font-size: 12px; + line-height: 20px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* 编辑:透明输入框垫在底下接收输入和选区,着色层盖在上面只负责显示,高度由着色层撑开 */ .cr-editor { position: relative; diff --git a/vscode/webview-ui/src/codeReader/subRows.test.ts b/vscode/webview-ui/src/codeReader/subRows.test.ts new file mode 100644 index 0000000..5358625 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/subRows.test.ts @@ -0,0 +1,36 @@ +import type { PropertyRow } from './types' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../../../src/codeReader/blockView' +import { createPythonAdapter } from '../../../src/codeReader/languages/python' +import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' +import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' +import { subRowsLayout, tableCells } from './subRows' + +const x = { text: 'x', role: 'variable' as const, offset: 4 } + +describe('属性行下面的子行怎么排', () => { + it('给了列名的排成表格,没给的排成列表,没有子行的不展开', () => { + const items = [{ cells: [[x], []] }] + expect(subRowsLayout({ label: '输入参数', value: [], items, columns: ['名称', '类型', '默认值'] })).toBe('table') + expect(subRowsLayout({ label: '导入项', value: [], items })).toBe('list') + expect(subRowsLayout({ label: '输入参数', value: [], items: [], columns: ['名称', '类型', '默认值'] })).toBeUndefined() + expect(subRowsLayout({ label: '名称', value: [x] })).toBeUndefined() + }) + + it('表格的格子按列名的列数取:少给的留空,多给的不显示', () => { + expect(tableCells({ cells: [[x]] }, ['名称', '类型', '默认值'])).toEqual([[x], [], []]) + expect(tableCells({ cells: [[x], [x], [x], [x]] }, ['名称', '类型'])).toEqual([[x], [x]]) + }) + + it('真实的 Python 函数:输入参数排成表格,导入项、字典的各项排成列表', async () => { + const source = [ + 'from m import a', + 'def f(k: int, v=1): pass', + 'D = {"k": 1}', + ].join('\n') + const py = createPythonAdapter(WASM_DIR) + const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator)) + const layouts = views.flatMap(view => 'properties' in view ? view.properties.flatMap((row: PropertyRow) => subRowsLayout(row) ? [[row.label, subRowsLayout(row)]] : []) : []) + expect(layouts).toEqual([['导入项', 'list'], ['输入参数', 'table'], ['值', 'list']]) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/subRows.ts b/vscode/webview-ui/src/codeReader/subRows.ts new file mode 100644 index 0000000..86c12ef --- /dev/null +++ b/vscode/webview-ui/src/codeReader/subRows.ts @@ -0,0 +1,16 @@ +import type { Inline, PropertyItem, PropertyRow } from './types' + +/** + * 一行下面展开的子行怎么排:翻译插件给了列名的(输入参数)排成带表头和边框的表格,没给的(导入项、字典 / 对象的各项)排成列表; + * 没有子行就什么都不展开。 + */ +export function subRowsLayout(row: PropertyRow): 'table' | 'list' | undefined { + if (!row.items?.length) + return undefined + return row.columns ? 'table' : 'list' +} + +/** 表格里一个子行的格子:按列名的列数取,子行少给的格子留空、多给的不显示。 */ +export function tableCells(item: PropertyItem, columns: string[]): Inline[][] { + return columns.map((_, i) => item.cells[i] ?? []) +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 513c292..df47076 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -52,11 +52,15 @@ export interface PropertyItem extends Defines { items?: PropertyItem[] } -/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项、字典 / 对象的各项) */ +/** + * 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项、字典 / 对象的各项)。 + * columns 是子行各列的列名(输入参数的名称 / 类型 / 默认值):给了列名的子行排成带表头和边框的表格,没给的排成列表 + */ export interface PropertyRow extends Defines { label: string value: PreviewText items?: PropertyItem[] + columns?: string[] } interface BlockViewBase { diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 7c46219..5b0b380 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -60,7 +60,7 @@ export function useReveal( return } // 块正在编辑时页面上没有属性表,就闪整块 - const row = plan?.row === undefined ? null : document.querySelector(`:is(.cr-prop-row, .cr-prop-item)[data-id="${plan.row}"]`) + const row = plan?.row === undefined ? null : document.querySelector(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`) const el = row ?? (plan && document.querySelector(`.cr-block[data-id="${plan.id}"]`)) if (el) { const rect = el.getBoundingClientRect()