窗体设计器:工具箱、拖放、属性与事件窗口,读写 VS 格式的 Designer.cs

- Designer.cs 在浏览器端解析为语句列表,未改动的语句保留原文:
  教材 25 个参考窗体和全部示例读入再写出逐字节不变
- 编辑操作:添加 / 删除 / 移动 / 改名控件、属性、事件、列表项;
  布局语句增删规则与 VS 一致(对全部现有文件执行无变化)
- 双击控件或事件:绑定事件并在窗体代码末尾插入处理方法,跳到代码
- 预览复用运行时的 ControlView,设计时与运行时外观一致
- 修正:窗体属性 this.ClientSize = new Size(...) 被误认成创建控件;
  示例 Designer.cs 的块头注释改为 VS 的 "// " 格式
- 测试:73 个(读写往返、布局规则、编辑操作、插入事件处理方法)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 11:11:48 +08:00
1 parent 03e80ce2e2
commit ea5a25af06
20 files changed
+2767 -132

No files matched your search

+63 -9
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { NButton, NConfigProvider, NPopconfirm, NSelect, NTab, NTabs, NTag, zhCN } from 'naive-ui'
import type { SourceFile } from '@iot/shared'
import FormDesigner from './designer/FormDesigner.vue'
import CodeEditor from './editor/CodeEditor.vue'
import { clearDraft, loadDraft, saveDraft } from './editor/drafts'
import { createRunSession } from './run/session'
@@ -16,7 +17,24 @@ const { state } = session
const samples = ref<{ label: string; value: string }[]>([])
const sampleId = ref<string>()
const files = ref<SourceFile[]>([])
const activeFile = ref<string>()
/** 当前标签页:文件路径,或 "设计:X.Designer.cs" 表示窗体 X 的设计器 */
const activeTab = ref<string>()
const DESIGN = '设计:'
/** 每个 X.Designer.cs 对应一个设计器标签页,和它的代码文件 X.cs */
const forms = computed(() =>
files.value
.filter((f) => f.path.endsWith('.Designer.cs'))
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') })),
)
const tabs = computed(() => [
...forms.value.map((f) => ({ name: f.tab, label: `${f.code} [设计]` })),
...files.value.map((f) => ({ name: f.path, label: f.path })),
])
const activeForm = computed(() => forms.value.find((f) => f.tab === activeTab.value))
/** 代码编辑器显示的文件:设计器打开时保持上一次的代码文件 */
const activeFile = computed(() => (activeForm.value ? undefined : activeTab.value))
const fileText = (path: string) => files.value.find((f) => f.path === path)?.content ?? ''
/** 当前代码是否和示例原始代码不同(来自浏览器里保存的草稿) */
const modified = ref(false)
const editorRef = ref<InstanceType<typeof CodeEditor>>()
@@ -59,9 +77,8 @@ async function loadSample(id: string, discardDraft = false) {
const draft = discardDraft ? null : loadDraft(id)
modified.value = draft !== null
files.value = draft ?? (await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json())
// 默认打开窗体的代码文件(不一定叫 Form1.cs,如教材的 frm_analog.cs)
activeFile.value =
files.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? files.value[0]?.path
// 和 VS 一样,默认打开主窗体的设计器
activeTab.value = forms.value[0]?.tab ?? files.value.find((f) => f.path !== 'Program.cs')?.path ?? files.value[0]?.path
}
function updateFile(path: string, content: string) {
@@ -73,6 +90,29 @@ function updateFile(path: string, content: string) {
saveTimer = setTimeout(() => sampleId.value && saveDraft(sampleId.value, files.value), 500)
}
/** 设计器改动了 Designer.cs(以及插入事件处理方法时的代码文件) */
function onDesignerUpdate(designerText: string, codeText?: string) {
const form = activeForm.value
if (!form) return
updateFile(form.designer, designerText)
if (codeText !== undefined) updateFile(form.code, codeText)
}
/** 设计器里双击控件或事件:切到代码文件并跳到事件处理方法 */
async function openCode(line: number) {
const form = activeForm.value
if (!form) return
activeTab.value = form.code
await nextTick()
editorRef.value?.reveal(form.code, line, 13)
}
async function showDiagnostic(file: string, line: number, column: number) {
activeTab.value = file
await nextTick()
editorRef.value?.reveal(file, line, column)
}
function restoreOriginal() {
if (sampleId.value) void loadSample(sampleId.value, true)
}
@@ -110,12 +150,21 @@ function run() {
</span>
</header>
<main class="workspace">
<main class="workspace" :class="{ designing: activeForm }">
<section class="editor">
<NTabs v-model:value="activeFile" type="card" size="small">
<NTab v-for="file in files" :key="file.path" :name="file.path">{{ file.path }}</NTab>
<NTabs v-model:value="activeTab" type="card" size="small">
<NTab v-for="tab in tabs" :key="tab.name" :name="tab.name">{{ tab.label }}</NTab>
</NTabs>
<FormDesigner
v-if="activeForm"
class="code"
:designer-text="fileText(activeForm.designer)"
:code-text="fileText(activeForm.code)"
@update="onDesignerUpdate"
@open-code="openCode"
/>
<CodeEditor
v-show="!activeForm"
ref="editorRef"
class="code"
:files="files"
@@ -150,7 +199,7 @@ function run() {
:key="`d${i}`"
:class="['diag', d.severity]"
title="点击跳到出错的代码"
@click="editorRef?.reveal(d.file, d.line, d.column)"
@click="showDiagnostic(d.file, d.line, d.column)"
>
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
@@ -203,6 +252,11 @@ body {
min-height: 0;
}
/* 设计器需要工具箱、窗体和属性窗口,左栏放宽 */
.workspace.designing {
grid-template-columns: minmax(0, 3fr) minmax(360px, 2fr);
}
.editor {
display: flex;
flex-direction: column;
+641
View File
@@ -0,0 +1,641 @@
<script setup lang="ts">
// 窗体设计器(仿 VS):左边工具箱,中间窗体,下方组件栏,右边属性 / 事件窗口。
// 窗体用运行时同一套 ControlView 渲染;上面盖一层透明的操作层,负责选中、拖动、改大小、放置新控件。
// 每次改动都重新生成 Designer.cs,通过 update 事件交给页面保存。
import { computed, ref, shallowRef, watch } from 'vue'
import ControlView from '../winforms/ControlView.vue'
import { displayText, fontCss, type FontValue } from '../winforms/format'
import { CONTROLS, type ControlDef, FORM_DEF, controlDef, isContainerType } from './catalog'
import { ensureHandler } from './code'
import { type DesignerDocument, DesignerParseError, generateDesigner, parseDesigner } from './document'
import {
addControl,
childrenOf,
getEventHandler,
getPropText,
listControls,
moveControl,
removeControl,
renameControl,
setEvent,
setItems,
setProp,
typeOf,
} from './edit'
import PropertyGrid from './PropertyGrid.vue'
import { componentsOf, previewTree } from './preview'
import { parseValue, type Value } from './values'
const props = defineProps<{
designerText: string
codeText: string
}>()
const emit = defineEmits<{
/** Designer.cs 改了;codeText 不为空时窗体代码文件也改了(插入了事件处理方法) */
update: [designerText: string, codeText?: string]
/** 跳到窗体代码文件的某一行 */
openCode: [line: number]
}>()
// ---------- 文档 ----------
const parsed = computed<{ doc?: DesignerDocument; error?: string }>(() => {
try {
return { doc: parseDesigner(props.designerText) }
} catch (error) {
return { error: error instanceof DesignerParseError ? error.message : String(error) }
}
})
/** 拖动过程中的临时文档(松手时才写回文件) */
const dragDoc = shallowRef<DesignerDocument>()
const doc = computed(() => dragDoc.value ?? parsed.value.doc)
const tree = computed(() => (doc.value ? previewTree(doc.value) : undefined))
const components = computed(() => (doc.value ? componentsOf(doc.value) : []))
/** 选中的控件;'' 表示窗体 */
const selected = ref('')
watch(parsed, () => {
if (selected.value && doc.value && !listControls(doc.value).some((c) => c.name === selected.value)) selected.value = ''
})
function commit(next: DesignerDocument, codeText?: string) {
dragDoc.value = undefined
emit('update', generateDesigner(next), codeText)
}
// ---------- 几何 ----------
interface Rect {
x: number
y: number
width: number
height: number
}
function valueOf(d: DesignerDocument, target: string, path: string): Value | undefined {
const text = getPropText(d, target, path)
return text === undefined ? undefined : parseValue(text)
}
function localRect(d: DesignerDocument, name: string): Rect {
const location = valueOf(d, name, 'Location')
const size = valueOf(d, name, 'Size')
return {
x: location?.kind === 'point' ? location.x : 0,
y: location?.kind === 'point' ? location.y : 0,
width: size?.kind === 'size' ? size.width : 75,
height: size?.kind === 'size' ? size.height : 23,
}
}
/** 控件在窗体客户区里的绝对位置(Location 相对于所在容器) */
function absRect(d: DesignerDocument, name: string): Rect {
const rect = localRect(d, name)
const parent = listControls(d).find((c) => c.name === name)?.parent
if (parent) {
const p = absRect(d, parent)
rect.x += p.x
rect.y += p.y
}
return rect
}
const clientSize = computed(() => {
const v = doc.value && valueOf(doc.value, '', 'ClientSize')
return v?.kind === 'size' ? v : { width: 300, height: 300 }
})
const contains = (r: Rect, x: number, y: number) => x >= r.x && y >= r.y && x < r.x + r.width && y < r.y + r.height
/** 找到点下的最上层控件(先查子控件;同一层里 Controls 中靠前的在上面) */
function hitTest(d: DesignerDocument, x: number, y: number, container = '', skip?: Set<string>): string | null {
for (const child of childrenOf(d, container)) {
if (skip?.has(child) || !contains(absRect(d, child), x, y)) continue
if (isContainerType(typeOf(d, child) ?? '')) return hitTest(d, x, y, child, skip) ?? child
return child
}
return null
}
/** 点所在的最深一层容器(窗体为 ''),拖放控件时用来决定放进哪个容器 */
function containerAt(d: DesignerDocument, x: number, y: number, skip?: Set<string>): string {
const hit = hitTest(d, x, y, '', skip)
if (hit === null) return ''
if (isContainerType(typeOf(d, hit) ?? '')) return hit
return listControls(d).find((c) => c.name === hit)?.parent ?? ''
}
function descendants(d: DesignerDocument, name: string): Set<string> {
const set = new Set([name])
for (const child of childrenOf(d, name)) for (const n of descendants(d, child)) set.add(n)
return set
}
const selectedRect = computed(() => (doc.value && selected.value ? absRect(doc.value, selected.value) : undefined))
const selectedAutoSize = computed(() => doc.value && selected.value && getPropText(doc.value, selected.value, 'AutoSize') === 'true')
// ---------- 指针操作:选中、移动、改大小 ----------
const surface = ref<HTMLElement>()
type Handle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
const HANDLES: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
let drag:
| { mode: 'move'; name: string; startX: number; startY: number; origin: Rect; moved: boolean }
| { mode: 'resize'; name: string; handle: Handle; startX: number; startY: number; origin: Rect }
| { mode: 'form'; startX: number; startY: number; origin: { width: number; height: number } }
| undefined
function pointer(event: PointerEvent | DragEvent) {
const box = surface.value!.getBoundingClientRect()
return { x: event.clientX - box.left, y: event.clientY - box.top }
}
function onPointerDown(event: PointerEvent) {
if (!doc.value || event.button !== 0) return
surface.value!.focus()
const { x, y } = pointer(event)
const hit = hitTest(doc.value, x, y)
selected.value = hit ?? ''
if (hit) {
drag = { mode: 'move', name: hit, startX: x, startY: y, origin: absRect(doc.value, hit), moved: false }
surface.value!.setPointerCapture(event.pointerId)
}
}
function startResize(event: PointerEvent, handle: Handle) {
if (!doc.value || !selected.value) return
const { x, y } = pointer(event)
drag = { mode: 'resize', name: selected.value, handle, startX: x, startY: y, origin: localRect(doc.value, selected.value) }
surface.value!.setPointerCapture(event.pointerId)
}
function startFormResize(event: PointerEvent) {
if (!doc.value) return
selected.value = ''
const { x, y } = pointer(event)
drag = { mode: 'form', startX: x, startY: y, origin: { ...clientSize.value } }
surface.value!.setPointerCapture(event.pointerId)
}
function onPointerMove(event: PointerEvent) {
const base = parsed.value.doc
if (!drag || !base) return
const { x, y } = pointer(event)
const dx = Math.round(x - drag.startX)
const dy = Math.round(y - drag.startY)
if (drag.mode === 'move') {
if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 3) return
drag.moved = true
const local = localRect(base, drag.name)
dragDoc.value = setProp(base, drag.name, 'Location', { kind: 'point', x: local.x + dx, y: local.y + dy })
} else if (drag.mode === 'resize') {
const r = { ...drag.origin }
const h = drag.handle
if (h.includes('e')) r.width = Math.max(4, drag.origin.width + dx)
if (h.includes('s')) r.height = Math.max(4, drag.origin.height + dy)
if (h.includes('w')) {
r.width = Math.max(4, drag.origin.width - dx)
r.x = drag.origin.x + drag.origin.width - r.width
}
if (h.includes('n')) {
r.height = Math.max(4, drag.origin.height - dy)
r.y = drag.origin.y + drag.origin.height - r.height
}
let next = setProp(base, drag.name, 'Location', { kind: 'point', x: r.x, y: r.y })
next = setProp(next, drag.name, 'Size', { kind: 'size', width: r.width, height: r.height })
dragDoc.value = next
} else {
dragDoc.value = setProp(base, '', 'ClientSize', {
kind: 'size',
width: Math.max(80, drag.origin.width + dx),
height: Math.max(40, drag.origin.height + dy),
})
}
}
function onPointerUp(event: PointerEvent) {
const base = parsed.value.doc
const current = drag
drag = undefined
if (!current || !base || !dragDoc.value) {
dragDoc.value = undefined
return
}
if (current.mode === 'move') {
// 松手的位置在另一个容器里:把控件移进去(坐标换算成相对新容器)
const { x, y } = pointer(event)
const target = containerAt(dragDoc.value, x, y, descendants(base, current.name))
const abs = absRect(dragDoc.value, current.name)
const origin = target ? absRect(dragDoc.value, target) : { x: 0, y: 0 }
commit(moveControl(base, current.name, target, { x: abs.x - origin.x, y: abs.y - origin.y }))
} else {
commit(dragDoc.value)
}
}
// ---------- 键盘 ----------
function onKeyDown(event: KeyboardEvent) {
const d = parsed.value.doc
if (!d || !selected.value) return
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
const name = selected.value
selected.value = ''
commit(removeControl(d, name))
return
}
const step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]
if (step && listControls(d).find((c) => c.name === selected.value)?.parent !== null) {
event.preventDefault()
const r = localRect(d, selected.value)
commit(setProp(d, selected.value, 'Location', { kind: 'point', x: r.x + step[0], y: r.y + step[1] }))
}
if (event.key === 'Escape') {
selected.value = listControls(d).find((c) => c.name === selected.value)?.parent ?? ''
}
}
// ---------- 工具箱 ----------
const TOOLBOX_GROUPS = ['常用控件', '容器', '组件', '物联网设备'] as const
const DRAG_TYPE = 'application/x-winforms-control'
function onToolDragStart(event: DragEvent, def: ControlDef) {
event.dataTransfer!.setData(DRAG_TYPE, def.type)
event.dataTransfer!.effectAllowed = 'copy'
}
function onDragOver(event: DragEvent) {
if (event.dataTransfer?.types.includes(DRAG_TYPE)) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}
function onDrop(event: DragEvent) {
const d = parsed.value.doc
const type = event.dataTransfer?.getData(DRAG_TYPE)
if (!d || !type) return
event.preventDefault()
const { x, y } = pointer(event)
place(d, controlDef(type), x, y)
}
/** 双击工具箱:放在选中的容器(或窗体)左上角 */
function onToolDoubleClick(def: ControlDef) {
const d = parsed.value.doc
if (!d) return
// 直接放进选中的容器,不按坐标判断(左上角可能正好压在另一个分组框上)
const container = !def.component && selected.value && isContainerType(typeOf(d, selected.value) ?? '') ? selected.value : ''
const result = addControl(d, def, container, { x: 12, y: container ? 20 : 12 })
selected.value = result.name
commit(result.doc)
}
function place(d: DesignerDocument, def: ControlDef, x: number, y: number) {
const container = def.component ? '' : containerAt(d, x, y)
const origin = container ? absRect(d, container) : { x: 0, y: 0 }
const result = addControl(d, def, container, { x: x - origin.x, y: y - origin.y })
selected.value = result.name
commit(result.doc)
}
// ---------- 属性窗口的回调 ----------
function onSetProp(target: string, path: string, value: Value | null) {
const d = parsed.value.doc
if (!d) return
let next = setProp(d, target, path, value)
// AutoSize 的控件:文字或字体变化时按文字重新计算大小(VS 也会这样更新 Size)
const def = target ? controlDef(typeOf(d, target) ?? '') : undefined
if (def?.autoSize && (path === 'Text' || path === 'AutoSize') && getPropText(next, target, 'AutoSize') === 'true') {
const text = valueOf(next, target, 'Text')
const size = def.autoSize(text?.kind === 'string' ? text.value : '')
next = setProp(next, target, 'Size', { kind: 'size', ...size })
}
commit(next)
}
function onSetItems(target: string, items: string[]) {
const d = parsed.value.doc
if (d) commit(setItems(d, target, items))
}
function onRename(from: string, to: string) {
const d = parsed.value.doc
if (!d) return
selected.value = to
commit(renameControl(d, from, to))
}
/** 绑定事件并在代码里生成处理方法(已存在则直接跳过去),然后跳到代码 */
function openEvent(target: string, eventName: string, handler?: string) {
const d = parsed.value.doc
if (!d) return
const def = target ? controlDef(typeOf(d, target) ?? '') : FORM_DEF
const event = def.events.find((e) => e.name === eventName)
if (!event) return
const name = handler ?? getEventHandler(d, target, eventName) ?? `${target || d.formName}_${eventName}`
const next = setEvent(d, target, eventName, event.delegate, name)
try {
const result = ensureHandler(props.codeText, d.className, name, event.args)
commit(next, result.code === props.codeText ? undefined : result.code)
emit('openCode', result.line)
} catch (error) {
commit(next)
alert(String(error))
}
}
function clearEvent(target: string, eventName: string) {
const d = parsed.value.doc
if (d) commit(setEvent(d, target, eventName, '', null))
}
/** 双击控件:生成默认事件(按钮的 Click、窗体的 Load……) */
function onDoubleClick(event: MouseEvent) {
const d = parsed.value.doc
if (!d) return
const box = surface.value!.getBoundingClientRect()
const hit = hitTest(d, event.clientX - box.left, event.clientY - box.top) ?? ''
const def = hit ? controlDef(typeOf(d, hit) ?? '') : FORM_DEF
openEvent(hit, def.defaultEvent)
}
function onComponentDoubleClick(name: string) {
const d = parsed.value.doc
if (d) openEvent(name, controlDef(typeOf(d, name) ?? '').defaultEvent)
}
const formTitle = computed(() => String(tree.value?.props.Text ?? ''))
const formStyle = computed(() => ({
width: `${clientSize.value.width}px`,
height: `${clientSize.value.height}px`,
color: (tree.value?.props.ForeColor as string | undefined) ?? undefined,
backgroundColor: (tree.value?.props.BackColor as string | undefined) ?? undefined,
...fontCss(tree.value?.props.Font as FontValue | undefined),
}))
const childrenTopLast = computed(() => [...(tree.value?.children ?? [])].reverse())
const noop = () => {}
</script>
<template>
<div v-if="parsed.error" class="cannot-open">
<p><b>设计器无法打开这个窗体。</b></p>
<p>{{ parsed.error }}</p>
<p>Designer.cs 的内容不是窗体设计器能识别的格式,可能是手动修改造成的。可以在代码里改回来,或者用"恢复原始代码"。</p>
</div>
<div v-else-if="doc && tree" class="designer">
<aside class="toolbox">
<div class="panel-title">工具箱</div>
<template v-for="group in TOOLBOX_GROUPS" :key="group">
<div class="group">{{ group }}</div>
<div
v-for="def in CONTROLS.filter((d) => d.group === group)"
:key="def.type"
class="tool"
draggable="true"
:title="`拖到窗体上,或双击添加`"
@dragstart="onToolDragStart($event, def)"
@dblclick="onToolDoubleClick(def)"
>
{{ def.label }}
</div>
</template>
</aside>
<section class="canvas">
<div class="window" :class="{ selected: selected === '' }">
<div class="titlebar" @pointerdown="selected = ''">
<span class="title">{{ displayText(formTitle) }}</span>
<span class="buttons">─ ☐ ✕</span>
</div>
<div class="client wf-client-host" :style="formStyle">
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="noop" />
<!-- 操作层:盖在窗体客户区上,接管所有鼠标操作 -->
<div
ref="surface"
class="surface"
tabindex="0"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@dblclick="onDoubleClick"
@keydown="onKeyDown"
@dragover="onDragOver"
@drop="onDrop"
>
<div
v-if="selectedRect"
class="selection"
:style="{ left: `${selectedRect.x}px`, top: `${selectedRect.y}px`, width: `${selectedRect.width}px`, height: `${selectedRect.height}px` }"
>
<template v-if="!selectedAutoSize">
<i v-for="h in HANDLES" :key="h" :class="['handle', h]" @pointerdown.stop="startResize($event, h)" />
</template>
</div>
</div>
<i class="form-resize" title="拖动改变窗体大小" @pointerdown.stop="startFormResize" />
</div>
</div>
<div class="tray">
<span class="tray-label">组件栏</span>
<span v-if="components.length === 0" class="tray-empty">把 Timer 等不显示在窗体上的组件拖到窗体上,会出现在这里</span>
<button
v-for="c in components"
:key="c.name"
class="component"
:class="{ active: selected === c.name }"
@click="selected = c.name"
@dblclick="onComponentDoubleClick(c.name)"
@keydown.delete="commit(removeControl(parsed.doc!, c.name)); selected = ''"
>
⏱ {{ c.name }}
</button>
</div>
</section>
<PropertyGrid
class="properties"
:doc="doc"
:selected="selected"
@set-prop="onSetProp"
@set-items="onSetItems"
@rename="onRename"
@open-event="openEvent"
@clear-event="clearEvent"
/>
</div>
</template>
<style scoped>
.designer {
display: grid;
grid-template-columns: 132px minmax(0, 1fr) 260px;
height: 100%;
min-height: 0;
border: 1px solid #e5e7eb;
}
.cannot-open {
padding: 24px;
color: #6b7280;
border: 1px solid #e5e7eb;
height: 100%;
box-sizing: border-box;
}
.toolbox {
overflow-y: auto;
border-right: 1px solid #e5e7eb;
background: #fafafa;
font-size: 12px;
}
.panel-title {
padding: 6px 8px;
font-weight: 600;
border-bottom: 1px solid #e5e7eb;
}
.group {
padding: 6px 8px 2px;
color: #6b7280;
font-size: 11px;
}
.tool {
padding: 4px 8px 4px 14px;
cursor: grab;
user-select: none;
}
.tool:hover {
background: #e8f0fe;
}
.canvas {
display: flex;
flex-direction: column;
min-width: 0;
overflow: auto;
background: #e9edf2;
padding: 16px;
gap: 12px;
}
.window {
align-self: flex-start;
border: 1px solid #9aa3ad;
background: #f0f0f0;
box-shadow: 0 2px 10px rgb(0 0 0 / 0.15);
}
.window.selected {
outline: 1px dashed #2563eb;
outline-offset: 2px;
}
.titlebar {
display: flex;
justify-content: space-between;
align-items: center;
height: 30px;
padding: 0 8px;
background: #fff;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 12px;
user-select: none;
}
.buttons {
color: #6b7280;
letter-spacing: 12px;
}
.client {
position: relative;
overflow: hidden;
font-family: "SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif;
font-size: 12px;
}
.surface {
position: absolute;
inset: 0;
outline: none;
cursor: default;
}
.selection {
position: absolute;
box-sizing: border-box;
border: 1px dashed #2563eb;
pointer-events: none;
}
.handle {
position: absolute;
width: 7px;
height: 7px;
background: #fff;
border: 1px solid #2563eb;
pointer-events: auto;
}
.handle.nw { left: -4px; top: -4px; cursor: nwse-resize; }
.handle.n { left: calc(50% - 4px); top: -4px; cursor: ns-resize; }
.handle.ne { right: -4px; top: -4px; cursor: nesw-resize; }
.handle.e { right: -4px; top: calc(50% - 4px); cursor: ew-resize; }
.handle.se { right: -4px; bottom: -4px; cursor: nwse-resize; }
.handle.s { left: calc(50% - 4px); bottom: -4px; cursor: ns-resize; }
.handle.sw { left: -4px; bottom: -4px; cursor: nesw-resize; }
.handle.w { left: -4px; top: calc(50% - 4px); cursor: ew-resize; }
.form-resize {
position: absolute;
right: 0;
bottom: 0;
width: 10px;
height: 10px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, #9aa3ad 50%);
}
.tray {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: #fff;
border: 1px solid #d5dbe2;
font-size: 12px;
}
.tray-label {
color: #6b7280;
}
.tray-empty {
color: #9ca3af;
}
.component {
padding: 2px 8px;
border: 1px solid #d1d5db;
border-radius: 3px;
background: #f9fafb;
cursor: pointer;
font: inherit;
}
.component.active {
border-color: #2563eb;
background: #e8f0fe;
}
.properties {
min-height: 0;
}
</style>
+389
View File
@@ -0,0 +1,389 @@
<script setup lang="ts">
// 属性窗口和事件窗口(仿 VS):显示选中控件的常用属性;改动立即写回 Designer.cs。
import { computed, ref, watch } from 'vue'
import { type ControlDef, FORM_DEF, type PropDef, controlDef } from './catalog'
import type { DesignerDocument } from './document'
import { checkName, getEventHandler, getItems, getPropText, typeOf } from './edit'
import { NAMED_COLORS, hexToRgb, parseValue, rgb, type Value } from './values'
const props = defineProps<{
doc: DesignerDocument
/** '' 表示窗体本身 */
selected: string
}>()
const emit = defineEmits<{
setProp: [target: string, path: string, value: Value | null]
setItems: [target: string, items: string[]]
rename: [from: string, to: string]
/** 双击事件或点"生成":绑定(必要时生成)事件处理方法并跳到代码 */
openEvent: [target: string, event: string, handler?: string]
clearEvent: [target: string, event: string]
}>()
const tab = ref<'props' | 'events'>('props')
const def = computed<ControlDef>(() => (props.selected === '' ? FORM_DEF : controlDef(typeOf(props.doc, props.selected) ?? '')))
const title = computed(() => `${props.selected || props.doc.formName} ${def.value.type.split('.').at(-1)}`)
function current(prop: PropDef): Value | undefined {
const text = getPropText(props.doc, props.selected, prop.name)
return text === undefined ? prop.default : parseValue(text)
}
/** 设成默认值时删除这条属性语句(VS 不写默认值) */
function set(prop: PropDef, value: Value) {
const isDefault = prop.default && JSON.stringify(prop.default) === JSON.stringify(value)
emit('setProp', props.selected, prop.name, isDefault ? null : value)
}
function reset(prop: PropDef) {
emit('setProp', props.selected, prop.name, null)
}
// ---------- 名称 ----------
const nameDraft = ref('')
const nameError = ref<string | null>(null)
watch(() => props.selected, () => {
nameDraft.value = props.selected || props.doc.formName
nameError.value = null
}, { immediate: true })
function commitName() {
const name = nameDraft.value.trim()
if (!props.selected || name === props.selected) return
nameError.value = checkName(props.doc, name, props.selected)
if (!nameError.value) emit('rename', props.selected, name)
}
// ---------- 各类型的读写 ----------
function str(prop: PropDef) {
const v = current(prop)
return v?.kind === 'string' || v?.kind === 'char' ? v.value : ''
}
function num(prop: PropDef) {
const v = current(prop)
return v?.kind === 'int' || v?.kind === 'float' ? v.value : 0
}
function boolOf(prop: PropDef) {
const v = current(prop)
return v?.kind === 'bool' ? v.value : false
}
function point(prop: PropDef) {
const v = current(prop)
return v?.kind === 'point' ? v : { x: 0, y: 0 }
}
function size(prop: PropDef) {
const v = current(prop)
return v?.kind === 'size' ? v : { width: 0, height: 0 }
}
function enumOf(prop: PropDef) {
const v = current(prop)
return v?.kind === 'enum' ? v.member : ''
}
function color(prop: PropDef) {
const v = current(prop)
return v?.kind === 'color' ? v : undefined
}
function font(prop: PropDef) {
const v = current(prop)
return v?.kind === 'font' ? v : undefined
}
function raw(prop: PropDef) {
const v = current(prop)
return v?.kind === 'raw' ? v.text : undefined
}
function setColor(prop: PropDef, choice: string) {
if (choice === '') return reset(prop)
if (choice.startsWith('#')) {
const [r, g, b] = hexToRgb(choice)
return set(prop, rgb(r, g, b))
}
const css = NAMED_COLORS[choice]
const [r, g, b] = css === 'transparent' ? [255, 255, 255] : hexToRgb(css)
set(prop, { kind: 'color', css, name: choice, r, g, b, a: css === 'transparent' ? 0 : 255 })
}
const FONT_FAMILIES = ['宋体', '黑体', '楷体', '微软雅黑', 'Arial', 'Times New Roman']
function setFont(prop: PropDef, change: Partial<{ family: string; size: number; bold: boolean; italic: boolean }>) {
const base = font(prop) ?? { kind: 'font' as const, family: '宋体', size: 9, bold: false, italic: false, underline: false, strikeout: false }
set(prop, { ...base, ...change })
}
const itemsText = computed(() => getItems(props.doc, props.selected).map((item) => {
const v = parseValue(item)
return v.kind === 'string' ? v.value : item
}).join('\n'))
function setItemsFromText(text: string) {
emit('setItems', props.selected, text.split('\n').map((s) => s.trimEnd()).filter((s) => s !== ''))
}
const handlerOf = (event: string) => getEventHandler(props.doc, props.selected, event) ?? ''
function renameHandler(event: string, value: string) {
const name = value.trim()
if (!name) emit('clearEvent', props.selected, event)
else if (name !== handlerOf(event)) emit('openEvent', props.selected, event, name)
}
</script>
<template>
<div class="grid">
<div class="grid-title" :title="title">{{ title }}</div>
<div class="tabs">
<button :class="{ active: tab === 'props' }" @click="tab = 'props'">属性</button>
<button :class="{ active: tab === 'events' }" title="事件" @click="tab = 'events'">⚡ 事件</button>
</div>
<div v-if="tab === 'props'" class="rows">
<div class="row">
<label title="控件的名称,代码里用 this.名称 访问它">(Name)</label>
<div class="editor">
<input v-model="nameDraft" :disabled="selected === ''" @change="commitName" @keydown.enter="commitName" />
<div v-if="nameError" class="error">{{ nameError }}</div>
<div v-else-if="selected !== ''" class="note">改名后,代码里用到旧名字的地方要自己修改</div>
</div>
</div>
<div v-for="prop in def.props" :key="prop.name" class="row">
<label :title="prop.label">{{ prop.name }}</label>
<div class="editor">
<code v-if="raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
<input v-else-if="prop.type === 'string'" :value="str(prop)" @change="set(prop, { kind: 'string', value: ($event.target as HTMLInputElement).value })" />
<textarea v-else-if="prop.type === 'multiline'" rows="2" :value="str(prop)"
@change="set(prop, { kind: 'string', value: ($event.target as HTMLTextAreaElement).value })" />
<input v-else-if="prop.type === 'char'" maxlength="1" class="short" :value="str(prop)"
@change="($event.target as HTMLInputElement).value ? set(prop, { kind: 'char', value: ($event.target as HTMLInputElement).value }) : reset(prop)" />
<select v-else-if="prop.type === 'bool'" :value="String(boolOf(prop))"
@change="set(prop, { kind: 'bool', value: ($event.target as HTMLSelectElement).value === 'true' })">
<option value="true">True</option>
<option value="false">False</option>
</select>
<input v-else-if="prop.type === 'int'" type="number" :value="num(prop)"
@change="set(prop, { kind: 'int', value: Number(($event.target as HTMLInputElement).value) })" />
<span v-else-if="prop.type === 'point'" class="pair">
X <input type="number" :value="point(prop).x" @change="set(prop, { kind: 'point', x: Number(($event.target as HTMLInputElement).value), y: point(prop).y })" />
Y <input type="number" :value="point(prop).y" @change="set(prop, { kind: 'point', x: point(prop).x, y: Number(($event.target as HTMLInputElement).value) })" />
</span>
<span v-else-if="prop.type === 'size'" class="pair">
宽 <input type="number" min="1" :value="size(prop).width" @change="set(prop, { kind: 'size', width: Number(($event.target as HTMLInputElement).value), height: size(prop).height })" />
高 <input type="number" min="1" :value="size(prop).height" @change="set(prop, { kind: 'size', width: size(prop).width, height: Number(($event.target as HTMLInputElement).value) })" />
</span>
<select v-else-if="prop.type === 'enum'" :value="enumOf(prop)"
@change="set(prop, { kind: 'enum', type: prop.enumType!, member: ($event.target as HTMLSelectElement).value })">
<option v-for="option in prop.options" :key="option" :value="option">{{ option }}</option>
</select>
<span v-else-if="prop.type === 'color'" class="color">
<i class="swatch" :style="{ background: color(prop)?.css ?? 'transparent' }" />
<select :value="color(prop)?.name ?? (color(prop) ? 'custom' : '')" @change="setColor(prop, ($event.target as HTMLSelectElement).value)">
<option value="">(默认)</option>
<option v-for="(_, name) in NAMED_COLORS" :key="name" :value="name">{{ name }}</option>
<option v-if="color(prop) && !color(prop)!.name" value="custom" disabled>{{ color(prop)!.css }}</option>
</select>
<input type="color" title="自定义颜色" :value="color(prop)?.css.startsWith('#') ? color(prop)!.css : '#000000'"
@change="setColor(prop, ($event.target as HTMLInputElement).value)" />
</span>
<span v-else-if="prop.type === 'font'" class="font">
<select :value="font(prop)?.family ?? ''" @change="($event.target as HTMLSelectElement).value ? setFont(prop, { family: ($event.target as HTMLSelectElement).value }) : reset(prop)">
<option value="">(默认:宋体 9)</option>
<option v-for="family in FONT_FAMILIES" :key="family" :value="family">{{ family }}</option>
</select>
<template v-if="font(prop)">
<input type="number" min="5" max="72" step="0.5" class="short" :value="font(prop)!.size"
@change="setFont(prop, { size: Number(($event.target as HTMLInputElement).value) })" />
<label class="check"><input type="checkbox" :checked="font(prop)!.bold" @change="setFont(prop, { bold: ($event.target as HTMLInputElement).checked })" />粗</label>
<label class="check"><input type="checkbox" :checked="font(prop)!.italic" @change="setFont(prop, { italic: ($event.target as HTMLInputElement).checked })" />斜</label>
</template>
</span>
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
<div class="hint">{{ prop.label }}</div>
</div>
</div>
</div>
<div v-else class="rows">
<div class="note events-note">双击事件名生成事件处理方法,并跳到代码</div>
<div v-for="event in def.events" :key="event.name" class="row">
<label class="event-name" :title="`${event.label}(双击生成处理方法)`" @dblclick="emit('openEvent', selected, event.name)">{{ event.name }}</label>
<div class="editor">
<span class="pair">
<input :value="handlerOf(event.name)" placeholder="(未绑定)" @change="renameHandler(event.name, ($event.target as HTMLInputElement).value)" />
<button class="go" title="生成或跳到事件处理方法" @click="emit('openEvent', selected, event.name)">→</button>
</span>
<div class="hint">{{ event.label }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.grid {
display: flex;
flex-direction: column;
min-height: 0;
font-size: 12px;
background: #fff;
border-left: 1px solid #e5e7eb;
}
.grid-title {
padding: 6px 8px;
font-weight: 600;
border-bottom: 1px solid #e5e7eb;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tabs {
display: flex;
border-bottom: 1px solid #e5e7eb;
}
.tabs button {
flex: 1;
padding: 4px;
border: none;
background: #f7f7f8;
cursor: pointer;
}
.tabs button.active {
background: #fff;
font-weight: 600;
box-shadow: inset 0 -2px #2563eb;
}
.rows {
flex: 1;
overflow: auto;
}
.row {
display: grid;
grid-template-columns: 92px 1fr;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid #f1f1f3;
}
.row > label {
padding-top: 3px;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
}
.event-name {
cursor: pointer;
}
.event-name:hover {
color: #2563eb;
}
.editor {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.editor input:not([type="checkbox"]):not([type="color"]),
.editor select,
.editor textarea {
width: 100%;
box-sizing: border-box;
font: inherit;
padding: 2px 4px;
border: 1px solid #d1d5db;
border-radius: 3px;
}
.pair,
.color,
.font {
display: flex;
align-items: center;
gap: 4px;
}
.pair input {
min-width: 0;
}
.short {
width: 56px !important;
flex: none;
}
.swatch {
flex: none;
width: 14px;
height: 14px;
border: 1px solid #9ca3af;
background-image: linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%);
}
.color input[type="color"] {
flex: none;
width: 24px;
height: 22px;
padding: 0;
border: none;
}
.check {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.raw {
font-size: 11px;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint,
.note {
color: #9ca3af;
font-size: 11px;
}
.events-note {
padding: 6px 8px;
}
.error {
color: #dc2626;
font-size: 11px;
}
.go {
flex: none;
padding: 1px 6px;
border: 1px solid #d1d5db;
border-radius: 3px;
background: #f9fafb;
cursor: pointer;
}
</style>
+304
View File
@@ -0,0 +1,304 @@
// 设计器支持的控件:工具箱分组、属性窗口里的属性、事件窗口里的事件、拖入窗体时的初始属性。
// 只列出仿真运行时已经实现的控件和属性;默认值与 WinForms 一致(等于默认值的属性不写进 Designer.cs)。
import type { Value } from './values'
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items'
export interface PropDef {
name: string
/** 属性窗口里显示的中文说明 */
label: string
type: PropType
/** 枚举属性的完整类型名和可选值 */
enumType?: string
options?: string[]
/** 等于这个值时不写进 Designer.cs(WinForms 的默认值) */
default?: Value
category: '外观' | '行为' | '布局' | '数据'
}
export interface EventDef {
name: string
label: string
delegate: string
/** 事件处理方法第二个参数的类型 */
args: string
}
export interface ControlDef {
/** 完整类型名,如 System.Windows.Forms.Button */
type: string
/** 工具箱和属性窗口里显示的名字 */
label: string
group: '常用控件' | '容器' | '组件' | '物联网设备'
/** 新控件的名字前缀:button → button1 */
namePrefix: string
/** 非可视组件(如 Timer):不在窗体上,显示在窗体下方的组件栏 */
component?: boolean
container?: boolean
/** 双击控件时生成的事件 */
defaultEvent: string
props: PropDef[]
events: EventDef[]
/** 拖入窗体时写入的属性(不含 Location、Name、TabIndex,这些由设计器填写) */
initial: (name: string) => [string, Value][]
/** AutoSize 时根据文字计算大小 */
autoSize?: (text: string) => { width: number; height: number }
}
const str = (value: string): Value => ({ kind: 'string', value })
const bool = (value: boolean): Value => ({ kind: 'bool', value })
const int = (value: number): Value => ({ kind: 'int', value })
const size = (width: number, height: number): Value => ({ kind: 'size', width, height })
const enumValue = (type: string, member: string): Value => ({ kind: 'enum', type, member })
const EVENT = { delegate: 'System.EventHandler', args: 'EventArgs' }
const MOUSE = { delegate: 'System.Windows.Forms.MouseEventHandler', args: 'MouseEventArgs' }
const COMMON_PROPS: PropDef[] = [
{ name: 'Text', label: '显示的文字', type: 'string', default: str(''), category: '外观' },
{ name: 'BackColor', label: '背景色', type: 'color', category: '外观' },
{ name: 'ForeColor', label: '前景色(文字颜色)', type: 'color', category: '外观' },
{ name: 'Font', label: '字体', type: 'font', category: '外观' },
{ name: 'Location', label: '位置(相对于所在容器)', type: 'point', category: '布局' },
{ name: 'Size', label: '大小', type: 'size', category: '布局' },
{ name: 'Enabled', label: '是否可用', type: 'bool', default: bool(true), category: '行为' },
{ name: 'Visible', label: '是否可见', type: 'bool', default: bool(true), category: '行为' },
{ name: 'TabIndex', label: 'Tab 键顺序', type: 'int', category: '行为' },
]
const COMMON_EVENTS: EventDef[] = [
{ name: 'Click', label: '单击', ...EVENT },
{ name: 'DoubleClick', label: '双击', ...EVENT },
{ name: 'MouseDown', label: '鼠标按下', ...MOUSE },
{ name: 'MouseUp', label: '鼠标松开', ...MOUSE },
{ name: 'MouseEnter', label: '鼠标进入', ...EVENT },
{ name: 'MouseLeave', label: '鼠标离开', ...EVENT },
{ name: 'TextChanged', label: 'Text 改变', ...EVENT },
{ name: 'Enter', label: '获得焦点', ...EVENT },
{ name: 'Leave', label: '失去焦点', ...EVENT },
]
const AUTO_SIZE: PropDef = { name: 'AutoSize', label: '根据文字自动调整大小', type: 'bool', default: bool(false), category: '布局' }
/** 宋体 9pt 下文字的大致宽度:半角约 6 像素,全角约 12 像素(与 VS 设计器算出的 Size 接近) */
export function textWidth(text: string) {
let width = 0
for (const ch of text) width += ch.charCodeAt(0) > 0xff ? 12 : 6
return width
}
const FORMS = 'System.Windows.Forms'
export const CONTROLS: ControlDef[] = [
{
type: `${FORMS}.Button`,
label: 'Button 按钮',
group: '常用控件',
namePrefix: 'button',
defaultEvent: 'Click',
props: [...COMMON_PROPS],
events: COMMON_EVENTS,
initial: (name) => [['Size', size(75, 23)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
},
{
type: `${FORMS}.Label`,
label: 'Label 标签',
group: '常用控件',
namePrefix: 'label',
defaultEvent: 'Click',
props: [...COMMON_PROPS, AUTO_SIZE, {
name: 'BorderStyle', label: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'], default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
}],
events: COMMON_EVENTS,
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 5, 12)], ['Text', str(name)]],
autoSize: (text) => ({ width: textWidth(text) + 5, height: 12 }),
},
{
type: `${FORMS}.TextBox`,
label: 'TextBox 文本框',
group: '常用控件',
namePrefix: 'textBox',
defaultEvent: 'TextChanged',
props: [
...COMMON_PROPS.map((p) => (p.name === 'Text' ? { ...p, type: 'multiline' as const } : p)),
{ name: 'Multiline', label: '多行', type: 'bool', default: bool(false), category: '行为' },
{ name: 'ReadOnly', label: '只读', type: 'bool', default: bool(false), category: '行为' },
{ name: 'PasswordChar', label: '密码字符(如 *)', type: 'char', category: '行为' },
{ name: 'UseSystemPasswordChar', label: '用系统密码字符显示', type: 'bool', default: bool(false), category: '行为' },
{
name: 'ScrollBars', label: '滚动条', type: 'enum', enumType: `${FORMS}.ScrollBars`,
options: ['None', 'Horizontal', 'Vertical', 'Both'], default: enumValue(`${FORMS}.ScrollBars`, 'None'), category: '外观',
},
],
events: COMMON_EVENTS,
initial: () => [['Size', size(100, 21)]],
},
{
type: `${FORMS}.ComboBox`,
label: 'ComboBox 下拉框',
group: '常用控件',
namePrefix: 'comboBox',
defaultEvent: 'SelectedIndexChanged',
props: [
...COMMON_PROPS,
{ name: 'Items', label: '列表项(每行一项)', type: 'items', category: '数据' },
{
name: 'DropDownStyle', label: '下拉样式', type: 'enum', enumType: `${FORMS}.ComboBoxStyle`,
options: ['DropDown', 'DropDownList', 'Simple'], default: enumValue(`${FORMS}.ComboBoxStyle`, 'DropDown'), category: '外观',
},
],
events: [...COMMON_EVENTS, { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }, { name: 'DropDown', label: '展开列表', ...EVENT }],
initial: () => [['FormattingEnabled', bool(true)], ['Size', size(121, 20)]],
},
{
type: `${FORMS}.CheckBox`,
label: 'CheckBox 复选框',
group: '常用控件',
namePrefix: 'checkBox',
defaultEvent: 'CheckedChanged',
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '是否勾选', type: 'bool', default: bool(false), category: '外观' }],
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '勾选状态改变', ...EVENT }],
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 24, 16)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
autoSize: (text) => ({ width: textWidth(text) + 24, height: 16 }),
},
{
type: `${FORMS}.RadioButton`,
label: 'RadioButton 单选按钮',
group: '常用控件',
namePrefix: 'radioButton',
defaultEvent: 'CheckedChanged',
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '是否选中', type: 'bool', default: bool(false), category: '外观' }],
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '选中状态改变', ...EVENT }],
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 17, 16)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
autoSize: (text) => ({ width: textWidth(text) + 17, height: 16 }),
},
{
type: `${FORMS}.GroupBox`,
label: 'GroupBox 分组框',
group: '容器',
namePrefix: 'groupBox',
container: true,
defaultEvent: 'Enter',
props: [...COMMON_PROPS],
events: COMMON_EVENTS,
initial: (name) => [['Size', size(200, 100)], ['TabStop', bool(false)], ['Text', str(name)]],
},
{
type: `${FORMS}.Panel`,
label: 'Panel 面板',
group: '容器',
namePrefix: 'panel',
container: true,
defaultEvent: 'Click',
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
initial: () => [['Size', size(200, 100)]],
},
{
type: `${FORMS}.Timer`,
label: 'Timer 定时器',
group: '组件',
namePrefix: 'timer',
component: true,
defaultEvent: 'Tick',
props: [
{ name: 'Interval', label: '间隔(毫秒)', type: 'int', default: int(100), category: '行为' },
{ name: 'Enabled', label: '启动后立即计时', type: 'bool', default: bool(false), category: '行为' },
],
events: [{ name: 'Tick', label: '时间到', ...EVENT }],
initial: () => [],
},
{
type: 'WinFormsDeviceControlLibrary.LED',
label: 'LED 显示屏',
group: '物联网设备',
namePrefix: 'led',
defaultEvent: 'Load',
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
initial: () => [['Size', size(800, 130)]],
},
{
type: 'WinFormsDeviceControlLibrary.Lamp',
label: 'Lamp 灯泡',
group: '物联网设备',
namePrefix: 'lamp',
defaultEvent: 'Load',
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
initial: () => [['BackColor', { kind: 'color', css: '#ffffff', name: 'White', r: 255, g: 255, b: 255, a: 255 }], ['Size', size(105, 185)]],
},
{
type: 'WinFormsDeviceControlLibrary.Alarm',
label: 'Alarm 报警灯',
group: '物联网设备',
namePrefix: 'alarm',
defaultEvent: 'Load',
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
initial: () => [['Size', size(150, 150)]],
},
]
/** 窗体本身 */
export const FORM_DEF: ControlDef = {
type: `${FORMS}.Form`,
label: 'Form 窗体',
group: '容器',
namePrefix: 'Form',
container: true,
defaultEvent: 'Load',
props: [
{ name: 'Text', label: '标题栏文字', type: 'string', default: str(''), category: '外观' },
{ name: 'BackColor', label: '背景色', type: 'color', category: '外观' },
{ name: 'ForeColor', label: '前景色(文字颜色)', type: 'color', category: '外观' },
{ name: 'Font', label: '字体', type: 'font', category: '外观' },
{ name: 'ClientSize', label: '客户区大小(不含标题栏和边框)', type: 'size', category: '布局' },
{
name: 'FormBorderStyle', label: '边框样式', type: 'enum', enumType: `${FORMS}.FormBorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D', 'FixedDialog', 'Sizable', 'FixedToolWindow', 'SizableToolWindow'],
default: enumValue(`${FORMS}.FormBorderStyle`, 'Sizable'), category: '外观',
},
{
name: 'StartPosition', label: '启动位置', type: 'enum', enumType: `${FORMS}.FormStartPosition`,
options: ['Manual', 'CenterScreen', 'WindowsDefaultLocation', 'WindowsDefaultBounds', 'CenterParent'],
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'), category: '布局',
},
{ name: 'MaximizeBox', label: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
{ name: 'MinimizeBox', label: '显示最小化按钮', type: 'bool', default: bool(true), category: '外观' },
],
events: [
{ name: 'Load', label: '加载', ...EVENT },
{ name: 'Shown', label: '首次显示', ...EVENT },
{ name: 'FormClosing', label: '正在关闭', delegate: `${FORMS}.FormClosingEventHandler`, args: 'FormClosingEventArgs' },
{ name: 'FormClosed', label: '已关闭', delegate: `${FORMS}.FormClosedEventHandler`, args: 'FormClosedEventArgs' },
{ name: 'Activated', label: '被激活', ...EVENT },
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
],
initial: () => [],
}
const BY_TYPE = new Map(CONTROLS.map((def) => [def.type, def]))
/** 按类型名查控件定义;设计器不认识的类型(如 PictureBox)返回一个只有通用属性的定义 */
export function controlDef(type: string): ControlDef {
return (
BY_TYPE.get(type) ?? {
type,
label: type.split('.').at(-1)!,
group: '常用控件',
namePrefix: type.split('.').at(-1)!.replace(/^./, (c) => c.toLowerCase()),
defaultEvent: 'Click',
props: COMMON_PROPS,
events: COMMON_EVENTS,
initial: () => [],
}
)
}
/** 设计器里会重新排列子控件的容器类型 */
export function isContainerType(type: string) {
return type === FORM_DEF.type || controlDef(type).container === true
}
+41
View File
@@ -0,0 +1,41 @@
/// <reference types="bun" />
import { expect, test } from 'bun:test'
import { resolve } from 'node:path'
import { ensureHandler } from './code'
const FORM1 = resolve(import.meta.dir, '../../../samples/p2-t1-LED显示屏/Form1.cs')
test('插入事件处理方法:放在类的最后,与上一个方法空一行,返回方法体内的行号', async () => {
const code = await Bun.file(FORM1).text()
const { code: next, line } = ensureHandler(code, 'Form1', 'Form1_Load', 'EventArgs')
const lines = next.split('\n')
expect(lines.slice(line - 4, line + 1)).toEqual([
'',
' private void Form1_Load(object sender, EventArgs e)',
' {',
'',
' }',
])
// 方法的 "}" 之后就是类的 "}"
expect(lines[line + 1]).toBe(' }')
})
test('方法已经存在:不重复插入,只返回它的位置', async () => {
const code = await Bun.file(FORM1).text()
const { code: next, line } = ensureHandler(code, 'Form1', 'btn_Send_Click', 'EventArgs')
expect(next).toBe(code)
expect(next.split('\n')[line - 3]).toContain('private void btn_Send_Click')
})
test('类里还没有成员时不留多余空行', () => {
const code = 'namespace A\n{\n public partial class F : Form\n {\n }\n}\n'
const { code: next, line } = ensureHandler(code, 'F', 'F_Load', 'EventArgs')
expect(next).toBe('namespace A\n{\n public partial class F : Form\n {\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n }\n}\n')
expect(line).toBe(7)
})
test('字符串和注释里的大括号不影响查找类的结尾', () => {
const code = 'class F\n{\n string s = "}{";\n // }\n char c = \'}\';\n}\n'
const { code: next } = ensureHandler(code, 'F', 'F_Load', 'EventArgs')
expect(next.endsWith(" char c = '}';\n\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n}\n")).toBe(true)
})
+76
View File
@@ -0,0 +1,76 @@
// 窗体代码文件(如 Form1.cs)的修改:双击控件或事件时,插入事件处理方法,并返回光标应放的行号。
export interface HandlerResult {
code: string
/** 方法体内空行的行号(从 1 开始),编辑器跳到这里 */
line: number
}
/** 方法已经存在时只返回它的位置;否则插在窗体类的最后,格式与 VS 一致 */
export function ensureHandler(code: string, className: string, handler: string, args: string): HandlerResult {
const existing = new RegExp(`void\\s+${handler}\\s*\\(`).exec(code)
if (existing) return { code, line: lineOf(code, existing.index) + 2 }
const eol = code.includes('\r\n') ? '\r\n' : '\n'
const close = classClosingBrace(code, className)
if (close < 0) throw new Error(`在代码文件中找不到类 ${className}`)
const lineStart = code.lastIndexOf('\n', close) + 1
const classIndent = /^[ \t]*/.exec(code.slice(lineStart))![0]
const indent = `${classIndent} `
// 类里已有成员时,与上一个成员之间空一行
const before = code.slice(0, lineStart)
const hasMembers = !/\{\s*$/.test(before)
const method = [
...(hasMembers ? [''] : []),
`${indent}private void ${handler}(object sender, ${args} e)`,
`${indent}{`,
'',
`${indent}}`,
].join(eol)
const next = `${before}${method}${eol}${code.slice(lineStart)}`
return { code: next, line: lineOf(next, before.length) + (hasMembers ? 3 : 2) }
}
function lineOf(text: string, index: number) {
let line = 1
for (let i = 0; i < index; i++) if (text[i] === '\n') line++
return line
}
/** 找到 class className 的类体结束 "}" 的位置(跳过字符串、字符和注释) */
export function classClosingBrace(code: string, className: string): number {
const decl = new RegExp(`\\bclass\\s+${className}\\b`).exec(code)
if (!decl) return -1
let i = code.indexOf('{', decl.index)
if (i < 0) return -1
let depth = 0
while (i < code.length) {
const c = code[i]
if (code.startsWith('//', i)) {
const end = code.indexOf('\n', i)
i = end < 0 ? code.length : end
continue
}
if (code.startsWith('/*', i)) {
const end = code.indexOf('*/', i + 2)
i = end < 0 ? code.length : end + 2
continue
}
if (c === '@' && code[i + 1] === '"') {
i += 2
while (i < code.length && !(code[i] === '"' && code[i + 1] !== '"')) i += code[i] === '"' ? 2 : 1
i++
continue
}
if (c === '"' || c === "'") {
i++
while (i < code.length && code[i] !== c && code[i] !== '\n') i += code[i] === '\\' ? 2 : 1
i++
continue
}
if (c === '{') depth++
else if (c === '}' && --depth === 0) return i
i++
}
return -1
}
+41
View File
@@ -0,0 +1,41 @@
/// <reference types="bun" />
import { describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { generateDesigner, parseDesigner } from './document'
const ROOT = resolve(import.meta.dir, '../../..')
// 教材配套的参考工程不在仓库里(版权),本机有时才测
const REFERENCE_DIR = resolve(ROOT, 'docs/物联网编程与应用配套/物联网编程与应用')
async function designerFiles(dir: string) {
const files: string[] = []
for await (const path of new Bun.Glob('**/*.Designer.cs').scan({ cwd: dir })) {
if (path.includes('/obj/') || path.includes('/Properties/')) continue
files.push(resolve(dir, path))
}
return files.sort()
}
describe('读入 Designer.cs 再原样写出,内容逐字节不变', async () => {
const files = [
...(await designerFiles(resolve(ROOT, 'samples'))),
...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : []),
]
for (const file of files) {
test(file.replace(`${ROOT}/`, ''), async () => {
const text = await Bun.file(file).text()
expect(generateDesigner(parseDesigner(text))).toBe(text)
})
}
})
test('窗体属性 this.ClientSize = new Size(...) 不会被当成创建控件', async () => {
const text = await Bun.file(resolve(ROOT, 'samples/p2-t2-模拟量采集/frm_analog.Designer.cs')).text()
const doc = parseDesigner(text)
const creates = doc.prologue.filter((s) => s.kind === 'create').map((s) => (s.kind === 'create' ? s.name : ''))
expect(creates).not.toContain('ClientSize')
expect(creates).not.toContain('AutoScaleDimensions')
const formProps = doc.blocks.at(-1)!.stmts.flatMap((s) => (s.kind === 'prop' ? [s.path] : []))
expect(formProps).toContain('ClientSize')
})
+316
View File
@@ -0,0 +1,316 @@
// Designer.cs 的读写:把 InitializeComponent 解析成一条条语句,每条保留原文。
// 没修改过的语句原样输出,修改过的按 VS 的格式重新生成,所以 VS 生成的文件读入后再写出,内容逐字节不变。
// 只认 VS 生成的标准结构(和 VS 一样);结构不对时抛出 DesignerParseError,设计器提示无法打开。
export class DesignerParseError extends Error {}
export type Stmt =
/** this.x = new T(...) */
| { kind: 'create'; name: string; type: string; args: string; text?: string }
/** this[.x].Path = value;target 为 '' 表示窗体本身 */
| { kind: 'prop'; target: string; path: string; value: string; text?: string }
/** this[.x].Event += new Delegate(this.handler) */
| { kind: 'event'; target: string; event: string; delegate: string; handler: string; text?: string }
/** this[.x].Controls.Add(this.child) */
| { kind: 'add'; target: string; child: string; text?: string }
/** this.x.Items.AddRange(new object[] { ... }),items 是每一项的 C# 字面量原文 */
| { kind: 'items'; target: string; items: string[]; text?: string }
/** SuspendLayout / ResumeLayout(false) / PerformLayout / BeginInit / EndInit */
| { kind: 'layout'; target: string; op: LayoutOp; text?: string }
/** 其他语句(局部变量、图表的 Series 设置等),原样保留 */
| { kind: 'raw'; text: string }
export type LayoutOp = 'SuspendLayout' | 'ResumeLayout' | 'PerformLayout' | 'BeginInit' | 'EndInit'
export interface Block {
/** 控件名;窗体的块用窗体类名 */
name: string
stmts: Stmt[]
}
export interface DesignerDocument {
/** 窗体的 Name(窗体属性块的名字,即最后一个块);通常与类名相同,但也可能不同(如教材的 AddUser 窗体叫 frmExam_5_82) */
formName: string
/** partial class 后面的窗体类名 */
className: string
/** InitializeComponent 方法体之前的全部原文(含方法体的 "{" 和换行) */
head: string
/** 第一个注释块之前的语句:components、创建控件、SuspendLayout、BeginInit 等 */
prologue: Stmt[]
/** 各控件的属性块(含窗体),顺序与文件一致 */
blocks: Block[]
/** 窗体块之后的 ResumeLayout、PerformLayout、EndInit */
epilogue: Stmt[]
/** 最后一条语句之后到文件末尾的原文(含方法的 "}"、#endregion、字段声明) */
tail: string
eol: string
indent: string
}
// ---------- 语句切分 ----------
interface Piece {
kind: 'stmt' | 'comment'
text: string
}
/** 把方法体切成语句和注释行;语句以顶层的 ";" 结束,跳过字符串、字符和注释里的符号 */
function splitBody(body: string): Piece[] {
const pieces: Piece[] = []
let i = 0
while (i < body.length) {
const ch = body[i]
if (/\s/.test(ch)) {
i++
continue
}
if (body.startsWith('//', i)) {
const end = body.indexOf('\n', i)
const stop = end < 0 ? body.length : end
pieces.push({ kind: 'comment', text: body.slice(i, stop).replace(/\r$/, '') })
i = stop
continue
}
const start = i
let depth = 0
while (i < body.length) {
const c = body[i]
if (c === '"' || c === "'") {
i = skipLiteral(body, i)
continue
}
if (c === '@' && body[i + 1] === '"') {
i = skipVerbatim(body, i + 1)
continue
}
if (c === '(' || c === '{' || c === '[') depth++
else if (c === ')' || c === '}' || c === ']') depth--
else if (c === ';' && depth === 0) {
pieces.push({ kind: 'stmt', text: body.slice(start, i + 1) })
i++
break
}
i++
}
if (i >= body.length && !body.slice(start).trim().endsWith(';') && body.slice(start).trim()) {
throw new DesignerParseError(`语句没有以分号结束:${body.slice(start, start + 40)}`)
}
}
return pieces
}
function skipLiteral(text: string, i: number) {
const quote = text[i]
i++
while (i < text.length && text[i] !== quote) {
if (text[i] === '\\') i++
if (text[i] === '\n') throw new DesignerParseError('字符串没有结束')
i++
}
return i + 1
}
function skipVerbatim(text: string, i: number) {
i++
while (i < text.length) {
if (text[i] === '"') {
if (text[i + 1] === '"') i += 2
else return i + 1
} else i++
}
throw new DesignerParseError('字符串没有结束')
}
// ---------- 解析 ----------
const CREATE = /^this\.(\w+) = new ([\w.]+)\(([^()]*)\);$/
const LAYOUT = /^this\.(?:(\w+)\.)?(SuspendLayout|ResumeLayout|PerformLayout)\((?:false)?\);$/
const INIT = /^\(\(System\.ComponentModel\.ISupportInitialize\)\(this\.(\w+)\)\)\.(BeginInit|EndInit)\(\);$/
const EVENT = /^this\.(?:(\w+)\.)?(\w+) \+= new ([\w.]+)\(this\.(\w+)\);$/
const ADD = /^this\.(?:(\w+)\.)?Controls\.Add\(this\.(\w+)\);$/
const ITEMS = /^this\.(\w+)\.Items\.AddRange\(new object\[\] \{([\s\S]*)\}\);$/
const PROP = /^this\.([\w.]+) = ([\s\S]+);$/
/** 一个 Designer.cs 文件里窗体类的名字:partial class 后面的标识符 */
function findFormName(text: string) {
const match = /partial\s+class\s+(\w+)/.exec(text)
if (!match) throw new DesignerParseError('找不到 partial class 声明')
return match[1]
}
export function parseDesigner(text: string): DesignerDocument {
const eol = text.includes('\r\n') ? '\r\n' : '\n'
const className = findFormName(text)
const method = /private\s+void\s+InitializeComponent\s*\(\s*\)\s*\{[^\S\n]*\r?\n/.exec(text)
if (!method) throw new DesignerParseError('找不到 InitializeComponent 方法')
const bodyStart = method.index + method[0].length
const bodyEnd = findMethodEnd(text, bodyStart)
const body = text.slice(bodyStart, bodyEnd)
const pieces = splitBody(body)
// 找到最后一条语句在 body 里的结束位置,之后的原文(空行 + "}")归入 tail
const lastStmt = [...pieces].reverse().find((p) => p.kind === 'stmt')
const lastEnd = lastStmt ? body.lastIndexOf(lastStmt.text) + lastStmt.text.length : 0
const tail = body.slice(lastEnd).replace(/^[^\S\n]*\r?\n/, '') + text.slice(bodyEnd)
const indentMatch = /^([ \t]*)\S/m.exec(body)
const indent = indentMatch ? indentMatch[1] : ' '
// 第一遍:找出所有创建的控件。只有声明了字段的才算控件——
// 窗体属性也可能写成 this.ClientSize = new System.Drawing.Size(...),形式和创建控件一样
const fields = new Set([...text.matchAll(/^\s*private\s+[\w.]+\s+(\w+)\s*;/gm)].map((m) => m[1]))
const controls = new Set<string>()
for (const piece of pieces) {
const m = piece.kind === 'stmt' ? CREATE.exec(piece.text) : null
if (m && m[1] !== 'components' && fields.has(m[1])) controls.add(m[1])
}
const doc: DesignerDocument = { formName: className, className, head: text.slice(0, bodyStart), prologue: [], blocks: [], epilogue: [], tail, eol, indent }
let block: Block | undefined
let headerLines: string[] = []
for (const piece of pieces) {
if (piece.kind === 'comment') {
headerLines.push(piece.text)
// VS 的块头是三行注释:"// "、"// 名字"、"// "
if (headerLines.length === 3) {
const name = /^\/\/ (\w+)$/.exec(headerLines[1])?.[1]
if (!name || headerLines[0].trim() !== '//' || headerLines[2].trim() !== '//') {
throw new DesignerParseError(`无法识别的注释:${headerLines.join(' ')}`)
}
block = { name, stmts: [] }
doc.blocks.push(block)
headerLines = []
}
continue
}
if (headerLines.length) throw new DesignerParseError(`无法识别的注释:${headerLines.join(' ')}`)
const stmt = classify(piece.text, controls)
if (block) block.stmts.push(stmt)
else doc.prologue.push(stmt)
}
// 最后一个块是窗体本身;块末尾的 ResumeLayout / PerformLayout / EndInit 归入 epilogue
const formBlock = doc.blocks.at(-1)
if (formBlock) {
doc.formName = formBlock.name
while (formBlock.stmts.length && formBlock.stmts.at(-1)!.kind === 'layout') {
doc.epilogue.unshift(formBlock.stmts.pop()!)
}
}
return doc
}
function classify(text: string, controls: Set<string>): Stmt {
let m: RegExpExecArray | null
if ((m = CREATE.exec(text)) && controls.has(m[1])) return { kind: 'create', name: m[1], type: m[2], args: m[3], text }
if ((m = LAYOUT.exec(text))) return { kind: 'layout', target: m[1] ?? '', op: m[2] as LayoutOp, text }
if ((m = INIT.exec(text))) return { kind: 'layout', target: m[1], op: m[2] as LayoutOp, text }
if ((m = EVENT.exec(text))) return { kind: 'event', target: targetOf(m[1], controls), event: m[2], delegate: m[3], handler: m[4], text }
if ((m = ADD.exec(text))) return { kind: 'add', target: targetOf(m[1], controls), child: m[2], text }
if ((m = ITEMS.exec(text)) && controls.has(m[1])) return { kind: 'items', target: m[1], items: splitItems(m[2]), text }
if ((m = PROP.exec(text))) {
const [first, ...rest] = m[1].split('.')
if (controls.has(first) && rest.length) return { kind: 'prop', target: first, path: rest.join('.'), value: m[2], text }
return { kind: 'prop', target: '', path: m[1], value: m[2], text }
}
return { kind: 'raw', text }
}
/** this.x.Event:x 是控件名时是控件的事件;否则(如 this.Load)是窗体的事件 */
function targetOf(name: string | undefined, controls: Set<string>) {
if (!name) return ''
if (controls.has(name)) return name
throw new DesignerParseError(`代码引用了不存在的控件 ${name}`)
}
/** 拆分 new object[] { "a", "b" } 里的各项(逗号分隔,跳过字符串里的逗号) */
function splitItems(inner: string): string[] {
const items: string[] = []
let i = 0
let start = 0
while (i < inner.length) {
const c = inner[i]
if (c === '"' || c === "'") {
i = skipLiteral(inner, i)
continue
}
if (c === ',') {
items.push(inner.slice(start, i).trim())
start = i + 1
}
i++
}
const last = inner.slice(start).trim()
if (last) items.push(last)
return items
}
/** 从方法体开始位置找到与之匹配的 "}"(跳过字符串和注释) */
function findMethodEnd(text: string, start: number) {
let depth = 1
let i = start
while (i < text.length) {
const c = text[i]
if (text.startsWith('//', i)) {
const end = text.indexOf('\n', i)
i = end < 0 ? text.length : end
continue
}
if (c === '"' || c === "'") {
i = skipLiteral(text, i)
continue
}
if (c === '{') depth++
else if (c === '}') {
depth--
if (depth === 0) {
// 回到这一行的行首,"}" 前的缩进归入 tail
return text.lastIndexOf('\n', i) + 1
}
}
i++
}
throw new DesignerParseError('InitializeComponent 方法没有结束')
}
// ---------- 生成 ----------
export function renderStmt(stmt: Stmt, indent = ' ', eol = '\n'): string {
if (stmt.text !== undefined) return stmt.text
const self = (target: string) => (target ? `this.${target}` : 'this')
switch (stmt.kind) {
case 'create':
return `this.${stmt.name} = new ${stmt.type}(${stmt.args});`
case 'prop':
return `${self(stmt.target)}.${stmt.path} = ${stmt.value};`
case 'event':
return `${self(stmt.target)}.${stmt.event} += new ${stmt.delegate}(this.${stmt.handler});`
case 'add':
return `${self(stmt.target)}.Controls.Add(this.${stmt.child});`
case 'items': {
// VS 的格式:每项单独一行,与语句同样缩进,最后一项后紧跟 "});"
const lines = stmt.items.map((item, i) => indent + item + (i < stmt.items.length - 1 ? ',' : '});'))
return `this.${stmt.target}.Items.AddRange(new object[] {${eol}${lines.join(eol)}`
}
case 'layout':
if (stmt.op === 'BeginInit' || stmt.op === 'EndInit') {
return `((System.ComponentModel.ISupportInitialize)(this.${stmt.target})).${stmt.op}();`
}
return `${self(stmt.target)}.${stmt.op}(${stmt.op === 'ResumeLayout' ? 'false' : ''});`
case 'raw':
return stmt.text
}
}
export function generateDesigner(doc: DesignerDocument): string {
const { indent, eol } = doc
const lines: string[] = []
const emit = (stmt: Stmt) => lines.push(indent + renderStmt(stmt, indent, eol))
doc.prologue.forEach(emit)
for (const block of doc.blocks) {
lines.push(`${indent}// `, `${indent}// ${block.name}`, `${indent}// `)
block.stmts.forEach(emit)
}
doc.epilogue.forEach(emit)
return doc.head + lines.join(eol) + eol + doc.tail
}
+130
View File
@@ -0,0 +1,130 @@
/// <reference types="bun" />
import { describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { controlDef } from './catalog'
import { generateDesigner, parseDesigner } from './document'
import { addControl, childrenOf, getPropText, listControls, moveControl, removeControl, renameControl, setEvent, setItems, setProp, syncLayout } from './edit'
const ROOT = resolve(import.meta.dir, '../../..')
const REFERENCE_DIR = resolve(ROOT, 'docs/物联网编程与应用配套/物联网编程与应用')
async function designerFiles(dir: string) {
const files: string[] = []
for await (const path of new Bun.Glob('**/*.Designer.cs').scan({ cwd: dir })) {
if (path.includes('/obj/') || path.includes('/Properties/')) continue
files.push(resolve(dir, path))
}
return files.sort()
}
describe('布局语句的规则与 VS 一致:对现有文件执行一遍不产生任何变化', async () => {
const files = [...(await designerFiles(resolve(ROOT, 'samples'))), ...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : [])]
for (const file of files) {
test(file.replace(`${ROOT}/`, ''), async () => {
const text = await Bun.file(file).text()
const doc = parseDesigner(text)
syncLayout(doc)
expect(generateDesigner(doc)).toBe(text)
})
}
})
const LED_SAMPLE = resolve(ROOT, 'samples/p2-t1-LED显示屏/Form1.Designer.cs')
test('往分组框里添加按钮:创建语句、属性块、Controls.Add、字段声明都按 VS 格式写入', async () => {
const doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
const { doc: next, name } = addControl(doc, controlDef('System.Windows.Forms.Button'), 'groupBox1', { x: 20, y: 100 })
expect(name).toBe('button1')
const text = generateDesigner(next)
expect(text).toContain(' this.button1 = new System.Windows.Forms.Button();\n')
expect(text).toContain(
[
' // ',
' // button1',
' // ',
' this.button1.Location = new System.Drawing.Point(20, 100);',
' this.button1.Name = "button1";',
' this.button1.Size = new System.Drawing.Size(75, 23);',
' this.button1.TabIndex = 6;',
' this.button1.Text = "button1";',
' this.button1.UseVisualStyleBackColor = true;',
].join('\n'),
)
// 新控件在最上层:Controls.Add 排在第一个
expect(childrenOf(next, 'groupBox1')[0]).toBe('button1')
expect(text).toContain(' private System.Windows.Forms.Button button1;\n')
// 属性块在窗体块之前
expect(text.indexOf('// button1')).toBeLessThan(text.indexOf('// Form1'))
})
test('添加 Timer:自动加上 components 容器,不放进窗体', async () => {
const doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
const { doc: next } = addControl(doc, controlDef('System.Windows.Forms.Timer'), '', { x: 0, y: 0 })
const text = generateDesigner(next)
expect(text).toContain(' this.components = new System.ComponentModel.Container();\n')
expect(text).toContain('this.timer1 = new System.Windows.Forms.Timer(this.components);')
expect(listControls(next).find((c) => c.name === 'timer1')?.parent).toBeNull()
})
test('第一次往空的分组框里放控件:补上 SuspendLayout / ResumeLayout / PerformLayout', async () => {
let doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
doc = addControl(doc, controlDef('System.Windows.Forms.GroupBox'), '', { x: 12, y: 150 }).doc
expect(generateDesigner(doc)).not.toContain('this.groupBox2.SuspendLayout();')
doc = addControl(doc, controlDef('System.Windows.Forms.Label'), 'groupBox2', { x: 6, y: 20 }).doc
const text = generateDesigner(doc)
expect(text).toContain('this.groupBox2.SuspendLayout();')
expect(text).toContain(' this.groupBox2.ResumeLayout(false);\n this.groupBox2.PerformLayout();')
// 标签是 AutoSize,窗体本身没有直接的 AutoSize 子控件
expect(text).not.toContain(' this.PerformLayout();')
})
test('设置、修改、删除属性;改动的语句按排序插入', async () => {
let doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
doc = setProp(doc, 'btn_Send', 'Text', { kind: 'string', value: '发送 "LED"' })
doc = setProp(doc, 'btn_Send', 'BackColor', { kind: 'color', css: '#ff0000', name: 'Red', r: 255, g: 0, b: 0, a: 255 })
const text = generateDesigner(doc)
expect(text).toContain('this.btn_Send.Text = "发送 \\"LED\\"";')
// BackColor 排在 Location 之前
expect(text).toContain(' this.btn_Send.BackColor = System.Drawing.Color.Red;\n this.btn_Send.Location')
doc = setProp(doc, 'btn_Send', 'BackColor', null)
expect(generateDesigner(doc)).not.toContain('btn_Send.BackColor')
})
test('改名:所有引用和字段声明一起改', async () => {
const doc = renameControl(parseDesigner(await Bun.file(LED_SAMPLE).text()), 'btn_Send', 'btnSend')
const text = generateDesigner(doc)
expect(text).not.toContain('btn_Send.')
expect(text).toContain('this.btnSend.Name = "btnSend";')
expect(text).toContain('this.groupBox1.Controls.Add(this.btnSend);')
expect(text).toContain('private System.Windows.Forms.Button btnSend;')
// 事件处理方法名不变(和 VS 一样)
expect(text).toContain('this.btnSend.Click += new System.EventHandler(this.btn_Send_Click);')
})
test('删除分组框:连同里面的控件和字段一起删除,布局语句同步', async () => {
const doc = removeControl(parseDesigner(await Bun.file(LED_SAMPLE).text()), 'groupBox1')
const text = generateDesigner(doc)
for (const name of ['groupBox1', 'btn_Send', 'com_Port', 'label1']) expect(text).not.toContain(`this.${name}`)
expect(text).not.toContain('private System.Windows.Forms.Button btn_Send;')
expect(listControls(doc).map((c) => c.name)).toEqual(['led'])
})
test('把控件从分组框拖到窗体上', async () => {
const doc = moveControl(parseDesigner(await Bun.file(LED_SAMPLE).text()), 'btn_Send', '', { x: 30, y: 200 })
expect(childrenOf(doc, '')).toContain('btn_Send')
expect(childrenOf(doc, 'groupBox1')).not.toContain('btn_Send')
expect(getPropText(doc, 'btn_Send', 'Location')).toBe('new System.Drawing.Point(30, 200)')
})
test('事件绑定与下拉框列表项', async () => {
let doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
doc = setEvent(doc, '', 'Load', 'System.EventHandler', 'Form1_Load')
doc = setItems(doc, 'com_Port', ['COM1', 'COM2'])
const text = generateDesigner(doc)
expect(text).toContain(' this.Text = "LED显示屏";\n this.Load += new System.EventHandler(this.Form1_Load);')
expect(text).toContain('this.com_Port.Items.AddRange(new object[] {\n "COM1",\n "COM2"});')
// 列表项排在 FormattingEnabled 之后、Location 之前
expect(text.indexOf('Items.AddRange')).toBeGreaterThan(text.indexOf('com_Port.FormattingEnabled'))
expect(text.indexOf('Items.AddRange')).toBeLessThan(text.indexOf('com_Port.Location'))
})
+365
View File
@@ -0,0 +1,365 @@
// 对 Designer.cs 文档的编辑操作。每个操作返回新文档,不修改传入的文档。
// 修改过的语句清掉原文(text),生成时按 VS 格式重写;其余语句原样保留。
import { type ControlDef, controlDef, isContainerType } from './catalog'
import type { Block, DesignerDocument, Stmt } from './document'
import { renderValue, type Value } from './values'
export interface ControlInfo {
name: string
type: string
/** 所在容器:'' 表示直接放在窗体上;null 表示不在窗体上(如 Timer 组件) */
parent: string | null
}
// ---------- 查询 ----------
export function listControls(doc: DesignerDocument): ControlInfo[] {
const parents = new Map<string, string>()
for (const block of doc.blocks) {
for (const stmt of block.stmts) if (stmt.kind === 'add') parents.set(stmt.child, stmt.target)
}
return doc.prologue.flatMap((stmt) =>
stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : [],
)
}
/** 某个容器里的子控件,按 Controls.Add 的顺序(第一个在最上层) */
export function childrenOf(doc: DesignerDocument, parent: string): string[] {
return findBlock(doc, parent)?.stmts.flatMap((s) => (s.kind === 'add' ? [s.child] : [])) ?? []
}
export function findBlock(doc: DesignerDocument, target: string): Block | undefined {
const name = target || doc.formName
return doc.blocks.find((b) => b.name === name)
}
export function getPropText(doc: DesignerDocument, target: string, path: string): string | undefined {
const stmt = findBlock(doc, target)?.stmts.find((s) => s.kind === 'prop' && s.path === path)
return stmt?.kind === 'prop' ? stmt.value : undefined
}
export function getEventHandler(doc: DesignerDocument, target: string, event: string): string | undefined {
const stmt = findBlock(doc, target)?.stmts.find((s) => s.kind === 'event' && s.event === event)
return stmt?.kind === 'event' ? stmt.handler : undefined
}
export function getItems(doc: DesignerDocument, target: string): string[] {
const stmt = findBlock(doc, target)?.stmts.find((s) => s.kind === 'items')
return stmt?.kind === 'items' ? stmt.items : []
}
export function typeOf(doc: DesignerDocument, name: string) {
return listControls(doc).find((c) => c.name === name)?.type
}
// ---------- 修改 ----------
function clone(doc: DesignerDocument): DesignerDocument {
return structuredClone(doc)
}
/** 取得控件(或窗体)的属性块;没有就新建,位置在窗体块之前(与 VS 一样按创建顺序排列) */
function ensureBlock(doc: DesignerDocument, target: string): Block {
const existing = findBlock(doc, target)
if (existing) return existing
const block: Block = { name: target || doc.formName, stmts: [] }
const formIndex = doc.blocks.findIndex((b) => b.name === doc.formName)
doc.blocks.splice(formIndex < 0 ? doc.blocks.length : formIndex, 0, block)
return block
}
/** VS 按属性名排序输出:Controls.Add 当作 "Controls"、Items.AddRange 当作 "Items",事件排在所有属性之后 */
function sortKey(stmt: Stmt): string | undefined {
if (stmt.kind === 'prop') return stmt.path
if (stmt.kind === 'add') return 'Controls'
if (stmt.kind === 'items') return 'Items'
return undefined
}
function insertSorted(block: Block, stmt: Stmt) {
const key = sortKey(stmt)!
let index = block.stmts.findIndex((s) => s.kind === 'event' || (sortKey(s) !== undefined && sortKey(s)! > key))
if (index < 0) index = block.stmts.length
block.stmts.splice(index, 0, stmt)
}
export function setProp(doc: DesignerDocument, target: string, path: string, value: Value | null): DesignerDocument {
const next = clone(doc)
applyProp(next, target, path, value)
if (path === 'AutoSize') syncLayout(next)
return next
}
function applyProp(doc: DesignerDocument, target: string, path: string, value: Value | null) {
const block = ensureBlock(doc, target)
const index = block.stmts.findIndex((s) => s.kind === 'prop' && s.path === path)
if (value === null) {
if (index >= 0) block.stmts.splice(index, 1)
return
}
const text = renderValue(value)
if (index >= 0) {
const stmt = block.stmts[index] as Extract<Stmt, { kind: 'prop' }>
if (stmt.value !== text) block.stmts[index] = { kind: 'prop', target: stmt.target, path, value: text }
return
}
insertSorted(block, { kind: 'prop', target, path, value: text })
}
export function setItems(doc: DesignerDocument, target: string, items: string[]): DesignerDocument {
const next = clone(doc)
const block = ensureBlock(next, target)
const index = block.stmts.findIndex((s) => s.kind === 'items')
if (index >= 0) block.stmts.splice(index, 1)
if (items.length) insertSorted(block, { kind: 'items', target, items: items.map((s) => renderValue({ kind: 'string', value: s })) })
return next
}
/** 设置事件处理方法;handler 为 null 时删除这个事件绑定 */
export function setEvent(doc: DesignerDocument, target: string, event: string, delegate: string, handler: string | null): DesignerDocument {
const next = clone(doc)
const block = ensureBlock(next, target)
const index = block.stmts.findIndex((s) => s.kind === 'event' && s.event === event)
if (index >= 0) block.stmts.splice(index, 1)
if (handler) {
const stmt: Stmt = { kind: 'event', target, event, delegate, handler }
// 事件排在属性之后,按事件名排序
let at = block.stmts.findIndex((s) => s.kind === 'event' && s.event > event)
if (at < 0) at = block.stmts.length
block.stmts.splice(index >= 0 ? index : at, 0, stmt)
}
return next
}
const KEYWORDS = new Set(
'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' '),
)
/** 检查控件名是否可用;可用返回 null,否则返回原因 */
export function checkName(doc: DesignerDocument, name: string, current?: string): string | null {
if (!/^[A-Za-z_一-龥][A-Za-z0-9_一-龥]*$/.test(name)) return '名称只能由字母、数字、下划线组成,且不能以数字开头'
if (KEYWORDS.has(name)) return `“${name}”是 C# 的关键字,不能作为名称`
if (name !== current && (name === doc.formName || name === doc.className || name === 'components' || listControls(doc).some((c) => c.name === name))) {
return `已经有名为“${name}”的控件`
}
return null
}
export function uniqueName(doc: DesignerDocument, prefix: string) {
for (let n = 1; ; n++) if (checkName(doc, `${prefix}${n}`) === null) return `${prefix}${n}`
}
/** 往容器里添加控件(parent 为 '' 表示窗体);组件(如 Timer)不放进容器。返回新文档和控件名 */
export function addControl(
doc: DesignerDocument,
def: ControlDef,
parent: string,
location: { x: number; y: number },
): { doc: DesignerDocument; name: string } {
const next = clone(doc)
const name = uniqueName(next, def.namePrefix)
// 组件需要 components 容器:this.timer1 = new System.Windows.Forms.Timer(this.components);
if (def.component && !next.prologue.some((s) => s.kind === 'raw' && s.text.startsWith('this.components = new'))) {
next.prologue.unshift({ kind: 'raw', text: 'this.components = new System.ComponentModel.Container();' })
}
const create: Stmt = { kind: 'create', name, type: def.type, args: def.component ? 'this.components' : '' }
const lastCreate = next.prologue.findLastIndex((s) => s.kind === 'create')
const componentsAt = next.prologue.findIndex((s) => s.kind === 'raw' && s.text.startsWith('this.components = new'))
next.prologue.splice(lastCreate >= 0 ? lastCreate + 1 : componentsAt + 1, 0, create)
const block = ensureBlock(next, name)
const props: [string, Value][] = [...def.initial(name)]
if (!def.component) {
props.push(['Location', { kind: 'point', x: Math.round(location.x), y: Math.round(location.y) }])
props.push(['Name', { kind: 'string', value: name }])
props.push(['TabIndex', { kind: 'int', value: childrenOf(next, parent).length }])
}
for (const [path, value] of props) insertSorted(block, { kind: 'prop', target: name, path, value: renderValue(value) })
if (!def.component) insertChild(next, parent, name)
next.tail = addField(next.tail, def.type, name, next.eol)
syncLayout(next)
return { doc: next, name }
}
/** 新加入的子控件在最上层:Controls.Add 放在已有的 Controls.Add 之前 */
function insertChild(doc: DesignerDocument, parent: string, child: string) {
const block = ensureBlock(doc, parent)
const stmt: Stmt = { kind: 'add', target: parent, child }
const first = block.stmts.findIndex((s) => s.kind === 'add')
if (first >= 0) block.stmts.splice(first, 0, stmt)
else insertSorted(block, stmt)
}
/** 删除控件及其包含的全部子控件。事件处理方法留在代码里(和 VS 一样) */
export function removeControl(doc: DesignerDocument, name: string): DesignerDocument {
const next = clone(doc)
const doomed = new Set<string>()
const collect = (n: string) => {
doomed.add(n)
for (const child of childrenOf(next, n)) collect(child)
}
collect(name)
const keep = (s: Stmt) =>
!(
(s.kind === 'create' && doomed.has(s.name)) ||
(s.kind === 'add' && doomed.has(s.child)) ||
(s.kind === 'layout' && doomed.has(s.target))
)
next.prologue = next.prologue.filter(keep)
next.epilogue = next.epilogue.filter(keep)
next.blocks = next.blocks.filter((b) => !doomed.has(b.name))
for (const block of next.blocks) block.stmts = block.stmts.filter(keep)
for (const n of doomed) next.tail = removeField(next.tail, n)
syncLayout(next)
return next
}
/** 改名:更新所有引用这个控件的语句和字段声明。代码文件里的引用需要学生自己改 */
export function renameControl(doc: DesignerDocument, from: string, to: string): DesignerDocument {
const next = clone(doc)
const fix = (s: Stmt): Stmt => {
switch (s.kind) {
case 'create':
return s.name === from ? { ...s, name: to, text: undefined } : s
case 'prop':
if (s.target !== from) return s
return s.path === 'Name' ? { ...s, target: to, value: renderValue({ kind: 'string', value: to }), text: undefined } : { ...s, target: to, text: undefined }
case 'event':
case 'items':
return s.target === from ? { ...s, target: to, text: undefined } : s
case 'add':
return s.target === from || s.child === from
? { ...s, target: s.target === from ? to : s.target, child: s.child === from ? to : s.child, text: undefined }
: s
case 'layout':
return s.target === from ? { ...s, target: to, text: undefined } : s
case 'raw':
return s.text.includes(`this.${from}`) ? { kind: 'raw', text: s.text.replace(new RegExp(`\\bthis\\.${from}\\b`, 'g'), `this.${to}`) } : s
}
}
next.prologue = next.prologue.map(fix)
next.epilogue = next.epilogue.map(fix)
for (const block of next.blocks) {
if (block.name === from) block.name = to
block.stmts = block.stmts.map(fix)
}
next.tail = next.tail.replace(new RegExp(`(private [\\w.]+ )${from};`), `$1${to};`)
return next
}
/** 移动控件:可以换到另一个容器里(location 是相对新容器的坐标) */
export function moveControl(doc: DesignerDocument, name: string, parent: string, location: { x: number; y: number }): DesignerDocument {
let next = clone(doc)
const current = listControls(next).find((c) => c.name === name)?.parent
if (current !== null && current !== undefined && current !== parent) {
for (const block of next.blocks) block.stmts = block.stmts.filter((s) => !(s.kind === 'add' && s.child === name))
insertChild(next, parent, name)
syncLayout(next)
}
applyProp(next, name, 'Location', { kind: 'point', x: Math.round(location.x), y: Math.round(location.y) })
return next
}
// ---------- 布局语句 ----------
/**
* 按 VS 的规则维护 SuspendLayout / ResumeLayout / PerformLayout:
* 有子控件的容器在开头 SuspendLayout、末尾 ResumeLayout(false);
* 子控件里有 AutoSize 的,末尾再加 PerformLayout()。已有的语句不调整顺序,只增删。
*/
export function syncLayout(doc: DesignerDocument) {
const containers = ['', ...listControls(doc).filter((c) => isContainerType(c.type)).map((c) => c.name)]
for (const container of containers) {
const children = childrenOf(doc, container)
const hasChildren = children.length > 0 || container === ''
const autoChild = children.some((child) => isAutoSized(doc, child))
if (container === '') {
// 窗体:SuspendLayout / ResumeLayout 总是有;PerformLayout 取决于直接子控件
ensureLayout(doc, '', 'SuspendLayout', true)
ensureLayout(doc, '', 'ResumeLayout', true)
ensureLayout(doc, '', 'PerformLayout', autoChild)
continue
}
ensureLayout(doc, container, 'SuspendLayout', hasChildren)
ensureLayout(doc, container, 'ResumeLayout', hasChildren)
ensureLayout(doc, container, 'PerformLayout', hasChildren && autoChild)
}
}
/**
* 子控件是否自动调整大小(VS 据此决定是否输出 PerformLayout)。
* 除了 AutoSize = true,TextBox、TrackBar 的 AutoSize 默认就是 true(多行 TextBox 也算,只是不生效),
* PictureBox 的 SizeMode 也可以是 AutoSize。
*/
function isAutoSized(doc: DesignerDocument, name: string) {
const autoSize = getPropText(doc, name, 'AutoSize')
if (autoSize !== undefined) return autoSize === 'true'
switch (typeOf(doc, name)) {
case 'System.Windows.Forms.TextBox':
case 'System.Windows.Forms.TrackBar':
return true
case 'System.Windows.Forms.PictureBox':
return getPropText(doc, name, 'SizeMode')?.endsWith('.AutoSize') === true
default:
return false
}
}
function ensureLayout(doc: DesignerDocument, target: string, op: 'SuspendLayout' | 'ResumeLayout' | 'PerformLayout', wanted: boolean) {
const list = op === 'SuspendLayout' ? doc.prologue : doc.epilogue
const index = list.findIndex((s) => s.kind === 'layout' && s.target === target && s.op === op)
if (!wanted) {
if (index >= 0) list.splice(index, 1)
return
}
if (index >= 0) return
const stmt: Stmt = { kind: 'layout', target, op }
if (op === 'SuspendLayout') {
// 窗体的 SuspendLayout 在最后;容器的排在 BeginInit 和窗体的 SuspendLayout 之前
const at = target === '' ? -1 : list.findIndex((s) => s.kind === 'layout' && (s.op === 'BeginInit' || s.target === ''))
list.splice(at < 0 ? list.length : at, 0, stmt)
} else if (target === '') {
// 窗体的 ResumeLayout(false) 在 EndInit 之后,PerformLayout 紧跟其后
const resume = list.findIndex((s) => s.kind === 'layout' && s.target === '' && s.op === 'ResumeLayout')
list.splice(op === 'PerformLayout' && resume >= 0 ? resume + 1 : list.length, 0, stmt)
} else {
// 容器:ResumeLayout(false) + PerformLayout() 成对,排在 EndInit 和窗体的 ResumeLayout 之前
const resume = list.findIndex((s) => s.kind === 'layout' && s.target === target && s.op === 'ResumeLayout')
const at = op === 'PerformLayout' && resume >= 0
? resume + 1
: list.findIndex((s) => s.kind === 'layout' && (s.op === 'EndInit' || s.target === ''))
list.splice(at < 0 ? list.length : at, 0, stmt)
}
}
// ---------- 字段声明 ----------
const FIELD = /^([ \t]*)private ([\w.]+) (\w+);[ \t]*$/gm
function addField(tail: string, type: string, name: string, eol: string) {
const fields = [...tail.matchAll(FIELD)]
const last = fields.at(-1)
if (last) {
const lineEnd = tail.indexOf('\n', last.index! + last[0].length)
const at = lineEnd < 0 ? tail.length : lineEnd + 1
return `${tail.slice(0, at)}${last[1]}private ${type} ${name};${eol}${tail.slice(at)}`
}
// 还没有字段:放在 #endregion 之后
const region = /#endregion[^\n]*\n/.exec(tail)
if (!region) return tail
const at = region.index + region[0].length
return `${tail.slice(0, at)}${eol} private ${type} ${name};${eol}${tail.slice(at)}`
}
function removeField(tail: string, name: string) {
return tail.replace(new RegExp(`^[ \\t]*private [\\w.]+ ${name};[ \\t]*\\r?\\n`, 'm'), '')
}
/** 窗体或控件的显示名:窗体用类名 */
export function displayType(doc: DesignerDocument, name: string) {
if (name === '') return 'Form'
const type = typeOf(doc, name)
return type ? controlDef(type).label : ''
}
+87
View File
@@ -0,0 +1,87 @@
// 把设计器文档转换成与运行时相同格式的控件树,直接用运行时的 ControlView 渲染,
// 保证设计时看到的样子和运行时一致。
import type { ControlNode } from '@iot/shared'
import { controlDef } from './catalog'
import type { DesignerDocument } from './document'
import { childrenOf, findBlock, getItems, listControls } from './edit'
import { parseValue, type Value } from './values'
/** 窗体在预览树里的 id;控件的 id 就是控件名 */
export const FORM_ID = '__form'
/** 运行时控件树里的类型名:System.Windows.Forms.Button → Button,教材控件库保留完整类名 */
function clientType(type: string) {
return type.startsWith('System.Windows.Forms.') ? type.slice('System.Windows.Forms.'.length) : type
}
/** 把 Value 转换成运行时消息里的属性值格式(见 web/src/winforms/format.ts) */
export function runtimeValue(value: Value): unknown {
switch (value.kind) {
case 'string':
case 'char':
return value.value
case 'bool':
case 'int':
case 'float':
return value.value
case 'point':
return { x: value.x, y: value.y }
case 'size':
return { width: value.width, height: value.height }
case 'color':
return value.css
case 'font':
return { family: value.family, size: value.size, bold: value.bold, italic: value.italic, underline: value.underline, strikeout: value.strikeout }
case 'enum':
return value.member
case 'raw':
return undefined
}
}
function propsOf(doc: DesignerDocument, target: string): Record<string, unknown> {
const props: Record<string, unknown> = {}
for (const stmt of findBlock(doc, target)?.stmts ?? []) {
if (stmt.kind !== 'prop' || stmt.path.includes('.')) continue
const value = runtimeValue(parseValue(stmt.value))
if (value !== undefined) props[stmt.path] = value
}
return props
}
/** 教材控件在 Designer.cs 里不写、但运行时构造函数会设置的外观 */
const DEVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
'WinFormsDeviceControlLibrary.LED': { BackColor: '#000000', ForeColor: '#ff0000', Font: { family: '宋体', size: 70, bold: false, italic: false, underline: false, strikeout: false } },
'WinFormsDeviceControlLibrary.Lamp': { BackColor: '#ffffff' },
'WinFormsDeviceControlLibrary.Alarm': { BackColor: '#ffffff' },
}
export function previewTree(doc: DesignerDocument): ControlNode {
const types = new Map(listControls(doc).map((c) => [c.name, c.type]))
const build = (name: string): ControlNode => {
const type = types.get(name)!
const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) }
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
props.Visible = true
if (type === 'System.Windows.Forms.ComboBox') props.Items = getItems(doc, name).map((item) => {
const v = parseValue(item)
return v.kind === 'string' ? v.value : item
})
// 设计器里不需要滚动:LED 只显示黑底
if (type === 'WinFormsDeviceControlLibrary.LED') props.LedText = ''
return { id: name, type: clientType(type), name, props, children: childrenOf(doc, name).filter((c) => types.has(c)).map(build) }
}
const formProps = propsOf(doc, '')
return {
id: FORM_ID,
type: 'Form',
name: doc.formName,
props: { ClientSize: { width: 300, height: 300 }, ...formProps, Visible: true },
children: childrenOf(doc, '').filter((c) => types.has(c)).map(build),
}
}
/** 非可视组件(Timer 等),显示在窗体下方的组件栏 */
export function componentsOf(doc: DesignerDocument) {
return listControls(doc).filter((c) => c.parent === null && controlDef(c.type).component === true)
}
+174
View File
@@ -0,0 +1,174 @@
// 属性值的 C# 表达式 ⇄ 结构化值。读入时尽量识别;识别不了的保留原文(属性窗口里只读显示)。
// 写出时按 VS 设计器的格式,例如 new System.Drawing.Point(12, 12)、"文字"、System.Drawing.Color.White。
export type Value =
| { kind: 'string'; value: string }
| { kind: 'char'; value: string }
| { kind: 'bool'; value: boolean }
| { kind: 'int'; value: number }
| { kind: 'float'; value: number }
| { kind: 'point'; x: number; y: number }
| { kind: 'size'; width: number; height: number }
| { kind: 'color'; css: string; name?: string; r: number; g: number; b: number; a: number }
| { kind: 'font'; family: string; size: number; bold: boolean; italic: boolean; underline: boolean; strikeout: boolean; charset?: number }
| { kind: 'enum'; type: string; member: string }
| { kind: 'raw'; text: string }
// ---------- 读 ----------
export function parseValue(text: string): Value {
const t = text.trim()
let m: RegExpExecArray | null
if (/^"(?:[^"\\]|\\.)*"$/.test(t)) return { kind: 'string', value: unescapeString(t.slice(1, -1)) }
if (/^@"(?:[^"]|"")*"$/.test(t)) return { kind: 'string', value: t.slice(2, -1).replace(/""/g, '"') }
if (/^'(?:[^'\\]|\\.)'$/.test(t)) return { kind: 'char', value: unescapeString(t.slice(1, -1)) }
if (t === 'true' || t === 'false') return { kind: 'bool', value: t === 'true' }
if (/^-?\d+$/.test(t)) return { kind: 'int', value: Number(t) }
if ((m = /^(-?\d+(?:\.\d+)?)[Ff]$/.exec(t))) return { kind: 'float', value: Number(m[1]) }
if ((m = /^new System\.Drawing\.Point\((-?\d+), (-?\d+)\)$/.exec(t))) return { kind: 'point', x: +m[1], y: +m[2] }
if ((m = /^new System\.Drawing\.Size\((-?\d+), (-?\d+)\)$/.exec(t))) return { kind: 'size', width: +m[1], height: +m[2] }
if ((m = /^System\.Drawing\.(?:Color|SystemColors)\.(\w+)$/.exec(t))) return namedColor(m[1])
if ((m = /^System\.Drawing\.Color\.FromArgb\(([\s\S]+)\)$/.exec(t))) {
// VS 的写法是 ((int)(((byte)(53)))),手写的可能是 53
const parts = m[1].includes('(byte)') ? byteParts(m[1]) : m[1].split(',').map((n) => Number(n.trim()))
if (parts.length === 3) return rgb(parts[0], parts[1], parts[2])
if (parts.length === 4) return rgb(parts[1], parts[2], parts[3], parts[0])
}
if ((m = /^new System\.Drawing\.Font\("((?:[^"\\]|\\.)*)", (\d+(?:\.\d+)?)F(.*)\)$/.exec(t))) {
const rest = m[3]
const charset = /\(\(byte\)\((\d+)\)\)/.exec(rest)
return {
kind: 'font',
family: unescapeString(m[1]),
size: Number(m[2]),
bold: rest.includes('FontStyle.Bold'),
italic: rest.includes('FontStyle.Italic'),
underline: rest.includes('FontStyle.Underline'),
strikeout: rest.includes('FontStyle.Strikeout'),
charset: charset ? Number(charset[1]) : undefined,
}
}
if ((m = /^((?:System|WinFormsDeviceControlLibrary)(?:\.\w+)+)\.(\w+)$/.exec(t))) return { kind: 'enum', type: m[1], member: m[2] }
return { kind: 'raw', text: t }
}
/** 从 ((int)(((byte)(53)))) 这样的写法中逐个分量取出数值 */
function byteParts(args: string) {
return [...args.matchAll(/\(byte\)\((\d+)\)/g)].map((x) => Number(x[1]))
}
function unescapeString(s: string) {
return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, c: string) => {
if (c.length === 5) return String.fromCharCode(parseInt(c.slice(1), 16))
return ({ n: '\n', r: '\r', t: '\t', '0': '\0', '\\': '\\', '"': '"', "'": "'" } as Record<string, string>)[c] ?? c
})
}
// ---------- 写 ----------
export function renderValue(value: Value): string {
switch (value.kind) {
case 'string':
return `"${escapeString(value.value)}"`
case 'char':
return `'${value.value === "'" ? "\\'" : escapeString(value.value)}'`
case 'bool':
return String(value.value)
case 'int':
return String(Math.round(value.value))
case 'float':
return `${value.value}F`
case 'point':
return `new System.Drawing.Point(${value.x}, ${value.y})`
case 'size':
return `new System.Drawing.Size(${value.width}, ${value.height})`
case 'color':
if (value.name) return `System.Drawing.${SYSTEM_COLORS[value.name] ? 'SystemColors' : 'Color'}.${value.name}`
return value.a === 255
? `System.Drawing.Color.FromArgb(${[value.r, value.g, value.b].map(byteArg).join(', ')})`
: `System.Drawing.Color.FromArgb(${[value.a, value.r, value.g, value.b].map(byteArg).join(', ')})`
case 'font': {
const styles = [
value.bold && 'Bold',
value.italic && 'Italic',
value.underline && 'Underline',
value.strikeout && 'Strikeout',
].filter(Boolean) as string[]
const style = styles.length ? styles.map((s) => `System.Drawing.FontStyle.${s}`).join(' | ') : 'System.Drawing.FontStyle.Regular'
// 与中文 Windows 上的 VS 一致:带 GB2312 字符集 134
return `new System.Drawing.Font("${escapeString(value.family)}", ${value.size}F, ${style}, System.Drawing.GraphicsUnit.Point, ((byte)(${value.charset ?? 134})))`
}
case 'enum':
return `${value.type}.${value.member}`
case 'raw':
return value.text
}
}
const byteArg = (n: number) => `((int)(((byte)(${n}))))`
function escapeString(s: string) {
return s.replace(/[\\"\r\n\t\0]/g, (c) => ({ '\\': '\\\\', '"': '\\"', '\r': '\\r', '\n': '\\n', '\t': '\\t', '\0': '\\0' })[c]!)
}
// ---------- 颜色 ----------
/** 常用的命名颜色(System.Drawing.Color),属性窗口里提供这些选项 */
export const NAMED_COLORS: Record<string, string> = {
Transparent: 'transparent',
Black: '#000000',
White: '#ffffff',
Red: '#ff0000',
Green: '#008000',
Blue: '#0000ff',
Yellow: '#ffff00',
Orange: '#ffa500',
Gray: '#808080',
DimGray: '#696969',
Silver: '#c0c0c0',
LightGray: '#d3d3d3',
DarkBlue: '#00008b',
SkyBlue: '#87ceeb',
LightGreen: '#90ee90',
Lime: '#00ff00',
Purple: '#800080',
Brown: '#a52a2a',
Pink: '#ffc0cb',
Cyan: '#00ffff',
}
/** 系统颜色(System.Drawing.SystemColors),按 Windows 10 默认主题取值 */
const SYSTEM_COLORS: Record<string, string> = {
Control: '#f0f0f0',
ControlText: '#000000',
Window: '#ffffff',
WindowText: '#000000',
ControlDark: '#a0a0a0',
ControlLight: '#e3e3e3',
Highlight: '#0078d7',
HighlightText: '#ffffff',
GrayText: '#6d6d6d',
InactiveCaption: '#bfcddb',
}
function namedColor(name: string): Value {
const css = NAMED_COLORS[name] ?? SYSTEM_COLORS[name] ?? cssNamed(name)
if (css === 'transparent') return { kind: 'color', css, name, r: 255, g: 255, b: 255, a: 0 }
const [r, g, b] = hexToRgb(css)
return { kind: 'color', css, name, r, g, b, a: 255 }
}
/** 不在列表里的颜色名(如 Color.Gold)交给浏览器:CSS 颜色名和 .NET 的颜色名基本一致 */
function cssNamed(name: string) {
return name.toLowerCase()
}
export function rgb(r: number, g: number, b: number, a = 255): Value {
const css = a === 255 ? `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}` : `rgba(${r},${g},${b},${(a / 255).toFixed(3)})`
return { kind: 'color', css, r, g, b, a }
}
export function hexToRgb(hex: string): [number, number, number] {
const n = Number.parseInt(hex.replace('#', ''), 16)
return Number.isNaN(n) ? [0, 0, 0] : [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
+18 -4
View File
@@ -1,15 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": [
"ES2023",
"DOM",
"DOM.Iterable"
],
"jsx": "preserve",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"types": ["vite/client"]
"types": [
"vite/client"
]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../shared"]
"include": [
"src/**/*.ts",
"src/**/*.vue",
"vite.config.ts",
"../shared"
],
"exclude": [
"src/**/*.test.ts"
]
}