✨ feat(vscode): 代码阅读卡片横排多个概念图标

This commit is contained in:
cruldra committed 2026-10-03 03:11:47 +08:00
1 parent 11fcbef225
commit 394cd2fc1c
19 files changed
+328 -99

No files matched your search

+1 -1
View File
@@ -108,7 +108,7 @@ describe('块的筛选属性:宿主从 kind 和 facts 推出通用属性', ()
})
function view(id: string, attributes: Record<string, string>, children: BlockView[] = []): BlockView {
return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes, children, translation: [] }
return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes, concepts: [{ id: 'statement', label: '语句', icon: 'code' }], children, translation: [] }
}
describe('文件里出现过的属性', () => {
+43 -1
View File
@@ -1,5 +1,5 @@
import type { BlockView } from './blockView'
import type { LanguageAdapter, Translator } from './contract'
import type { LanguageAdapter, LibraryRule, Translator } from './contract'
import { describe, expect, it } from 'vitest'
import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView'
import { replaceLines } from './editText'
@@ -333,6 +333,48 @@ describe('blockView', () => {
})
})
it('概念列表:语言的种类在前,语言另认的接着,规则的种类和另认的在后;同一个 id 只留第一个', async () => {
const language: LanguageAdapter = {
...py,
concepts: block => block.kind === 'class'
? [
{ id: 'class', label: '重复的类', icon: 'zap' },
{ id: 'python.abstract', label: '抽象', icon: 'braces' },
]
: [],
}
const rule: LibraryRule = {
id: 'demo',
label: '演示',
languageIds: ['python'],
libraries: [],
claim: block => block.kind === 'class'
? {
kind: 'demo.thing',
badge: { label: '东西', color: { dark: '#111111', light: '#222222' }, icon: 'package' },
rows: [],
absorbs: [],
concepts: [
{ id: 'class', label: '又一个类', icon: 'lock' },
{ id: 'demo.thing', label: '重复的东西', icon: 'tag', color: { dark: '#333333', light: '#444444' } },
{ id: 'demo.extra', label: '额外', icon: 'lock', color: { dark: '#555555', light: '#666666' } },
],
}
: undefined,
}
const source = 'class Box:\n pass\n'
const [view] = await withBlocks(language, 'python', source, blocks =>
toBlockViews(blocks, { source, language, markers: [] }, pythonZhTranslator, { rules: [rule], report: () => {} }))
expect(view?.concepts).toEqual([
{ id: 'class', label: '类', icon: 'box' },
{ id: 'python.abstract', label: '抽象', icon: 'braces' },
{ id: 'demo.thing', label: '东西', icon: 'package', color: { dark: '#111111', light: '#222222' } },
{ id: 'demo.extra', label: '额外', icon: 'lock', color: { dark: '#555555', light: '#666666' } },
])
expect(view?.kind).toBe('demo.thing')
expect(view?.attributes.kind).toBe('demo.thing')
})
it('readDefinitionKind:offset 落在哪块自己的名字上就是那块的种类,里层的块优先;不在名字上没有', async () => {
const source = 'class UserService:\n def update_user(self):\n pass\n\nclass NotFound(Exception):\n pass\n\nlimit = 3\n'
const kind = (text: string): Promise<string | undefined> => readDefinitionKind(runtime, py, 'python', source, source.indexOf(text))
+37 -2
View File
@@ -1,7 +1,8 @@
import type { Tree } from '@vscode/tree-sitter-wasm'
import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime'
import { blockAttributes } from './attributes'
import { KIND_ICONS } from './contract'
import { commonIndent, splitLines } from './editText'
import { activeRules, claimBlock, importsIn } from './libraryRules'
import { BlockAnchors } from './qa/anchor'
@@ -29,7 +30,12 @@ interface BlockViewBase {
defines?: SourceRange
/** 筛选属性(含 kind),见 blockAttributes;库规则认领的块叠上规则给的,kind 是规则的种类 id。 */
attributes: BlockAttributes
/** 库规则的卡片才有:种类的名字、颜色和图标(RuleCard.badge)。预览同类块一样默认收起它的子块。 */
/**
* 这块认出来的概念,从左到右:语言的种类(再加语言插件另认的),库规则的卡片再加规则的种类和规则另认的。
* 同一个 id 只留第一个。webview 按这个画图标,不自己用种类查。
*/
concepts: BlockConcept[]
/** 库规则的卡片才有:筛选栏里这个种类的名字,卡片边框的颜色(RuleCard.badge)。图标在 concepts 里。预览同类块一样默认收起它的子块。 */
badge?: KindBadge
/** 库规则放进父卡片里的子块才有(见 RuleSlot):画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */
slot?: BlockSlot
@@ -297,6 +303,33 @@ export function toBlockViews(
const [only] = steps
return layer(steps, part.document, part.translator, NO_RULES)(steps.length === 1 && only ? only.children : steps, `${id}.`, scope, from)
}
/**
* 这块的概念,从左到右,同一个 id 只留第一个:语言的种类(图标、名字用内置种类和翻译插件起的种类名),
* 语言插件另认的,库规则的卡片再加规则的种类和规则另认的。card 不填就是没有规则卡片(认成现成块的也算)。
*/
function blockConcepts(block: CodeBlock, card?: RuleCard): BlockConcept[] {
const seen = new Set<string>()
const concepts: BlockConcept[] = []
const add = (concept: BlockConcept): void => {
if (seen.has(concept.id))
return
seen.add(concept.id)
concepts.push(concept)
}
add({
id: block.kind,
label: layerTranslator?.attributeNames?.values.kind?.[block.kind] ?? block.kind,
icon: KIND_ICONS[block.kind],
})
for (const concept of layerDocument.language.concepts?.(block) ?? [])
add(concept)
if (card) {
add({ id: card.kind, label: card.badge.label, icon: card.badge.icon, color: card.badge.color })
for (const concept of card.concepts ?? [])
add(concept)
}
return concepts
}
/** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):编辑范围是 span,头部是块自己那部分,子块照常交给各规则看。scope 见 BlockAnchors.place */
function plain(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView {
const name = ownName(block)
@@ -309,6 +342,7 @@ export function toBlockViews(
headerEndLine: block.header.endRow,
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
attributes: blockAttributes(block),
concepts: blockConcepts(block),
children: [
...list(block.children, `${id}.`, inner),
...embeddedChildren(block, id, inner, block.children.length),
@@ -343,6 +377,7 @@ export function toBlockViews(
headerEndLine: span.endRow,
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
attributes: { ...blockAttributes(block), ...attributes, kind },
concepts: blockConcepts(block, replacement),
badge,
children: list(kept, `${id}.`, inner, 0, slots),
properties: rows,
+65 -3
View File
@@ -652,6 +652,11 @@ export interface LanguageAdapter {
steps?: (root: Node, source: string, languageId: string) => StepBlocks
/** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */
displayNames?: DisplayNameReader
/**
* 这块除了自己的种类,还认得出的概念(异步、抽象)。认到才返回;不填就只有种类那一个。
* 种类那一个由宿主按内置种类的图标和翻译插件起的种类名补在最前面,这里不要再报种类。
*/
concepts?: (block: CodeBlock) => BlockConcept[]
}
// ── 中文名(见「中文名」)──
@@ -777,7 +782,59 @@ export const KIND_ICON_NAMES = [
export type KindIconName = (typeof KIND_ICON_NAMES)[number]
/** 库规则的种类在预览里的样子:块左边只画图标(种类色),名字是悬停提示和读屏念的文字,筛选栏里这个种类也叫它。 */
/**
* 内置种类用哪个图标。宿主排预览时写进概念列表,webview 只按名字画。
* 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。
*/
export const KIND_ICONS: Record<BlockKind, KindIconName> = {
import: 'import',
function: 'square-function',
class: 'box',
type: 'braces',
enum: 'list-ordered',
exception: 'triangle-alert',
field: 'tag',
const: 'lock',
variable: 'variable',
if: 'split',
else: 'corner-down-right',
loop: 'repeat',
try: 'shield',
catch: 'shield-alert',
finally: 'shield-check',
return: 'corner-down-left',
statement: 'code',
comment: 'message-square-text',
interpreter: 'terminal',
options: 'sliders-horizontal',
case: 'signpost',
command: 'square-terminal',
pipeline: 'square-terminal',
directive: 'terminal',
stage: 'layers',
run: 'square-terminal',
copy: 'copy',
setting: 'sliders-horizontal',
label: 'tag',
health: 'activity',
start: 'play',
}
/**
* 一块上认出的一个概念:稳定 id、悬停名、图标名(只能从 KIND_ICON_NAMES 里挑)、颜色。
* 语言的种类不填颜色,图标按 id 用 CSS 里这种种类的颜色;规则声明的概念填颜色,只给这个图标上色。
*/
export interface BlockConcept {
/** 稳定 id。语言的种类就是块的种类(class);规则的种类是 RuleCard.kind;规则另认的由规则起。 */
id: string
/** 悬停提示和读屏念的名字。 */
label: string
icon: KindIconName
/** 不填是语言的种类(颜色在 CSS)。规则的概念填规则给的颜色,不改卡片边框。 */
color?: ThemeColor
}
/** 库规则的种类:筛选栏里叫 label,卡片边框用 color,icon 进概念列表。卡片左边不只这一个图标(见 BlockConcept)。 */
export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }
/**
@@ -793,12 +850,17 @@ export type RuleBlock = RuleCard | RuleFacts | RuleOmit
*/
export interface RuleOmit { omit: true }
/** 规则自己的卡片:一张属性表,种类的图标、颜色、名字和每一行都由规则给(用词归规则)。 */
/** 规则自己的卡片:一张属性表。种类(badge)决定筛选和边框色;卡片左边的图标是语言的概念再加这个种类、以及 concepts。用词归规则。 */
export interface RuleCard {
/** 种类 id,写成「规则 id.名字」(sqlalchemy.table):筛选的 kind 属性值就是它,筛选记住的也是它,要稳定。 */
kind: string
/** 种类的名字、颜色和图标:筛选栏里这个种类也叫这个名字。 */
/** 种类的名字、颜色和图标:筛选栏里这个种类也叫这个名字,卡片边框用这个颜色。图标进概念列表,不换掉语言的概念。 */
badge: KindBadge
/**
* 认领时额外认出的概念,排在这个种类(badge)后面。语言认的概念仍在更前面;同一个 id 只留第一个。
* 不改筛选的种类,也不改卡片边框色(仍是 badge)。
*/
concepts?: BlockConcept[]
rows: PropertyRow[]
/** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */
defines?: SourceRange
+1 -1
View File
@@ -20,7 +20,7 @@ function textView(translation: Inline[] | { markdown: string }, extra: Partial<B
}
function propertyView(properties: PropertyRow[]): BlockView {
return { id: '0', anchor: 'a', kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, children: [], properties }
return { id: '0', anchor: 'a', kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children: [], properties }
}
describe('inlineDiff', () => {
@@ -135,6 +135,43 @@ describe('dataclasses 库规则(Python)', () => {
expect(sandbox.badge).toEqual({ label: '数据类', color: { dark: '#ef7fb0', light: '#b3306c' }, icon: 'package' })
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values)
.toContainEqual({ id: 'dataclasses.dataclass', name: '数据类', count: 4 })
expect(sandbox.concepts.map(concept => concept.label)).toEqual(['类', '数据类', '不可修改'])
})
it('概念图标:普通类只有「类」;数据类是「类」和「数据类」,frozen 再加一把锁;slots 不加;悬停名是这些', async () => {
const list = await views([
'class Plain:',
' x = 1',
'',
'from dataclasses import dataclass',
'',
'@dataclass',
'class Open:',
' x: int',
'',
'@dataclass(frozen=False)',
'class Soft:',
' x: int',
'',
'@dataclass(frozen=True)',
'class Closed:',
' x: int',
'',
'@dataclass(frozen=True, slots=True)',
'class Packed:',
' x: int',
'',
].join('\n'))
const plain = list.find(view => view.kind === 'class')
expect(plain?.concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }])
const dataClass = { id: 'dataclasses.dataclass', label: '数据类', icon: 'package' as const, color: { dark: '#ef7fb0', light: '#b3306c' } }
const frozen = { id: 'dataclasses.frozen', label: '不可修改', icon: 'lock' as const, color: { dark: '#9cdcfe', light: '#001080' } }
expect(named(list, 'Open').concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }, dataClass])
expect(named(list, 'Soft').concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }, dataClass])
expect(named(list, 'Closed').concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }, dataClass, frozen])
expect(named(list, 'Packed').concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }, dataClass, frozen])
expect(named(list, 'Closed').attributes.kind).toBe('dataclasses.dataclass')
expect(named(list, 'Closed').badge?.color).toEqual(dataClass.color)
})
it('编辑范围是整个类,每行只在一块里能编辑;改卡片写回整个类', async () => {
+12 -1
View File
@@ -1,4 +1,4 @@
import type { Access, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import type { Access, BlockConcept, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import type { Call, LibraryNames } from './pythonTree'
import { memberDocs, withLeadingComments } from './children'
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
@@ -17,6 +17,8 @@ const TYPE_LIBRARIES = ['dataclasses', 'typing', 'typing_extensions']
const KIND = 'dataclasses.dataclass'
const COLOR: ThemeColor = { dark: '#ef7fb0', light: '#b3306c' }
const ICON: KindIconName = 'package'
/** frozen=True 的锁,颜色同常量:一眼是「不可修改」。 */
const FROZEN_COLOR: ThemeColor = { dark: '#9cdcfe', light: '#001080' }
/** `@dataclass(...)` 各参数不写时的值:写成这个值等于没写,「选项」里不讲。 */
const OPTION_DEFAULTS: Record<string, boolean> = {
@@ -238,6 +240,13 @@ function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): Prop
}
}
/** frozen=True 才算一个概念(锁,悬停「不可修改」)。slots 没有一眼能看懂的图标,不加。 */
function frozenConcept(call: Call | undefined, words: Words): BlockConcept[] {
if (call?.keywords.get('frozen')?.type !== 'true')
return []
return [{ id: 'dataclasses.frozen', label: words.options['frozen=True'], icon: 'lock', color: FROZEN_COLOR }]
}
function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefined, env: Env): RuleCard {
const { words, context } = env
const { labels } = words
@@ -260,9 +269,11 @@ function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefine
items: fields.map(field => fieldItem(field, children, env)),
},
]
const concepts = frozenConcept(call, words)
return {
kind: KIND,
badge: { label: words.kind, color: COLOR, icon: ICON },
...(concepts.length > 0 && { concepts }),
rows,
...(facts.name && { defines: range(facts.name) }),
absorbs: withLeadingComments(children, [...fields.map(field => field.block), ...markers]),
@@ -15,7 +15,21 @@ vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, v
const BADGE: KindBadge = { label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' }
function view(id: string, kind: string, text: string, extra: Partial<BlockView> = {}): BlockView {
return { id, anchor: id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children: [], translation: [{ text, role: 'plain' }], ...extra } as BlockView
const badge = extra.badge
return {
id,
anchor: id,
kind,
startLine: 0,
endLine: 0,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [{ id: kind, label: badge?.label ?? kind, icon: badge?.icon ?? 'box', ...(badge && { color: badge.color }) }],
children: [],
translation: [{ text, role: 'plain' }],
...extra,
} as BlockView
}
/** 一张组件卡片:第 1 行下面放着一个副作用,第 2 行的两个子行各讲一个处理函数,后面还有一个留下的注释块。 */
@@ -251,7 +251,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
</button>
)
: <span className="w-3.5 shrink-0" />}
<KindIcon block={block} name={tree.kindName(block.kind)} />
<KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1">
{editing
? (
@@ -303,7 +303,7 @@ function RemovedCard({ placed, tree }: { placed: PlacedRemoved, tree: BlockTreeS
>
<Chevron size={14} strokeWidth={1.8} />
</button>
<KindIcon block={block} name={kindName} />
<KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1">
<div className="cr-removed-head cursor-pointer truncate text-[12.5px] leading-5" onClick={toggle}>
<span className="cr-removed-label">已删除 · </span>
@@ -3,7 +3,7 @@ import type { CSSProperties, ReactElement } from 'react'
import type { GroupStyle, Inline, SegmentGroup } from './types'
import { useContext, useState } from 'react'
import { DisplayNameContext, shownName } from './displayNames'
import { KIND_ICON_COMPONENTS, KIND_ICONS } from './kindIcons'
import { KIND_ICON_COMPONENTS } from './kindIcons'
import { isCurrent, segmentOccurrence, sourceOf } from './occurrences'
import { HighlightContext } from './useOccurrences'
@@ -47,10 +47,10 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
* 颜色在 codeReader.css 的 .cr-group[data-style] 里,框自己给了颜色(color)时用它。
*/
const GROUP_ICONS: Record<GroupStyle, LucideIcon | undefined> = {
iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop],
condition: KIND_ICON_COMPONENTS[KIND_ICONS.if],
iteration: KIND_ICON_COMPONENTS.repeat,
condition: KIND_ICON_COMPONENTS.split,
branch: undefined,
skip: KIND_ICON_COMPONENTS[KIND_ICONS.return],
skip: KIND_ICON_COMPONENTS['corner-down-left'],
tag: undefined,
classes: undefined,
}
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { changeStops, diffTree, foldUnchanged, nextStop, openedByDiff, removedTitle, statsParts } from './diffView'
function block(id: string, anchor: string, children: BlockView[] = [], extra: Partial<BlockView> = {}): BlockView {
return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, children, translation: [], ...extra } as BlockView
return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children, translation: [], ...extra } as BlockView
}
function removed(parent: string, after: string | undefined, kind = 'function'): RemovedView {
@@ -4,7 +4,7 @@ import { coveredByEditing, startEditing } from './editing'
import { revealPlan, uncoveredPlan } from './reveal'
function block(id: string, kind: string, [startLine, endLine]: [number, number], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine, code: '', codeTokens: [], attributes: { kind }, children, properties: [] }
return { id, kind, startLine, endLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties: [] }
}
/**
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, toggleValue } from './filter'
function block(id: string, kind: BlockKind, extra: Record<string, string> = {}, children: BlockView[] = []): BlockView {
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, children, translation: [] }
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] }
}
const NONE = new Set<string>()
@@ -1,10 +1,15 @@
import type { BlockView, KindIconName } from './types'
import type { BlockConcept, KindIconName } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { KIND_ICON_COMPONENTS, KIND_ICONS, KindIcon } from './kindIcons'
import { KIND_ICONS } from '../../../src/codeReader/contract'
import { KIND_ICON_COMPONENTS, KindIcon } from './kindIcons'
function render(block: Pick<BlockView, 'kind' | 'badge'>, name: string): string {
return renderToStaticMarkup(<KindIcon block={block} name={name} />)
function render(concepts: readonly BlockConcept[]): string {
return renderToStaticMarkup(<KindIcon concepts={concepts} />)
}
function concept(id: string, label: string, icon: KindIconName, color?: BlockConcept['color']): BlockConcept {
return { id, label, icon, ...(color && { color }) }
}
/** 标签之间的字:图标(svg)没有字 */
@@ -49,23 +54,44 @@ describe('块的种类图标', () => {
it('这组图标里每个名字画的就是 lucide 里同名的图标', () => {
for (const name of Object.keys(KIND_ICON_COMPONENTS) as KindIconName[]) {
const html = render({ kind: 'sqlalchemy.table', badge: { label: name, color: { dark: '#fff', light: '#000' }, icon: name } }, name)
const html = render([concept(name, name, name)])
expect(html, name).toContain(`class="lucide lucide-${name}"`)
}
})
it('只画图标不带字,名字在悬停提示(data-tooltip,不用原生 title)和 aria-label 里;20px 的框、16px 的图形', () => {
const html = render({ kind: 'function' }, '函数')
it('只画图标不带字,名字在悬停提示(data-tooltip,不用原生 title)里;读屏念这个名字;20px 的框、16px 的图形', () => {
const html = render([concept('function', '函数', 'square-function')])
expect(visibleText(html)).toBe('')
expect(html).toMatch(/^<span class="cr-kind [^"]*size-5[^"]*" role="img" data-tooltip="函数" aria-label="函数"><svg/)
expect(html).toContain('role="img" aria-label="函数"')
expect(html).toContain('data-tooltip="函数"')
expect(html).toContain('data-kind="function"')
expect(html).not.toContain(' title=')
expect(html).toContain('lucide-square-function')
expect(html).toContain('width="16" height="16"')
expect(html).toContain('size-5')
})
it('库规则的卡片用规则给的图标,名字是规则给的', () => {
const html = render({ kind: 'sqlalchemy.table', badge: { label: '数据表', color: { dark: '#e8a860', light: '#a3590d' }, icon: 'database' } }, '数据表')
expect(html).toContain('lucide-database')
expect(html).toContain('data-tooltip="数据表" aria-label="数据表"')
it('认到几个就横排几个:悬停各自的名字,读屏把名字连起来念;规则的图标用规则给的颜色', () => {
const html = render([
concept('class', '类', 'box'),
concept('dataclasses.dataclass', '数据类', 'package', { dark: '#ef7fb0', light: '#b3306c' }),
concept('dataclasses.frozen', '不可修改', 'lock', { dark: '#9cdcfe', light: '#001080' }),
])
expect(visibleText(html)).toBe('')
expect(html).toContain('aria-label="类,数据类,不可修改"')
expect(html).toContain('data-tooltip="类"')
expect(html).toContain('data-tooltip="数据类"')
expect(html).toContain('data-tooltip="不可修改"')
expect(html).toContain('data-kind="class"')
expect(html).toContain('lucide-box')
expect(html).toContain('lucide-package')
expect(html).toContain('lucide-lock')
expect(html).toContain('--kind-dark:#ef7fb0')
expect(html).toContain('--kind-light:#b3306c')
expect(html).toContain('--kind-dark:#9cdcfe')
expect(html).toContain('--kind-light:#001080')
expect(html).not.toContain(' title=')
expect(html.match(/size-5/g)).toHaveLength(3)
expect(html).toContain('gap-0.5')
})
})
+18 -47
View File
@@ -1,6 +1,6 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactElement } from 'react'
import type { BlockKind, BlockView, KindIconName } from './types'
import type { CSSProperties, ReactElement } from 'react'
import type { BlockConcept, KindIconName } from './types'
import {
Activity,
Atom,
@@ -79,53 +79,24 @@ export const KIND_ICON_COMPONENTS: Record<KindIconName, LucideIcon> = {
}
/**
* 内置种类的图标:块左边只画图标,名字是悬停提示。
* 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。
* 卡片左边的概念图标,横排,各 20px,间距紧。图标名由宿主放进概念列表,这里只按名字画。
* 没有颜色的是语言的种类,按 id 用 CSS 的种类色;有颜色的只给这个图标上色。
* 每个图标悬停自己的名字(data-tooltip,见 components/ui/tooltip.tsx);读屏把名字连起来念。
*/
export const KIND_ICONS: Record<BlockKind, KindIconName> = {
import: 'import',
function: 'square-function',
class: 'box',
type: 'braces',
enum: 'list-ordered',
exception: 'triangle-alert',
field: 'tag',
const: 'lock',
variable: 'variable',
if: 'split',
else: 'corner-down-right',
loop: 'repeat',
try: 'shield',
catch: 'shield-alert',
finally: 'shield-check',
return: 'corner-down-left',
statement: 'code',
comment: 'message-square-text',
interpreter: 'terminal',
options: 'sliders-horizontal',
case: 'signpost',
command: 'square-terminal',
pipeline: 'square-terminal',
directive: 'terminal',
stage: 'layers',
run: 'square-terminal',
copy: 'copy',
setting: 'sliders-horizontal',
label: 'tag',
health: 'activity',
start: 'play',
}
/**
* 块左边的种类:只画图标,和一行字等高(20px 的框、16px 的图形),颜色是种类色(--kind)。图标是库规则给的(badge.icon),
* 没有就是内置种类的(KIND_ICONS,没带 badge 的块 kind 是 BlockKind);名字(翻译插件给种类起的,库规则的种类是规则给的)
* 写在悬停提示(data-tooltip,见 components/ui/tooltip.tsx)和读屏念的 aria-label 里。
*/
export function KindIcon({ block, name }: { block: Pick<BlockView, 'kind' | 'badge'>, name: string }): ReactElement {
const Icon = KIND_ICON_COMPONENTS[block.badge?.icon ?? KIND_ICONS[block.kind as BlockKind]]
export function KindIcon({ concepts }: { concepts: readonly BlockConcept[] }): ReactElement {
return (
<span className="cr-kind flex size-5 shrink-0 items-center justify-center" role="img" data-tooltip={name} aria-label={name}>
<Icon size={16} strokeWidth={2.2} aria-hidden />
<span className="flex shrink-0 items-center gap-0.5" role="img" aria-label={concepts.map(concept => concept.label).join(',')}>
{concepts.map((concept) => {
const Icon = KIND_ICON_COMPONENTS[concept.icon]
const tint = concept.color
? { 'data-badge': '' as const, 'style': { '--kind-dark': concept.color.dark, '--kind-light': concept.color.light } as CSSProperties }
: { 'data-kind': concept.id }
return (
<span key={concept.id} className="cr-kind flex size-5 shrink-0 items-center justify-center" data-tooltip={concept.label} aria-hidden {...tint}>
<Icon size={16} strokeWidth={2.2} aria-hidden />
</span>
)
})}
</span>
)
}
@@ -21,11 +21,11 @@ function word(text: string): Segment {
}
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, translation }
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation }
}
function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, properties }
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties }
}
/**
@@ -127,7 +127,7 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
const blocks: BlockView[] = [
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } },
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, concepts: [{ id: 'comment', label: '注释', icon: 'message-square-text' }], children: [], translation: { markdown: '见 `get_db`' } },
factBlock('1', 'function', 1, [
{ label: '说明', value: { markdown: '同 `get_db`' } },
{ label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] },
+16 -3
View File
@@ -101,7 +101,18 @@ export type KindIconName
| 'message-square-text' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'layers' | 'copy' | 'activity' | 'play'
| 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' | 'atom' | 'zap' | 'mouse-pointer-click'
/** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */
/**
* 一块上认出的一个概念。宿主放进预览数据:稳定 id、悬停名、图标名。
* 颜色不填的是语言的种类,按 id 用 CSS 的种类色;填了的只给这个图标上色。
*/
export interface BlockConcept {
id: string
label: string
icon: KindIconName
color?: ThemeColor
}
/** 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个。 */
export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }
interface BlockViewBase {
@@ -109,7 +120,7 @@ interface BlockViewBase {
id: string
/** 块的位置记号:问答挂在它上面(QaThreadView.anchor),文件别处改了照样对得上;webview 只拿它比相等,不拆开看 */
anchor: string
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table)。筛选用它;图标不看它,看 concepts */
kind: string
/** 编辑范围的起止行,0 起:块的完整源码,头部连同各子孙块、结尾(块编辑时子孙都不画,见 coveredByEditing) */
startLine: number
@@ -122,7 +133,9 @@ interface BlockViewBase {
defines?: SourceRange
/** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */
attributes: Record<string, string>
/** 库规则认领的块才有:种类的名字、颜色和图标,由规则给;和类块一样默认收起子块 */
/** 这块认出来的概念,从左到右。webview 按图标名画,不自己用种类查 */
concepts: BlockConcept[]
/** 库规则的卡片才有:卡片边框的颜色,筛选栏里这个种类的名字。和类块一样默认收起子块 */
badge?: KindBadge
/** 库规则放进父卡片里的子块才有:画在父卡片属性表第 row 行下面,不排在父卡片的子块里;item 见 BlockSlot */
slot?: BlockSlot
@@ -15,7 +15,7 @@ function memoryStorage(initial?: unknown): ViewStateStorage {
}
function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView {
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children, translation: [] }
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] }
}
/**