第 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:
xuyueandClaude Opus 5.5 committed 2026-10-22 10:15:14 +08:00
commit 98be8331bc
71 files changed
+7919

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<!-- 仿真库在 System.* 命名空间里定义类型,关闭隐式 using 以免混淆 -->
<ImplicitUsings>disable</ImplicitUsings>
<!-- 需要 zh-CN 区域设置:日期格式要和中文 Windows 一致 -->
<InvariantGlobalization>false</InvariantGlobalization>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
+5
View File
@@ -0,0 +1,5 @@
<Solution>
<Project Path="Sim.WinForms/Sim.WinForms.csproj" />
<Project Path="Sim.Devices/Sim.Devices.csproj" />
<Project Path="Sim.Runner/Sim.Runner.csproj" />
</Solution>
@@ -0,0 +1,151 @@
using System;
using System.Linq;
using DeviceCtrlLibrary.Model;
using Sim.Host;
namespace DeviceCtrlLibrary;
/// <summary>
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数。
/// </summary>
public class DeviceCtrlHelper
{
// 教材 dll 里这些是公开静态字段(不是属性),这里保持一致
public static SerialPortHelper serialPortHelper = new();
public static LEDHelper ledHelper = new();
public static ADAM4017Helper adam4017Helper = new();
public static ADAM4150Helper adam4150Helper = new();
public static ZigBeeHelper zigBeeHelper = new();
}
public class SerialPortHelper
{
private string? _portName;
private int _baudRate;
public void Open(string portName, int BaudRate)
{
_portName = portName;
_baudRate = BaudRate;
}
/// <summary>返回一串十六进制字节(大写、空格分隔、末尾带空格),未打开时返回 "Error"</summary>
public string GetStringData()
{
if (string.IsNullOrWhiteSpace(_portName) || _baudRate == 0) return "Error";
var bytes = new byte[27];
Random.Shared.NextBytes(bytes);
return string.Concat(bytes.Select(b => $"{b:X2} "));
}
public void Close() => _portName = null;
}
public class LEDHelper
{
private const string SendFailed = "Error:发送失败";
private string? _portName;
public void OpenLedCom(string portName) => _portName = portName;
/// <summary>成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"</summary>
public string SendLedString(string sendString)
{
if (string.IsNullOrWhiteSpace(_portName)) return SendFailed;
SimHost.SendDevice("led", "text", sendString);
return sendString;
}
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留空方法让书上代码能编译</summary>
public void Close() => _portName = null;
}
public class ADAM4017Helper
{
private string? _portName;
private int[] _channels = { 0, 1, 2, 3, 4, 5, 6 };
public void Open(string PortName, int Temperature_Port = 0, int Humidity_Port = 1, int Light_Port = 2,
int AirPressure_Port = 3, int AirQuality_Port = 4, int CO2_Port = 5, int Wind_Port = 6)
{
_portName = PortName;
_channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port };
}
/// <summary>
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
/// 串口未打开时所有属性为 null,与教材仿真版一致。
/// </summary>
public ADAM4017ResModel SetData()
{
var model = new ADAM4017ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model;
model.temperatureValue = Read(0);
model.humidityValue = Read(1);
model.lightValue = Read(2);
model.airPressureValue = Read(3);
model.airQualityValue = Read(4);
model.CO2Value = Read(5);
model.windValue = Read(6);
return model;
}
private string Read(int quantity) =>
TrainingBoard.AnalogInputs.TryGetValue(_channels[quantity], out var value) ? value.ToString() : "0";
public void Close() => _portName = null;
}
public class ADAM4150Helper
{
private string? _portName;
private int[] _channels = { 0, 1, 2, 3 };
public void Open(string PortName, int BodyInfrared_Port = 0, int Fire_Port = 1, int Smoke_Port = 2, int Infrared_Port = 3)
{
_portName = PortName;
_channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port };
}
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false</summary>
public ADAM4150ResModel SetData()
{
var model = new ADAM4150ResModel();
if (string.IsNullOrWhiteSpace(_portName)) return model;
model.bodyInfraredValue = Read(0);
model.fireValue = Read(1);
model.smokeValue = Read(2);
model.infraredValue = Read(3);
return model;
}
private bool Read(int sensor) => TrainingBoard.DigitalInputs.TryGetValue(_channels[sensor], out var value) && value;
/// <summary>控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯),通道号有效时返回 true</summary>
public bool OnOff(int DO, bool IsOpen)
{
if (DO < 0 || DO > 4) return false;
SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
return true;
}
public void Close() => _portName = null;
}
public class ZigBeeHelper
{
private string? _portName;
public void Open(string PortName) => _portName = PortName;
/// <summary>控制 ZigBee 继电器节点 0、1,通道号有效时返回 true</summary>
public bool OnOff(int RelayNum, bool IsOpen)
{
if (RelayNum < 0 || RelayNum > 1) return false;
SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 });
return true;
}
public void Close() => _portName = null;
}
@@ -0,0 +1,30 @@
// 与教材 dll 一致:ADAM-4017 的数值全部是字符串,ADAM-4150 的数值全部是 bool。
namespace DeviceCtrlLibrary.Model;
public class ADAM4017ResModel
{
public string temperatureValue { get; set; }
public string humidityValue { get; set; }
public string lightValue { get; set; }
public string airPressureValue { get; set; }
public string airQualityValue { get; set; }
public string CO2Value { get; set; }
public string windValue { get; set; }
}
public class ADAM4150ResModel
{
public bool bodyInfraredValue { get; set; }
public bool fireValue { get; set; }
public bool smokeValue { get; set; }
public bool infraredValue { get; set; }
}
public class ZigBeeResModel
{
public string waterLevelValue { get; set; }
public string waterTemperatureValue { get; set; }
public string soilMoistureValue { get; set; }
// 拼写与教材 dll 一致
public string soilTemperatuerValue { get; set; }
}
@@ -0,0 +1,25 @@
using System.Collections.Concurrent;
namespace DeviceCtrlLibrary;
/// <summary>
/// 虚拟实训台的设备状态。第 0 阶段先放在运行器进程里、用固定的默认值;
/// 第 1 阶段改为向 Bun 服务器上的实训台查询,数值由网页上的滑块和开关控制。
/// </summary>
internal static class TrainingBoard
{
/// <summary>ADAM-4017 各通道的读数(Vin0 温度、Vin1 湿度、Vin2 光照……),按教材格式保存为整数</summary>
public static readonly ConcurrentDictionary<int, int> AnalogInputs = new()
{
[0] = 25, // 温度 ℃
[1] = 60, // 湿度 %
[2] = 500, // 光照 lx
[3] = 101, // 气压 kPa
[4] = 60, // 空气质量
[5] = 600, // CO2 ppm
[6] = 20, // 风速
};
/// <summary>ADAM-4150 数字量输入(DI0 人体红外、DI1 火焰、DI2 烟雾、DI3 红外对射)</summary>
public static readonly ConcurrentDictionary<int, bool> DigitalInputs = new();
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- 教材配套类库的仿真版:DeviceCtrlLibrary(设备控制)和 WinFormsDeviceControlLibrary(设备控件)。
类名、方法名、参数名都与教材 dll 一致(包括原文拼写,如 IpAdderss),保证教材代码原样编译。 -->
<PropertyGroup>
<AssemblyName>Sim.Devices</AssemblyName>
<RootNamespace>DeviceCtrlLibrary</RootNamespace>
<NoWarn>$(NoWarn);CS1591;CS8618;IDE1006</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,36 @@
using System.Drawing;
using System.Text.Json.Nodes;
using System.Windows.Forms;
namespace WinFormsDeviceControlLibrary;
/// <summary>
/// 教材控件库的 LED 显示屏:默认黑底、宋体 70pt 红字,setLedText 后文字从右向左滚动(滚动由浏览器完成)。
/// </summary>
[ClientType("WinFormsDeviceControlLibrary.LED")]
public class LED : UserControl
{
private string _ledText = "";
public LED()
{
BackColor = Color.Black;
ForeColor = Color.Red;
Font = new Font("宋体", 70F);
}
protected override Size DefaultSize => new(800, 130);
public void setLedText(string LedText)
{
EnsureThreadAccess();
_ledText = LedText ?? "";
NotifyChanged("LedText", _ledText);
}
protected override void WriteProps(JsonObject props)
{
base.WriteProps(props);
props["LedText"] = _ledText;
}
}
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.CodeAnalysis;
namespace Sim.Runner;
/// <summary>
/// 针对教材上常见写法错误的中文提示(书上代码有 True/False 大写、Catch(EventArgs ex) 等印刷错误,学生照抄后会遇到)。
/// 只在编译器的原始报错之外补充一句说明,不改变报错本身。
/// </summary>
internal static class CompileHints
{
public static string? For(Diagnostic diagnostic)
{
var source = diagnostic.Location.SourceTree?.GetText().ToString(diagnostic.Location.SourceSpan) ?? "";
return (diagnostic.Id, source) switch
{
("CS0103", "True" or "False") => $"C# 的布尔值是小写的 {source.ToLowerInvariant()}。",
("CS0103", "Catch") or ("CS0246", "Catch") => "catch 要小写,括号里写异常类型,例如 catch (Exception ex)。",
("CS0246", var name) when name.StartsWith("bool") || name.StartsWith("private") || name.StartsWith("public") || name.StartsWith("string") || name.StartsWith("int") =>
$"“{name}”像是类型和名字之间少了一个空格。",
("CS0103", _) => $"找不到“{source}”。检查拼写和大小写(C# 区分大小写),以及控件的 Name 是否和代码里一致。",
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
_ => null,
};
}
}
+84
View File
@@ -0,0 +1,84 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Sim.Runner;
/// <summary>
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
/// </summary>
internal static class CompileServer
{
public const string AssemblyFile = "StudentApp.dll";
public const string PdbFile = "StudentApp.pdb";
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
public static int Run()
{
using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
using var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true };
WarmUp();
output.WriteLine(new JsonObject { ["type"] = "ready" }.ToJsonString(JsonOptions));
while (input.ReadLine() is { } line)
{
JsonObject reply;
JsonNode? id = null;
try
{
var request = JsonNode.Parse(line)!.AsObject();
id = request["id"]?.DeepClone();
reply = Compile((string?)request["dir"] ?? throw new ArgumentException("缺少 dir"));
}
catch (Exception ex)
{
reply = new JsonObject { ["type"] = "compileFailed", ["message"] = ex.Message };
}
reply["id"] = id;
output.WriteLine(reply.ToJsonString(JsonOptions));
}
return 0;
}
private static JsonObject Compile(string dir)
{
var watch = Stopwatch.StartNew();
var result = StudentCompiler.Compile(dir);
if (result.Success)
{
File.WriteAllBytes(Path.Combine(dir, AssemblyFile), result.Assembly);
File.WriteAllBytes(Path.Combine(dir, PdbFile), result.Pdb);
}
return new JsonObject
{
["type"] = "compiled",
["ok"] = result.Success,
["diagnostics"] = result.Diagnostics,
["ms"] = watch.ElapsedMilliseconds,
};
}
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
private static void WarmUp()
{
var dir = Directory.CreateTempSubdirectory("sim-warmup-");
try
{
File.WriteAllText(Path.Combine(dir.FullName, "Program.cs"),
"using System.Windows.Forms; static class P { static void Main() { var f = new Form(); f.Text = \"预热\"; } }");
StudentCompiler.Compile(dir.FullName);
}
finally
{
dir.Delete(recursive: true);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.IO;
using System.Text;
using Sim.Host;
namespace Sim.Runner;
/// <summary>把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用)</summary>
internal sealed class ConsoleForwarder : TextWriter
{
private readonly StringBuilder _buffer = new();
public override Encoding Encoding => Encoding.UTF8;
public override void Write(char value)
{
lock (_buffer)
{
_buffer.Append(value);
if (value == '\n') FlushBuffer();
}
}
public override void Write(string? value)
{
if (value == null) return;
lock (_buffer)
{
_buffer.Append(value);
if (value.Contains('\n')) FlushBuffer();
}
}
public override void Flush()
{
lock (_buffer) FlushBuffer();
}
private void FlushBuffer()
{
if (_buffer.Length == 0) return;
SimHost.SendConsole(_buffer.ToString());
_buffer.Clear();
}
}
+21
View File
@@ -0,0 +1,21 @@
using System;
using System.IO;
using Sim.Runner;
// 两种模式,都通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条):
// dotnet Sim.Runner.dll compile-server 常驻编译服务,全服务器共用一个,编译学生工程并把程序集写回工程目录
// dotnet Sim.Runner.dll run <工程目录> 运行已编译的学生程序,每次运行一个进程
// 运行模式不加载 Roslyn,单个学生程序的内存占用因此小得多。
return args switch
{
["compile-server"] => CompileServer.Run(),
["run", var dir] when Directory.Exists(dir) => StudentProgramHost.Run(dir),
_ => Usage(),
};
static int Usage()
{
Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner run <工程目录>");
return 2;
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- 运行器:编译学生工程,在模拟的界面线程上运行,通过标准输入输出与 Bun 服务器交换 JSON 消息。
用法:dotnet Sim.Runner.dll <工程目录> -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>Sim.Runner</AssemblyName>
<RootNamespace>Sim.Runner</RootNamespace>
<!-- 只保留简体中文的编译器提示资源 -->
<SatelliteResourceLanguages>zh-Hans</SatelliteResourceLanguages>
<TieredPGO>true</TieredPGO>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
</ItemGroup>
</Project>
+116
View File
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
/// <summary>用 Roslyn 在内存里编译学生工程,报错信息用简体中文</summary>
internal static class StudentCompiler
{
private static readonly CultureInfo Chinese = CultureInfo.GetCultureInfo("zh-Hans");
/// <summary>
/// 框架里不能给学生代码引用的程序集:System.Drawing.dll 门面把 Font、Image 等转发到 Linux 不支持的
/// System.Drawing.Common,会和仿真库里的同名类型冲突。
/// </summary>
private static readonly HashSet<string> ExcludedFrameworkAssemblies = new(StringComparer.OrdinalIgnoreCase)
{
"System.Drawing.dll",
};
private static readonly string[] SimAssemblies = { "Sim.WinForms.dll", "Sim.Devices.dll" };
public sealed record Result(bool Success, byte[] Assembly, byte[] Pdb, JsonArray Diagnostics);
public static Result Compile(string projectDir)
{
var parseOptions = new CSharpParseOptions(LanguageVersion.Latest);
var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
.Where(path => !IsBuildOutput(projectDir, path))
.OrderBy(path => path, StringComparer.Ordinal)
.Select(path => CSharpSyntaxTree.ParseText(
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
parseOptions,
path: Path.GetRelativePath(projectDir, path)))
.ToList();
var options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
.WithOptimizationLevel(OptimizationLevel.Debug)
.WithNullableContextOptions(NullableContextOptions.Disable)
.WithAllowUnsafe(false)
.WithConcurrentBuild(true);
var compilation = CSharpCompilation.Create("StudentApp", trees, References.Value, options);
using var peStream = new MemoryStream();
using var pdbStream = new MemoryStream();
var emit = compilation.Emit(peStream, pdbStream,
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
var diagnostics = new JsonArray();
foreach (var diagnostic in emit.Diagnostics
.Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning)
.OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1))
{
diagnostics.Add(ToJson(diagnostic));
}
return new Result(emit.Success, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
}
private static JsonObject ToJson(Diagnostic diagnostic)
{
var span = diagnostic.Location.GetMappedLineSpan();
var message = diagnostic.GetMessage(Chinese);
var json = new JsonObject
{
["file"] = span.Path,
["line"] = span.StartLinePosition.Line + 1,
["column"] = span.StartLinePosition.Character + 1,
["severity"] = diagnostic.Severity == DiagnosticSeverity.Error ? "error" : "warning",
["code"] = diagnostic.Id,
["message"] = message,
};
var hint = CompileHints.For(diagnostic);
if (hint != null) json["hint"] = hint;
return json;
}
private static bool IsBuildOutput(string projectDir, string path)
{
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
return relative.StartsWith("bin/") || relative.StartsWith("obj/") || relative.Contains("/bin/") || relative.Contains("/obj/");
}
/// <summary>学生代码可引用的程序集:.NET 框架(去掉冲突的门面)+ 仿真库</summary>
private static readonly Lazy<IReadOnlyList<MetadataReference>> References = new(() =>
{
var frameworkDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
var framework = Directory.EnumerateFiles(frameworkDir, "*.dll")
.Where(path => !ExcludedFrameworkAssemblies.Contains(Path.GetFileName(path)))
.Where(IsManagedAssembly);
var sim = SimAssemblies.Select(name => Path.Combine(AppContext.BaseDirectory, name));
return framework.Concat(sim).Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)).ToList();
});
private static bool IsManagedAssembly(string path)
{
try
{
AssemblyName.GetAssemblyName(path);
return true;
}
catch (BadImageFormatException)
{
return false; // 框架目录里的原生库
}
}
}
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Windows.Forms;
using Sim.Host;
namespace Sim.Runner;
/// <summary>
/// 运行一个已编译的学生程序:在模拟的界面线程上执行 Main,主线程从标准输入读取浏览器的操作。
/// 这个类不能引用 Roslyn 的任何类型,否则运行模式也会加载编译器。
/// </summary>
internal static class StudentProgramHost
{
public static int Run(string dir)
{
var processStart = Process.GetCurrentProcess().StartTime;
SimHost.Initialize(Console.OpenStandardOutput());
var console = new ConsoleForwarder();
Console.SetOut(console);
Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容
// 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出
var zhCN = CultureInfo.GetCultureInfo("zh-CN");
CultureInfo.DefaultThreadCurrentCulture = zhCN;
CultureInfo.DefaultThreadCurrentUICulture = zhCN;
CultureInfo.CurrentCulture = zhCN;
CultureInfo.CurrentUICulture = zhCN;
var assembly = AssemblyLoadContext.Default.LoadFromStream(
new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.AssemblyFile))),
new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.PdbFile))));
SimHost.StudentAssembly = assembly;
var entryPoint = assembly.EntryPoint;
if (entryPoint == null)
{
SimHost.Send(new JsonObject { ["type"] = "exception", ["message"] = "程序中找不到 Main 方法", ["detail"] = "" });
return 1;
}
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
// 学生创建的后台线程抛出未处理异常时,和真实程序一样整个程序结束
if (e.ExceptionObject is Exception ex) SimHost.ReportException(ex);
console.Flush();
};
var uiThread = new Thread(() =>
{
SimHost.Ui.BindToCurrentThread();
SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());
SimHost.Send(new JsonObject { ["type"] = "started", ["ms"] = (long)(DateTime.Now - processStart).TotalMilliseconds });
try
{
var parameters = entryPoint.GetParameters().Length == 0 ? null : new object[] { Array.Empty<string>() };
entryPoint.Invoke(null, parameters);
}
catch (Exception ex)
{
SimHost.ReportException(ex);
}
finally
{
console.Flush();
SimHost.FlushChanges();
Environment.Exit(0);
}
}) { Name = "UI" };
uiThread.Start();
ReadActions();
// 服务器关闭了标准输入:结束运行
Environment.Exit(0);
return 0;
}
/// <summary>读取浏览器发来的操作,排队到界面线程执行</summary>
private static void ReadActions()
{
using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
while (input.ReadLine() is { } line)
{
JsonObject? message;
try
{
message = JsonNode.Parse(line) as JsonObject;
}
catch (JsonException)
{
continue;
}
switch ((string?)message?["type"])
{
case "action":
if (SimHost.FindControl((string?)message!["id"] ?? "") is { } control)
{
var action = UserAction.FromJson(message);
SimHost.Ui.Post(() => control.PerformUserAction(action));
}
break;
case "reply":
SimHost.CompleteRequest((int?)message!["requestId"] ?? 0, message["result"]?.DeepClone());
break;
case "stop":
Environment.Exit(0);
break;
}
}
}
}
+119
View File
@@ -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);
}
+56
View File
@@ -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();
}
}
+47
View File
@@ -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);
}
}
+176
View File
@@ -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();
}
}
+61
View File
@@ -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;
}
+503
View File
@@ -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; }
}
+100
View File
@@ -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();
}
+43
View File
@@ -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);
+208
View File
@@ -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();
}
}
+34
View File
@@ -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();
}
}
+45
View File
@@ -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;
}
}
+108
View File
@@ -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 }
+16
View File
@@ -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();
}
+78
View File
@@ -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);
}
}
+57
View File
@@ -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)!;
}
+26
View File
@@ -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,
};
}
+148
View File
@@ -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; }
}
}
+80
View File
@@ -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();
}
}
}
+38
View File
@@ -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 操作实训台设备。");
}
+10
View File
@@ -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>