停靠布局、背景图片、无边框窗体;运行区按比例缩小、可放大

- Dock:和 WinForms 一样按 Controls 倒序依次停靠,扣掉 Panel 边框、GroupBox 标题占的地方;
  改大小、显示隐藏、加入移出控件时重新布局;SuspendLayout / ResumeLayout / PerformLayout
- 设计器里每次修改后按同样的规则排好停靠的控件(designer/layout.ts);属性窗口加"停靠"
- BackgroundImage + BackgroundImageLayout(平铺、居中、拉伸、缩放):窗体、面板、图片框,
  设计器里从项目资源选;无边框窗体没有标题栏,大小不含边框
- 背景色沿用父控件:没设背景色的标签、面板、分组框等画父控件的背景色(不是背景图片),
  和 WinForms 一样;分组框标题处边框断开、透出背景;文本框的 BackColor 生效
- 图片框可以放子控件,把鼠标按下、松开发给程序
- 运行区:窗体比运行区大时整体按比例缩小并显示比例;"放大"占满整个网页(窗体在左、实训台在右),Esc 还原
- Paint 事件:VS 双击面板生成的空 panel1_Paint 能编译(平台由浏览器绘制,不触发)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-23 15:14:34 +08:00
1 parent 42e258ddcd
commit d60868d912
17 files changed
+600 -49

No files matched your search

