From d60868d912c2e1abf7172028d43b1ba74dfa91a7 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 23 Oct 2026 15:14:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=9C=E9=9D=A0=E5=B8=83=E5=B1=80=E3=80=81?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E5=9B=BE=E7=89=87=E3=80=81=E6=97=A0=E8=BE=B9?= =?UTF-8?q?=E6=A1=86=E7=AA=97=E4=BD=93=EF=BC=9B=E8=BF=90=E8=A1=8C=E5=8C=BA?= =?UTF-8?q?=E6=8C=89=E6=AF=94=E4=BE=8B=E7=BC=A9=E5=B0=8F=E3=80=81=E5=8F=AF?= =?UTF-8?q?=E6=94=BE=E5=A4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- runtime/Sim.WinForms/Drawing/Graphics.cs | 14 +++ runtime/Sim.WinForms/Forms/Containers.cs | 26 ++++- runtime/Sim.WinForms/Forms/Control.cs | 122 ++++++++++++++++++-- runtime/Sim.WinForms/Forms/Enums.cs | 2 + runtime/Sim.WinForms/Forms/EventArgs.cs | 17 +++ runtime/Sim.WinForms/Forms/Form.cs | 9 +- web/src/designer/FormDesigner.vue | 4 +- web/src/designer/catalog.ts | 22 +++- web/src/designer/layout.test.ts | 70 ++++++++++++ web/src/designer/layout.ts | 105 +++++++++++++++++ web/src/grading/screenshots.ts | 2 +- web/src/run/RunArea.vue | 137 ++++++++++++++++++++--- web/src/winforms/ControlView.vue | 35 ++++-- web/src/winforms/FormWindow.vue | 18 ++- web/src/winforms/format.ts | 13 ++- web/src/winforms/images.ts | 22 +++- web/src/winforms/winforms.css | 31 +++-- 17 files changed, 600 insertions(+), 49 deletions(-) create mode 100644 runtime/Sim.WinForms/Drawing/Graphics.cs create mode 100644 web/src/designer/layout.test.ts create mode 100644 web/src/designer/layout.ts diff --git a/runtime/Sim.WinForms/Drawing/Graphics.cs b/runtime/Sim.WinForms/Drawing/Graphics.cs new file mode 100644 index 0000000..63ccf15 --- /dev/null +++ b/runtime/Sim.WinForms/Drawing/Graphics.cs @@ -0,0 +1,14 @@ +namespace System.Drawing; + +/// +/// 绘图面。平台的控件由浏览器绘制,学生程序不能自己画(没有 DrawLine 等方法); +/// 只为让 VS 生成的空 Paint 事件处理程序(panel1_Paint)能编译。 +/// +public sealed class Graphics : IDisposable +{ + private Graphics() + { + } + + public void Dispose() => GC.SuppressFinalize(this); +} diff --git a/runtime/Sim.WinForms/Forms/Containers.cs b/runtime/Sim.WinForms/Forms/Containers.cs index e070199..30531b9 100644 --- a/runtime/Sim.WinForms/Forms/Containers.cs +++ b/runtime/Sim.WinForms/Forms/Containers.cs @@ -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(); + } + } + + /// 边框占掉的宽度:单线 1 像素,立体 2 像素 + 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; + + /// 分组框的边框和标题占掉的地方(宋体 9pt 时和 WinForms 一致) + 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)); + } + } } diff --git a/runtime/Sim.WinForms/Forms/Control.cs b/runtime/Sim.WinForms/Forms/Control.cs index 851b079..3f1738d 100644 --- a/runtime/Sim.WinForms/Forms/Control.cs +++ b/runtime/Sim.WinForms/Forms/Control.cs @@ -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(); + } + } + + /// 背景图片;BackgroundImageLayout 决定平铺(默认)、居中、拉伸或按比例缩放 + 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; + /// 重绘。平台由浏览器绘制控件,不会触发这个事件(VS 双击 Panel 生成的空处理程序照常能编译) + 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(); + } + + /// + /// 停靠布局(和 WinForms 一样):按 Controls 集合从后往前,Top / Bottom / Left / Right 依次贴边并占掉那一条, + /// Fill 占剩下的全部。VS 在 Designer.cs 里写的位置大小就是这样排出来的;代码里改停靠、大小、可见时重新排。 + /// + 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); + } + } + + /// 停靠布局给出的位置大小:不再反过来触发父容器的布局 + private void SetBoundsFromLayout(Rectangle bounds) + { + SetProp(ref _location, bounds.Location, nameof(Location)); + if (SetProp(ref _size, bounds.Size, nameof(Size))) PerformLayout(); + } + + /// 容器里用来停靠子控件的区域:客户区去掉内边距(分组框、带边框的面板再去掉边框) + 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(); } /// 浏览器按这个类型名选择渲染方式。学生自己继承的控件按最近的仿真库基类渲染。 @@ -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 }); } diff --git a/runtime/Sim.WinForms/Forms/Enums.cs b/runtime/Sim.WinForms/Forms/Enums.cs index ee669bc..6a2bb36 100644 --- a/runtime/Sim.WinForms/Forms/Enums.cs +++ b/runtime/Sim.WinForms/Forms/Enums.cs @@ -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 } diff --git a/runtime/Sim.WinForms/Forms/EventArgs.cs b/runtime/Sim.WinForms/Forms/EventArgs.cs index fa91bbf..20e6b27 100644 --- a/runtime/Sim.WinForms/Forms/EventArgs.cs +++ b/runtime/Sim.WinForms/Forms/EventArgs.cs @@ -41,3 +41,20 @@ public class FormClosedEventArgs : EventArgs } public delegate void FormClosedEventHandler(object? sender, FormClosedEventArgs e); + +/// 重绘事件的参数。平台由浏览器绘制控件,不支持在 Graphics 上自己画 +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); diff --git a/runtime/Sim.WinForms/Forms/Form.cs b/runtime/Sim.WinForms/Forms/Form.cs index 05b8162..7c14bb8 100644 --- a/runtime/Sim.WinForms/Forms/Form.cs +++ b/runtime/Sim.WinForms/Forms/Form.cs @@ -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 diff --git a/web/src/designer/FormDesigner.vue b/web/src/designer/FormDesigner.vue index a6f7c3d..0c32b7a 100644 --- a/web/src/designer/FormDesigner.vue +++ b/web/src/designer/FormDesigner.vue @@ -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) } // ---------- 几何 ---------- diff --git a/web/src/designer/catalog.ts b/web/src/designer/catalog.ts index c74fa9d..e929e74 100644 --- a/web/src/designer/catalog.ts +++ b/web/src/designer/catalog.ts @@ -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: '外观' }, ], diff --git a/web/src/designer/layout.test.ts b/web/src/designer/layout.test.ts new file mode 100644 index 0000000..3a60c40 --- /dev/null +++ b/web/src/designer/layout.test.ts @@ -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) +}) diff --git a/web/src/designer/layout.ts b/web/src/designer/layout.ts new file mode 100644 index 0000000..7158c91 --- /dev/null +++ b/web/src/designer/layout.ts @@ -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 +} diff --git a/web/src/grading/screenshots.ts b/web/src/grading/screenshots.ts index 5802ee2..a458c62 100644 --- a/web/src/grading/screenshots.ts +++ b/web/src/grading/screenshots.ts @@ -29,7 +29,7 @@ export async function captureForms(files: SourceFile[]): Promise { } 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('.wf-window') diff --git a/web/src/run/RunArea.vue b/web/src/run/RunArea.vue index bf4deeb..4848af6 100644 --- a/web/src/run/RunArea.vue +++ b/web/src/run/RunArea.vue @@ -1,9 +1,10 @@