diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index c6fff73..ab413c2 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -2104,27 +2104,33 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab ## 颜色 -词性(暗色取 VS Code Dark+,亮色取 Light+): +词性和内置种类的颜色跟着当前颜色主题走,和编辑器里看到的代码颜色一致(`theme/`): -| 词性 | 暗色 | 亮色 | -| --- | --- | --- | -| keyword | #c586c0 | #af00db | -| storage / operator | #569cd6 | #0000ff | -| variable | #9cdcfe | #001080 | -| function | #dcdcaa | #795e26 | -| type | #4ec9b0 | #267f99 | -| string | #ce9178 | #a31515 | -| number / literal | #b5cea8 | #098658 | -| comment | #6a9955 | #008000 | -| hint | `--vscode-descriptionForeground` | 同左 | -| constant | #4fc1ff | #0070c1 | -| plain | `--vscode-editor-foreground` | 同左 | +- 当前主题照 VS Code 自己的规则定:开着 `window.autoDetectHighContrast` 且当前是高对比主题时取两个高对比的设置,开着 `window.autoDetectColorScheme` 时按当前主题的明暗取 `workbench.preferredDarkColorTheme` / `preferredLightColorTheme`,否则是 `workbench.colorTheme`;老名字照 VS Code 换成现在的。在 `vscode.extensions.all`(内置主题也在里面)各扩展贡献的 `themes` 里按 id(没有用 label,再没有用文件名)找到主题文件。 +- 读主题文件:JSON 带注释、尾逗号;`include` 的在前、自己的在后;`tokenColors` 可以是数组,也可以是指向 `.tmTheme` 的路径;主题本身也可以是 `.tmTheme`(plist,`plist` 包解析)。 +- 叠用户设置:`editor.tokenColorCustomizations`(几组颜色、`textMateRules`、`"[主题名]"` 专属块)排在主题后面;`editor.semanticHighlighting.enabled`、主题的 `semanticHighlighting`、`semanticTokenColors` 和 `editor.semanticTokenColorCustomizations`(`enabled`、`rules`、专属块)定开没开语义上色、语义规则。先后照 VS Code 的 `colorThemeData.ts`。 +- 每种颜色查什么在 `theme/codeColors.ts` 的对照表:开了语义上色时先看语义规则(函数 `function`、类型 `class`、变量 `variable`、常量 `variable.readonly`、枚举 `enum`、构建阶段 / 模块 `namespace`),没有再依次查几个 TextMate 范围。范围照 vscode-textmate 匹配:规则的范围按点分段是查的范围的前缀才算,段数多的胜,一样具体时后写的胜,逗号分隔的选择器拆开,带父级的选择器不算。都没配的用编辑器前景色(`--vscode-editor-foreground`)。语义规则不分语言(所有预览共用一份),带语言的规则不算。 +- 宿主共用一份(`ThemeColors`),换主题(`onDidChangeActiveColorTheme`)、改上面这些设置时作废重读,发 `codeColors` 给所有 ready 的预览;页面 ready 时先于第一份文档发。页面设成 body 上的 `--cr-<词性>`、`--cr-kind-<种类>`(`codeColors.ts`)。主题找不到、读不出来时记一条警告,全用编辑器前景色。 + +| 词性 | 查什么 | +| --- | --- | +| keyword | `keyword.control` | +| storage / operator | `storage.type` | +| variable | 语义 `variable`;`variable.other.readwrite`、`entity.name.variable` | +| function | 语义 `function`;`entity.name.function`、`support.function` | +| type | 语义 `class`;`entity.name.type.class`、`support.class` | +| string | `string` | +| number / literal | `constant.numeric` | +| comment | `comment` | +| hint | `--vscode-descriptionForeground` | +| constant | 语义 `variable.readonly`;`variable.other.constant`、`variable.other.readwrite`、`entity.name.variable` | +| plain | `--vscode-editor-foreground` | 属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认导出、全部、整个模块、匿名按 hint;输入参数表格的列名是界面文字(`descriptionForeground`),不是着色的词。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 带自己颜色的段(`Segment.color`,库规则按库的配色给,例如 FastAPI 的 HTTP 方法、成功的状态码):暗色、亮色两个写在元素的 style 上(`--color-dark` / `--color-light`),`.cr-colored` 按主题挑一个,盖过词性的颜色;词性照旧报(`role`)。 -种类(种类图标的颜色 + 块底 7% + 边框 35%;亮色主题同色系;图标见「预览(代码块树)」):import 导入 #c586c0;function 函数、impl 实现 #dcdcaa;class 类、type 类型 #4ec9b0;trait 特质同声明关键字的蓝;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);Shell 的 interpreter 解释器同语句 #9d9d9d,options 选项 #e5c07b(亮色 #9a6a00),case 分情况同条件 #ce9178,command 命令、pipeline 管道 #d7ba7d(亮色 #7c5f12);Dockerfile 的 directive 解析指令同语句,stage 阶段、module 模块 #56b6c2(亮色 #127f8c),run 执行同命令,copy 复制同导入,setting 设置同选项,label 标签同变量,health 健康检查同尝试 / 捕获,start 启动同返回(原型 DOCKER_KINDS 的颜色);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c,FastAPI 接口 #61afef / #1a6fb5,FastAPI 路由器 #7aa2f7 / #34548a。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 +种类(种类图标的颜色 + 块底 7% + 边框 35%;图标见「预览(代码块树)」):同一类语义共用一个 `--cr-kind-*`,查主题的什么见下;主题给几类配了同一个色就同色,靠图标区分。import 导入、copy 复制查 `keyword.control.import`;function 函数、impl 实现同函数名;class 类、type 类型同类型名;trait 特质同 storage;attribute 同 constant;enum 枚举查语义 `enum`、`entity.name.type.enum`;exception 异常查 `support.type.exception`;field 字段、const 常量、variable 变量、label 标签同变量名;if 条件、else 否则、case 分情况查 `keyword.control.conditional`;loop 循环查 `keyword.control.loop`;try 尝试、catch 捕获、finally 最终、health 健康检查查 `keyword.control.trycatch`;return 返回、start 启动查 `keyword.control.flow`;statement 语句、interpreter 解释器、directive 解析指令查 `source`(一般就是编辑器前景色);comment 注释查 `comment`(虚线框);options 选项、setting 设置查 `constant.other.option`;command 命令、pipeline 管道、run 执行查 `entity.name.command`,没配落回 `entity.name.function.call`;stage 阶段、module 模块查语义 `namespace`、`entity.name.namespace`。查不到具体的会落回更泛的(`keyword.control.loop` 落回 `keyword.control`)。库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c,FastAPI 接口 #61afef / #1a6fb5,FastAPI 路由器 #7aa2f7 / #34548a。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 ## 代码位置 @@ -2240,8 +2246,12 @@ vscode/src/codeReader/ editor/openTarget.ts 打开别的文件用哪种编辑器:语言有代码阅读的用预览(滚到定义处),其余用默认的打开方式(选中名字);侧栏点文件、跳到定义、注释里的链接共用 editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键 + theme/currentTheme.ts 当前正在用的颜色主题:照 VS Code 的规则定设置里的名字,在装着的扩展里找主题文件 + theme/themeFile.ts 读颜色主题文件:JSON(带注释、include 链、tokenColors 指向 .tmTheme)或 .tmTheme(不碰 VS Code) + theme/tokenStyles.ts 主题叠上用户设置后一个 TextMate 范围、一种语义记号是什么颜色,照 VS Code 和 vscode-textmate 的规则(不碰 VS Code) + theme/codeColors.ts 词性、种类 → 查什么的对照表;ThemeColors:所有预览共用的一份代码配色,换主题、改设置时重读、通知预览 index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx(不带路径的引用的行内规则也在 markdown.ts),仓库链接和引用的点击、问坏没坏在 repoLink.ts / useRepoLinks.ts,悬停看定义卡片什么时候弹、收、放在哪在 definitionPeek.ts、接到页面上在 useDefinitionPeek.ts、浮窗和只读的卡片在 DefinitionPeekPopup.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts;钉住卡片:钉哪几张、顶上去多少在 stickyScroll.ts,量页面、收设置在 useStickyScroll.ts,钉住的几行在 StickyLines.tsx) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx(不带路径的引用的行内规则也在 markdown.ts),仓库链接和引用的点击、问坏没坏在 repoLink.ts / useRepoLinks.ts,悬停看定义卡片什么时候弹、收、放在哪在 definitionPeek.ts、接到页面上在 useDefinitionPeek.ts、浮窗和只读的卡片在 DefinitionPeekPopup.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,扩展端发来的代码配色设成颜色变量在 codeColors.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts;钉住卡片:钉哪几张、顶上去多少在 stickyScroll.ts,量页面、收设置在 useStickyScroll.ts,钉住的几行在 StickyLines.tsx) ``` ## 测试 diff --git a/vscode/package.json b/vscode/package.json index 7a11a5e..77a3f4e 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -3004,7 +3004,9 @@ "diff": "^9.0.0", "handlebars": "^4.7.9", "ignore": "^7.0.10", + "jsonc-parser": "^3.3.1", "minimatch": "^10.2.6", + "plist": "^5.0.0", "toml": "^5.0.0" }, "devDependencies": { diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index 0fa2a9e..aebf3fc 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -28,9 +28,15 @@ importers: ignore: specifier: ^7.0.10 version: 7.0.10 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 minimatch: specifier: ^10.2.6 version: 10.2.6 + plist: + specifier: ^5.0.0 + version: 5.0.0 toml: specifier: ^5.0.0 version: 5.0.0 @@ -1494,6 +1500,10 @@ packages: '@vue/shared@3.5.39': resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} + engines: {node: '>=14.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3189,6 +3199,10 @@ packages: platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + plist@5.0.0: + resolution: {integrity: sha512-20N+g1DvMm/DFRbsvER7tT4wDryq0WunK7VMkDaiJcKNapAnUMkTsAnacFYf8n420F4Hf6/hefgmJRkMb1M0fg==} + engines: {node: '>=18'} + pluralize@2.0.0: resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} @@ -3888,6 +3902,10 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5264,6 +5282,8 @@ snapshots: '@vue/shared@3.5.39': {} + '@xmldom/xmldom@0.9.12': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -7219,6 +7239,11 @@ snapshots: platform@1.3.6: {} + plist@5.0.0: + dependencies: + '@xmldom/xmldom': 0.9.12 + xmlbuilder: 15.1.1 + pluralize@2.0.0: {} pluralize@8.0.0: {} @@ -7926,6 +7951,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 0c5d223..19a4ed9 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -891,7 +891,8 @@ export type KindIconName = (typeof KIND_ICON_NAMES)[number] /** * 内置种类用哪个图标。宿主排预览时写进概念列表,webview 只按名字画。 - * 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。 + * 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理; + * 按当前颜色主题取色,见 theme/codeColors.ts,主题给几类配了同一个色就同色),图标区分具体是哪种。 */ export const KIND_ICONS: Record = { import: 'import', diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 63070b2..0e7dc55 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -12,6 +12,7 @@ import type { DocumentDiff } from '../diff/previewDiff' import type { ShownName } from '../nameResolution' import type { ProfileChoice } from '../qa/askProfiles' import type { AskRound } from '../qa/asks' +import type { CodeColors } from '../theme/codeColors' import type { Excluded, FilterState } from './blockFilter' export type { Excluded, ShownName } @@ -138,6 +139,8 @@ export interface StickyScrollSettings { * stickyScroll:VS Code 的 Sticky Scroll 设置(按这份文件取,语言自己的设置优先),见 StickyScrollSettings;ready 后发一次,设置改了再发 * occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的 * filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览 + * codeColors:当前颜色主题(叠上用户设置里改的)给代码的配色,见 CodeColors,页面设成词性、种类的颜色变量; + * ready 后先于第一份文档发一次,换了颜色主题、改了代码配色的设置再发给所有预览 * qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发 * profiles:kind 那种对话输入框旁边的 profile 下拉:选项和选中的那项;ready 后两种各发一次,任何一个预览改了哪种就把哪种发给所有预览 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 @@ -170,6 +173,7 @@ export type ExtensionToWebview | { type: 'moveOccurrence', step: 1 | -1 } | { type: 'displayNames', version: number, names: ShownName[] } | ({ type: 'filter' } & FilterState) + | ({ type: 'codeColors' } & CodeColors) | QaMessage | ({ type: 'profiles', kind: ChatKind } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string } diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 37aeba5..ed3e89a 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -3,6 +3,7 @@ import type { DiffBase, OpenedFrom } from '../diff/diffBase' import type { ProfileChoice } from '../qa/askProfiles' import type { RewriteRequest } from '../qa/codeQa' import type { SymbolNode } from '../symbolLinks' +import type { CodeColors } from '../theme/codeColors' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { describe, expect, it, vi } from 'vitest' import { window, workspace } from 'vscode' @@ -62,6 +63,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise [], qa, async () => [], @@ -162,8 +164,27 @@ function profileSet(selected: string) { } } +const COLORS = { roles: { keyword: '#C586C0' }, kinds: { loop: '#C586C0' } } as unknown as CodeColors + +/** 共用的代码配色:眼下是 colors,publish 模拟换了颜色主题、通知订阅的预览 */ +function themeColors(colors: CodeColors) { + const listeners = new Set<(value: CodeColors) => void>() + let current = colors + return { + current: async () => current, + subscribe: (listener: (value: CodeColors) => void) => { + listeners.add(listener) + return { dispose: () => listeners.delete(listener) } + }, + publish: (value: CodeColors) => { + current = value + listeners.forEach(listener => listener(value)) + }, + } +} + /** 对比基准只认从「未提交的更改」点开的:从那儿点开的对比上一次提交,别处打开的不比 */ -function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }) { +function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, colors = themeColors(COLORS)) { const subscribe = () => ({ dispose: () => {} }) const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined) const qa = { @@ -179,6 +200,7 @@ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }) { {}, {}, { subscribe, current: { excluded: {}, onlyChanges: false } }, + colors, async () => [], qa, async () => [], @@ -314,6 +336,28 @@ describe('预览对比什么看这个标签页是从哪儿打开的', () => { }) }) +describe('代码配色跟着当前颜色主题', () => { + it('ready 后先于第一份文档发;换了主题发给 ready 了的预览,还没 ready 的等 ready 时发眼下的', async () => { + const colors = themeColors(COLORS) + const { provider, document } = opening(undefined, colors) + const page = tab() + provider.resolveCustomTextEditor(document as never, page.panel) + const light = { roles: { keyword: '#AF00DB' }, kinds: { loop: '#AF00DB' } } as unknown as CodeColors + colors.publish(light) + expect(page.posted()).toEqual([]) + + await page.send({ type: 'ready' }) + const types = page.posted().map(message => message.type) + expect(page.posted()).toContainEqual({ type: 'codeColors', ...light }) + expect(types.indexOf('codeColors')).toBeGreaterThanOrEqual(0) + expect(types.indexOf('codeColors')).toBeLessThan(types.indexOf('document')) + + page.clear() + colors.publish(COLORS) + expect(page.posted()).toEqual([{ type: 'codeColors', ...COLORS }]) + }) +}) + describe('问 AI 和重写注释的 profile 下拉各管各的', () => { it('ready 后两套各发一次;哪一套变了只发哪一套', async () => { const profiles = { ask: profileSet('sonnet'), rewrite: profileSet('dsflash') } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 0a245c0..8e9d399 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -12,6 +12,7 @@ import type { CodeReaderRegistry } from '../registry' import type { LookupSources } from '../ruleLookups' import type { TreeSitterRuntime } from '../runtime' import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks' +import type { ThemeColors } from '../theme/codeColors' import type { BlockFilter } from './blockFilter' import type { FoundLocation } from './definitions' import type { ChatKind, DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, SymbolReference, WebviewToExtension } from './messages' @@ -82,6 +83,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly registry: CodeReaderRegistry, private readonly runtime: TreeSitterRuntime, private readonly filter: BlockFilter, + /** 代码配色:各预览共用当前颜色主题的那一份,换了主题、改了代码配色的设置时发给订阅的 */ + readonly codeColors: Pick, /** 库规则标的引用去哪儿查定义(交给标它的规则) */ readonly resolveReference: (reference: RuleReference) => Promise, /** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */ @@ -284,6 +287,11 @@ class Preview { if (this.ready) this.post({ type: 'filter', ...state }) }), + // 同上:没 ready 的等 ready 时在第一份文档之前发眼下的 + owner.codeColors.subscribe((colors) => { + if (this.ready) + this.post({ type: 'codeColors', ...colors }) + }), // 项目的入口变了:重新发文档,卡片左边的入口图标跟着变 owner.entries.onDidChange((uris) => { if (uris.some(uri => uri.toString() === document.uri.toString())) @@ -605,6 +613,8 @@ class Preview { this.postQa() for (const kind of CHAT_KINDS) void this.postProfiles(kind) + // 也先于文档发:第一份文档一画出来就是主题的颜色,不会先没颜色再闪一下 + this.post({ type: 'codeColors', ...await this.owner.codeColors.current() }) await this.push() if (this.pendingPathFocus) this.focusPathAsk() diff --git a/vscode/src/codeReader/fixtures/themes/base.json b/vscode/src/codeReader/fixtures/themes/base.json new file mode 100644 index 0000000..cf07524 --- /dev/null +++ b/vscode/src/codeReader/fixtures/themes/base.json @@ -0,0 +1,12 @@ +{ + // 被 include 的主题:它的规则排在 include 它的主题前面 + "name": "Base", + "semanticHighlighting": true, + "tokenColors": [ + { "scope": "keyword", "settings": { "foreground": "#111111" } }, + { "scope": "string", "settings": { "foreground": "#222222" } } + ], + "semanticTokenColors": { + "function": "#333333" + } +} diff --git a/vscode/src/codeReader/fixtures/themes/classic.tmTheme b/vscode/src/codeReader/fixtures/themes/classic.tmTheme new file mode 100644 index 0000000..c7a0443 --- /dev/null +++ b/vscode/src/codeReader/fixtures/themes/classic.tmTheme @@ -0,0 +1,42 @@ + + + + + name + Classic + settings + + + settings + + background + #000000 + foreground + #FFFFFF + + + + name + Comment + scope + comment + settings + + foreground + #666666 + + + + name + Control + scope + keyword.control, storage + settings + + foreground + #777777 + + + + + diff --git a/vscode/src/codeReader/fixtures/themes/nested/child.json b/vscode/src/codeReader/fixtures/themes/nested/child.json new file mode 100644 index 0000000..78bb987 --- /dev/null +++ b/vscode/src/codeReader/fixtures/themes/nested/child.json @@ -0,0 +1,11 @@ +{ + "name": "Child", + /* include 的路径从这个文件所在的目录算起 */ + "include": "../base.json", + "tokenColors": [ + { "scope": "string", "settings": { "foreground": "#444444" } } + ], + "semanticTokenColors": { + "function": { "foreground": "#555555" } + } +} diff --git a/vscode/src/codeReader/fixtures/themes/noSettings.tmTheme b/vscode/src/codeReader/fixtures/themes/noSettings.tmTheme new file mode 100644 index 0000000..8c0d252 --- /dev/null +++ b/vscode/src/codeReader/fixtures/themes/noSettings.tmTheme @@ -0,0 +1,7 @@ + + + + settings + not an array + + diff --git a/vscode/src/codeReader/fixtures/themes/split.json b/vscode/src/codeReader/fixtures/themes/split.json new file mode 100644 index 0000000..0562f35 --- /dev/null +++ b/vscode/src/codeReader/fixtures/themes/split.json @@ -0,0 +1,4 @@ +{ + "name": "Split", + "tokenColors": "./classic.tmTheme" +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 9811583..201e81d 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -56,6 +56,7 @@ import { thiserrorRule } from './rules/thiserror' import { bundledWasmDir, indexGrammar, TreeSitterRuntime } from './runtime' import { SlotPool } from './slots/slotPool' import { SLOTS } from './slots/slots' +import { readCodeColors, ThemeColors } from './theme/codeColors' import { bashZhTranslator } from './translators/bashZh' import { dockerfileZhTranslator } from './translators/dockerfileZh' import { pythonZhTranslator } from './translators/pythonZh' @@ -121,11 +122,14 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const heads = new HeadBases() // 预览对比什么:审查工作副本里的对比 PR 的目标分支,从「未提交的更改」一栏点开的有改动的文件对比上一次提交,别处打开的不比 const diffBases = previewDiffBases(review, heads) + // 预览里代码的上色、卡片的种类色跟着当前颜色主题走 + const codeColors = new ThemeColors(readCodeColors) const previews = new PreviewEditorProvider( context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), + codeColors, resolve, qa, namesIn, @@ -143,6 +147,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { context.subscriptions.push( qa, + codeColors, notes, entries, indexes, diff --git a/vscode/src/codeReader/theme/codeColors.test.ts b/vscode/src/codeReader/theme/codeColors.test.ts new file mode 100644 index 0000000..fc3d76e --- /dev/null +++ b/vscode/src/codeReader/theme/codeColors.test.ts @@ -0,0 +1,181 @@ +import type { CodeColors } from './codeColors' +import type { ColorCustomizations } from './tokenStyles' +import * as path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { logger } from '../../logging/logger' +import { codeColorsOf, readCodeColors, ThemeColors } from './codeColors' +import { TokenStyles } from './tokenStyles' + +/** VS Code 那头:用户设置(键写全)、装着的扩展,换主题、改设置时要通知的 */ +const host = vi.hoisted(() => ({ + settings: {} as Record, + extensions: [] as unknown[], + themeChanged: new Set<() => void>(), + configChanged: new Set<(e: { affectsConfiguration: (key: string) => boolean }) => void>(), +})) + +vi.mock('vscode', () => ({ + ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }, + window: { + activeColorTheme: { kind: 2 }, + onDidChangeActiveColorTheme: (listener: () => void) => { + host.themeChanged.add(listener) + return { dispose: () => host.themeChanged.delete(listener) } + }, + }, + extensions: { get all() { return host.extensions } }, + workspace: { + getConfiguration: (section?: string) => ({ + get: (key: string) => host.settings[section ? `${section}.${key}` : key], + inspect: () => undefined, + }), + onDidChangeConfiguration: (listener: (e: { affectsConfiguration: (key: string) => boolean }) => void) => { + host.configChanged.add(listener) + return { dispose: () => host.configChanged.delete(listener) } + }, + }, +})) +vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } })) + +const FOREGROUND = 'var(--vscode-editor-foreground)' +const NO_CUSTOM: ColorCustomizations = { tokenColors: undefined, semanticTokenColors: undefined, semanticHighlighting: undefined } + +function rule(scope: string, foreground: string): unknown { + return { scope, settings: { foreground } } +} + +/** 一套主题:TextMate 规则只配了这几种,语义规则只配了函数 */ +function styles(semanticHighlighting: boolean): TokenStyles { + return new TokenStyles('T', { + textMateRules: [ + rule('keyword', '#000001'), + rule('keyword.control', '#000002'), + rule('keyword.control.loop', '#000003'), + rule('storage', '#000004'), + rule('entity.name.function', '#000005'), + rule('entity.name.type', '#000006'), + rule('comment', '#000007'), + ], + semanticHighlighting, + semanticRules: [['function', '#000008']], + }, NO_CUSTOM) +} + +function fireThemeChange(): void { + host.themeChanged.forEach(listener => listener()) +} + +function fireConfigChange(changed: string): void { + host.configChanged.forEach(listener => listener({ affectsConfiguration: key => changed === key || changed.startsWith(`${key}.`) })) +} + +beforeEach(() => { + host.settings = { 'workbench.colorTheme': 'Child' } + host.extensions = [{ + extensionPath: path.join(__dirname, '../fixtures/themes'), + packageJSON: { contributes: { themes: [{ id: 'Child', path: './nested/child.json' }, { id: 'Gone', path: './gone.json' }] } }, + }] + vi.mocked(logger.add).mockClear() +}) + +describe('对照表:每种颜色查哪些范围', () => { + it('关了语义上色:只查 TextMate 范围,具体的查不到落回更泛的(分支落回 keyword.control),种类和词性查一样的就一样;都没配的是编辑器前景色', () => { + const colors = codeColorsOf(styles(false)) + + expect(colors.roles).toMatchObject({ keyword: '#000002', storage: '#000004', function: '#000005', type: '#000006', comment: '#000007', variable: FOREGROUND, string: FOREGROUND }) + expect(colors.kinds).toMatchObject({ import: '#000002', branch: '#000002', loop: '#000003', return: '#000002', function: '#000005', command: '#000005', type: '#000006', enum: '#000006', comment: '#000007', data: FOREGROUND, statement: FOREGROUND }) + }) + + it('开了语义上色:函数、类这些先看语义规则,没有的再看 TextMate 范围', () => { + const colors = codeColorsOf(styles(true)) + + expect(colors.roles.function).toBe('#000008') + expect(colors.kinds.function).toBe('#000008') + expect(colors.roles.type).toBe('#000006') + expect(colors.kinds.command).toBe('#000005') + }) + + it('没有主题可查时全是编辑器前景色', () => { + const colors = codeColorsOf(undefined) + + expect(new Set([...Object.values(colors.roles), ...Object.values(colors.kinds)])).toEqual(new Set([FOREGROUND])) + }) +}) + +describe('读当前主题的代码配色', () => { + it('读当前主题的文件(连 include 的),叠上用户设置里改的、这个主题专属的', async () => { + host.settings['editor.tokenColorCustomizations'] = { '[Child]': { comments: '#ABCDEF' } } + + const colors = await readCodeColors() + + expect(colors.roles).toMatchObject({ keyword: '#111111', storage: FOREGROUND, string: '#444444', comment: '#ABCDEF', function: '#555555' }) + expect(logger.add).not.toHaveBeenCalled() + }) + + it('用户关了语义上色:函数不看语义规则', async () => { + host.settings['editor.semanticHighlighting.enabled'] = false + + expect((await readCodeColors()).roles.function).toBe(FOREGROUND) + }) + + it('找不到主题、主题文件读不到:记一条警告,全用编辑器前景色', async () => { + for (const name of ['Missing', 'Gone']) { + host.settings['workbench.colorTheme'] = name + vi.mocked(logger.add).mockClear() + + expect(await readCodeColors()).toEqual(codeColorsOf(undefined)) + expect(logger.add).toHaveBeenCalledWith(expect.objectContaining({ level: 'warn', source: 'codeReader', message: expect.stringContaining(name) })) + } + }) +}) + +/** 一份能认出来是第几次读的配色 */ +function colorsNumbered(n: number): CodeColors { + return { roles: { keyword: `#00000${n}` }, kinds: {} } as unknown as CodeColors +} + +describe('所有预览共用的一份配色', () => { + it('只读一次;换了颜色主题、改了代码配色的设置时重读,读好了发给订阅的;无关的设置不管,停用后不再管', async () => { + let reads = 0 + const read = vi.fn(async () => colorsNumbered(++reads)) + const colors = new ThemeColors(read) + const got: CodeColors[] = [] + colors.subscribe(value => got.push(value)) + + expect(await colors.current()).toEqual(colorsNumbered(1)) + expect(await colors.current()).toEqual(colorsNumbered(1)) + expect(read).toHaveBeenCalledTimes(1) + + fireThemeChange() + await vi.waitFor(() => expect(got).toEqual([colorsNumbered(2)])) + fireConfigChange('editor.fontSize') + fireConfigChange('editor.semanticHighlighting.enabled') + await vi.waitFor(() => expect(got).toEqual([colorsNumbered(2), colorsNumbered(3)])) + for (const key of ['workbench.colorTheme', 'workbench.preferredDarkColorTheme', 'window.autoDetectColorScheme', 'editor.tokenColorCustomizations', 'editor.semanticTokenColorCustomizations']) + fireConfigChange(key) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(8)) + expect(await colors.current()).toEqual(colorsNumbered(8)) + + colors.dispose() + fireThemeChange() + expect(read).toHaveBeenCalledTimes(8) + }) + + it('接连换了两次:先前读的过时了不发,current 等到最新的那份', async () => { + const pending: ((value: CodeColors) => void)[] = [] + const colors = new ThemeColors(() => new Promise(resolve => pending.push(resolve))) + const got: CodeColors[] = [] + colors.subscribe(value => got.push(value)) + + const first = colors.current() + fireThemeChange() + fireThemeChange() + pending[2]!(colorsNumbered(3)) + pending[0]!(colorsNumbered(1)) + pending[1]!(colorsNumbered(2)) + + expect(await first).toEqual(colorsNumbered(3)) + await Promise.resolve() + expect(got).toEqual([colorsNumbered(3)]) + }) +}) diff --git a/vscode/src/codeReader/theme/codeColors.ts b/vscode/src/codeReader/theme/codeColors.ts new file mode 100644 index 0000000..c391903 --- /dev/null +++ b/vscode/src/codeReader/theme/codeColors.ts @@ -0,0 +1,191 @@ +import type { Disposable } from 'vscode' +import { window, workspace } from 'vscode' +import { logger } from '../../logging/logger' +import { currentTheme } from './currentTheme' +import { readThemeTokens } from './themeFile' +import { TokenStyles } from './tokenStyles' + +/** 代码上色的几种词性(codeReader.css 的 --cr-<词性>),源码和译文共用;operator 用 storage 的,literal 用 number 的 */ +export type RoleColor = 'keyword' | 'storage' | 'variable' | 'constant' | 'function' | 'type' | 'string' | 'number' | 'comment' + +/** 卡片的几类种类色(codeReader.css 的 --cr-kind-<种类>):同一类语义共用一个颜色,图标区分具体是哪种 */ +export type KindColor = 'import' | 'function' | 'type' | 'enum' | 'exception' | 'data' | 'branch' | 'loop' | 'error' | 'return' | 'statement' | 'comment' | 'options' | 'command' | 'stage' + +/** 当前颜色主题给代码的配色,值是 CSS 颜色:主题(连同用户设置)配了的是十六进制色,没配的是编辑器前景色 */ +export interface CodeColors { + roles: Record + kinds: Record +} + +/** + * 一种颜色从主题里怎么取:开了语义上色时先查语义规则里的 semantic(`类型.修饰`,语言扩展给名字标的那种), + * 没有规则配色再依次查 scopes 这几个 TextMate 范围,取第一个配了色的。 + * 查一个范围时规则配的是它的前缀也算(照 vscode-textmate),所以 keyword.control.loop 查不到会落回 keyword.control、keyword。 + * 带 semantic 的,scopes 就是 VS Code 在语义规则没配色时替这种记号查的那几个范围(tokenClassificationRegistry.ts),两条路一致。 + */ +interface ColorSource { + semantic?: string + scopes: string[] +} + +/** 函数名:语义记号 function,查不到看函数定义和内置函数 */ +const FUNCTION: ColorSource = { semantic: 'function', scopes: ['entity.name.function', 'support.function'] } +/** 类型名:语义记号 class(Python、TypeScript 的类型名大多是类),查不到看类名和内置类 */ +const TYPE: ColorSource = { semantic: 'class', scopes: ['entity.name.type.class', 'support.class'] } +/** 变量名:语义记号 variable,查不到看普通变量 */ +const VARIABLE: ColorSource = { semantic: 'variable', scopes: ['variable.other.readwrite', 'entity.name.variable'] } + +/** 每种词性从主题里怎么取色 */ +const ROLE_SOURCES: Record = { + // 流程关键字:import if for return try await … + keyword: { scopes: ['keyword.control'] }, + // 声明关键字和字面量关键字:def class const function async True None …(语法里都是 storage.type) + storage: { scopes: ['storage.type'] }, + variable: VARIABLE, + // 当常量用的名字:语义记号是只读的变量,查不到时 VS Code 先看常量、再看普通变量 + constant: { semantic: 'variable.readonly', scopes: ['variable.other.constant', 'variable.other.readwrite', 'entity.name.variable'] }, + function: FUNCTION, + type: TYPE, + string: { scopes: ['string'] }, + number: { scopes: ['constant.numeric'] }, + comment: { scopes: ['comment'] }, +} + +/** 每类卡片从主题里怎么取色:取写这类代码打头的那个关键字、或这类代码定义的名字的颜色 */ +const KIND_SOURCES: Record = { + // 导入:import / from + import: { scopes: ['keyword.control.import'] }, + // 函数、方法:同函数名 + function: FUNCTION, + // 类、类型:同类型名 + type: TYPE, + // 枚举:语义记号 enum,查不到看枚举名,再落回类型名的 entity.name.type + enum: { semantic: 'enum', scopes: ['entity.name.type.enum'] }, + // 异常类:内置异常类型(Python 的 ValueError 这些) + exception: { scopes: ['support.type.exception'] }, + // 字段、常量、变量:同变量名 + data: VARIABLE, + // 条件、分支:if / else / case + branch: { scopes: ['keyword.control.conditional'] }, + // 循环:for / while + loop: { scopes: ['keyword.control.loop'] }, + // 异常处理:try / catch / finally(TypeScript 语法的叫法) + error: { scopes: ['keyword.control.trycatch'] }, + // 返回、启动:return 这类跳走的关键字 + return: { scopes: ['keyword.control.flow'] }, + // 普通语句:就是一段普通代码,取整段源码的范围,主题一般不配,即编辑器前景色 + statement: { scopes: ['source'] }, + comment: { scopes: ['comment'] }, + // 选项、设置:命令行的 -e 这类选项 + options: { scopes: ['constant.other.option'] }, + // 命令:shell 里的命令名,语法标成命令、外面套着函数调用,主题没配命令时落回函数调用的颜色 + command: { scopes: ['entity.name.command', 'entity.name.function.call'] }, + // 构建阶段、模块:语义记号 namespace,查不到看命名空间名 + stage: { semantic: 'namespace', scopes: ['entity.name.namespace'] }, +} + +/** 主题没配色时用的编辑器前景色(webview 里 VS Code 给的变量,用户改过的也算上了) */ +const EDITOR_FOREGROUND = 'var(--vscode-editor-foreground)' + +function colorOf(styles: TokenStyles | undefined, source: ColorSource): string { + if (!styles) + return EDITOR_FOREGROUND + const semantic = styles.semanticHighlighting && source.semantic !== undefined ? styles.semanticColor(source.semantic) : undefined + return semantic ?? source.scopes.map(scope => styles.scopeColor(scope)).find(color => color !== undefined) ?? EDITOR_FOREGROUND +} + +function colorsOf(styles: TokenStyles | undefined, sources: Record): Record { + return Object.fromEntries(Object.entries(sources).map(([name, source]) => [name, colorOf(styles, source)])) as Record +} + +/** 按对照表从 styles 取代码配色;没有 styles(主题读不出来)时全是编辑器前景色。 */ +export function codeColorsOf(styles: TokenStyles | undefined): CodeColors { + return { roles: colorsOf(styles, ROLE_SOURCES), kinds: colorsOf(styles, KIND_SOURCES) } +} + +/** 读当前颜色主题的文件,叠上用户设置里改的代码配色,取代码配色。主题找不到、读不出来时记一条警告,全用编辑器前景色。 */ +export async function readCodeColors(): Promise { + const theme = currentTheme() + const warn = (reason: string): CodeColors => { + logger.add({ level: 'warn', source: 'codeReader', message: `代码配色用编辑器前景色:颜色主题「${theme.name}」${reason}` }) + return codeColorsOf(undefined) + } + if (theme.file === undefined) + return warn('在装着的扩展里找不到') + try { + const tokens = await readThemeTokens(theme.file) + const editor = workspace.getConfiguration('editor') + return codeColorsOf(new TokenStyles(theme.name, tokens, { + tokenColors: editor.get('tokenColorCustomizations'), + semanticTokenColors: editor.get('semanticTokenColorCustomizations'), + semanticHighlighting: editor.get('semanticHighlighting.enabled'), + })) + } + catch (err) { + return warn(`的主题文件读不出来:${err instanceof Error ? err.message : String(err)}`) + } +} + +/** 改了会让当前主题、代码配色变的设置 */ +const THEME_SETTINGS = [ + 'workbench.colorTheme', + 'workbench.preferredDarkColorTheme', + 'workbench.preferredLightColorTheme', + 'workbench.preferredHighContrastColorTheme', + 'workbench.preferredHighContrastLightColorTheme', + 'window.autoDetectColorScheme', + 'window.autoDetectHighContrast', + 'editor.tokenColorCustomizations', + 'editor.semanticTokenColorCustomizations', + 'editor.semanticHighlighting', +] + +/** + * 所有预览共用的一份代码配色:头一回要时读,之后换了颜色主题、改了上面这些设置就作废重读,读好了发给订阅的。 + * 接连变了几次时只发最后读好的那份,先前读的过时了不发。 + */ +export class ThemeColors implements Disposable { + private colors: Promise | undefined + private readonly listeners = new Set<(colors: CodeColors) => void>() + private readonly disposables: Disposable[] + + constructor(private readonly read: () => Promise) { + this.disposables = [ + window.onDidChangeActiveColorTheme(() => this.reread()), + workspace.onDidChangeConfiguration((e) => { + if (THEME_SETTINGS.some(key => e.affectsConfiguration(key))) + this.reread() + }), + ] + } + + /** 眼下的配色:正在重读时等最新的那份读好。 */ + async current(): Promise { + let pending: Promise + let colors: CodeColors + do { + pending = this.colors ??= this.read() + colors = await pending + } while (pending !== this.colors) + return colors + } + + /** 重读好了就调 listener,带上新的配色。 */ + subscribe(listener: (colors: CodeColors) => void): Disposable { + this.listeners.add(listener) + return { dispose: () => this.listeners.delete(listener) } + } + + dispose(): void { + this.disposables.forEach(disposable => disposable.dispose()) + this.listeners.clear() + } + + private reread(): void { + const pending = this.colors = this.read() + void pending.then((colors) => { + if (pending === this.colors) + this.listeners.forEach(listener => listener(colors)) + }) + } +} diff --git a/vscode/src/codeReader/theme/currentTheme.test.ts b/vscode/src/codeReader/theme/currentTheme.test.ts new file mode 100644 index 0000000..748e68f --- /dev/null +++ b/vscode/src/codeReader/theme/currentTheme.test.ts @@ -0,0 +1,92 @@ +import * as path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { currentTheme } from './currentTheme' + +/** VS Code 那头:用户设置、设置的默认值、当前主题是哪一类(1 亮、2 暗、3 高对比暗、4 高对比亮)、装着的扩展 */ +const host = vi.hoisted(() => ({ + settings: {} as Record, + defaults: {} as Record, + kind: 2, + extensions: [] as unknown[], +})) + +vi.mock('vscode', () => ({ + ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }, + window: { get activeColorTheme() { return { kind: host.kind } } }, + extensions: { get all() { return host.extensions } }, + workspace: { + getConfiguration: () => ({ + get: (key: string) => host.settings[key] ?? host.defaults[key], + inspect: (key: string) => ({ key, defaultValue: host.defaults[key] }), + }), + }, +})) + +function extension(extensionPath: string, themes: unknown[]): unknown { + return { extensionPath, packageJSON: { contributes: { themes } } } +} + +beforeEach(() => { + host.settings = {} + host.defaults = { + 'workbench.colorTheme': 'Dark 2026', + 'workbench.preferredDarkColorTheme': 'Dark 2026', + 'workbench.preferredLightColorTheme': 'Light 2026', + 'workbench.preferredHighContrastColorTheme': 'Default High Contrast', + 'workbench.preferredHighContrastLightColorTheme': 'Default High Contrast Light', + 'window.autoDetectColorScheme': false, + 'window.autoDetectHighContrast': true, + } + host.kind = 2 + host.extensions = [] +}) + +describe('当前用的是哪个主题(照 VS Code 的 themeConfiguration.ts)', () => { + it('没开自动跟随系统:就是 workbench.colorTheme', () => { + host.settings = { 'workbench.colorTheme': 'Gerry Oceanic', 'workbench.preferredDarkColorTheme': 'Dark+' } + expect(currentTheme().name).toBe('Gerry Oceanic') + }) + + it('开了 window.autoDetectColorScheme:当前主题是暗色取 preferredDarkColorTheme,亮色取 preferredLightColorTheme', () => { + host.settings = { 'window.autoDetectColorScheme': true, 'workbench.colorTheme': 'Gerry Oceanic', 'workbench.preferredDarkColorTheme': 'Dark+', 'workbench.preferredLightColorTheme': 'Light+' } + expect(currentTheme().name).toBe('Dark+') + host.kind = 1 + expect(currentTheme().name).toBe('Light+') + }) + + it('高对比:开着 window.autoDetectHighContrast 取两个高对比的;关了按明暗走', () => { + host.settings = { 'window.autoDetectColorScheme': true, 'workbench.preferredDarkColorTheme': 'Dark+', 'workbench.preferredLightColorTheme': 'Light+' } + host.kind = 3 + expect(currentTheme().name).toBe('Default High Contrast') + host.kind = 4 + expect(currentTheme().name).toBe('Default High Contrast Light') + host.settings['window.autoDetectHighContrast'] = false + expect(currentTheme().name).toBe('Light+') + }) + + it('设置里写的老名字照 VS Code 换成现在的:Default Dark+ 是 Dark+,VS Code Light 是默认的亮色主题', () => { + host.settings = { 'workbench.colorTheme': 'Default Dark+' } + expect(currentTheme().name).toBe('Dark+') + host.settings = { 'workbench.colorTheme': 'VS Code Light' } + expect(currentTheme().name).toBe('Light 2026') + }) +}) + +describe('在装着的扩展贡献的主题里找主题文件', () => { + it('设置里的名字对的是 id,没有 id 对 label,再没有对文件名;路径从扩展目录算起', () => { + host.extensions = [ + { extensionPath: '/ext/plain', packageJSON: {} }, + extension('/ext/defaults', [{ id: 'Dark+', label: '%darkPlus%', path: './themes/dark_plus.json' }]), + extension('/ext/gerry', [{ label: 'Gerry Oceanic', path: './gerryOceanic.json' }, { path: './themes/Nameless.tmTheme' }]), + ] + + host.settings = { 'workbench.colorTheme': 'Dark+' } + expect(currentTheme()).toEqual({ name: 'Dark+', file: path.join('/ext/defaults', 'themes/dark_plus.json') }) + host.settings = { 'workbench.colorTheme': 'Gerry Oceanic' } + expect(currentTheme()).toEqual({ name: 'Gerry Oceanic', file: path.join('/ext/gerry', 'gerryOceanic.json') }) + host.settings = { 'workbench.colorTheme': 'Nameless.tmTheme' } + expect(currentTheme().file).toBe(path.join('/ext/gerry', 'themes/Nameless.tmTheme')) + host.settings = { 'workbench.colorTheme': '%darkPlus%' } + expect(currentTheme()).toEqual({ name: '%darkPlus%' }) + }) +}) diff --git a/vscode/src/codeReader/theme/currentTheme.ts b/vscode/src/codeReader/theme/currentTheme.ts new file mode 100644 index 0000000..612b934 --- /dev/null +++ b/vscode/src/codeReader/theme/currentTheme.ts @@ -0,0 +1,81 @@ +import type { WorkspaceConfiguration } from 'vscode' +import * as path from 'node:path' +import { ColorThemeKind, extensions, window, workspace } from 'vscode' + +/** 当前正在用的颜色主题:设置里写的名字,和它的主题文件;装着的扩展里找不到这个主题时没有 file */ +export interface CurrentTheme { + name: string + file?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * 主题名写在哪个设置里(themeConfiguration.ts 的 getColorThemeSettingId):开着 window.autoDetectHighContrast、系统是高对比时取两个高对比的; + * 开着 window.autoDetectColorScheme 时按系统明暗取 preferredDark / preferredLight;都没有就是 workbench.colorTheme。 + * 扩展看不到系统的明暗,按当前主题是哪一类算(VS Code 正是按系统明暗挑的这个主题)。 + */ +function themeSettingKey(kind: ColorThemeKind, config: WorkspaceConfiguration): string { + const light = kind === ColorThemeKind.Light || kind === ColorThemeKind.HighContrastLight + const highContrast = kind === ColorThemeKind.HighContrast || kind === ColorThemeKind.HighContrastLight + if (highContrast && config.get('window.autoDetectHighContrast')) + return light ? 'workbench.preferredHighContrastLightColorTheme' : 'workbench.preferredHighContrastColorTheme' + if (config.get('window.autoDetectColorScheme')) + return light ? 'workbench.preferredLightColorTheme' : 'workbench.preferredDarkColorTheme' + return 'workbench.colorTheme' +} + +/** + * 设置里写的老名字换成现在的(workbenchThemeService.ts 的 migrateThemeSettingsId):内置主题去掉了 Default 前缀, + * 早先的 Experimental / VS Code 两个主题换成默认的暗色、亮色主题(就是 preferredDark / preferredLight 两个设置的默认值)。 + */ +function migratedName(name: string, config: WorkspaceConfiguration): string { + switch (name) { + case 'Default Dark Modern': + return 'Dark Modern' + case 'Default Light Modern': + return 'Light Modern' + case 'Default Dark+': + return 'Dark+' + case 'Default Light+': + return 'Light+' + case 'Experimental Dark': + case 'VS Code Dark': + return config.inspect('workbench.preferredDarkColorTheme')?.defaultValue ?? name + case 'Experimental Light': + case 'VS Code Light': + return config.inspect('workbench.preferredLightColorTheme')?.defaultValue ?? name + } + return name +} + +/** + * 装着的扩展(内置的也在里面)贡献的主题里设置名是 name 的那个的主题文件。设置名照 VS Code(colorThemeData.ts 的 fromExtensionTheme): + * 是主题的 id,没有 id 用 label,再没有用文件名。 + */ +function themeFile(name: string): string | undefined { + for (const extension of extensions.all) { + const packageJSON: unknown = extension.packageJSON + const themes = isRecord(packageJSON) && isRecord(packageJSON.contributes) ? packageJSON.contributes.themes : undefined + if (!Array.isArray(themes)) + continue + for (const theme of themes) { + if (!isRecord(theme) || typeof theme.path !== 'string') + continue + const settingsName = theme.id || theme.label || path.basename(theme.path) + if (settingsName === name) + return path.join(extension.extensionPath, theme.path) + } + } + return undefined +} + +/** 照 VS Code 自己的规则定当前正在用的颜色主题,找到它的主题文件。 */ +export function currentTheme(): CurrentTheme { + const config = workspace.getConfiguration() + const name = migratedName(config.get(themeSettingKey(window.activeColorTheme.kind, config)) ?? '', config) + const file = themeFile(name) + return { name, ...(file !== undefined && { file }) } +} diff --git a/vscode/src/codeReader/theme/themeFile.test.ts b/vscode/src/codeReader/theme/themeFile.test.ts new file mode 100644 index 0000000..f1dd50c --- /dev/null +++ b/vscode/src/codeReader/theme/themeFile.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { describe, expect, it } from 'vitest' +import { readThemeTokens } from './themeFile' + +const THEMES = path.join(__dirname, '../fixtures/themes') + +function rule(scope: string, foreground: string): unknown { + return { scope, settings: { foreground } } +} + +describe('读颜色主题文件', () => { + it('主题 JSON 带注释;include 的路径从这个文件所在的目录算起,被 include 的规则在前、自己的在后;有一个说开语义上色就开', async () => { + const tokens = await readThemeTokens(path.join(THEMES, 'nested/child.json')) + + expect(tokens.textMateRules).toEqual([rule('keyword', '#111111'), rule('string', '#222222'), rule('string', '#444444')]) + expect(tokens.semanticRules).toEqual([['function', '#333333'], ['function', { foreground: '#555555' }]]) + expect(tokens.semanticHighlighting).toBe(true) + }) + + it('tokenColors 可以是指向 .tmTheme 的路径;主题本身也可以是 .tmTheme,规则照 settings 原样读,没有范围的全局设置也留着', async () => { + const global = { settings: { background: '#000000', foreground: '#FFFFFF' } } + const comment = { name: 'Comment', scope: 'comment', settings: { foreground: '#666666' } } + const control = { name: 'Control', scope: 'keyword.control, storage', settings: { foreground: '#777777' } } + + expect(await readThemeTokens(path.join(THEMES, 'split.json'))).toEqual({ textMateRules: [global, comment, control], semanticHighlighting: false, semanticRules: [] }) + expect((await readThemeTokens(path.join(THEMES, 'classic.tmTheme'))).textMateRules).toEqual([global, comment, control]) + }) + + it('读不到、不是合法的 JSON、不是对象、.tmTheme 的 settings 不是数组时报错', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'theme-')) + await writeFile(path.join(dir, 'broken.json'), '{ "tokenColors": [') + await writeFile(path.join(dir, 'list.json'), '[]') + + await expect(readThemeTokens(path.join(THEMES, 'missing.json'))).rejects.toThrow() + await expect(readThemeTokens(path.join(dir, 'broken.json'))).rejects.toThrow('broken.json') + await expect(readThemeTokens(path.join(dir, 'list.json'))).rejects.toThrow('list.json') + await expect(readThemeTokens(path.join(THEMES, 'noSettings.tmTheme'))).rejects.toThrow('noSettings.tmTheme') + }) + + it('主题 JSON 里的尾逗号照 VS Code 允许', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'theme-')) + await writeFile(path.join(dir, 'trailing.json'), '{ "tokenColors": [{ "scope": "string", "settings": { "foreground": "#123456" } },], }') + + expect((await readThemeTokens(path.join(dir, 'trailing.json'))).textMateRules).toEqual([rule('string', '#123456')]) + }) +}) diff --git a/vscode/src/codeReader/theme/themeFile.ts b/vscode/src/codeReader/theme/themeFile.ts new file mode 100644 index 0000000..7d36abc --- /dev/null +++ b/vscode/src/codeReader/theme/themeFile.ts @@ -0,0 +1,66 @@ +import type { ParseError } from 'jsonc-parser' +import { readFile } from 'node:fs/promises' +import * as path from 'node:path' +import { parse, printParseErrorCode } from 'jsonc-parser' + +/** + * 一个颜色主题里跟代码上色有关的部分,按 VS Code 读主题的顺序排(colorThemeData.ts 的 _loadColorTheme): + * include 进来的在前、自己的在后,后面的盖前面的。 + */ +export interface ThemeTokens { + /** TextMate 上色规则(tokenColors、.tmTheme 的 settings),原样留着,怎么认见 tokenStyles.ts */ + textMateRules: unknown[] + /** 主题自己说开不开语义上色:include 链上有一个说开就开 */ + semanticHighlighting: boolean + /** 语义上色规则(semanticTokenColors):选择器和它的值 */ + semanticRules: [string, unknown][] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** 读 .tmTheme(plist):settings 就是它的 TextMate 规则,没有范围的那几条是全局设置,照 VS Code 也留着 */ +async function readTmTheme(file: string, into: ThemeTokens): Promise { + // plist 只发了 ES 模块,扩展是 CommonJS,只能动态导入 + const { parse: parsePlist } = await import('plist') + const theme = parsePlist(await readFile(file, 'utf8')) + if (!isRecord(theme) || !Array.isArray(theme.settings)) + throw new Error(`${file} 里的 settings 不是数组`) + into.textMateRules.push(...theme.settings) +} + +async function readInto(file: string, into: ThemeTokens): Promise { + if (path.extname(file) !== '.json') + return readTmTheme(file, into) + const errors: ParseError[] = [] + const theme: unknown = parse(await readFile(file, 'utf8'), errors, { allowTrailingComma: true }) + if (errors.length > 0) + throw new Error(`${file} 不是合法的 JSON:${errors.map(error => printParseErrorCode(error.error)).join('、')}`) + if (!isRecord(theme)) + throw new Error(`${file} 不是 JSON 对象`) + if (typeof theme.include === 'string') + await readInto(path.join(path.dirname(file), theme.include), into) + // 老格式:整个文件就是 .tmTheme 的 settings,别的不看 + if (Array.isArray(theme.settings)) { + into.textMateRules.push(...theme.settings) + return + } + into.semanticHighlighting ||= Boolean(theme.semanticHighlighting) + const tokenColors = theme.tokenColors + if (Array.isArray(tokenColors)) + into.textMateRules.push(...tokenColors) + else if (typeof tokenColors === 'string') + await readTmTheme(path.join(path.dirname(file), tokenColors), into) + else if (tokenColors) + throw new Error(`${file} 的 tokenColors 既不是规则数组,也不是 .tmTheme 的路径`) + if (isRecord(theme.semanticTokenColors)) + into.semanticRules.push(...Object.entries(theme.semanticTokenColors)) +} + +/** 读 file 这个颜色主题(JSON 或 .tmTheme)连同它 include 的;读不到、格式不对时抛错。 */ +export async function readThemeTokens(file: string): Promise { + const tokens: ThemeTokens = { textMateRules: [], semanticHighlighting: false, semanticRules: [] } + await readInto(file, tokens) + return tokens +} diff --git a/vscode/src/codeReader/theme/tokenStyles.test.ts b/vscode/src/codeReader/theme/tokenStyles.test.ts new file mode 100644 index 0000000..e632683 --- /dev/null +++ b/vscode/src/codeReader/theme/tokenStyles.test.ts @@ -0,0 +1,165 @@ +import type { ThemeTokens } from './themeFile' +import type { ColorCustomizations } from './tokenStyles' +import { describe, expect, it } from 'vitest' +import { TokenStyles } from './tokenStyles' + +function theme(textMateRules: unknown[], extra: Partial = {}): ThemeTokens { + return { textMateRules, semanticHighlighting: false, semanticRules: [], ...extra } +} + +const NO_CUSTOM: ColorCustomizations = { tokenColors: undefined, semanticTokenColors: undefined, semanticHighlighting: undefined } + +function rule(scope: unknown, foreground: string): unknown { + return { scope, settings: { foreground } } +} + +describe('按 TextMate 范围取色(同 vscode-textmate)', () => { + it('规则的范围按点分段是查的范围的前缀才算;段数多的胜,一样具体时后写的胜;只给字体样式的规则不改颜色', () => { + const styles = new TokenStyles('T', theme([ + rule('keyword', '#111111'), + rule('keyword.control', '#222222'), + rule('keyword.control', '#333333'), + rule('keyword.con', '#444444'), + rule('keyword.control.flow.python', '#555555'), + { scope: 'keyword.control.loop', settings: { fontStyle: 'italic' } }, + ]), NO_CUSTOM) + + expect(styles.scopeColor('keyword.control.conditional')).toBe('#333333') + expect(styles.scopeColor('keyword.control.loop')).toBe('#333333') + expect(styles.scopeColor('keyword.control.flow')).toBe('#333333') + expect(styles.scopeColor('keyword.other')).toBe('#111111') + expect(styles.scopeColor('keywords')).toBeUndefined() + }) + + it('逗号分隔的选择器拆开;数组里的每一项各是一个选择器', () => { + const styles = new TokenStyles('T', theme([ + rule(',string , comment,', '#123456'), + rule(['entity.name.function', 'support.function'], '#654321'), + ]), NO_CUSTOM) + + expect(styles.scopeColor('string.quoted')).toBe('#123456') + expect(styles.scopeColor('comment.line')).toBe('#123456') + expect(styles.scopeColor('support.function.builtin')).toBe('#654321') + }) + + it('带父级的选择器(空格分隔)对单独一个范围的查询不生效', () => { + const styles = new TokenStyles('T', theme([ + rule('keyword', '#00FF00'), + rule('source.python keyword.control', '#FF0000'), + ]), NO_CUSTOM) + + expect(styles.scopeColor('keyword.control')).toBe('#00FF00') + }) + + it('不是十六进制色的、没有范围的规则不算;颜色统一成大写、补全简写、去掉不透明的 FF', () => { + const styles = new TokenStyles('T', theme([ + { settings: { foreground: '#FFFFFF' } }, + rule('string', 'red'), + rule('comment', '#abcf'), + rule('constant', '#12345678'), + rule('variable', '#123456ff'), + ]), NO_CUSTOM) + + expect(styles.scopeColor('string')).toBeUndefined() + expect(styles.scopeColor('comment')).toBe('#AABBCC') + expect(styles.scopeColor('constant')).toBe('#12345678') + expect(styles.scopeColor('variable')).toBe('#123456') + }) +}) + +describe('叠用户设置 editor.tokenColorCustomizations', () => { + const THEME = theme([ + rule('entity.name.function', '#AAAAAA'), + rule('string', '#BBBBBB'), + rule('comment', '#CCCCCC'), + rule('keyword', '#DDDDDD'), + ]) + + it('全局的几组颜色和 textMateRules 排在主题的后面、盖过主题;这个主题的专属块再排在后面;别的主题的专属块不管', () => { + const styles = new TokenStyles('Gerry Oceanic', THEME, { + ...NO_CUSTOM, + tokenColors: { + 'functions': '#000001', + 'comments': { foreground: '#000002', fontStyle: 'italic' }, + 'textMateRules': [rule('string', '#000003')], + '[Gerry Oceanic]': { strings: '#000004', textMateRules: [rule('string.quoted', '#000005')] }, + '[Dark+]': { functions: '#999999' }, + }, + }) + + expect(styles.scopeColor('entity.name.function')).toBe('#000001') + expect(styles.scopeColor('support.function')).toBe('#000001') + expect(styles.scopeColor('comment.line')).toBe('#000002') + expect(styles.scopeColor('string')).toBe('#000004') + expect(styles.scopeColor('string.quoted.double')).toBe('#000005') + }) + + it('专属块的主题名可以用 * 通配开头、结尾或两头,几个主题可以写在一个键里;几块都对上时数组接起来、别的后面的盖前面的', () => { + const custom = (key: string): ColorCustomizations => ({ ...NO_CUSTOM, tokenColors: { [key]: { functions: '#000001' } } }) + for (const key of ['[Gerry*]', '[*Oceanic]', '[*rry Oce*]', '[Dark+][Gerry Oceanic]']) + expect(new TokenStyles('Gerry Oceanic', THEME, custom(key)).scopeColor('entity.name.function'), key).toBe('#000001') + expect(new TokenStyles('Gerry Oceanic', THEME, custom('[Gerry]')).scopeColor('entity.name.function')).toBe('#AAAAAA') + + const merged = new TokenStyles('Gerry Oceanic', THEME, { + ...NO_CUSTOM, + tokenColors: { + '[Gerry*]': { functions: '#000001', textMateRules: [rule('string', '#000002')] }, + '[*Oceanic]': { functions: '#000003', textMateRules: [rule('comment', '#000004')] }, + }, + }) + expect(merged.scopeColor('entity.name.function')).toBe('#000003') + expect(merged.scopeColor('string')).toBe('#000002') + expect(merged.scopeColor('comment')).toBe('#000004') + }) + + it('keywords 那组里的 `keyword - keyword.operator` 是带排除的选择器,对单独一个范围的查询不生效,同组别的范围照常', () => { + const styles = new TokenStyles('T', THEME, { ...NO_CUSTOM, tokenColors: { keywords: '#000001' } }) + + expect(styles.scopeColor('keyword.control.flow')).toBe('#000001') + expect(styles.scopeColor('storage.type')).toBe('#000001') + expect(styles.scopeColor('keyword.other')).toBe('#DDDDDD') + }) +}) + +describe('语义上色', () => { + it('开没开:设置给了 true / false 就按设置;configuredByTheme 时先看 semanticTokenColorCustomizations.enabled(专属块优先),再看老的 tokenColorCustomizations.semanticHighlighting,最后看主题', () => { + const on = theme([], { semanticHighlighting: true }) + const off = theme([]) + + expect(new TokenStyles('T', on, NO_CUSTOM).semanticHighlighting).toBe(true) + expect(new TokenStyles('T', on, { ...NO_CUSTOM, semanticHighlighting: 'configuredByTheme' }).semanticHighlighting).toBe(true) + expect(new TokenStyles('T', off, NO_CUSTOM).semanticHighlighting).toBe(false) + expect(new TokenStyles('T', on, { ...NO_CUSTOM, semanticHighlighting: false }).semanticHighlighting).toBe(false) + expect(new TokenStyles('T', off, { ...NO_CUSTOM, semanticHighlighting: true, semanticTokenColors: { enabled: false } }).semanticHighlighting).toBe(true) + expect(new TokenStyles('T', off, { ...NO_CUSTOM, semanticTokenColors: { enabled: true } }).semanticHighlighting).toBe(true) + expect(new TokenStyles('T', off, { ...NO_CUSTOM, semanticTokenColors: { 'enabled': false, '[T]': { enabled: true } } }).semanticHighlighting).toBe(true) + expect(new TokenStyles('T', off, { ...NO_CUSTOM, tokenColors: { semanticHighlighting: true } }).semanticHighlighting).toBe(true) + expect(new TokenStyles('T', off, { ...NO_CUSTOM, tokenColors: { 'semanticHighlighting': false, '[T]': { semanticHighlighting: true } } }).semanticHighlighting).toBe(true) + // 照 VS Code:专属块合起来时 false 这类假值被跳过,专属块里写 false 不起作用 + expect(new TokenStyles('T', on, { ...NO_CUSTOM, semanticTokenColors: { '[T]': { enabled: false } } }).semanticHighlighting).toBe(true) + expect(new TokenStyles('T', off, { ...NO_CUSTOM, tokenColors: { semanticHighlighting: false }, semanticTokenColors: { enabled: true } }).semanticHighlighting).toBe(true) + }) + + it('语义规则:主题的在前,用户的 rules、专属块的 rules 在后;修饰词越多越具体,一样具体时后写的胜;带语言的、只给字体样式的不算', () => { + const styles = new TokenStyles('T', theme([], { + semanticRules: [ + ['function', '#000001'], + ['function', { fontStyle: 'bold' }], + ['variable', '#000002'], + ['*.readonly', '#000003'], + ['class:python', '#000009'], + ], + }), { + ...NO_CUSTOM, + semanticTokenColors: { + 'rules': { variable: { foreground: '#000004' } }, + '[T]': { rules: { 'variable.readonly': '#000005' } }, + }, + }) + + expect(styles.semanticColor('function')).toBe('#000001') + expect(styles.semanticColor('variable')).toBe('#000004') + expect(styles.semanticColor('variable.readonly')).toBe('#000005') + expect(styles.semanticColor('class')).toBeUndefined() + }) +}) diff --git a/vscode/src/codeReader/theme/tokenStyles.ts b/vscode/src/codeReader/theme/tokenStyles.ts new file mode 100644 index 0000000..b3d2862 --- /dev/null +++ b/vscode/src/codeReader/theme/tokenStyles.ts @@ -0,0 +1,220 @@ +import type { ThemeTokens } from './themeFile' + +/** + * 用户设置里改的代码配色,原样从设置里读来:editor.tokenColorCustomizations、editor.semanticTokenColorCustomizations、 + * editor.semanticHighlighting.enabled(true / false / configuredByTheme)。 + */ +export interface ColorCustomizations { + tokenColors: unknown + semanticTokenColors: unknown + semanticHighlighting: unknown +} + +/** editor.tokenColorCustomizations 里几组颜色各管哪些范围(colorThemeData.ts 的 tokenGroupToScopesMap) */ +const TOKEN_GROUP_SCOPES: Record = { + comments: ['comment', 'punctuation.definition.comment'], + strings: ['string', 'meta.embedded.assembly'], + keywords: ['keyword - keyword.operator', 'keyword.control', 'storage', 'storage.type'], + numbers: ['constant.numeric'], + types: ['entity.name.type', 'entity.name.class', 'support.type', 'support.class'], + functions: ['entity.name.function', 'support.function'], + variables: ['variable', 'entity.name.variable'], +} + +/** 一条只有一个范围、不带父级的 TextMate 规则:范围,前景色 */ +interface ScopeRule { scope: string, foreground: string } + +/** 一条有前景色的语义规则:选择器(`类型.修饰.修饰:语言`),前景色 */ +interface SemanticRule { selector: string, foreground: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** 颜色照 VS Code 规整(colorThemeData.ts 的 normalizeColor):#RGB、#RGBA、#RRGGBB、#RRGGBBAA 统一成大写的六位或八位,不透明的去掉 FF;别的写法不算颜色。 */ +function normalizeColor(color: unknown): string | undefined { + if (typeof color !== 'string' || !/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color)) + return undefined + const hex = (color.length <= 5 ? [...color.slice(1)].map(digit => digit + digit).join('') : color.slice(1)).toUpperCase() + return `#${hex.length === 8 && hex.endsWith('FF') ? hex.slice(0, 6) : hex}` +} + +/** 设置里的键是不是专属某几个主题的块(`"[主题名]"`) */ +function isThemeScope(key: string): boolean { + return key.startsWith('[') && key.endsWith(']') +} + +/** 专属块写的主题名对不对得上 themeName:可以用 * 通配开头、结尾或两头(colorThemeData.ts 的 isThemeScopeMatch) */ +function themeMatches(pattern: string, themeName: string): boolean { + const wildStart = pattern.startsWith('*') + const wildEnd = pattern.endsWith('*') + return pattern === themeName + || (wildStart && wildEnd && themeName.includes(pattern.slice(1, -1))) + || (wildEnd && themeName.startsWith(pattern.slice(0, -1))) + || (wildStart && themeName.endsWith(pattern.slice(1))) +} + +/** + * 设置里专属 themeName 的那几块合成一块(colorThemeData.ts 的 getThemeSpecificColors):一个键里能写几个主题(`"[A][B]"`), + * 几块都对上时数组接起来,别的后面的盖前面的。一块都没对上是 undefined。 + */ +function themeScoped(settings: Record, themeName: string): Record | undefined { + let merged: Record | undefined + for (const [key, block] of Object.entries(settings)) { + if (!isThemeScope(key) || !isRecord(block)) + continue + for (const [, pattern] of key.matchAll(/\[(.+?)\]/g)) { + if (!themeMatches(pattern!, themeName)) + continue + merged ??= {} + for (const [name, value] of Object.entries(block)) { + const earlier = merged[name] + if (Array.isArray(earlier) && Array.isArray(value)) + merged[name] = [...earlier, ...value] + else if (value) + merged[name] = value + } + } + } + return merged +} + +/** + * editor.tokenColorCustomizations 一块里加的 TextMate 规则(colorThemeData.ts 的 addCustomTokenColors): + * 先几组颜色,再 textMateRules,好让 textMateRules 里更具体的盖过几组颜色。 + */ +function customTextMateRules(customizations: Record): unknown[] { + const rules: unknown[] = [] + for (const [group, scopes] of Object.entries(TOKEN_GROUP_SCOPES)) { + const value = customizations[group] + if (value) { + const settings = typeof value === 'string' ? { foreground: value } : value + rules.push(...scopes.map(scope => ({ scope, settings }))) + } + } + if (Array.isArray(customizations.textMateRules)) + rules.push(...customizations.textMateRules) + return rules +} + +/** editor.semanticTokenColorCustomizations 一块里的 rules;键是专属块的不算 */ +function customSemanticRules(customizations: Record): [string, unknown][] { + return isRecord(customizations.rules) ? Object.entries(customizations.rules).filter(([key]) => !isThemeScope(key)) : [] +} + +/** + * TextMate 规则按写的先后排好,拆成一个个范围(照 vscode-textmate 的 parseTheme):字符串的范围去掉首尾的逗号、按逗号拆, + * 数组的每项各是一个选择器;选择器按空格分段,最后一段是范围、前面的是父级。查的是单独一个范围,带父级的规则不会生效,不留。 + * 没有范围、前景色不是十六进制色的也不留(VS Code 交给 vscode-textmate 之前就去掉了)。 + */ +function scopeRules(rules: unknown[]): ScopeRule[] { + return rules.flatMap((rule) => { + if (!isRecord(rule) || !rule.scope || !isRecord(rule.settings)) + return [] + const foreground = normalizeColor(rule.settings.foreground) + if (!foreground) + return [] + const selectors: unknown[] = typeof rule.scope === 'string' ? rule.scope.replace(/^,+|,+$/g, '').split(',') : Array.isArray(rule.scope) ? rule.scope : [] + return selectors.flatMap((selector) => { + const scope = typeof selector === 'string' ? selector.trim() : '' + return scope !== '' && !scope.includes(' ') ? [{ scope, foreground }] : [] + }) + }) +} + +/** 语义规则的值里的前景色:直接写的颜色,或 `{ foreground }`;只给字体样式的没有 */ +function semanticForeground(value: unknown): string | undefined { + return normalizeColor(isRecord(value) ? value.foreground : value) +} + +/** 语义记号选择器拆成类型、修饰词、语言:`类型.修饰.修饰:语言`(tokenClassificationRegistry.ts 的 parseClassifierString) */ +function parseClassifier(selector: string): { type: string, modifiers: string[], language?: string } { + let end = selector.length + let language: string | undefined + const modifiers: string[] = [] + for (let i = end - 1; i >= 0; i--) { + if (selector[i] !== ':' && selector[i] !== '.') + continue + const segment = selector.slice(i + 1, end) + if (selector[i] === ':') + language = segment + else + modifiers.push(segment) + end = i + } + return { type: selector.slice(0, end), modifiers, ...(language !== undefined && { language }) } +} + +/** + * 语义规则的选择器对这种记号有多具体(tokenClassificationRegistry.ts 的 parseTokenSelector):对不上是 -1; + * 类型对上了(不是 * 通配)加 100,选择器写的修饰词这种记号都得有,每个加 100。 + * 配色所有预览共用、不分语言,带语言的规则不算;查的都是没有父类型的基本类型,类型层级用不上。 + */ +function selectorScore(selector: string, token: { type: string, modifiers: string[] }): number { + const { type, modifiers, language } = parseClassifier(selector) + if (type === '' || language !== undefined || (type !== '*' && type !== token.type) || modifiers.some(modifier => !token.modifiers.includes(modifier))) + return -1 + return (type === '*' ? 0 : 100) + modifiers.length * 100 +} + +/** 几个设置值里最后一个写了 true / false 的 */ +function lastBoolean(...values: unknown[]): boolean | undefined { + return values.filter((value): value is boolean => typeof value === 'boolean').at(-1) +} + +/** + * 一个颜色主题叠上用户设置之后,代码里的记号是什么颜色。规则和先后照 VS Code(colorThemeData.ts): + * 主题的规则在前,用户设置里全局的在后,专属这个主题的块再在后;同样具体时后写的胜。 + * 只管前景色,字体样式不管。 + */ +export class TokenStyles { + /** + * 编辑器开没开语义上色(semanticTokensConfig.ts 的 isSemanticColoringEnabled):editor.semanticHighlighting.enabled 给了 true / false 就按它; + * 否则先看 semanticTokenColorCustomizations.enabled,再看老的 tokenColorCustomizations.semanticHighlighting(都是专属块优先),最后看主题 + */ + readonly semanticHighlighting: boolean + private readonly scopeRules: ScopeRule[] + private readonly semanticRules: SemanticRule[] + + /** themeName:设置里写的主题名,专属块按它对 */ + constructor(themeName: string, theme: ThemeTokens, customizations: ColorCustomizations) { + const tokenColors = isRecord(customizations.tokenColors) ? customizations.tokenColors : {} + const tokenColorsScoped = themeScoped(tokenColors, themeName) ?? {} + const semanticColors = isRecord(customizations.semanticTokenColors) ? customizations.semanticTokenColors : {} + const semanticScoped = themeScoped(semanticColors, themeName) ?? {} + this.scopeRules = scopeRules([...theme.textMateRules, ...customTextMateRules(tokenColors), ...customTextMateRules(tokenColorsScoped)]) + this.semanticRules = [...theme.semanticRules, ...customSemanticRules(semanticColors), ...customSemanticRules(semanticScoped)].flatMap(([selector, value]) => { + const foreground = semanticForeground(value) + return foreground ? [{ selector, foreground }] : [] + }) + this.semanticHighlighting = lastBoolean(customizations.semanticHighlighting) + ?? lastBoolean(semanticColors.enabled, semanticScoped.enabled) + ?? lastBoolean(tokenColors.semanticHighlighting, tokenColorsScoped.semanticHighlighting) + ?? theme.semanticHighlighting + } + + /** + * TextMate 范围 scope 的前景色,照 vscode-textmate 查单独一个范围:规则的范围按点分段是 scope 的前缀才算, + * 段数多的胜,一样具体时后写的胜。没有规则配色是 undefined(编辑器用前景色)。 + */ + scopeColor(scope: string): string | undefined { + let best: ScopeRule | undefined + for (const rule of this.scopeRules) { + if ((rule.scope === scope || scope.startsWith(`${rule.scope}.`)) && (!best || rule.scope.length >= best.scope.length)) + best = rule + } + return best?.foreground + } + + /** 语义记号 classifier(`类型.修饰`)的前景色,只看语义规则:最具体的胜,一样具体时后写的胜。没有规则配色是 undefined。 */ + semanticColor(classifier: string): string | undefined { + const { type, modifiers } = parseClassifier(classifier) + let best: { score: number, foreground: string } | undefined + for (const rule of this.semanticRules) { + const score = selectorScore(rule.selector, { type, modifiers }) + if (score >= 0 && (!best || score >= best.score)) + best = { score, foreground: rule.foreground } + } + return best?.foreground + } +} diff --git a/vscode/webview-ui/src/codeReader/App.test.tsx b/vscode/webview-ui/src/codeReader/App.test.tsx index f5ea87c..6331ad6 100644 --- a/vscode/webview-ui/src/codeReader/App.test.tsx +++ b/vscode/webview-ui/src/codeReader/App.test.tsx @@ -156,3 +156,18 @@ describe('标签页切走再切回来,调用树弹框照旧开着', () => { expect(saved?.callTree).toBeUndefined() }) }) + +describe('代码配色跟着当前颜色主题', () => { + it('扩展端发来的配色设成 body 上的变量:词性的 --cr-<词性>,种类的 --cr-kind-<种类>;换了主题再发来就换', async () => { + const style = document.body.style + await load() + await deliver({ type: 'codeColors', roles: { keyword: '#C586C0', function: '#DCDCAA' }, kinds: { loop: 'var(--vscode-editor-foreground)' } } as ExtensionToWebview) + expect(style.getPropertyValue('--cr-keyword')).toBe('#C586C0') + expect(style.getPropertyValue('--cr-function')).toBe('#DCDCAA') + expect(style.getPropertyValue('--cr-kind-loop')).toBe('var(--vscode-editor-foreground)') + + await deliver({ type: 'codeColors', roles: { keyword: '#AF00DB', function: '#795E26' }, kinds: { loop: '#AF00DB' } } as ExtensionToWebview) + expect(style.getPropertyValue('--cr-keyword')).toBe('#AF00DB') + expect(style.getPropertyValue('--cr-kind-loop')).toBe('#AF00DB') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 8bf785f..ee58fb5 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AskThread, FEATURE_THREAD } from './AskThread' import { BlockLevel } from './BlockTree' import { CallTreeDialog } from './CallTreeDialog' +import { applyCodeColors } from './codeColors' import { DefinitionPeekPopup } from './DefinitionPeekPopup' import { DiffBar, NewFileBanner } from './DiffBar' import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView' @@ -196,6 +197,9 @@ export function App(): ReactElement | null { setKeep(NONE) setOpenedRuns(NONE) return + case 'codeColors': + applyCodeColors(document.body.style, msg) + return case 'diff': setDiff(msg.diff) return diff --git a/vscode/webview-ui/src/codeReader/codeColors.ts b/vscode/webview-ui/src/codeReader/codeColors.ts new file mode 100644 index 0000000..5f5e178 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/codeColors.ts @@ -0,0 +1,9 @@ +import type { CodeColors } from './types' + +/** 扩展端按当前颜色主题算好的代码配色设成 style 上的颜色变量:词性的 --cr-<词性>,种类的 --cr-kind-<种类>(codeReader.css 里用)。 */ +export function applyCodeColors(style: CSSStyleDeclaration, { roles, kinds }: CodeColors): void { + for (const [role, color] of Object.entries(roles)) + style.setProperty(`--cr-${role}`, color) + for (const [kind, color] of Object.entries(kinds)) + style.setProperty(`--cr-kind-${kind}`, color) +} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 4577854..35501b2 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -1,33 +1,7 @@ @import "tailwindcss"; -/* 词性与块种类的配色:暗色(含高对比)取 VS Code Dark+,亮色取 Light+。其余界面颜色一律用 --vscode-* 变量。 */ +/* 词性与块种类的配色(--cr-keyword 这些、--cr-kind-*)由扩展端按当前颜色主题算好发来,设在 body 上(codeColors.ts)。其余界面颜色一律用 --vscode-* 变量。 */ body { - --cr-keyword: #c586c0; - --cr-storage: #569cd6; - --cr-variable: #9cdcfe; - --cr-function: #dcdcaa; - --cr-type: #4ec9b0; - --cr-string: #ce9178; - --cr-number: #b5cea8; - --cr-comment: #6a9955; - --cr-constant: #4fc1ff; - - --cr-kind-import: #c586c0; - --cr-kind-function: #dcdcaa; - --cr-kind-type: #4ec9b0; - --cr-kind-enum: #b180d7; - --cr-kind-exception: #f14c4c; - --cr-kind-data: #9cdcfe; - --cr-kind-branch: #ce9178; - --cr-kind-loop: #8f9cff; - --cr-kind-error: #f48771; - --cr-kind-return: #89d185; - --cr-kind-statement: #9d9d9d; - --cr-kind-comment: #6a9955; - --cr-kind-options: #e5c07b; - --cr-kind-command: #d7ba7d; - --cr-kind-stage: #56b6c2; - margin: 0; font-family: var(--vscode-font-family, system-ui, sans-serif); font-size: var(--vscode-font-size, 13px); @@ -35,36 +9,8 @@ body { background: var(--vscode-editor-background); } -body.vscode-light, -body.vscode-high-contrast-light { - --cr-keyword: #af00db; - --cr-storage: #0000ff; - --cr-variable: #001080; - --cr-function: #795e26; - --cr-type: #267f99; - --cr-string: #a31515; - --cr-number: #098658; - --cr-comment: #008000; - --cr-constant: #0070c1; - - --cr-kind-import: #af00db; - --cr-kind-function: #795e26; - --cr-kind-type: #267f99; - --cr-kind-enum: #652d90; - --cr-kind-exception: #cd3131; - --cr-kind-data: #001080; - --cr-kind-branch: #a31515; - --cr-kind-loop: #4f56d6; - --cr-kind-error: #b5452f; - --cr-kind-return: #388a34; - --cr-kind-statement: #6f6f6f; - --cr-kind-comment: #008000; - --cr-kind-options: #9a6a00; - --cr-kind-command: #7c5f12; - --cr-kind-stage: #127f8c; -} - -/* 种类颜色:同一类语义共用一个颜色,图标区分具体是哪种(kindIcons.tsx)。卡片取自己的种类,子层取所属块的种类 */ +/* 种类颜色:同一类语义共用一个颜色(每类查主题的哪个范围见扩展端 theme/codeColors.ts,主题给几类配了同一个色就同色), + 图标区分具体是哪种(kindIcons.tsx)。卡片取自己的种类,子层取所属块的种类 */ :is([data-kind="import"], [data-kind="copy"]) { --kind: var(--cr-kind-import); } [data-kind="function"] { --kind: var(--cr-kind-function); } :is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 1293938..7da57e9 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -464,6 +464,18 @@ export interface StickyScrollSettings { /** 这个标签页是从哪儿打开的:从侧栏「未提交的更改」一栏点开的是 `uncommitted`,别处打开的是 `other`;页面只记着,重启 VS Code 后恢复时报给扩展端。 */ export type OpenedFrom = 'uncommitted' | 'other' +/** 代码上色的几种词性(codeReader.css 的 --cr-<词性>),源码和译文共用;operator 用 storage 的,literal 用 number 的 */ +export type RoleColor = 'keyword' | 'storage' | 'variable' | 'constant' | 'function' | 'type' | 'string' | 'number' | 'comment' + +/** 卡片的几类种类色(codeReader.css 的 --cr-kind-<种类>):同一类语义共用一个颜色,图标区分具体是哪种 */ +export type KindColor = 'import' | 'function' | 'type' | 'enum' | 'exception' | 'data' | 'branch' | 'loop' | 'error' | 'return' | 'statement' | 'comment' | 'options' | 'command' | 'stage' + +/** 当前颜色主题给代码的配色,值是 CSS 颜色:主题(连同用户设置)配了的是十六进制色,没配的是编辑器前景色 */ +export interface CodeColors { + roles: Record + kinds: Record +} + /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义 @@ -472,6 +484,7 @@ export type OpenedFrom = 'uncommitted' | 'other' * stickyScroll:VS Code 的 Sticky Scroll 设置(按这份文件取,语言自己的设置优先),见 StickyScrollSettings;ready 后来一次,设置改了再来 * occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的 * filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档来一次,之后任何一个预览改了都会来 + * codeColors:当前颜色主题给代码的配色,设成 body 上的颜色变量(见 codeColors.ts);ready 后先于第一份文档来一次,换了颜色主题、改了代码配色的设置再来 * qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来 * profiles:kind 那种对话的 profile 下拉;ready 后两种各来一次,任何一个预览改了哪种就来哪种 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 @@ -541,6 +554,7 @@ export type ExtensionToWebview | { type: 'moveOccurrence', step: 1 | -1 } | { type: 'displayNames', version: number, names: ShownName[] } | { type: 'filter', excluded: Excluded, onlyChanges: boolean } + | ({ type: 'codeColors' } & CodeColors) | QaMessage | ({ type: 'profiles', kind: ChatKind } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string }