+3 -1
View File
@@ -22,6 +22,7 @@ import {
setProp,
typeOf,
} from './edit'
import { dockLayout } from './layout'
import PropertyGrid from './PropertyGrid.vue'
import { componentsOf, previewTree } from './preview'
import { parseValue, type Value } from './values'
@@ -84,9 +85,10 @@ watch(parsed, () => {
if (selected.value && doc.value && !listControls(doc.value).some((c) => c.name === selected.value)) selected.value = ''
})
/** 每次修改后像 VS 一样把停靠的控件排好(没有停靠控件时文档不变) */
function commit(next: DesignerDocument, codeText?: string) {
dragDoc.value = undefined
emit('update', generateDesigner(next), codeText)
emit('update', generateDesigner(dockLayout(next)), codeText)
}
// ---------- 几何 ----------
+20 -2
View File
@@ -59,6 +59,8 @@ 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 FORMS = 'System.Windows.Forms'
const EVENT = { delegate: 'System.EventHandler', args: 'EventArgs' }
const MOUSE = { delegate: 'System.Windows.Forms.MouseEventHandler', args: 'MouseEventArgs' }
@@ -72,6 +74,21 @@ const COMMON_PROPS: PropDef[] = [
{ name: 'Enabled', label: '可用', description: '是否可用', type: 'bool', default: bool(true), category: '行为' },
{ name: 'Visible', label: '可见', description: '是否可见', type: 'bool', default: bool(true), category: '行为' },
{ name: 'TabIndex', label: 'Tab 顺序', description: 'Tab 键顺序', type: 'int', category: '行为' },
{
name: 'Dock', label: '停靠', description: '贴在所在容器的哪一边(并随容器拉伸);填充是占满剩下的地方', type: 'enum', enumType: `${FORMS}.DockStyle`,
options: ['None', 'Top', 'Bottom', 'Left', 'Right', 'Fill'], optionLabels: { None: '不停靠', Top: '顶部', Bottom: '底部', Left: '左边', Right: '右边', Fill: '填充' },
default: enumValue(`${FORMS}.DockStyle`, 'None'), category: '布局',
},
]
/** 背景图片(面板、图片框、窗体):从项目资源里选,排列方式默认平铺 */
const BACKGROUND_PROPS: PropDef[] = [
{ name: 'BackgroundImage', label: '背景图片', description: '背景图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
{
name: 'BackgroundImageLayout', label: '背景排列', description: '背景图片怎样摆放', type: 'enum', enumType: `${FORMS}.ImageLayout`,
options: ['None', 'Tile', 'Center', 'Stretch', 'Zoom'], optionLabels: { None: '左上角', Tile: '平铺', Center: '居中', Stretch: '拉伸填满', Zoom: '按比例缩放' },
default: enumValue(`${FORMS}.ImageLayout`, 'Tile'), category: '外观',
},
]
const COMMON_EVENTS: EventDef[] = [
@@ -95,7 +112,6 @@ export function textWidth(text: string) {
return width
}
const FORMS = 'System.Windows.Forms'
export const CONTROLS: ControlDef[] = [
{
@@ -188,6 +204,7 @@ export const CONTROLS: ControlDef[] = [
props: [
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor' && p.name !== 'Font'),
{ name: 'Image', label: '图片', description: '显示的图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
...BACKGROUND_PROPS,
{
name: 'SizeMode', label: '显示方式', description: '图片在图片框里怎样显示', type: 'enum', enumType: `${FORMS}.PictureBoxSizeMode`,
options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'],
@@ -327,7 +344,7 @@ export const CONTROLS: ControlDef[] = [
namePrefix: 'panel',
container: true,
defaultEvent: 'Click',
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
props: [...COMMON_PROPS.filter((p) => p.name !== 'Text'), ...BACKGROUND_PROPS],
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
initial: () => [['Size', size(200, 100)]],
},
@@ -413,6 +430,7 @@ export const FORM_DEF: ControlDef = {
optionLabels: { Manual: '手动指定', CenterScreen: '屏幕中央', WindowsDefaultLocation: '系统默认位置', WindowsDefaultBounds: '系统默认位置和大小', CenterParent: '父窗体中央' },
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'), category: '布局',
},
...BACKGROUND_PROPS,
{ name: 'MaximizeBox', label: '最大化按钮', description: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
{ name: 'MinimizeBox', label: '最小化按钮', description: '显示最小化按钮', type: 'bool', default: bool(true), category: '外观' },
],
+70
View File
@@ -0,0 +1,70 @@
import { expect, test } from 'bun:test'
import { parseDesigner } from './document'
import { getPropText } from './edit'
import { dockLayout } from './layout'
/** 一个窗体:panel1(Top)、panel2(Fill,里面有 panel3 Right);后加入 Controls 的先停靠 */
const DESIGNER = `namespace 智慧校园
{
partial class Main
{
private void InitializeComponent()
{
this.panel1 = new System.Windows.Forms.Panel();
this.panel2 = new System.Windows.Forms.Panel();
this.panel3 = new System.Windows.Forms.Panel();
this.SuspendLayout();
//
// panel1
//
this.panel1.Dock = System.Windows.Forms.DockStyle.Top;
this.panel1.Location = new System.Drawing.Point(5, 5);
this.panel1.Name = "panel1";
this.panel1.Size = new System.Drawing.Size(100, 54);
//
// panel2
//
this.panel2.Controls.Add(this.panel3);
this.panel2.Dock = System.Windows.Forms.DockStyle.Fill;
this.panel2.Location = new System.Drawing.Point(0, 0);
this.panel2.Name = "panel2";
this.panel2.Size = new System.Drawing.Size(10, 10);
//
// panel3
//
this.panel3.Dock = System.Windows.Forms.DockStyle.Right;
this.panel3.Location = new System.Drawing.Point(0, 0);
this.panel3.Name = "panel3";
this.panel3.Size = new System.Drawing.Size(120, 30);
//
// Main
//
this.ClientSize = new System.Drawing.Size(800, 600);
this.Controls.Add(this.panel2);
this.Controls.Add(this.panel1);
this.Name = "Main";
this.ResumeLayout(false);
}
private System.Windows.Forms.Panel panel1;
private System.Windows.Forms.Panel panel2;
private System.Windows.Forms.Panel panel3;
}
}
`
test('停靠布局:顶部贴边拉满宽度,填充占剩下的,嵌套的右停靠贴着父面板右边', () => {
const doc = dockLayout(parseDesigner(DESIGNER))
expect(getPropText(doc, 'panel1', 'Location')).toBe('new System.Drawing.Point(0, 0)')
expect(getPropText(doc, 'panel1', 'Size')).toBe('new System.Drawing.Size(800, 54)')
expect(getPropText(doc, 'panel2', 'Location')).toBe('new System.Drawing.Point(0, 54)')
expect(getPropText(doc, 'panel2', 'Size')).toBe('new System.Drawing.Size(800, 546)')
expect(getPropText(doc, 'panel3', 'Location')).toBe('new System.Drawing.Point(680, 0)')
expect(getPropText(doc, 'panel3', 'Size')).toBe('new System.Drawing.Size(120, 546)')
})
test('停靠布局:已经排好的窗体不变', () => {
const doc = dockLayout(parseDesigner(DESIGNER))
expect(dockLayout(doc)).toEqual(doc)
})
+105
View File
@@ -0,0 +1,105 @@
// 设计器里的停靠布局:和 VS 一样,改了停靠、大小等之后立即把停靠的控件排好,位置大小写进 Designer.cs。
// 规则和运行时(runtime/Sim.WinForms/Forms/Control.cs 的 PerformLayout)相同:按 Controls 集合从后往前,
// 上 / 下 / 左 / 右依次贴边并占掉那一条,填充占剩下的全部。设计时不可见的控件也照样占位(VS 设计器里看得见它们)。
import type { DesignerDocument } from './document'
import { childrenOf, getPropText, setProp, typeOf } from './edit'
import { parseValue } from './values'
interface Rect {
x: number
y: number
width: number
height: number
}
function sizeOf(doc: DesignerDocument, name: string, path = 'Size') {
const text = getPropText(doc, name, path)
const value = text === undefined ? undefined : parseValue(text)
return value?.kind === 'size' ? { width: value.width, height: value.height } : undefined
}
function pointOf(doc: DesignerDocument, name: string) {
const text = getPropText(doc, name, 'Location')
const value = text === undefined ? undefined : parseValue(text)
return value?.kind === 'point' ? { x: value.x, y: value.y } : { x: 0, y: 0 }
}
function dockOf(doc: DesignerDocument, name: string) {
const text = getPropText(doc, name, 'Dock')
const value = text === undefined ? undefined : parseValue(text)
return value?.kind === 'enum' ? value.member : 'None'
}
/** new System.Windows.Forms.Padding(2) 或 (1, 2, 3, 4) */
function paddingOf(doc: DesignerDocument, name: string) {
const m = /Padding\((\d+)(?:,\s*(\d+),\s*(\d+),\s*(\d+))?\)/.exec(getPropText(doc, name, 'Padding') ?? '')
if (!m) return { left: 0, top: 0, right: 0, bottom: 0 }
return m[2] === undefined
? { left: +m[1], top: +m[1], right: +m[1], bottom: +m[1] }
: { left: +m[1], top: +m[2], right: +m[3], bottom: +m[4] }
}
/** 容器里用来停靠的区域:客户区去掉内边距;带边框的面板去掉边框,分组框去掉边框和标题 */
function displayRect(doc: DesignerDocument, container: string): Rect | undefined {
const size = container === '' ? sizeOf(doc, '', 'ClientSize') : sizeOf(doc, container)
if (!size) return undefined
const pad = paddingOf(doc, container)
let rect = { x: pad.left, y: pad.top, width: size.width - pad.left - pad.right, height: size.height - pad.top - pad.bottom }
const type = container === '' ? '' : typeOf(doc, container)
if (type === 'System.Windows.Forms.GroupBox') rect = { x: rect.x + 3, y: rect.y + 17, width: rect.width - 6, height: rect.height - 20 }
if (type === 'System.Windows.Forms.Panel') {
const border = (getPropText(doc, container, 'BorderStyle') ?? '').split('.').at(-1)
const inset = border === 'FixedSingle' ? 1 : border === 'Fixed3D' ? 2 : 0
rect = { ...rect, width: rect.width - 2 * inset, height: rect.height - 2 * inset }
}
return { ...rect, width: Math.max(0, rect.width), height: Math.max(0, rect.height) }
}
/** 把整个窗体的停靠控件排好;没有停靠控件或位置已经对时原样返回 */
export function dockLayout(doc: DesignerDocument): DesignerDocument {
let next = doc
const place = (name: string, bounds: Rect) => {
const point = pointOf(next, name)
if (point.x !== bounds.x || point.y !== bounds.y) next = setProp(next, name, 'Location', { kind: 'point', x: bounds.x, y: bounds.y })
const size = sizeOf(next, name)
if (size?.width !== bounds.width || size?.height !== bounds.height) next = setProp(next, name, 'Size', { kind: 'size', width: bounds.width, height: bounds.height })
}
const layout = (container: string) => {
const children = childrenOf(next, container)
let rect = displayRect(next, container)
if (rect) {
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i]
const dock = dockOf(next, child)
if (dock === 'None') continue
const size = sizeOf(next, child) ?? { width: 0, height: 0 }
switch (dock) {
case 'Top':
place(child, { x: rect.x, y: rect.y, width: rect.width, height: size.height })
rect = { ...rect, y: rect.y + size.height, height: Math.max(0, rect.height - size.height) }
break
case 'Bottom':
place(child, { x: rect.x, y: rect.y + rect.height - size.height, width: rect.width, height: size.height })
rect = { ...rect, height: Math.max(0, rect.height - size.height) }
break
case 'Left':
place(child, { x: rect.x, y: rect.y, width: size.width, height: rect.height })
rect = { ...rect, x: rect.x + size.width, width: Math.max(0, rect.width - size.width) }
break
case 'Right':
place(child, { x: rect.x + rect.width - size.width, y: rect.y, width: size.width, height: rect.height })
rect = { ...rect, width: Math.max(0, rect.width - size.width) }
break
default:
place(child, rect)
}
}
}
for (const child of children) layout(child)
}
layout('')
return next
}