第 0 阶段:教材项目 2 任务 1 在网页中原样运行
- runtime:仿真 WinForms 子集(Form、Button、Label、TextBox、ComboBox、 GroupBox、Timer、MessageBox、界面线程消息循环、跨线程检查)、 教材设备类库(DeviceCtrlHelper、LED 控件)、运行器(常驻编译服务 + 运行模式) - server:Bun + Hono,WebSocket 会话,每次运行一个进程,消息转发 - web:Vue + Naive UI,WinForms 风格窗体渲染、MessageBox、实训台 LED 屏 - 端到端测试与 100 个程序并发的压力测试 - 教材逐任务摘要(docs/教材摘要) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
98be8331bc
71 files changed
+7919
No files matched your search
@@ -0,0 +1,119 @@
|
||||
// System.Drawing.Common 在 Linux 上不受支持。Point、Size、Color、SystemColors 等框架自带(System.Drawing.Primitives),
|
||||
// 这里只补上学生代码会用到、框架里又没有的类型。字体只记录名称和样式,真正的渲染在浏览器里完成。
|
||||
// 注意:框架的 System.Drawing.dll 门面把 Font 等转发到 System.Drawing.Common,编译学生代码时不能引用它。
|
||||
using System;
|
||||
|
||||
namespace System.Drawing;
|
||||
|
||||
public enum ContentAlignment
|
||||
{
|
||||
TopLeft = 1,
|
||||
TopCenter = 2,
|
||||
TopRight = 4,
|
||||
MiddleLeft = 16,
|
||||
MiddleCenter = 32,
|
||||
MiddleRight = 64,
|
||||
BottomLeft = 256,
|
||||
BottomCenter = 512,
|
||||
BottomRight = 1024,
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum FontStyle
|
||||
{
|
||||
Regular = 0,
|
||||
Bold = 1,
|
||||
Italic = 2,
|
||||
Underline = 4,
|
||||
Strikeout = 8,
|
||||
}
|
||||
|
||||
public enum GraphicsUnit
|
||||
{
|
||||
World = 0,
|
||||
Display = 1,
|
||||
Pixel = 2,
|
||||
Point = 3,
|
||||
Inch = 4,
|
||||
Document = 5,
|
||||
Millimeter = 6,
|
||||
}
|
||||
|
||||
public sealed class FontFamily
|
||||
{
|
||||
public FontFamily(string name) => Name = name;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public override string ToString() => $"[FontFamily: Name={Name}]";
|
||||
}
|
||||
|
||||
public sealed class Font : IDisposable, ICloneable
|
||||
{
|
||||
public Font(string familyName, float emSize)
|
||||
: this(familyName, emSize, FontStyle.Regular, GraphicsUnit.Point) { }
|
||||
|
||||
public Font(string familyName, float emSize, FontStyle style)
|
||||
: this(familyName, emSize, style, GraphicsUnit.Point) { }
|
||||
|
||||
public Font(string familyName, float emSize, GraphicsUnit unit)
|
||||
: this(familyName, emSize, FontStyle.Regular, unit) { }
|
||||
|
||||
public Font(string familyName, float emSize, FontStyle style, GraphicsUnit unit)
|
||||
{
|
||||
if (emSize <= 0 || float.IsNaN(emSize) || float.IsInfinity(emSize))
|
||||
throw new ArgumentException($"“emSize”的值“{emSize}”无效。“emSize”应大于 0 且小于或等于 System.Single.MaxValue。", nameof(emSize));
|
||||
Name = string.IsNullOrWhiteSpace(familyName) ? "Microsoft Sans Serif" : familyName;
|
||||
Size = emSize;
|
||||
Style = style;
|
||||
Unit = unit;
|
||||
}
|
||||
|
||||
public Font(string familyName, float emSize, FontStyle style, GraphicsUnit unit, byte gdiCharSet)
|
||||
: this(familyName, emSize, style, unit) => GdiCharSet = gdiCharSet;
|
||||
|
||||
public Font(string familyName, float emSize, FontStyle style, GraphicsUnit unit, byte gdiCharSet, bool gdiVerticalFont)
|
||||
: this(familyName, emSize, style, unit, gdiCharSet) { }
|
||||
|
||||
public Font(FontFamily family, float emSize) : this(family.Name, emSize) { }
|
||||
|
||||
public Font(FontFamily family, float emSize, FontStyle style) : this(family.Name, emSize, style) { }
|
||||
|
||||
public Font(Font prototype, FontStyle newStyle) : this(prototype.Name, prototype.Size, newStyle, prototype.Unit) { }
|
||||
|
||||
public string Name { get; }
|
||||
public float Size { get; }
|
||||
public FontStyle Style { get; }
|
||||
public GraphicsUnit Unit { get; }
|
||||
public byte GdiCharSet { get; } = 1;
|
||||
public FontFamily FontFamily => new(Name);
|
||||
|
||||
public bool Bold => (Style & FontStyle.Bold) != 0;
|
||||
public bool Italic => (Style & FontStyle.Italic) != 0;
|
||||
public bool Underline => (Style & FontStyle.Underline) != 0;
|
||||
public bool Strikeout => (Style & FontStyle.Strikeout) != 0;
|
||||
|
||||
/// <summary>按 96 DPI 换算成磅</summary>
|
||||
public float SizeInPoints => Unit switch
|
||||
{
|
||||
GraphicsUnit.Pixel => Size * 72f / 96f,
|
||||
GraphicsUnit.Inch => Size * 72f,
|
||||
GraphicsUnit.Millimeter => Size * 72f / 25.4f,
|
||||
GraphicsUnit.Document => Size * 72f / 300f,
|
||||
_ => Size,
|
||||
};
|
||||
|
||||
/// <summary>大致的行高(像素),中文字体约为字号的 1.33 倍</summary>
|
||||
public int Height => (int)Math.Ceiling(SizeInPoints * 96f / 72f * 1.14f);
|
||||
|
||||
public object Clone() => new Font(Name, Size, Style, Unit, GdiCharSet);
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
public override string ToString() => $"[Font: Name={Name}, Size={Size}, Units={(int)Unit}, GdiCharSet={GdiCharSet}, GdiVerticalFont=False]";
|
||||
|
||||
public override bool Equals(object? obj) =>
|
||||
obj is Font other && other.Name == Name && other.Size == Size && other.Style == Style && other.Unit == Unit;
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Name, Size, Style, Unit);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public static class Application
|
||||
{
|
||||
internal static readonly List<Form> OpenFormsList = new();
|
||||
private static bool _exitRequested;
|
||||
|
||||
public static FormCollection OpenForms { get; } = new();
|
||||
|
||||
public static string StartupPath => AppContext.BaseDirectory.TrimEnd('/', '\\');
|
||||
public static string ExecutablePath => System.IO.Path.Combine(StartupPath, "app.exe");
|
||||
public static string ProductName => "WindowsFormsApplication";
|
||||
|
||||
public static void EnableVisualStyles() { }
|
||||
|
||||
public static void SetCompatibleTextRenderingDefault(bool defaultValue) { }
|
||||
|
||||
public static void SetHighDpiMode(object mode) { }
|
||||
|
||||
/// <summary>显示主窗体并运行消息循环,直到主窗体关闭或调用 Application.Exit()</summary>
|
||||
public static void Run(Form mainForm)
|
||||
{
|
||||
mainForm.Show();
|
||||
SimHost.Ui.RunUntil(() => _exitRequested || mainForm.IsClosed);
|
||||
Exit();
|
||||
}
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
SimHost.Ui.RunUntil(() => _exitRequested);
|
||||
}
|
||||
|
||||
public static void Exit()
|
||||
{
|
||||
_exitRequested = true;
|
||||
foreach (var form in OpenFormsList.ToArray()) form.CloseCore(CloseReason.ApplicationExitCall);
|
||||
SimHost.Ui.Wake();
|
||||
}
|
||||
|
||||
public static void ExitThread() => Exit();
|
||||
|
||||
public static void DoEvents() => SimHost.Ui.DoEvents();
|
||||
|
||||
public sealed class FormCollection : IEnumerable<Form>
|
||||
{
|
||||
public int Count => OpenFormsList.Count;
|
||||
public Form this[int index] => OpenFormsList[index];
|
||||
public Form? this[string name] => OpenFormsList.Find(f => f.Name == name);
|
||||
public IEnumerator<Form> GetEnumerator() => OpenFormsList.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Nodes;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public abstract class ButtonBase : Control
|
||||
{
|
||||
private FlatStyle _flatStyle = FlatStyle.Standard;
|
||||
|
||||
public bool UseVisualStyleBackColor { get; set; } = true;
|
||||
public bool UseMnemonic { get; set; } = true;
|
||||
public ContentAlignment TextAlign { get; set; } = ContentAlignment.MiddleCenter;
|
||||
|
||||
public FlatStyle FlatStyle
|
||||
{
|
||||
get => _flatStyle;
|
||||
set => SetProp(ref _flatStyle, value, nameof(FlatStyle));
|
||||
}
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["FlatStyle"] = _flatStyle.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public class Button : ButtonBase, IButtonControl
|
||||
{
|
||||
protected override Size DefaultSize => new(75, 23);
|
||||
|
||||
public DialogResult DialogResult { get; set; }
|
||||
|
||||
public void NotifyDefault(bool value) { }
|
||||
|
||||
public void PerformClick()
|
||||
{
|
||||
if (Enabled && Visible) OnClick(EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override void OnClick(EventArgs e)
|
||||
{
|
||||
// 按钮设置了 DialogResult 时,点击会把结果交给所在的模态窗体并关闭它
|
||||
if (DialogResult != DialogResult.None && FindForm() is { } form) form.DialogResult = DialogResult;
|
||||
base.OnClick(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public class ComboBox : Control
|
||||
{
|
||||
private int _selectedIndex = -1;
|
||||
private ComboBoxStyle _dropDownStyle = ComboBoxStyle.DropDown;
|
||||
|
||||
public ComboBox() => Items = new ObjectCollection(this);
|
||||
|
||||
protected override Size DefaultSize => new(121, 20);
|
||||
|
||||
public ObjectCollection Items { get; }
|
||||
|
||||
public bool FormattingEnabled { get; set; }
|
||||
public int MaxDropDownItems { get; set; } = 8;
|
||||
public int DropDownWidth { get; set; } = 121;
|
||||
public int DropDownHeight { get; set; } = 106;
|
||||
public bool Sorted { get; set; }
|
||||
public bool DroppedDown { get; set; }
|
||||
|
||||
public ComboBoxStyle DropDownStyle
|
||||
{
|
||||
get => _dropDownStyle;
|
||||
set => SetProp(ref _dropDownStyle, value, nameof(DropDownStyle));
|
||||
}
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
if (value < -1 || value >= Items.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“SelectedIndex”无效。");
|
||||
if (value == _selectedIndex) return;
|
||||
SetProp(ref _selectedIndex, value, nameof(SelectedIndex));
|
||||
// 选中项变化时 Text 随之变化(先 TextChanged,再 SelectedIndexChanged,与 WinForms 一致)
|
||||
Text = value >= 0 ? Items[value]?.ToString() ?? "" : "";
|
||||
OnSelectedIndexChanged(EventArgs.Empty);
|
||||
OnSelectedValueChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public object? SelectedItem
|
||||
{
|
||||
get => _selectedIndex >= 0 ? Items[_selectedIndex] : null;
|
||||
set => SelectedIndex = value == null ? -1 : Items.IndexOf(value);
|
||||
}
|
||||
|
||||
public string SelectedText { get; set; } = "";
|
||||
|
||||
public event EventHandler? SelectedIndexChanged;
|
||||
public event EventHandler? SelectedValueChanged;
|
||||
public event EventHandler? DropDown;
|
||||
public event EventHandler? DropDownClosed;
|
||||
|
||||
protected virtual void OnSelectedIndexChanged(EventArgs e) => SelectedIndexChanged?.Invoke(this, e);
|
||||
protected virtual void OnSelectedValueChanged(EventArgs e) => SelectedValueChanged?.Invoke(this, e);
|
||||
protected virtual void OnDropDown(EventArgs e) => DropDown?.Invoke(this, e);
|
||||
protected virtual void OnDropDownClosed(EventArgs e) => DropDownClosed?.Invoke(this, e);
|
||||
|
||||
public void BeginUpdate() { }
|
||||
public void EndUpdate() { }
|
||||
|
||||
protected override void OnUserAction(UserAction action)
|
||||
{
|
||||
switch (action.Action)
|
||||
{
|
||||
case "dropDown":
|
||||
// 教材在 Click 事件里刷新串口列表,所以 Click 必须在列表展开之前触发
|
||||
OnClick(EventArgs.Empty);
|
||||
OnDropDown(EventArgs.Empty);
|
||||
break;
|
||||
case "select":
|
||||
if (action.Index >= 0 && action.Index < Items.Count) SelectedIndex = action.Index;
|
||||
OnDropDownClosed(EventArgs.Empty);
|
||||
break;
|
||||
case "text" when _dropDownStyle != ComboBoxStyle.DropDownList:
|
||||
SetTextFromUser(action.Value ?? "");
|
||||
var match = Items.IndexOf(Text);
|
||||
if (match != _selectedIndex) _selectedIndex = match;
|
||||
break;
|
||||
default:
|
||||
base.OnUserAction(action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
internal void ItemsChanged()
|
||||
{
|
||||
if (_selectedIndex >= Items.Count) _selectedIndex = -1;
|
||||
NotifyChanged(nameof(Items), Items.Select(i => i?.ToString() ?? "").ToArray());
|
||||
}
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["Items"] = PropValue.From(Items.Select(i => i?.ToString() ?? "").ToArray());
|
||||
props["SelectedIndex"] = _selectedIndex;
|
||||
props["DropDownStyle"] = _dropDownStyle.ToString();
|
||||
}
|
||||
|
||||
public class ObjectCollection : IList, IEnumerable<object?>
|
||||
{
|
||||
private readonly List<object?> _items = new();
|
||||
private readonly ComboBox _owner;
|
||||
|
||||
public ObjectCollection(ComboBox owner) => _owner = owner;
|
||||
|
||||
public int Count => _items.Count;
|
||||
public bool IsReadOnly => false;
|
||||
bool IList.IsFixedSize => false;
|
||||
bool ICollection.IsSynchronized => false;
|
||||
object ICollection.SyncRoot => this;
|
||||
|
||||
public object? this[int index]
|
||||
{
|
||||
get => _items[index];
|
||||
set
|
||||
{
|
||||
_items[index] = value;
|
||||
_owner.ItemsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public int Add(object? item)
|
||||
{
|
||||
if (item == null) throw new ArgumentNullException(nameof(item), "值不能为 null。");
|
||||
_items.Add(item);
|
||||
_owner.ItemsChanged();
|
||||
return _items.Count - 1;
|
||||
}
|
||||
|
||||
public void AddRange(object[] items)
|
||||
{
|
||||
_items.AddRange(items);
|
||||
_owner.ItemsChanged();
|
||||
}
|
||||
|
||||
public void Insert(int index, object? item)
|
||||
{
|
||||
_items.Insert(index, item);
|
||||
_owner.ItemsChanged();
|
||||
}
|
||||
|
||||
public void Remove(object? item)
|
||||
{
|
||||
if (_items.Remove(item)) _owner.ItemsChanged();
|
||||
}
|
||||
|
||||
public void RemoveAt(int index)
|
||||
{
|
||||
_items.RemoveAt(index);
|
||||
_owner.ItemsChanged();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_items.Clear();
|
||||
_owner.SelectedIndex = -1;
|
||||
_owner.ItemsChanged();
|
||||
}
|
||||
|
||||
public bool Contains(object? item) => _items.Contains(item);
|
||||
public int IndexOf(object? item) => _items.IndexOf(item);
|
||||
public void CopyTo(Array array, int index) => ((ICollection)_items).CopyTo(array, index);
|
||||
public IEnumerator<object?> GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public class ScrollableControl : Control
|
||||
{
|
||||
public bool AutoScroll { get; set; }
|
||||
}
|
||||
|
||||
public class ContainerControl : ScrollableControl
|
||||
{
|
||||
public SizeF AutoScaleDimensions { get; set; }
|
||||
public AutoScaleMode AutoScaleMode { get; set; } = AutoScaleMode.Inherit;
|
||||
public Control? ActiveControl { get; set; }
|
||||
}
|
||||
|
||||
public class UserControl : ContainerControl
|
||||
{
|
||||
protected override Size DefaultSize => new(150, 150);
|
||||
|
||||
public event EventHandler? Load;
|
||||
|
||||
protected virtual void OnLoad(EventArgs e) => Load?.Invoke(this, e);
|
||||
|
||||
private bool _loaded;
|
||||
|
||||
/// <summary>UserControl 的 Load 在第一次随窗体显示时触发</summary>
|
||||
internal void RaiseLoadOnce()
|
||||
{
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
OnLoad(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public class Panel : ScrollableControl
|
||||
{
|
||||
private BorderStyle _borderStyle;
|
||||
|
||||
protected override Size DefaultSize => new(200, 100);
|
||||
|
||||
public BorderStyle BorderStyle
|
||||
{
|
||||
get => _borderStyle;
|
||||
set => SetProp(ref _borderStyle, value, nameof(BorderStyle));
|
||||
}
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["BorderStyle"] = _borderStyle.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public class GroupBox : Control
|
||||
{
|
||||
protected override Size DefaultSize => new(200, 100);
|
||||
|
||||
public FlatStyle FlatStyle { get; set; } = FlatStyle.Standard;
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Nodes;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// 仿真控件的基类。属性保存在本进程里;控件所在窗体显示后,属性变化以 patch 消息发给浏览器渲染。
|
||||
/// Font、BackColor、ForeColor 是"环境属性":没有显式设置时沿用父控件的值,只把显式设置的值发给浏览器。
|
||||
/// </summary>
|
||||
public class Control : Component, IWin32Window
|
||||
{
|
||||
private static readonly Font DefaultFontValue = new("宋体", 9F);
|
||||
|
||||
private string _name = "";
|
||||
private string _text = "";
|
||||
private Point _location;
|
||||
private Size _size;
|
||||
private bool _visible = true;
|
||||
private bool _enabled = true;
|
||||
private Color _backColor;
|
||||
private Color _foreColor;
|
||||
private Font? _font;
|
||||
private bool _autoSize;
|
||||
private DockStyle _dock;
|
||||
private AnchorStyles _anchor = AnchorStyles.Top | AnchorStyles.Left;
|
||||
private Control? _parent;
|
||||
|
||||
public Control()
|
||||
{
|
||||
SimId = SimHost.NewControlId();
|
||||
SimHost.Register(this);
|
||||
_size = DefaultSize;
|
||||
Controls = CreateControlsInstance();
|
||||
}
|
||||
|
||||
public Control(string text) : this() => _text = text ?? "";
|
||||
|
||||
/// <summary>平台内部使用:控件在浏览器端的编号</summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public string SimId { get; }
|
||||
|
||||
/// <summary>为 true 时,从非界面线程修改已显示控件的属性会抛出异常(和在 VS 里按 F5 调试时一致)</summary>
|
||||
public static bool CheckForIllegalCrossThreadCalls { get; set; } = true;
|
||||
|
||||
public static Color DefaultBackColor => Color.FromArgb(240, 240, 240);
|
||||
public static Color DefaultForeColor => Color.Black;
|
||||
public static Font DefaultFont => DefaultFontValue;
|
||||
|
||||
protected virtual Size DefaultSize => Size.Empty;
|
||||
|
||||
// ---------- 层级 ----------
|
||||
|
||||
public ControlCollection Controls { get; }
|
||||
|
||||
protected virtual ControlCollection CreateControlsInstance() => new(this);
|
||||
|
||||
public Control? Parent
|
||||
{
|
||||
get => _parent;
|
||||
set
|
||||
{
|
||||
if (value == _parent) return;
|
||||
_parent?.Controls.Remove(this);
|
||||
value?.Controls.Add(this);
|
||||
}
|
||||
}
|
||||
|
||||
internal void SetParentInternal(Control? parent) => _parent = parent;
|
||||
|
||||
public Form? FindForm()
|
||||
{
|
||||
for (var c = _parent; c != null; c = c._parent)
|
||||
if (c is Form form) return form;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>控件已经显示在浏览器里(所在窗体已打开)时,属性变化才需要发送</summary>
|
||||
internal virtual bool IsAttached
|
||||
{
|
||||
get
|
||||
{
|
||||
var root = this;
|
||||
while (root._parent != null) root = root._parent;
|
||||
return root is Form { IsOpenInBrowser: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 常用属性 ----------
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set => _name = value ?? "";
|
||||
}
|
||||
|
||||
public virtual string Text
|
||||
{
|
||||
get => _text;
|
||||
set
|
||||
{
|
||||
value ??= "";
|
||||
if (_text == value) return;
|
||||
EnsureThreadAccess();
|
||||
_text = value;
|
||||
NotifyChanged(nameof(Text), value);
|
||||
OnTextChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>来自浏览器输入的文字:只修改值并触发事件,不再回传给浏览器(避免打字时光标跳动)</summary>
|
||||
internal void SetTextFromUser(string value)
|
||||
{
|
||||
if (_text == value) return;
|
||||
_text = value;
|
||||
OnTextChanged(EventArgs.Empty);
|
||||
}
|
||||
|
||||
public Point Location
|
||||
{
|
||||
get => _location;
|
||||
set => SetProp(ref _location, value, nameof(Location));
|
||||
}
|
||||
|
||||
public Size Size
|
||||
{
|
||||
get => _size;
|
||||
set => SetProp(ref _size, value, nameof(Size));
|
||||
}
|
||||
|
||||
public int Left { get => _location.X; set => Location = new Point(value, _location.Y); }
|
||||
public int Top { get => _location.Y; set => Location = new Point(_location.X, value); }
|
||||
public int Width { get => _size.Width; set => Size = new Size(value, _size.Height); }
|
||||
public int Height { get => _size.Height; set => Size = new Size(_size.Width, value); }
|
||||
public int Right => Left + Width;
|
||||
public int Bottom => Top + Height;
|
||||
|
||||
public Rectangle Bounds
|
||||
{
|
||||
get => new(_location, _size);
|
||||
set
|
||||
{
|
||||
Location = value.Location;
|
||||
Size = value.Size;
|
||||
}
|
||||
}
|
||||
|
||||
public virtual Size ClientSize
|
||||
{
|
||||
get => Size;
|
||||
set => Size = value;
|
||||
}
|
||||
|
||||
public bool Visible
|
||||
{
|
||||
get => _visible;
|
||||
set
|
||||
{
|
||||
if (SetProp(ref _visible, value, nameof(Visible))) OnVisibleChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => _enabled;
|
||||
set
|
||||
{
|
||||
if (SetProp(ref _enabled, value, nameof(Enabled))) OnEnabledChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual Color BackColor
|
||||
{
|
||||
get => !_backColor.IsEmpty ? _backColor : _parent?.BackColor ?? DefaultBackColor;
|
||||
set => SetProp(ref _backColor, value, nameof(BackColor));
|
||||
}
|
||||
|
||||
public virtual Color ForeColor
|
||||
{
|
||||
get => !_foreColor.IsEmpty ? _foreColor : _parent?.ForeColor ?? DefaultForeColor;
|
||||
set => SetProp(ref _foreColor, value, nameof(ForeColor));
|
||||
}
|
||||
|
||||
public virtual Font Font
|
||||
{
|
||||
get => _font ?? _parent?.Font ?? DefaultFont;
|
||||
set => SetProp(ref _font, value, nameof(Font));
|
||||
}
|
||||
|
||||
public virtual bool AutoSize
|
||||
{
|
||||
get => _autoSize;
|
||||
set => SetProp(ref _autoSize, value, nameof(AutoSize));
|
||||
}
|
||||
|
||||
public DockStyle Dock
|
||||
{
|
||||
get => _dock;
|
||||
set => SetProp(ref _dock, value, nameof(Dock));
|
||||
}
|
||||
|
||||
public AnchorStyles Anchor
|
||||
{
|
||||
get => _anchor;
|
||||
set => SetProp(ref _anchor, value, nameof(Anchor));
|
||||
}
|
||||
|
||||
// 以下属性只保存值,暂不影响浏览器显示
|
||||
public int TabIndex { get; set; }
|
||||
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; }
|
||||
public bool CausesValidation { get; set; } = true;
|
||||
|
||||
public bool Focused { get; private set; }
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public IntPtr Handle => new(int.Parse(SimId));
|
||||
|
||||
public bool IsHandleCreated => IsAttached;
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
// ---------- 事件 ----------
|
||||
|
||||
public event EventHandler? Click;
|
||||
public event EventHandler? DoubleClick;
|
||||
public event MouseEventHandler? MouseClick;
|
||||
public event MouseEventHandler? MouseDown;
|
||||
public event MouseEventHandler? MouseUp;
|
||||
public event EventHandler? MouseEnter;
|
||||
public event EventHandler? MouseLeave;
|
||||
public event EventHandler? TextChanged;
|
||||
public event EventHandler? VisibleChanged;
|
||||
public event EventHandler? EnabledChanged;
|
||||
public event EventHandler? GotFocus;
|
||||
public event EventHandler? LostFocus;
|
||||
public event EventHandler? Enter;
|
||||
public event EventHandler? Leave;
|
||||
|
||||
protected virtual void OnClick(EventArgs e) => Click?.Invoke(this, e);
|
||||
protected virtual void OnDoubleClick(EventArgs e) => DoubleClick?.Invoke(this, e);
|
||||
protected virtual void OnMouseClick(MouseEventArgs e) => MouseClick?.Invoke(this, e);
|
||||
protected virtual void OnMouseDown(MouseEventArgs e) => MouseDown?.Invoke(this, e);
|
||||
protected virtual void OnMouseUp(MouseEventArgs e) => MouseUp?.Invoke(this, e);
|
||||
protected virtual void OnMouseEnter(EventArgs e) => MouseEnter?.Invoke(this, e);
|
||||
protected virtual void OnMouseLeave(EventArgs e) => MouseLeave?.Invoke(this, e);
|
||||
protected virtual void OnTextChanged(EventArgs e) => TextChanged?.Invoke(this, e);
|
||||
protected virtual void OnVisibleChanged(EventArgs e) => VisibleChanged?.Invoke(this, e);
|
||||
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 OnEnter(EventArgs e) => Enter?.Invoke(this, e);
|
||||
protected virtual void OnLeave(EventArgs e) => Leave?.Invoke(this, e);
|
||||
|
||||
/// <summary>平台内部使用:在界面线程上处理浏览器上报的用户操作</summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public void PerformUserAction(UserAction action)
|
||||
{
|
||||
if (!Enabled || !Visible) return;
|
||||
OnUserAction(action);
|
||||
}
|
||||
|
||||
/// <summary>按真实 WinForms 的顺序,把一个用户操作翻译成事件</summary>
|
||||
protected virtual void OnUserAction(UserAction action)
|
||||
{
|
||||
switch (action.Action)
|
||||
{
|
||||
case "click":
|
||||
var args = new MouseEventArgs(MouseButtons.Left, 1, action.X, action.Y, 0);
|
||||
OnClick(args);
|
||||
OnMouseClick(args);
|
||||
break;
|
||||
case "mouseDown":
|
||||
OnMouseDown(new MouseEventArgs(ToMouseButtons(action.Button), 1, action.X, action.Y, 0));
|
||||
break;
|
||||
case "mouseUp":
|
||||
OnMouseUp(new MouseEventArgs(ToMouseButtons(action.Button), 1, action.X, action.Y, 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static MouseButtons ToMouseButtons(string button) => button == "right" ? MouseButtons.Right : MouseButtons.Left;
|
||||
|
||||
// ---------- 方法 ----------
|
||||
|
||||
public void Show() => Visible = true;
|
||||
public void Hide() => Visible = false;
|
||||
public bool Focus() => true;
|
||||
public void Select() { }
|
||||
public void BringToFront() { }
|
||||
public void SendToBack() { }
|
||||
public void Refresh() { }
|
||||
public void Invalidate() { }
|
||||
public void Update() { }
|
||||
public void SuspendLayout() { }
|
||||
public void ResumeLayout() { }
|
||||
public void ResumeLayout(bool performLayout) { }
|
||||
public void PerformLayout() { }
|
||||
|
||||
public bool InvokeRequired => !SimHost.Ui.IsUiThread;
|
||||
|
||||
public object? Invoke(Delegate method) => Invoke(method, Array.Empty<object>());
|
||||
|
||||
public object? Invoke(Delegate method, params object?[]? args)
|
||||
{
|
||||
object? result = null;
|
||||
SimHost.Ui.Send(() => result = method.DynamicInvoke(args));
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Invoke(Action method) => SimHost.Ui.Send(method);
|
||||
|
||||
public IAsyncResult BeginInvoke(Delegate method) => BeginInvoke(method, Array.Empty<object>());
|
||||
|
||||
public IAsyncResult BeginInvoke(Delegate method, params object?[]? args)
|
||||
{
|
||||
var result = new SimpleAsyncResult();
|
||||
SimHost.Ui.Post(() =>
|
||||
{
|
||||
try { result.Value = method.DynamicInvoke(args); }
|
||||
finally { result.Complete(); }
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public object? EndInvoke(IAsyncResult asyncResult)
|
||||
{
|
||||
var result = (SimpleAsyncResult)asyncResult;
|
||||
result.AsyncWaitHandle.WaitOne();
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !IsDisposed)
|
||||
{
|
||||
IsDisposed = true;
|
||||
_parent?.Controls.Remove(this);
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
// ---------- 属性变化与快照 ----------
|
||||
|
||||
/// <summary>修改字段并在需要时通知浏览器;返回值表示是否真的改变</summary>
|
||||
protected bool SetProp<T>(ref T field, T value, string prop)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
EnsureThreadAccess();
|
||||
field = value;
|
||||
NotifyChanged(prop, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void NotifyChanged(string prop, object? value)
|
||||
{
|
||||
if (IsAttached) SimHost.QueueChange(this, prop, value);
|
||||
}
|
||||
|
||||
protected void EnsureThreadAccess()
|
||||
{
|
||||
if (CheckForIllegalCrossThreadCalls && IsAttached && !SimHost.Ui.IsUiThread)
|
||||
throw new InvalidOperationException($"线程间操作无效: 从不是创建控件“{Name}”的线程访问它。");
|
||||
}
|
||||
|
||||
/// <summary>平台内部使用:生成发给浏览器的控件树</summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public JsonObject Snapshot()
|
||||
{
|
||||
var props = new JsonObject();
|
||||
WriteProps(props);
|
||||
var children = new JsonArray();
|
||||
foreach (var child in Controls) children.Add(child.Snapshot());
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = SimId,
|
||||
["type"] = ClientType,
|
||||
["name"] = Name,
|
||||
["props"] = props,
|
||||
["children"] = children,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>写入浏览器渲染需要的属性;子类追加自己的属性</summary>
|
||||
protected virtual void WriteProps(JsonObject props)
|
||||
{
|
||||
props["Text"] = Text;
|
||||
props["Location"] = PropValue.From(_location);
|
||||
props["Size"] = PropValue.From(_size);
|
||||
props["Visible"] = _visible;
|
||||
props["Enabled"] = _enabled;
|
||||
props["BackColor"] = PropValue.ColorCss(_backColor);
|
||||
props["ForeColor"] = PropValue.ColorCss(_foreColor);
|
||||
props["Font"] = PropValue.From(_font);
|
||||
props["AutoSize"] = _autoSize;
|
||||
props["Dock"] = _dock.ToString();
|
||||
}
|
||||
|
||||
/// <summary>浏览器按这个类型名选择渲染方式。学生自己继承的控件按最近的仿真库基类渲染。</summary>
|
||||
private string ClientType
|
||||
{
|
||||
get
|
||||
{
|
||||
for (var type = GetType(); type != null; type = type.BaseType)
|
||||
{
|
||||
var attribute = type.GetCustomAttribute<ClientTypeAttribute>(inherit: false);
|
||||
if (attribute != null) return attribute.Name;
|
||||
if (type.Assembly == typeof(Control).Assembly) return type.Name;
|
||||
}
|
||||
return nameof(Control);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SimpleAsyncResult : IAsyncResult
|
||||
{
|
||||
private readonly System.Threading.ManualResetEvent _done = new(false);
|
||||
public object? Value { get; set; }
|
||||
public object? AsyncState => null;
|
||||
public System.Threading.WaitHandle AsyncWaitHandle => _done;
|
||||
public bool CompletedSynchronously => false;
|
||||
public bool IsCompleted { get; private set; }
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
IsCompleted = true;
|
||||
_done.Set();
|
||||
}
|
||||
}
|
||||
|
||||
public class ControlCollection : IEnumerable<Control>
|
||||
{
|
||||
private readonly List<Control> _items = new();
|
||||
|
||||
public ControlCollection(Control owner) => Owner = owner;
|
||||
|
||||
public Control Owner { get; }
|
||||
public int Count => _items.Count;
|
||||
public Control this[int index] => _items[index];
|
||||
public Control? this[string key] => _items.Find(c => string.Equals(c.Name, key, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public virtual void Add(Control? value)
|
||||
{
|
||||
if (value == null || value.Parent == Owner) return;
|
||||
value.Parent?.Controls.Remove(value);
|
||||
_items.Add(value);
|
||||
value.SetParentInternal(Owner);
|
||||
if (Owner.IsAttached)
|
||||
{
|
||||
SimHost.Send(new JsonObject
|
||||
{
|
||||
["type"] = "controlAdded",
|
||||
["parentId"] = Owner.SimId,
|
||||
["index"] = _items.Count - 1,
|
||||
["node"] = value.Snapshot(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void AddRange(Control[] controls)
|
||||
{
|
||||
foreach (var control in controls) Add(control);
|
||||
}
|
||||
|
||||
public void Remove(Control? value)
|
||||
{
|
||||
if (value == null || !_items.Remove(value)) return;
|
||||
var wasAttached = Owner.IsAttached;
|
||||
value.SetParentInternal(null);
|
||||
if (wasAttached) SimHost.Send(new JsonObject { ["type"] = "controlRemoved", ["id"] = value.SimId });
|
||||
}
|
||||
|
||||
public void RemoveAt(int index) => Remove(_items[index]);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var control in _items.ToArray()) Remove(control);
|
||||
}
|
||||
|
||||
public bool Contains(Control? control) => control != null && _items.Contains(control);
|
||||
public int IndexOf(Control? control) => control == null ? -1 : _items.IndexOf(control);
|
||||
|
||||
public IEnumerator<Control> GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>仿真库内部使用:指定浏览器端的渲染类型名(教材控件库的控件用完整类名)</summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class ClientTypeAttribute : Attribute
|
||||
{
|
||||
public ClientTypeAttribute(string name) => Name = name;
|
||||
|
||||
public string Name { get; }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// WinForms 中设计器代码和教材代码会用到的枚举与结构体,取值与 .NET Framework 一致。
|
||||
using System;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public enum AutoScaleMode { None, Font, Dpi, Inherit }
|
||||
|
||||
public enum FormBorderStyle { None, FixedSingle, Fixed3D, FixedDialog, Sizable, FixedToolWindow, SizableToolWindow }
|
||||
|
||||
public enum FormStartPosition { Manual, CenterScreen, WindowsDefaultLocation, WindowsDefaultBounds, CenterParent }
|
||||
|
||||
public enum FormWindowState { Normal, Minimized, Maximized }
|
||||
|
||||
public enum DialogResult { None, OK, Cancel, Abort, Retry, Ignore, Yes, No, TryAgain = 10, Continue = 11 }
|
||||
|
||||
public enum MessageBoxButtons { OK, OKCancel, AbortRetryIgnore, YesNoCancel, YesNo, RetryCancel, CancelTryContinue }
|
||||
|
||||
public enum MessageBoxIcon
|
||||
{
|
||||
None = 0,
|
||||
Hand = 16,
|
||||
Stop = 16,
|
||||
Error = 16,
|
||||
Question = 32,
|
||||
Exclamation = 48,
|
||||
Warning = 48,
|
||||
Asterisk = 64,
|
||||
Information = 64,
|
||||
}
|
||||
|
||||
public enum MessageBoxDefaultButton { Button1 = 0, Button2 = 256, Button3 = 512, Button4 = 768 }
|
||||
|
||||
public enum FlatStyle { Flat, Popup, Standard, System }
|
||||
|
||||
public enum BorderStyle { None, FixedSingle, Fixed3D }
|
||||
|
||||
public enum ScrollBars { None, Horizontal, Vertical, Both }
|
||||
|
||||
public enum ComboBoxStyle { Simple, DropDown, DropDownList }
|
||||
|
||||
public enum DockStyle { None, Top, Bottom, Left, Right, Fill }
|
||||
|
||||
[Flags]
|
||||
public enum AnchorStyles { None = 0, Top = 1, Bottom = 2, Left = 4, Right = 8 }
|
||||
|
||||
public enum ImeMode { Inherit = -1, NoControl = 0, On = 1, Off = 2, Disable = 3 }
|
||||
|
||||
public enum RightToLeft { No, Yes, Inherit }
|
||||
|
||||
[Flags]
|
||||
public enum MouseButtons { None = 0, Left = 0x100000, Right = 0x200000, Middle = 0x400000, XButton1 = 0x800000, XButton2 = 0x1000000 }
|
||||
|
||||
public enum CloseReason { None, WindowsShutDown, MdiFormClosing, UserClosing, TaskManagerClosing, FormOwnerClosing, ApplicationExitCall }
|
||||
|
||||
public struct Padding : IEquatable<Padding>
|
||||
{
|
||||
public static readonly Padding Empty = new(0);
|
||||
|
||||
public Padding(int all) : this(all, all, all, all) { }
|
||||
|
||||
public Padding(int left, int top, int right, int bottom)
|
||||
{
|
||||
Left = left;
|
||||
Top = top;
|
||||
Right = right;
|
||||
Bottom = bottom;
|
||||
}
|
||||
|
||||
public int Left { get; set; }
|
||||
public int Top { get; set; }
|
||||
public int Right { get; set; }
|
||||
public int Bottom { get; set; }
|
||||
public int All
|
||||
{
|
||||
get => Left == Top && Top == Right && Right == Bottom ? Left : -1;
|
||||
set => Left = Top = Right = Bottom = value;
|
||||
}
|
||||
public int Horizontal => Left + Right;
|
||||
public int Vertical => Top + Bottom;
|
||||
|
||||
public bool Equals(Padding other) => Left == other.Left && Top == other.Top && Right == other.Right && Bottom == other.Bottom;
|
||||
public override bool Equals(object? obj) => obj is Padding other && Equals(other);
|
||||
public override int GetHashCode() => HashCode.Combine(Left, Top, Right, Bottom);
|
||||
public static bool operator ==(Padding a, Padding b) => a.Equals(b);
|
||||
public static bool operator !=(Padding a, Padding b) => !a.Equals(b);
|
||||
public override string ToString() => $"{{Left={Left},Top={Top},Right={Right},Bottom={Bottom}}}";
|
||||
}
|
||||
|
||||
/// <summary>窗口句柄接口;MessageBox.Show(owner, ...) 的 owner 参数类型</summary>
|
||||
public interface IWin32Window
|
||||
{
|
||||
IntPtr Handle { get; }
|
||||
}
|
||||
|
||||
public interface IButtonControl
|
||||
{
|
||||
DialogResult DialogResult { get; set; }
|
||||
void NotifyDefault(bool value);
|
||||
void PerformClick();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public class MouseEventArgs : EventArgs
|
||||
{
|
||||
public MouseEventArgs(MouseButtons button, int clicks, int x, int y, int delta)
|
||||
{
|
||||
Button = button;
|
||||
Clicks = clicks;
|
||||
X = x;
|
||||
Y = y;
|
||||
Delta = delta;
|
||||
}
|
||||
|
||||
public MouseButtons Button { get; }
|
||||
public int Clicks { get; }
|
||||
public int X { get; }
|
||||
public int Y { get; }
|
||||
public int Delta { get; }
|
||||
public System.Drawing.Point Location => new(X, Y);
|
||||
}
|
||||
|
||||
public delegate void MouseEventHandler(object? sender, MouseEventArgs e);
|
||||
|
||||
public class FormClosingEventArgs : CancelEventArgs
|
||||
{
|
||||
public FormClosingEventArgs(CloseReason closeReason, bool cancel) : base(cancel) => CloseReason = closeReason;
|
||||
|
||||
public CloseReason CloseReason { get; }
|
||||
}
|
||||
|
||||
public delegate void FormClosingEventHandler(object? sender, FormClosingEventArgs e);
|
||||
|
||||
public class FormClosedEventArgs : EventArgs
|
||||
{
|
||||
public FormClosedEventArgs(CloseReason closeReason) => CloseReason = closeReason;
|
||||
|
||||
public CloseReason CloseReason { get; }
|
||||
}
|
||||
|
||||
public delegate void FormClosedEventHandler(object? sender, FormClosedEventArgs e);
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Nodes;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// 窗体。Show() 时先触发 Load,再把整个控件树发给浏览器;Close() 依次触发 FormClosing、FormClosed。
|
||||
/// </summary>
|
||||
public class Form : ContainerControl
|
||||
{
|
||||
// Windows 10 下可调边框窗体的标题栏和边框尺寸,Size = ClientSize + 边框
|
||||
private static readonly Size NonClientSize = new(16, 39);
|
||||
|
||||
private Size _clientSize = new(284, 261);
|
||||
private FormBorderStyle _formBorderStyle = FormBorderStyle.Sizable;
|
||||
private bool _maximizeBox = true;
|
||||
private bool _minimizeBox = true;
|
||||
private bool _controlBox = true;
|
||||
private bool _loaded;
|
||||
private bool _closed;
|
||||
private bool _modal;
|
||||
private DialogResult _dialogResult;
|
||||
|
||||
public Form()
|
||||
{
|
||||
base.Visible = false;
|
||||
}
|
||||
|
||||
internal bool IsOpenInBrowser { get; private set; }
|
||||
|
||||
public override Size ClientSize
|
||||
{
|
||||
get => _clientSize;
|
||||
set => SetProp(ref _clientSize, value, nameof(ClientSize));
|
||||
}
|
||||
|
||||
public new Size Size
|
||||
{
|
||||
get => _clientSize + NonClientSize;
|
||||
set => ClientSize = value - NonClientSize;
|
||||
}
|
||||
|
||||
public FormBorderStyle FormBorderStyle
|
||||
{
|
||||
get => _formBorderStyle;
|
||||
set => SetProp(ref _formBorderStyle, value, nameof(FormBorderStyle));
|
||||
}
|
||||
|
||||
public bool MaximizeBox
|
||||
{
|
||||
get => _maximizeBox;
|
||||
set => SetProp(ref _maximizeBox, value, nameof(MaximizeBox));
|
||||
}
|
||||
|
||||
public bool MinimizeBox
|
||||
{
|
||||
get => _minimizeBox;
|
||||
set => SetProp(ref _minimizeBox, value, nameof(MinimizeBox));
|
||||
}
|
||||
|
||||
public bool ControlBox
|
||||
{
|
||||
get => _controlBox;
|
||||
set => SetProp(ref _controlBox, value, nameof(ControlBox));
|
||||
}
|
||||
|
||||
public FormStartPosition StartPosition { get; set; } = FormStartPosition.WindowsDefaultLocation;
|
||||
public FormWindowState WindowState { get; set; }
|
||||
public bool ShowInTaskbar { get; set; } = true;
|
||||
public bool TopMost { get; set; }
|
||||
public bool KeyPreview { get; set; }
|
||||
public double Opacity { get; set; } = 1;
|
||||
public IButtonControl? AcceptButton { get; set; }
|
||||
public IButtonControl? CancelButton { get; set; }
|
||||
public Form? Owner { get; set; }
|
||||
public bool Modal => _modal;
|
||||
|
||||
public DialogResult DialogResult
|
||||
{
|
||||
get => _dialogResult;
|
||||
set
|
||||
{
|
||||
_dialogResult = value;
|
||||
// 模态窗体设置 DialogResult 后自动关闭
|
||||
if (_modal && value != DialogResult.None) Close();
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler? Load;
|
||||
public event EventHandler? Shown;
|
||||
public event EventHandler? Activated;
|
||||
public event FormClosingEventHandler? FormClosing;
|
||||
public event FormClosedEventHandler? FormClosed;
|
||||
|
||||
protected virtual void OnLoad(EventArgs e) => Load?.Invoke(this, e);
|
||||
protected virtual void OnShown(EventArgs e) => Shown?.Invoke(this, e);
|
||||
protected virtual void OnActivated(EventArgs e) => Activated?.Invoke(this, e);
|
||||
protected virtual void OnFormClosing(FormClosingEventArgs e) => FormClosing?.Invoke(this, e);
|
||||
protected virtual void OnFormClosed(FormClosedEventArgs e) => FormClosed?.Invoke(this, e);
|
||||
|
||||
public new void Show()
|
||||
{
|
||||
if (_closed) throw new ObjectDisposedException(Name, "无法访问已释放的对象。");
|
||||
if (IsOpenInBrowser)
|
||||
{
|
||||
Visible = true;
|
||||
return;
|
||||
}
|
||||
SimHost.Ui.Send(() => Open(modal: false));
|
||||
}
|
||||
|
||||
public void Show(IWin32Window? owner) => Show();
|
||||
|
||||
public DialogResult ShowDialog() => ShowDialog(null);
|
||||
|
||||
public DialogResult ShowDialog(IWin32Window? owner)
|
||||
{
|
||||
if (IsOpenInBrowser && Visible) throw new InvalidOperationException("窗体已可见,不能显示为模式对话框。");
|
||||
// 模态窗体关闭后不释放,可以再次 ShowDialog(Load 只在第一次触发)
|
||||
_closed = false;
|
||||
_dialogResult = DialogResult.None;
|
||||
_modal = true;
|
||||
Open(modal: true);
|
||||
// 嵌套消息循环:等到窗体关闭(或被隐藏)后 ShowDialog 才返回
|
||||
SimHost.Ui.RunUntil(() => _closed || !Visible);
|
||||
_modal = false;
|
||||
return _dialogResult == DialogResult.None ? DialogResult.Cancel : _dialogResult;
|
||||
}
|
||||
|
||||
private void Open(bool modal)
|
||||
{
|
||||
if (!_loaded)
|
||||
{
|
||||
_loaded = true;
|
||||
OnLoad(EventArgs.Empty);
|
||||
if (_closed) return; // Load 里调用了 Close()
|
||||
RaiseUserControlLoad(this);
|
||||
}
|
||||
base.Visible = true;
|
||||
IsOpenInBrowser = true;
|
||||
Application.OpenFormsList.Add(this);
|
||||
SimHost.Send(new JsonObject { ["type"] = "formShown", ["form"] = Snapshot(), ["modal"] = modal });
|
||||
OnShown(EventArgs.Empty);
|
||||
OnActivated(EventArgs.Empty);
|
||||
}
|
||||
|
||||
private static void RaiseUserControlLoad(Control parent)
|
||||
{
|
||||
foreach (var child in parent.Controls)
|
||||
{
|
||||
if (child is UserControl userControl) userControl.RaiseLoadOnce();
|
||||
RaiseUserControlLoad(child);
|
||||
}
|
||||
}
|
||||
|
||||
public void Close() => CloseCore(CloseReason.UserClosing);
|
||||
|
||||
internal void CloseCore(CloseReason reason)
|
||||
{
|
||||
if (_closed) return;
|
||||
if (!IsOpenInBrowser)
|
||||
{
|
||||
_closed = true;
|
||||
return;
|
||||
}
|
||||
var closing = new FormClosingEventArgs(reason, false);
|
||||
OnFormClosing(closing);
|
||||
if (closing.Cancel) return;
|
||||
_closed = true;
|
||||
if (_modal && _dialogResult == DialogResult.None) _dialogResult = DialogResult.Cancel;
|
||||
OnFormClosed(new FormClosedEventArgs(reason));
|
||||
IsOpenInBrowser = false;
|
||||
Application.OpenFormsList.Remove(this);
|
||||
SimHost.Send(new JsonObject { ["type"] = "formClosed", ["formId"] = SimId });
|
||||
SimHost.Ui.Wake();
|
||||
if (!_modal) Dispose();
|
||||
}
|
||||
|
||||
internal bool IsClosed => _closed;
|
||||
|
||||
public void Activate() { }
|
||||
|
||||
public void CenterToScreen() { }
|
||||
|
||||
protected override void OnUserAction(UserAction action)
|
||||
{
|
||||
if (action.Action == "close")
|
||||
{
|
||||
CloseCore(CloseReason.UserClosing);
|
||||
return;
|
||||
}
|
||||
base.OnUserAction(action);
|
||||
}
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["Size"] = PropValue.From(Size);
|
||||
props["ClientSize"] = PropValue.From(_clientSize);
|
||||
props["FormBorderStyle"] = _formBorderStyle.ToString();
|
||||
props["MaximizeBox"] = _maximizeBox;
|
||||
props["MinimizeBox"] = _minimizeBox;
|
||||
props["ControlBox"] = _controlBox;
|
||||
props["StartPosition"] = StartPosition.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public class Label : Control
|
||||
{
|
||||
private BorderStyle _borderStyle;
|
||||
private ContentAlignment _textAlign = ContentAlignment.TopLeft;
|
||||
|
||||
protected override Size DefaultSize => new(100, 23);
|
||||
|
||||
public BorderStyle BorderStyle
|
||||
{
|
||||
get => _borderStyle;
|
||||
set => SetProp(ref _borderStyle, value, nameof(BorderStyle));
|
||||
}
|
||||
|
||||
public ContentAlignment TextAlign
|
||||
{
|
||||
get => _textAlign;
|
||||
set => SetProp(ref _textAlign, value, nameof(TextAlign));
|
||||
}
|
||||
|
||||
public bool UseMnemonic { get; set; } = true;
|
||||
public FlatStyle FlatStyle { get; set; } = FlatStyle.Standard;
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["BorderStyle"] = _borderStyle.ToString();
|
||||
props["TextAlign"] = _textAlign.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
/// <summary>在浏览器里弹出对话框,阻塞到用户点击按钮后返回结果(期间界面线程照常处理 Timer 等消息)</summary>
|
||||
public static class MessageBox
|
||||
{
|
||||
public static DialogResult Show(string? text) => Show(text, "");
|
||||
|
||||
public static DialogResult Show(string? text, string? caption) =>
|
||||
Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.None);
|
||||
|
||||
public static DialogResult Show(string? text, string? caption, MessageBoxButtons buttons) =>
|
||||
Show(text, caption, buttons, MessageBoxIcon.None);
|
||||
|
||||
public static DialogResult Show(string? text, string? caption, MessageBoxButtons buttons, MessageBoxIcon icon) =>
|
||||
ShowCore(text, caption, buttons, icon);
|
||||
|
||||
public static DialogResult Show(string? text, string? caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) =>
|
||||
ShowCore(text, caption, buttons, icon);
|
||||
|
||||
public static DialogResult Show(IWin32Window? owner, string? text) => Show(text);
|
||||
|
||||
public static DialogResult Show(IWin32Window? owner, string? text, string? caption) => Show(text, caption);
|
||||
|
||||
public static DialogResult Show(IWin32Window? owner, string? text, string? caption, MessageBoxButtons buttons) =>
|
||||
Show(text, caption, buttons);
|
||||
|
||||
public static DialogResult Show(IWin32Window? owner, string? text, string? caption, MessageBoxButtons buttons, MessageBoxIcon icon) =>
|
||||
Show(text, caption, buttons, icon);
|
||||
|
||||
private static DialogResult ShowCore(string? text, string? caption, MessageBoxButtons buttons, MessageBoxIcon icon)
|
||||
{
|
||||
var reply = SimHost.Request(new JsonObject
|
||||
{
|
||||
["type"] = "messageBox",
|
||||
["text"] = text ?? "",
|
||||
["caption"] = caption ?? "",
|
||||
["buttons"] = buttons.ToString(),
|
||||
["icon"] = icon.ToString(),
|
||||
});
|
||||
return Enum.TryParse<DialogResult>((string?)reply, out var result) ? result : DialogResult.Cancel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Nodes;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public abstract class TextBoxBase : Control
|
||||
{
|
||||
private bool _multiline;
|
||||
private bool _readOnly;
|
||||
private BorderStyle _borderStyle = BorderStyle.Fixed3D;
|
||||
|
||||
public bool Multiline
|
||||
{
|
||||
get => _multiline;
|
||||
set => SetProp(ref _multiline, value, nameof(Multiline));
|
||||
}
|
||||
|
||||
public bool ReadOnly
|
||||
{
|
||||
get => _readOnly;
|
||||
set => SetProp(ref _readOnly, value, nameof(ReadOnly));
|
||||
}
|
||||
|
||||
public BorderStyle BorderStyle
|
||||
{
|
||||
get => _borderStyle;
|
||||
set => SetProp(ref _borderStyle, value, nameof(BorderStyle));
|
||||
}
|
||||
|
||||
public int MaxLength { get; set; } = 32767;
|
||||
public bool WordWrap { get; set; } = true;
|
||||
public bool HideSelection { get; set; } = true;
|
||||
public bool Modified { get; set; }
|
||||
public int SelectionStart { get; set; }
|
||||
public int SelectionLength { get; set; }
|
||||
public int TextLength => Text.Length;
|
||||
|
||||
public string[] Lines
|
||||
{
|
||||
get => Text.Replace("\r\n", "\n").Split('\n');
|
||||
set => Text = string.Join("\r\n", value);
|
||||
}
|
||||
|
||||
public void AppendText(string text) => Text += text;
|
||||
public void Clear() => Text = "";
|
||||
public void SelectAll() { }
|
||||
public void Select(int start, int length) { }
|
||||
public void ScrollToCaret() { }
|
||||
|
||||
protected override void OnUserAction(UserAction action)
|
||||
{
|
||||
if (action.Action == "text" && !ReadOnly)
|
||||
{
|
||||
Modified = true;
|
||||
SetTextFromUser(action.Value ?? "");
|
||||
return;
|
||||
}
|
||||
base.OnUserAction(action);
|
||||
}
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["Multiline"] = _multiline;
|
||||
props["ReadOnly"] = _readOnly;
|
||||
props["BorderStyle"] = _borderStyle.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public class TextBox : TextBoxBase
|
||||
{
|
||||
private char _passwordChar;
|
||||
private bool _useSystemPasswordChar;
|
||||
private ScrollBars _scrollBars;
|
||||
|
||||
protected override Size DefaultSize => new(100, 21);
|
||||
|
||||
public char PasswordChar
|
||||
{
|
||||
get => _passwordChar;
|
||||
set => SetProp(ref _passwordChar, value, nameof(PasswordChar));
|
||||
}
|
||||
|
||||
public bool UseSystemPasswordChar
|
||||
{
|
||||
get => _useSystemPasswordChar;
|
||||
set => SetProp(ref _useSystemPasswordChar, value, nameof(UseSystemPasswordChar));
|
||||
}
|
||||
|
||||
public ScrollBars ScrollBars
|
||||
{
|
||||
get => _scrollBars;
|
||||
set => SetProp(ref _scrollBars, value, nameof(ScrollBars));
|
||||
}
|
||||
|
||||
public HorizontalAlignment TextAlign { get; set; }
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["PasswordChar"] = PropValue.From(_passwordChar);
|
||||
props["UseSystemPasswordChar"] = _useSystemPasswordChar;
|
||||
props["ScrollBars"] = _scrollBars.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public enum HorizontalAlignment { Left, Right, Center }
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public delegate void MethodInvoker();
|
||||
|
||||
/// <summary>界面线程的同步上下文:Send 同步执行,Post 排队执行(教材项目 8 用它从后台线程更新界面)</summary>
|
||||
public sealed class WindowsFormsSynchronizationContext : SynchronizationContext
|
||||
{
|
||||
public override void Send(SendOrPostCallback d, object? state) => SimHost.Ui.Send(() => d(state));
|
||||
|
||||
public override void Post(SendOrPostCallback d, object? state) => SimHost.Ui.Post(() => d(state));
|
||||
|
||||
public override SynchronizationContext CreateCopy() => new WindowsFormsSynchronizationContext();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.ComponentModel;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
/// <summary>窗体定时器:Tick 事件总是在界面线程上触发,上一次 Tick 处理完之前不会重复排队</summary>
|
||||
public class Timer : Component
|
||||
{
|
||||
private System.Threading.Timer? _timer;
|
||||
private int _interval = 100;
|
||||
private bool _enabled;
|
||||
private int _tickQueued;
|
||||
|
||||
public Timer() { }
|
||||
|
||||
public Timer(IContainer container) : this() => container.Add(this);
|
||||
|
||||
public int Interval
|
||||
{
|
||||
get => _interval;
|
||||
set
|
||||
{
|
||||
if (value < 1) throw new ArgumentOutOfRangeException(nameof(value), $"值“{value}”对于 Interval 无效。Interval 必须大于 0。");
|
||||
_interval = value;
|
||||
if (_enabled) _timer?.Change(value, value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => _enabled;
|
||||
set
|
||||
{
|
||||
if (_enabled == value) return;
|
||||
_enabled = value;
|
||||
if (value)
|
||||
{
|
||||
_timer ??= new System.Threading.Timer(_ => QueueTick());
|
||||
_timer.Change(_interval, _interval);
|
||||
}
|
||||
else
|
||||
{
|
||||
_timer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public object? Tag { get; set; }
|
||||
|
||||
public event EventHandler? Tick;
|
||||
|
||||
public void Start() => Enabled = true;
|
||||
|
||||
public void Stop() => Enabled = false;
|
||||
|
||||
protected virtual void OnTick(EventArgs e) => Tick?.Invoke(this, e);
|
||||
|
||||
private void QueueTick()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _tickQueued, 1) == 1) return;
|
||||
SimHost.Ui.Post(() =>
|
||||
{
|
||||
_tickQueued = 0;
|
||||
if (_enabled) OnTick(EventArgs.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_enabled = false;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace Sim.Host;
|
||||
|
||||
/// <summary>把控件属性值转换成发给浏览器的 JSON(格式见 web/src/winforms/format.ts)</summary>
|
||||
public static class PropValue
|
||||
{
|
||||
public static JsonNode? From(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
string s => JsonValueOf(s),
|
||||
bool b => JsonValueOf(b),
|
||||
char c => JsonValueOf(c == '\0' ? "" : c.ToString()),
|
||||
int or long or short or byte => JsonValueOf(Convert.ToInt64(value, CultureInfo.InvariantCulture)),
|
||||
float or double or decimal => JsonValueOf(Convert.ToDouble(value, CultureInfo.InvariantCulture)),
|
||||
Enum e => JsonValueOf(e.ToString()),
|
||||
Point p => new JsonObject { ["x"] = p.X, ["y"] = p.Y },
|
||||
Size s => new JsonObject { ["width"] = s.Width, ["height"] = s.Height },
|
||||
Color c => ColorCss(c),
|
||||
Font f => new JsonObject
|
||||
{
|
||||
["family"] = f.Name,
|
||||
["size"] = f.SizeInPoints,
|
||||
["bold"] = f.Bold,
|
||||
["italic"] = f.Italic,
|
||||
["underline"] = f.Underline,
|
||||
["strikeout"] = f.Strikeout,
|
||||
},
|
||||
Padding p => new JsonObject { ["left"] = p.Left, ["top"] = p.Top, ["right"] = p.Right, ["bottom"] = p.Bottom },
|
||||
IEnumerable list => ListOf(list),
|
||||
_ => JsonValueOf(value.ToString() ?? ""),
|
||||
};
|
||||
|
||||
/// <summary>未设置的颜色(Color.Empty)返回 null,表示沿用父控件的颜色</summary>
|
||||
public static JsonNode? ColorCss(Color color)
|
||||
{
|
||||
if (color.IsEmpty) return null;
|
||||
if (color.A == 0) return "transparent";
|
||||
if (color.A < 255)
|
||||
return $"rgba({color.R},{color.G},{color.B},{(color.A / 255.0).ToString("0.###", CultureInfo.InvariantCulture)})";
|
||||
return $"#{color.R:x2}{color.G:x2}{color.B:x2}";
|
||||
}
|
||||
|
||||
private static JsonArray ListOf(IEnumerable items)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var item in items) array.Add(From(item is string or null ? item : item.ToString()));
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonNode JsonValueOf<T>(T value) => JsonValue.Create(value)!;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 运行器与服务器之间的消息格式,对应 shared/protocol.ts,两边修改时要同步。
|
||||
// 运行器 → 服务器的消息在各处用 JsonObject 直接构造;这里只集中定义从服务器收到的动作。
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Sim.Host;
|
||||
|
||||
/// <summary>浏览器上报的用户操作(shared/protocol.ts 中的 UserAction)</summary>
|
||||
public sealed class UserAction
|
||||
{
|
||||
public required string Action { get; init; }
|
||||
public string? Value { get; init; }
|
||||
public int Index { get; init; }
|
||||
public string Button { get; init; } = "left";
|
||||
public int X { get; init; }
|
||||
public int Y { get; init; }
|
||||
|
||||
public static UserAction FromJson(JsonObject json) => new()
|
||||
{
|
||||
Action = (string?)json["action"] ?? "",
|
||||
Value = (string?)json["value"],
|
||||
Index = (int?)json["index"] ?? -1,
|
||||
Button = (string?)json["button"] ?? "left",
|
||||
X = (int?)json["x"] ?? 0,
|
||||
Y = (int?)json["y"] ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace Sim.Host;
|
||||
|
||||
/// <summary>
|
||||
/// 运行器的全局状态:向服务器发消息、收集控件属性变化、登记控件、等待浏览器的应答。
|
||||
/// </summary>
|
||||
public static class SimHost
|
||||
{
|
||||
// 中文原样输出,不转义成 \uXXXX
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
private static readonly object WriteLock = new();
|
||||
private static readonly List<JsonObject> PendingChanges = new();
|
||||
private static readonly ConcurrentDictionary<string, Control> ControlsById = new();
|
||||
private static readonly ConcurrentDictionary<int, PendingRequest> Requests = new();
|
||||
private static TextWriter _output = TextWriter.Null;
|
||||
private static int _nextControlId;
|
||||
private static int _nextRequestId;
|
||||
|
||||
public static UiDispatcher Ui { get; } = new();
|
||||
|
||||
/// <summary>学生程序所在的程序集,用于筛选异常堆栈中属于学生代码的部分</summary>
|
||||
public static Assembly? StudentAssembly { get; set; }
|
||||
|
||||
/// <summary>运行器启动时调用:协议消息写到真正的标准输出(学生的 Console 输出另行转发)</summary>
|
||||
public static void Initialize(Stream stdout)
|
||||
{
|
||||
_output = new StreamWriter(stdout, new UTF8Encoding(false)) { AutoFlush = true };
|
||||
}
|
||||
|
||||
// ---------- 发送消息 ----------
|
||||
|
||||
public static void Send(JsonObject message)
|
||||
{
|
||||
lock (WriteLock)
|
||||
{
|
||||
FlushChangesLocked();
|
||||
_output.WriteLine(message.ToJsonString(JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>记录一个已显示控件的属性变化;在界面线程上会合并到本轮消息处理结束时一起发送</summary>
|
||||
public static void QueueChange(Control control, string prop, object? value)
|
||||
{
|
||||
var change = new JsonObject { ["id"] = control.SimId, ["prop"] = prop, ["value"] = PropValue.From(value) };
|
||||
lock (WriteLock)
|
||||
{
|
||||
PendingChanges.Add(change);
|
||||
if (!Ui.IsUiThread) FlushChangesLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public static void FlushChanges()
|
||||
{
|
||||
lock (WriteLock) FlushChangesLocked();
|
||||
}
|
||||
|
||||
private static void FlushChangesLocked()
|
||||
{
|
||||
if (PendingChanges.Count == 0) return;
|
||||
var changes = new JsonArray();
|
||||
foreach (var change in PendingChanges) changes.Add(change);
|
||||
PendingChanges.Clear();
|
||||
_output.WriteLine(new JsonObject { ["type"] = "patch", ["changes"] = changes }.ToJsonString(JsonOptions));
|
||||
}
|
||||
|
||||
public static void SendConsole(string text) => Send(new JsonObject { ["type"] = "console", ["text"] = text });
|
||||
|
||||
public static void SendDevice(string device, string action, object? data) => Send(new JsonObject
|
||||
{
|
||||
["type"] = "device",
|
||||
["device"] = device,
|
||||
["action"] = action,
|
||||
["data"] = PropValue.From(data),
|
||||
});
|
||||
|
||||
// ---------- 异常 ----------
|
||||
|
||||
public static void ReportException(Exception ex)
|
||||
{
|
||||
while (ex is TargetInvocationException { InnerException: not null } wrapper) ex = wrapper.InnerException!;
|
||||
Send(new JsonObject
|
||||
{
|
||||
["type"] = "exception",
|
||||
["message"] = $"{ex.GetType().FullName}: {ex.Message}",
|
||||
["detail"] = StudentStackTrace(ex),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>只保留学生代码的堆栈行,去掉仿真库内部的调用</summary>
|
||||
private static string StudentStackTrace(Exception ex)
|
||||
{
|
||||
var lines = (ex.StackTrace ?? "").Split('\n').Select(l => l.TrimEnd());
|
||||
var studentLines = lines.Where(l => l.Contains(".cs:") && !l.Contains("/runtime/Sim.")).ToList();
|
||||
return string.Join("\n", studentLines.Count > 0 ? studentLines : lines);
|
||||
}
|
||||
|
||||
// ---------- 控件登记 ----------
|
||||
|
||||
public static string NewControlId() => Interlocked.Increment(ref _nextControlId).ToString();
|
||||
|
||||
public static void Register(Control control) => ControlsById[control.SimId] = control;
|
||||
|
||||
public static Control? FindControl(string id) => ControlsById.TryGetValue(id, out var control) ? control : null;
|
||||
|
||||
// ---------- 请求与应答(如 MessageBox 等待用户点按钮) ----------
|
||||
|
||||
/// <summary>
|
||||
/// 向浏览器发出请求并阻塞等待应答。在界面线程上调用时会继续处理消息队列(和真实的模态对话框一样,Timer 照常触发)。
|
||||
/// </summary>
|
||||
public static JsonNode? Request(JsonObject message)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextRequestId);
|
||||
var pending = new PendingRequest();
|
||||
Requests[id] = pending;
|
||||
message["requestId"] = id;
|
||||
Send(message);
|
||||
if (Ui.IsUiThread) Ui.RunUntil(() => pending.Done.IsSet);
|
||||
else pending.Done.Wait();
|
||||
Requests.TryRemove(id, out _);
|
||||
return pending.Result;
|
||||
}
|
||||
|
||||
public static void CompleteRequest(int id, JsonNode? result)
|
||||
{
|
||||
if (!Requests.TryGetValue(id, out var pending)) return;
|
||||
pending.Result = result;
|
||||
pending.Done.Set();
|
||||
Ui.Wake();
|
||||
}
|
||||
|
||||
private sealed class PendingRequest
|
||||
{
|
||||
public ManualResetEventSlim Done { get; } = new();
|
||||
public JsonNode? Result { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
|
||||
namespace Sim.Host;
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 WinForms 的界面线程消息循环:所有用户操作、Timer.Tick、Invoke 都排队到界面线程上依次执行。
|
||||
/// </summary>
|
||||
public sealed class UiDispatcher
|
||||
{
|
||||
private readonly BlockingCollection<Action> _queue = new();
|
||||
|
||||
public Thread? UiThread { get; private set; }
|
||||
|
||||
public bool IsUiThread => Thread.CurrentThread == UiThread;
|
||||
|
||||
/// <summary>由运行器在启动学生程序的线程上调用一次</summary>
|
||||
public void BindToCurrentThread() => UiThread = Thread.CurrentThread;
|
||||
|
||||
public void Post(Action action) => _queue.Add(action);
|
||||
|
||||
/// <summary>在界面线程上同步执行(Control.Invoke、SynchronizationContext.Send)</summary>
|
||||
public void Send(Action action)
|
||||
{
|
||||
if (IsUiThread)
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
using var done = new ManualResetEventSlim();
|
||||
Exception? error = null;
|
||||
Post(() =>
|
||||
{
|
||||
try { action(); }
|
||||
catch (Exception ex) { error = ex; }
|
||||
finally { done.Set(); }
|
||||
});
|
||||
done.Wait();
|
||||
if (error != null) throw new System.Reflection.TargetInvocationException(error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 运行消息循环,直到 <paramref name="done"/> 为真。可以嵌套:
|
||||
/// Application.Run、ShowDialog、MessageBox.Show 都会在这里等待,期间 Timer 等照常执行。
|
||||
/// </summary>
|
||||
public void RunUntil(Func<bool> done)
|
||||
{
|
||||
while (!done())
|
||||
{
|
||||
if (_queue.TryTake(out var action, 50)) Execute(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>处理队列里已有的消息后返回(Application.DoEvents)</summary>
|
||||
public void DoEvents()
|
||||
{
|
||||
while (_queue.TryTake(out var action)) Execute(action);
|
||||
}
|
||||
|
||||
/// <summary>唤醒等待中的消息循环,让它重新检查结束条件</summary>
|
||||
public void Wake() => Post(static () => { });
|
||||
|
||||
private static void Execute(Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 真实 WinForms 会弹出"未经处理的异常"对话框,点"继续"后程序接着运行,这里同样继续
|
||||
SimHost.ReportException(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SimHost.FlushChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace System.IO.Ports;
|
||||
|
||||
/// <summary>
|
||||
/// 学生代码只用 SerialPort.GetPortNames() 填下拉框,设备读写都走 DeviceCtrlHelper。
|
||||
/// 这里返回实训台上的虚拟串口;直接读写串口不在教学范围内,调用时给出提示。
|
||||
/// </summary>
|
||||
public class SerialPort : Component
|
||||
{
|
||||
/// <summary>COM1 是电脑自带串口,COM2–COM5 对应实训台上的 4017、4150、ZigBee 协调器和 LED 屏</summary>
|
||||
private static readonly string[] VirtualPorts = { "COM1", "COM2", "COM3", "COM4", "COM5" };
|
||||
|
||||
public SerialPort() { }
|
||||
|
||||
public SerialPort(string portName) => PortName = portName;
|
||||
|
||||
public SerialPort(string portName, int baudRate) : this(portName) => BaudRate = baudRate;
|
||||
|
||||
public static string[] GetPortNames() => (string[])VirtualPorts.Clone();
|
||||
|
||||
public string PortName { get; set; } = "COM1";
|
||||
public int BaudRate { get; set; } = 9600;
|
||||
public int DataBits { get; set; } = 8;
|
||||
public int ReadTimeout { get; set; } = -1;
|
||||
public int WriteTimeout { get; set; } = -1;
|
||||
public bool IsOpen { get; private set; }
|
||||
|
||||
public void Open() => throw NotSupported();
|
||||
public void Close() => IsOpen = false;
|
||||
public void Write(string text) => throw NotSupported();
|
||||
public void WriteLine(string text) => throw NotSupported();
|
||||
public string ReadLine() => throw NotSupported();
|
||||
public string ReadExisting() => throw NotSupported();
|
||||
|
||||
private static NotSupportedException NotSupported() =>
|
||||
new("仿真平台不支持直接读写串口,请使用 DeviceCtrlHelper 操作实训台设备。");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- 仿真的 System.Windows.Forms 子集、System.Drawing 中框架缺少的类型(Font 等)、System.IO.Ports,
|
||||
以及运行器与浏览器之间的通信层(Sim.Host)。学生代码编译时引用本程序集。 -->
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Sim.WinForms</AssemblyName>
|
||||
<RootNamespace>System.Windows.Forms</RootNamespace>
|
||||
<!-- 仿真类型的公开 API 要和真实 WinForms 一致,很多成员没有 XML 注释 -->
|
||||
<NoWarn>$(NoWarn);CS1591;CS8618</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Reference in new issue
Block a user