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

- 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

+14
View File
@@ -0,0 +1,14 @@
namespace System.Drawing;
/// <summary>
/// 绘图面。平台的控件由浏览器绘制,学生程序不能自己画(没有 DrawLine 等方法);
/// 只为让 VS 生成的空 Paint 事件处理程序(panel1_Paint)能编译。
/// </summary>
public sealed class Graphics : IDisposable
{
private Graphics()
{
}
public void Dispose() => GC.SuppressFinalize(this);
}
+25 -1
View File
@@ -43,7 +43,21 @@ public class Panel : ScrollableControl
public BorderStyle BorderStyle
{
get => _borderStyle;
set => SetProp(ref _borderStyle, value, nameof(BorderStyle));
set
{
if (SetProp(ref _borderStyle, value, nameof(BorderStyle))) PerformLayout();
}
}
/// <summary>边框占掉的宽度:单线 1 像素,立体 2 像素</summary>
public override Rectangle DisplayRectangle
{
get
{
var inset = _borderStyle switch { BorderStyle.FixedSingle => 1, BorderStyle.Fixed3D => 2, _ => 0 };
var rect = base.DisplayRectangle;
return new Rectangle(rect.X, rect.Y, Math.Max(0, rect.Width - 2 * inset), Math.Max(0, rect.Height - 2 * inset));
}
}
protected override void WriteProps(JsonObject props)
@@ -58,4 +72,14 @@ public class GroupBox : Control
protected override Size DefaultSize => new(200, 100);
public FlatStyle FlatStyle { get; set; } = FlatStyle.Standard;
/// <summary>分组框的边框和标题占掉的地方(宋体 9pt 时和 WinForms 一致)</summary>
public override Rectangle DisplayRectangle
{
get
{
var rect = base.DisplayRectangle;
return new Rectangle(rect.X + 3, rect.Y + 17, Math.Max(0, rect.Width - 6), Math.Max(0, rect.Height - 20));
}
}
}
+114 -8
View File
@@ -28,6 +28,10 @@ public class Control : Component, IWin32Window
private Font? _font;
private bool _autoSize;
private DockStyle _dock;
private Padding _padding;
private int _layoutSuspendCount;
private Image? _backgroundImage;
private ImageLayout _backgroundImageLayout = ImageLayout.Tile;
private AnchorStyles _anchor = AnchorStyles.Top | AnchorStyles.Left;
private Control? _parent;
@@ -131,7 +135,13 @@ public class Control : Component, IWin32Window
public Size Size
{
get => _size;
set => SetProp(ref _size, value, nameof(Size));
set
{
if (!SetProp(ref _size, value, nameof(Size))) return;
// 大小变了:自己的停靠子控件重新排;自己是停靠的,父容器也要重新排
PerformLayout();
if (_dock != DockStyle.None) _parent?.PerformLayout();
}
}
public int Left { get => _location.X; set => Location = new Point(value, _location.Y); }
@@ -162,7 +172,9 @@ public class Control : Component, IWin32Window
get => _visible;
set
{
if (SetProp(ref _visible, value, nameof(Visible))) OnVisibleChanged(EventArgs.Empty);
if (!SetProp(ref _visible, value, nameof(Visible))) return;
if (_dock != DockStyle.None) _parent?.PerformLayout();
OnVisibleChanged(EventArgs.Empty);
}
}
@@ -202,7 +214,40 @@ public class Control : Component, IWin32Window
public DockStyle Dock
{
get => _dock;
set => SetProp(ref _dock, value, nameof(Dock));
set
{
if (SetProp(ref _dock, value, nameof(Dock))) _parent?.PerformLayout();
}
}
public Padding Padding
{
get => _padding;
set
{
if (_padding.Equals(value)) return;
_padding = value;
PerformLayout();
}
}
/// <summary>背景图片;BackgroundImageLayout 决定平铺(默认)、居中、拉伸或按比例缩放</summary>
public virtual Image? BackgroundImage
{
get => _backgroundImage;
set
{
LegacyThreads.CheckPoint();
if (ReferenceEquals(_backgroundImage, value)) return;
_backgroundImage = value;
NotifyChanged(nameof(BackgroundImage), value);
}
}
public virtual ImageLayout BackgroundImageLayout
{
get => _backgroundImageLayout;
set => SetProp(ref _backgroundImageLayout, value, nameof(BackgroundImageLayout));
}
public AnchorStyles Anchor
@@ -216,7 +261,6 @@ public class Control : Component, IWin32Window
public bool TabStop { get; set; } = true;
public object? Tag { get; set; }
public Padding Margin { get; set; } = new(3);
public Padding Padding { get; set; }
public ImeMode ImeMode { get; set; } = ImeMode.NoControl;
public virtual RightToLeft RightToLeft { get; set; } = RightToLeft.No;
public virtual bool AllowDrop { get; set; }
@@ -246,6 +290,8 @@ public class Control : Component, IWin32Window
public event EventHandler? LostFocus;
public event EventHandler? Enter;
public event EventHandler? Leave;
/// <summary>重绘。平台由浏览器绘制控件,不会触发这个事件(VS 双击 Panel 生成的空处理程序照常能编译)</summary>
public event PaintEventHandler? Paint;
protected virtual void OnClick(EventArgs e) => Click?.Invoke(this, e);
protected virtual void OnDoubleClick(EventArgs e) => DoubleClick?.Invoke(this, e);
@@ -259,6 +305,7 @@ public class Control : Component, IWin32Window
protected virtual void OnEnabledChanged(EventArgs e) => EnabledChanged?.Invoke(this, e);
protected virtual void OnGotFocus(EventArgs e) => GotFocus?.Invoke(this, e);
protected virtual void OnLostFocus(EventArgs e) => LostFocus?.Invoke(this, e);
protected virtual void OnPaint(PaintEventArgs e) => Paint?.Invoke(this, e);
protected virtual void OnEnter(EventArgs e) => Enter?.Invoke(this, e);
protected virtual void OnLeave(EventArgs e) => Leave?.Invoke(this, e);
@@ -305,10 +352,65 @@ public class Control : Component, IWin32Window
public void Refresh() { }
public void Invalidate() { }
public void Update() { }
public void SuspendLayout() { }
public void ResumeLayout() { }
public void ResumeLayout(bool performLayout) { }
public void PerformLayout() { }
public void SuspendLayout() => _layoutSuspendCount++;
public void ResumeLayout() => ResumeLayout(true);
public void ResumeLayout(bool performLayout)
{
if (_layoutSuspendCount > 0) _layoutSuspendCount--;
if (performLayout) PerformLayout();
}
/// <summary>
/// 停靠布局(和 WinForms 一样):按 Controls 集合从后往前,Top / Bottom / Left / Right 依次贴边并占掉那一条,
/// Fill 占剩下的全部。VS 在 Designer.cs 里写的位置大小就是这样排出来的;代码里改停靠、大小、可见时重新排。
/// </summary>
public void PerformLayout()
{
if (_layoutSuspendCount > 0) return;
var rect = DisplayRectangle;
for (var i = Controls.Count - 1; i >= 0; i--)
{
var child = Controls[i];
if (!child._visible || child._dock == DockStyle.None) continue;
var size = child._size;
Rectangle bounds;
switch (child._dock)
{
case DockStyle.Top:
bounds = new Rectangle(rect.X, rect.Y, rect.Width, size.Height);
rect = new Rectangle(rect.X, rect.Y + size.Height, rect.Width, Math.Max(0, rect.Height - size.Height));
break;
case DockStyle.Bottom:
bounds = new Rectangle(rect.X, rect.Bottom - size.Height, rect.Width, size.Height);
rect = new Rectangle(rect.X, rect.Y, rect.Width, Math.Max(0, rect.Height - size.Height));
break;
case DockStyle.Left:
bounds = new Rectangle(rect.X, rect.Y, size.Width, rect.Height);
rect = new Rectangle(rect.X + size.Width, rect.Y, Math.Max(0, rect.Width - size.Width), rect.Height);
break;
case DockStyle.Right:
bounds = new Rectangle(rect.Right - size.Width, rect.Y, size.Width, rect.Height);
rect = new Rectangle(rect.X, rect.Y, Math.Max(0, rect.Width - size.Width), rect.Height);
break;
default:
bounds = rect;
break;
}
child.SetBoundsFromLayout(bounds);
}
}
/// <summary>停靠布局给出的位置大小:不再反过来触发父容器的布局</summary>
private void SetBoundsFromLayout(Rectangle bounds)
{
SetProp(ref _location, bounds.Location, nameof(Location));
if (SetProp(ref _size, bounds.Size, nameof(Size))) PerformLayout();
}
/// <summary>容器里用来停靠子控件的区域:客户区去掉内边距(分组框、带边框的面板再去掉边框)</summary>
public virtual Rectangle DisplayRectangle =>
new(_padding.Left, _padding.Top, Math.Max(0, ClientSize.Width - _padding.Horizontal), Math.Max(0, ClientSize.Height - _padding.Vertical));
public bool InvokeRequired => !SimHost.Ui.IsUiThread;
@@ -409,6 +511,8 @@ public class Control : Component, IWin32Window
props["Font"] = PropValue.From(_font);
props["AutoSize"] = _autoSize;
props["Dock"] = _dock.ToString();
props["BackgroundImage"] = PropValue.From(_backgroundImage);
props["BackgroundImageLayout"] = _backgroundImageLayout.ToString();
}
/// <summary>浏览器按这个类型名选择渲染方式。学生自己继承的控件按最近的仿真库基类渲染。</summary>
@@ -459,6 +563,7 @@ public class Control : Component, IWin32Window
value.Parent?.Controls.Remove(value);
_items.Add(value);
value.SetParentInternal(Owner);
if (value.Dock != DockStyle.None) Owner.PerformLayout();
if (Owner.IsAttached)
{
SimHost.Send(new JsonObject
@@ -481,6 +586,7 @@ public class Control : Component, IWin32Window
if (value == null || !_items.Remove(value)) return;
var wasAttached = Owner.IsAttached;
value.SetParentInternal(null);
if (value.Dock != DockStyle.None) Owner.PerformLayout();
if (wasAttached) SimHost.Send(new JsonObject { ["type"] = "controlRemoved", ["id"] = value.SimId });
}
+2
View File
@@ -40,6 +40,8 @@ public enum ComboBoxStyle { Simple, DropDown, DropDownList }
public enum DockStyle { None, Top, Bottom, Left, Right, Fill }
public enum ImageLayout { None, Tile, Center, Stretch, Zoom }
[Flags]
public enum AnchorStyles { None = 0, Top = 1, Bottom = 2, Left = 4, Right = 8 }
+17
View File
@@ -41,3 +41,20 @@ public class FormClosedEventArgs : EventArgs
}
public delegate void FormClosedEventHandler(object? sender, FormClosedEventArgs e);
/// <summary>重绘事件的参数。平台由浏览器绘制控件,不支持在 Graphics 上自己画</summary>
public class PaintEventArgs : EventArgs, IDisposable
{
public PaintEventArgs(System.Drawing.Graphics graphics, System.Drawing.Rectangle clipRect)
{
Graphics = graphics;
ClipRectangle = clipRect;
}
public System.Drawing.Graphics Graphics { get; }
public System.Drawing.Rectangle ClipRectangle { get; }
public void Dispose() => GC.SuppressFinalize(this);
}
public delegate void PaintEventHandler(object? sender, PaintEventArgs e);
+6 -3
View File
@@ -33,13 +33,16 @@ public class Form : ContainerControl
public override Size ClientSize
{
get => _clientSize;
set => SetProp(ref _clientSize, value, nameof(ClientSize));
set
{
if (SetProp(ref _clientSize, value, nameof(ClientSize))) PerformLayout();
}
}
public new Size Size
{
get => _clientSize + NonClientSize;
set => ClientSize = value - NonClientSize;
get => _formBorderStyle == FormBorderStyle.None ? _clientSize : _clientSize + NonClientSize;
set => ClientSize = _formBorderStyle == FormBorderStyle.None ? value : value - NonClientSize;
}
public FormBorderStyle FormBorderStyle
+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
}
+1 -1
View File
@@ -29,7 +29,7 @@ export async function captureForms(files: SourceFile[]): Promise<Screenshot[]> {
}
const holder = document.createElement('div')
host.append(holder)
const app = createApp({ render: () => h(FormWindow, { form, modal: false, offset: 0, act: () => {} }) })
const app = createApp({ render: () => h(FormWindow, { form, modal: false, offset: 0, scale: 1, act: () => {} }) })
app.mount(holder)
await nextTick()
const frame = holder.querySelector<HTMLElement>('.wf-window')
+124 -13
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 运行区:学生程序的窗体(在一块"桌面"上)、提示框、虚拟实训台,以及编译错误和控制台输出。
// 实验页和老师的评阅页共用。
import { provide, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue'
import TrainingBoard from '../scene/TrainingBoard.vue'
import FormWindow from '../winforms/FormWindow.vue'
import type { SizeValue } from '../winforms/format'
import FormWindow, { WINDOW_MARGIN, WINDOW_STEP } from '../winforms/FormWindow.vue'
import { RUN_IMAGES } from '../winforms/images'
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
import type { RunSession } from './session'
@@ -16,19 +17,59 @@ provide(RUN_IMAGES, props.session.images)
/** 照片列表里点开看大图 */
const viewing = ref<{ path: string; url: string } | null>(null)
// ---------- 窗体比桌面大时整体缩小(项目 8 的窗体有 1000 多像素宽),"放大"后运行区占满整个网页 ----------
const maximized = ref(false)
const desktop = ref<HTMLElement>()
const desktopSize = ref({ width: 0, height: 0 })
const observer = new ResizeObserver(([entry]) => {
desktopSize.value = { width: entry.contentRect.width, height: entry.contentRect.height }
})
onMounted(() => observer.observe(desktop.value!))
onBeforeUnmount(() => observer.disconnect())
const TITLEBAR = 30
/** 按窗体最初的层叠位置算出放下所有窗体需要多大,桌面不够时按比例缩小(不放大);拖动窗体不影响缩放 */
const scale = computed(() => {
let width = 0
let height = 0
state.forms.forEach((form, index) => {
const p = form.root.props
if (p.Visible === false) return
const client = (p.ClientSize as SizeValue | undefined) ?? { width: 300, height: 300 }
const start = WINDOW_MARGIN + index * WINDOW_STEP
width = Math.max(width, start + client.width + 2 + WINDOW_MARGIN)
height = Math.max(height, start + client.height + 2 + (p.FormBorderStyle === 'None' ? 0 : TITLEBAR) + WINDOW_MARGIN)
})
const { width: w, height: h } = desktopSize.value
if (!width || !w || !h) return 1
return Math.min(1, w / width, h / height)
})
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && maximized.value) maximized.value = false
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<section class="run-area">
<div class="desktop">
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
:form="form.root"
:modal="form.modal"
:offset="index"
:act="session.act"
/>
<section class="run-area" :class="{ maximized }">
<div ref="desktop" class="desktop">
<div
class="desktop-canvas"
:style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined"
>
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
:form="form.root"
:modal="form.modal"
:offset="index"
:scale="scale"
:act="session.act"
/>
</div>
<MessageBoxDialog
v-for="box in state.messageBoxes"
:key="box.requestId"
@@ -36,6 +77,14 @@ const viewing = ref<{ path: string; url: string } | null>(null)
@reply="session.replyMessageBox(box.requestId, $event)"
/>
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
<div class="desktop-tools">
<span v-if="scale < 1" class="desktop-scale" title="窗体比运行区大,按比例缩小显示;点&quot;放大&quot;可以看得更清楚">
缩放 {{ Math.round(scale * 100) }}%
</span>
<button type="button" :title="maximized ? '还原(Esc)' : '运行区占满整个网页'" @click="maximized = !maximized">
{{ maximized ? '还原' : '放大' }}
</button>
</div>
</div>
<TrainingBoard :board="state.board" :playing="state.playing" @set="session.setBoard" @reset="session.resetBoard" />
<div v-if="state.savedImages.length" class="photos">
@@ -91,10 +140,72 @@ const viewing = ref<{ path: string; url: string } | null>(null)
min-height: 320px;
background: #2d6aa3;
border-radius: 4px;
/* 窗体比桌面区域大时可以滚动查看 */
/* 窗体被拖到桌面外面时可以滚动查看 */
overflow: auto;
}
.desktop-canvas {
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 100%;
transform-origin: 0 0;
}
.desktop-tools {
position: absolute;
top: 6px;
right: 6px;
z-index: 100001;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.desktop-scale {
padding: 2px 6px;
border-radius: 3px;
background: rgb(0 0 0 / 0.35);
color: #fff;
}
.desktop-tools button {
padding: 2px 10px;
border: 1px solid rgb(255 255 255 / 0.6);
border-radius: 3px;
background: rgb(0 0 0 / 0.35);
color: #fff;
font-size: 12px;
cursor: pointer;
}
.desktop-tools button:hover {
background: rgb(0 0 0 / 0.55);
}
/* 放大:桌面在左边占满高度,实训台、图片列表和输出在右边一栏 */
.run-area.maximized {
position: fixed;
inset: 0;
z-index: 900;
display: grid;
grid-template-columns: minmax(0, 1fr) 460px;
grid-auto-rows: min-content;
align-content: start;
background: #f3f4f6;
}
.run-area.maximized .desktop {
grid-column: 1;
grid-row: 1 / span 20;
height: calc(100vh - 16px);
}
.run-area.maximized > :not(.desktop) {
grid-column: 2;
}
.desktop-hint {
position: absolute;
inset: 0;
+28 -7
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
// 按控件类型渲染一个 WinForms 控件及其子控件。
// 用户操作只通过 act() 上报,由运行器按真实 WinForms 的顺序触发事件。
import { computed, inject, ref, type CSSProperties } from 'vue'
import { computed, inject, provide, ref, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import { boxCss, displayText } from './format'
import { RUN_IMAGES, type ImageValue } from './images'
import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format'
import { backgroundCss, RUN_IMAGES, type ImageValue } from './images'
import DataGridViewControl from './DataGridViewControl.vue'
import DateTimePickerControl from './DateTimePickerControl.vue'
import LedControl from './LedControl.vue'
@@ -19,12 +19,28 @@ const p = computed(() => props.node.props)
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染(后渲染的 DOM 元素在上面)
const childrenTopLast = computed(() => [...props.node.children].reverse())
const enabled = computed(() => p.value.Enabled !== false)
const style = computed(() => boxCss(p.value, props.node.type === 'Label' && p.value.AutoSize === true))
const runImages = inject(RUN_IMAGES, new Map<string, string>())
const ambient = inject(AMBIENT_BACK_COLOR, ref(DEFAULT_BACK_COLOR))
/** 这个控件实际的背景色,子控件没设置背景色时沿用 */
const backColor = computed(() => (p.value.BackColor as string | null) ?? ambient.value)
provide(AMBIENT_BACK_COLOR, backColor)
const style = computed(() => ({
...boxCss(p.value, props.node.type === 'Label' && p.value.AutoSize === true),
...(AMBIENT_BACK_TYPES.has(props.node.type) ? { backgroundColor: backColor.value } : {}),
...backgroundCss(p.value.BackgroundImage, p.value.BackgroundImageLayout, runImages),
}))
function act(action: UserAction) {
if (enabled.value) props.act(props.node.id, action)
}
/** 鼠标按下、松开(教材项目 8 的方向键图片:按下转动摄像机、换成按下的图片) */
function mouse(kind: 'mouseDown' | 'mouseUp', event: PointerEvent) {
if (event.target !== event.currentTarget && !(event.target as HTMLElement).matches('img')) return
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
act({ action: kind, button: event.button === 2 ? 'right' : 'left', x: Math.round(event.clientX - rect.left), y: Math.round(event.clientY - rect.top) })
}
// TextBox
const passwordChar = computed(() => {
if (p.value.UseSystemPasswordChar) return '●'
@@ -54,7 +70,6 @@ function selectItem(index: number) {
}
// PictureBox:按 SizeMode 摆放图片
const runImages = inject(RUN_IMAGES, new Map<string, string>())
const image = computed(() => p.value.Image as ImageValue | null | undefined)
const imageUrl = computed(() => (image.value ? image.value.url ?? runImages.get(image.value.key) : undefined))
// 设计器预览里不知道素材的尺寸,原样大小显示时让浏览器按图片本身的大小
@@ -163,14 +178,18 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
:class="`wf-picturebox-border-${p.BorderStyle ?? 'None'}`"
:style="style"
@click="act({ action: 'click' })"
@pointerdown="mouse('mouseDown', $event)"
@pointerup="mouse('mouseUp', $event)"
>
<img v-if="imageUrl" :src="imageUrl" :style="imageStyle" alt="" draggable="false" />
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
</div>
<DataGridViewControl v-else-if="node.type === 'DataGridView'" :node="node" :style="style" :enabled="enabled" :act="act" />
<DateTimePickerControl v-else-if="node.type === 'DateTimePicker'" :node="node" :style="style" :enabled="enabled" :act="act" />
<TrackBarControl v-else-if="node.type === 'TrackBar'" :node="node" :style="style" :enabled="enabled" :act="act" />
<!-- Windows Media Player(教材里通常设为不可见,只用来播放声音) -->
@@ -201,8 +220,10 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<!-- GroupBox -->
<div v-else-if="node.type === 'GroupBox'" class="wf-control wf-groupbox" :style="style">
<div class="wf-groupbox-frame" />
<span v-if="p.Text" class="wf-groupbox-caption">{{ displayText(p.Text) }}</span>
<!-- 标题处的边框断开、透出底下的背景(教材项目 8 的分组框放在背景图上,白色标题) -->
<fieldset class="wf-groupbox-frame">
<legend v-if="p.Text" class="wf-groupbox-caption">{{ displayText(p.Text) }}</legend>
</fieldset>
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
</div>
+14 -4
View File
@@ -1,29 +1,38 @@
<script lang="ts">
let topZ = 1
const nextZ = () => ++topZ
/** 第一个窗体离桌面左上角的距离,之后每个窗体错开的距离 */
export const WINDOW_MARGIN = 24
export const WINDOW_STEP = 32
</script>
<script setup lang="ts">
// 一个 WinForms 窗体:标题栏 + 客户区。窗体可以拖动标题栏移动。
import { computed, ref } from 'vue'
import { computed, inject, provide, ref } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import ControlView from './ControlView.vue'
import { displayText, fontCss, type FontValue, type SizeValue } from './format'
import { AMBIENT_BACK_COLOR, DEFAULT_BACK_COLOR, displayText, fontCss, type FontValue, type SizeValue } from './format'
import { backgroundCss, RUN_IMAGES } from './images'
const props = defineProps<{
form: ControlNode
modal: boolean
offset: number
/** 桌面的缩放比例(窗体太大时整体缩小),拖动时把鼠标移动的距离换算回去 */
scale: number
act: (id: string, action: UserAction) => void
}>()
const p = computed(() => props.form.props)
const runImages = inject(RUN_IMAGES, new Map<string, string>())
provide(AMBIENT_BACK_COLOR, computed(() => (p.value.BackColor as string | null) ?? DEFAULT_BACK_COLOR))
const borderless = computed(() => p.value.FormBorderStyle === 'None')
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染
const childrenTopLast = computed(() => [...props.form.children].reverse())
const clientSize = computed(() => (p.value.ClientSize as SizeValue | undefined) ?? { width: 300, height: 300 })
const position = ref({ x: 24 + props.offset * 32, y: 24 + props.offset * 32 })
const position = ref({ x: WINDOW_MARGIN + props.offset * WINDOW_STEP, y: WINDOW_MARGIN + props.offset * WINDOW_STEP })
// 点哪个窗口,哪个窗口到最前面(所有窗体共用一个递增的层级)
const zIndex = ref(nextZ())
@@ -39,7 +48,7 @@ function startDrag(event: PointerEvent) {
function drag(event: PointerEvent) {
if (!dragStart) return
position.value = { x: dragStart.px + event.clientX - dragStart.x, y: dragStart.py + event.clientY - dragStart.y }
position.value = { x: dragStart.px + (event.clientX - dragStart.x) / props.scale, y: dragStart.py + (event.clientY - dragStart.y) / props.scale }
}
function close() {
@@ -69,6 +78,7 @@ function close() {
color: (p.ForeColor as string | null) ?? undefined,
backgroundColor: (p.BackColor as string | null) ?? undefined,
...fontCss(p.Font as FontValue | null),
...backgroundCss(p.BackgroundImage, p.BackgroundImageLayout, runImages),
}"
>
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="act" />
+12 -1
View File
@@ -1,7 +1,7 @@
// 把运行器发来的 WinForms 属性值转换成 CSS。
// 运行器的序列化格式:Point → {x,y},Size → {width,height},
// Color → CSS 颜色字符串(未设置时为 null,表示继承父控件),Font → FontValue。
import type { CSSProperties } from 'vue'
import type { CSSProperties, InjectionKey, Ref } from 'vue'
export interface PointValue {
x: number
@@ -45,6 +45,17 @@ export function fontCss(font: FontValue | null | undefined): CSSProperties {
}
}
/**
* 父控件的背景色(BackColor 是"环境属性":控件没设置时沿用父控件的)。
* 父控件有背景图片时,没设置背景色的子控件在 WinForms 里画的是父控件的背景色而不是那张图,
* 所以这些控件要自己涂上这个颜色;"transparent" 表示父控件是透明的(Color.Transparent),子控件也透明。
*/
export const AMBIENT_BACK_COLOR: InjectionKey<Readonly<Ref<string>>> = Symbol('ambientBackColor')
export const DEFAULT_BACK_COLOR = '#f0f0f0'
/** 背景色沿用父控件的控件(按钮、文本框等有自己的默认底色) */
export const AMBIENT_BACK_TYPES = new Set(['Label', 'Panel', 'GroupBox', 'CheckBox', 'RadioButton', 'PictureBox', 'TrackBar'])
/** 控件外框:绝对定位,坐标相对父控件的客户区 */
export function boxCss(props: Record<string, unknown>, autoSize = false): CSSProperties {
const location = (props.Location as PointValue | undefined) ?? { x: 0, y: 0 }
+21 -1
View File
@@ -1,6 +1,6 @@
// 运行时图片:运行器发来的图片数据由 RunSession 保存成 blob 网址,控件属性里只带图片编号。
// 运行区 provide,ControlView inject;设计器预览没有运行时图片,取到空表。
import type { InjectionKey } from 'vue'
import type { CSSProperties, InjectionKey } from 'vue'
export const RUN_IMAGES: InjectionKey<ReadonlyMap<string, string>> = Symbol('runImages')
@@ -12,3 +12,23 @@ export interface ImageValue {
/** 设计器预览里的项目资源:直接给出素材网址(运行时用 key) */
url?: string
}
/** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
const image = value as ImageValue | null | undefined
const url = image ? image.url ?? images.get(image.key) : undefined
if (!url) return {}
const base = { backgroundImage: `url("${url}")` }
switch (layout) {
case 'None':
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: '0 0' }
case 'Center':
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: 'center' }
case 'Stretch':
return { ...base, backgroundRepeat: 'no-repeat', backgroundSize: '100% 100%' }
case 'Zoom':
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: 'contain' }
default:
return { ...base, backgroundRepeat: 'repeat', backgroundPosition: '0 0' }
}
}
+24 -7
View File
@@ -105,11 +105,20 @@
border-color: #0078d7;
}
.wf-textbox input[readonly],
.wf-textbox textarea[readonly] {
/* 文本框的背景色画在外层(控件的 BackColor 写在外层的 style 上),输入框本身透明 */
.wf-textbox {
background: #fff;
}
.wf-textbox:has(> [readonly]) {
background: #f0f0f0;
}
.wf-textbox input,
.wf-textbox textarea {
background: transparent;
}
.wf-combobox input {
padding-right: 18px;
}
@@ -391,6 +400,10 @@
background: #555;
}
.wf-chart {
background: #fff;
}
.wf-listbox {
overflow-y: auto;
overflow-x: hidden;
@@ -472,17 +485,21 @@
.wf-groupbox-frame {
position: absolute;
inset: 6px 0 0 0;
inset: 0;
margin: 0;
padding: 0 6px;
border: 1px solid #dcdcdc;
border-radius: 2px;
min-width: 0;
}
/* 没有标题时边框的上沿和有标题时一样在 6px 处 */
.wf-groupbox-frame:not(:has(legend)) {
top: 6px;
}
.wf-groupbox-caption {
position: absolute;
left: 6px;
top: 0;
padding: 0 2px;
background: #f0f0f0;
line-height: 12px;
white-space: pre;
}