feat: Phase 9 Serial MCP server + app icon & version title (v0.2.0)

Phase 9 (AI integration) complete -- AI agents can send/receive serial
while the user watches live in the GUI, with the GUI as sole COM-port owner.

- ETTerms.SerialMcp: stdio MCP server (net8.0 + ModelContextProtocol SDK)
  exposing serial_list / serial_attach / serial_write / serial_read
  (waitFor + timeoutMs) / serial_detach; forwards over named pipe.
- GUI SerialBridgeServer (pipe etterms-serial) + SerialBridge endpoint;
  SessionPage.WriteFromAi echoes MCP-sourced TX tagged [AI] (magenta).
- App icon: window title bar / taskbar / exe now use Choco_256x256.ico
  (embedded via AppAssets); title bar shows ETTerms Version vX.Y.Z.
- About page: large Choco icon (Zoom, no crop).
- Docs: ARCHITECTURE.md + CLAUDE.md mark Phase 9 done; docs/serial-mcp-guide.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:25:30 +08:00
co-authored by Claude Opus 4.8
parent f4207f8586
commit 28538594d7
21 changed files with 777 additions and 51 deletions
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ETTerms.SerialMcp</RootNamespace>
<AssemblyName>ETTerms.SerialMcp</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
</ItemGroup>
</Project>
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
// stdio MCP server:把 ETTerms GUI 持有的 serial session 暴露給 AIKiro / Claude CLI)。
// 自己不開 COM port,所有收發經 named pipe 轉給 GUI 的 SerialBridgeServer。
var builder = Host.CreateApplicationBuilder(args);
// stdio 傳輸:stdout 專供 JSON-RPClog 一律走 stderr,否則會污染協議。
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
+105
View File
@@ -0,0 +1,105 @@
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
namespace ETTerms.SerialMcp;
/// <summary>
/// 連到 ETTerms GUI 的 SerialBridgeServernamed pipe \\.\pipe\etterms-serial)。
/// 轉發 list / attach / write / detach,背景累積 RX 供 <c>serial_read</c> 消費。
/// 單例:整個 MCP server 生命週期共用同一條 pipe,連線狀態跨工具呼叫保留。
/// </summary>
public sealed class SerialBridgeClient
{
public static readonly SerialBridgeClient Instance = new();
private const string PipeName = "etterms-serial";
private readonly SemaphoreSlim _connLock = new(1, 1);
private readonly SemaphoreSlim _reqLock = new(1, 1);
private readonly object _wlock = new();
private readonly object _rxlock = new();
private readonly StringBuilder _rx = new();
private StreamWriter? _writer;
private TaskCompletionSource<JsonElement>? _pending;
private async Task EnsureConnected()
{
await _connLock.WaitAsync();
try
{
if (_writer != null) return;
var pipe = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await pipe.ConnectAsync(3000);
_writer = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true };
_ = Task.Run(() => ReadLoop(new StreamReader(pipe, new UTF8Encoding(false)), pipe));
}
finally { _connLock.Release(); }
}
private async Task ReadLoop(StreamReader reader, NamedPipeClientStream pipe)
{
try
{
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
JsonElement doc;
try { doc = JsonDocument.Parse(line).RootElement.Clone(); }
catch { continue; }
if (doc.TryGetProperty("op", out var op) && op.GetString() == "rx")
{
var d = doc.TryGetProperty("data", out var dd) ? dd.GetString() ?? "" : "";
lock (_rxlock) _rx.Append(d);
}
else _pending?.TrySetResult(doc);
}
}
catch { }
finally { lock (_wlock) _writer = null; try { pipe.Dispose(); } catch { } }
}
private async Task<string> Request(object req)
{
await EnsureConnected();
await _reqLock.WaitAsync();
try
{
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending = tcs;
lock (_wlock)
{
if (_writer == null) throw new IOException("bridge not connected; is the ETTerms GUI running?");
_writer.WriteLine(JsonSerializer.Serialize(req));
}
using var cts = new CancellationTokenSource(5000);
using var reg = cts.Token.Register(() => tcs.TrySetException(new TimeoutException("no response from ETTerms bridge")));
return (await tcs.Task).GetRawText();
}
finally { _pending = null; _reqLock.Release(); }
}
public Task<string> List() => Request(new { op = "list" });
public Task<string> Attach(string portName) => Request(new { op = "attach", session = portName });
public Task<string> Write(string text, bool newline) => Request(new { op = "write", data = text, newline });
public Task<string> Detach() => Request(new { op = "detach" });
/// <summary>取出累積的 RX;可等待子字串或逾時。回傳後清空 buffer。</summary>
public async Task<string> Read(string? waitFor, int timeoutMs)
{
await EnsureConnected();
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs <= 0 ? 1000 : timeoutMs);
while (true)
{
string snap;
lock (_rxlock) snap = _rx.ToString();
bool hit = string.IsNullOrEmpty(waitFor) ? snap.Length > 0 : snap.Contains(waitFor);
if (hit || DateTime.UtcNow >= deadline)
{
lock (_rxlock) _rx.Clear();
return snap;
}
await Task.Delay(50);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace ETTerms.SerialMcp;
/// <summary>
/// 暴露給 AI 的 serial 工具。COM port 由 ETTerms GUI 持有,這些工具經 named pipe 橋接過去;
/// AI 的寫入會即時顯示在 GUI 終端機(標 [AI])。使用前須先在 GUI 開好該 serial 連線。
/// </summary>
[McpServerToolType]
public static class SerialTools
{
[McpServerTool, Description("List serial sessions currently open in the ETTerms GUI (name + baud). The GUI owns the COM port.")]
public static Task<string> serial_list() => SerialBridgeClient.Instance.List();
[McpServerTool, Description("Attach to a serial session already open in the ETTerms GUI by COM port name (e.g. COM3). Required before write/read. Does not open the port itself.")]
public static Task<string> serial_attach(
[Description("COM port name of an already-open GUI session, e.g. COM3")] string portName)
=> SerialBridgeClient.Instance.Attach(portName);
[McpServerTool, Description("Send text to the attached serial session. The GUI displays it live tagged [AI].")]
public static Task<string> serial_write(
[Description("Text to send")] string text,
[Description("Append the session newline (acts like pressing Enter)")] bool appendNewline = true)
=> SerialBridgeClient.Instance.Write(text, appendNewline);
[McpServerTool, Description("Read output accumulated from the attached serial session. Optionally wait for a substring up to timeoutMs. Returns and clears the buffer.")]
public static Task<string> serial_read(
[Description("Substring to wait for; null/empty returns whatever has arrived")] string? waitFor = null,
[Description("Max time to wait in milliseconds")] int timeoutMs = 3000)
=> SerialBridgeClient.Instance.Read(waitFor, timeoutMs);
[McpServerTool, Description("Detach from the serial session. Does NOT close the GUI's port (the GUI keeps owning it).")]
public static Task<string> serial_detach() => SerialBridgeClient.Instance.Detach();
}
+35
View File
@@ -1,6 +1,7 @@
using System.Drawing;
using System.Reflection;
using System.Windows.Forms;
using ETTerms.Infrastructure;
namespace ETTerms.App;
@@ -54,6 +55,9 @@ public sealed class AboutView : UserControl
p.Controls.Add(MakeLine(" TTL Script Engine (ported from MyTeraTerm)", Theme.UiFont, Theme.TextDim));
}));
// Brand card — large app icon + logo
left.Controls.Add(MakeBrandCard());
// ═══ RIGHT PANEL — Changelog (scrollable) ═══
var right = new FlowLayoutPanel
{
@@ -110,6 +114,37 @@ public sealed class AboutView : UserControl
return card;
}
/// <summary>底部品牌卡:大的 Choco 圖示 + ETTerms 標誌圖。</summary>
private static Panel MakeBrandCard()
{
var card = new Panel
{
Width = 340, Height = 300, Margin = new Padding(0, 0, 0, 12),
BackColor = Theme.TabBack, Padding = new Padding(12)
};
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.TabBack, AutoSize = false
};
// 大圖示 (256x256),等比縮放塞滿框、不裁切
var iconImg = AppAssets.AppIcon(256);
if (iconImg != null)
{
flow.Controls.Add(new PictureBox
{
Width = 312, Height = 268, Margin = new Padding(0, 4, 0, 4),
SizeMode = PictureBoxSizeMode.Zoom,
BackColor = Theme.TabBack, Image = iconImg.ToBitmap()
});
iconImg.Dispose();
}
card.Controls.Add(flow);
return card;
}
private static Label MakeLine(string text, Font font, Color color, ContentAlignment align = ContentAlignment.MiddleLeft)
{
return new Label
+16
View File
@@ -1,3 +1,4 @@
using System.Reflection;
using System.Windows.Forms;
using ETTerms.App.Workspace;
using ETTerms.Infrastructure;
@@ -17,16 +18,30 @@ public partial class MainForm : Form
private readonly AboutView _about = new();
private readonly StatusStrip _status = new();
private readonly ToolStripStatusLabel _statusLabel = new();
private readonly Sessions.SerialBridgeServer _bridge = new();
public MainForm()
{
InitializeComponent();
ApplyIconAndTitle();
BuildLayout();
WireEvents();
AppSettings.Instance.ApplyWindowPosition(this);
_bridge.Start(); // 本機 named pipe,供 ETTerms.SerialMcp 橋接 serial
AppLogger.Info("MainForm initialized");
}
/// <summary>設定視窗 / 工作列圖示,並在標題列加上版本號。</summary>
private void ApplyIconAndTitle()
{
var icon = AppAssets.AppIcon();
if (icon != null) Icon = icon;
var v = Assembly.GetExecutingAssembly().GetName().Version;
var versionStr = v != null ? $"{v.Major}.{v.Minor}.{v.Build}" : "0.1.0";
Text = $"ETTerms Version v{versionStr}";
}
private void BuildLayout()
{
Controls.Add(_workspace); // Fill
@@ -74,6 +89,7 @@ public partial class MainForm : Form
protected override void OnFormClosed(FormClosedEventArgs e)
{
_bridge.Dispose();
AppSettings.Instance.SaveWindowPosition(this);
AppLogger.LogApplicationClose();
base.OnFormClosed(e);
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+9 -1
View File
@@ -10,11 +10,19 @@
<AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 -->
<Version>0.1.3</Version>
<Version>0.2.0</Version>
<Product>ETTerms</Product>
<Company>ETTerms Project</Company>
<!-- 視窗 / 工作列 / exe 圖示 -->
<ApplicationIcon>Assets\Choco_256x256.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- 視窗圖示 (title bar / taskbar / About 大圖),內嵌進組件 -->
<EmbeddedResource Include="Assets\Choco_256x256.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
<PackageReference Include="SnmpSharpNet" Version="0.9.7">
+32
View File
@@ -0,0 +1,32 @@
using System.Drawing;
using System.Reflection;
namespace ETTerms.Infrastructure;
/// <summary>
/// 存取內嵌進組件的圖示(見 ETTerms.csproj 的 EmbeddedResource)。
/// Manifest 名稱規則:RootNamespace.資料夾.檔名 → ETTerms.Assets.xxx。
/// </summary>
public static class AppAssets
{
private const string IconResource = "ETTerms.Assets.Choco_256x256.ico";
/// <summary>視窗 / 工作列圖示。找不到資源時回傳 null。</summary>
public static Icon? AppIcon() => LoadIcon(IconResource);
/// <summary>Choco 圖示,指定尺寸(About 用大圖)。</summary>
public static Icon? AppIcon(int size)
{
var icon = LoadIcon(IconResource);
if (icon == null) return null;
var sized = new Icon(icon, new Size(size, size));
icon.Dispose();
return sized;
}
private static Icon? LoadIcon(string name)
{
using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
return s == null ? null : new Icon(s);
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace ETTerms.Sessions;
/// <summary>
/// 一個可被 MCP 橋接的 serial session 端點:提供寫入(含 [AI] echo 回 GUI)與 RX 轉發。
/// 由 <see cref="SessionPage"/> 建立並註冊到 <see cref="SerialBridge"/>。
/// </summary>
public sealed class SerialBridgeEndpoint
{
private readonly Action<string, bool> _write;
public string Name { get; }
public int BaudRate { get; }
/// <summary>實體 port 收到的資料(背景緒觸發),由 pipe server 廣播給 MCP client。</summary>
public event Action<byte[]>? Rx;
public SerialBridgeEndpoint(string name, int baudRate, Action<string, bool> write)
{
Name = name;
BaudRate = baudRate;
_write = write;
}
public void Write(string text, bool appendNewline) => _write(text, appendNewline);
public void FeedRx(byte[] data) => Rx?.Invoke(data);
}
/// <summary>
/// GUI 目前開著、可供 MCP 橋接的 serial session 登錄表。port 永遠由 GUI 持有,
/// MCPETTerms.SerialMcp)經 named pipe attach 到這裡的端點來收發。
/// </summary>
public static class SerialBridge
{
private static readonly object _lock = new();
private static readonly List<SerialBridgeEndpoint> _eps = new();
public static void Register(SerialBridgeEndpoint ep) { lock (_lock) _eps.Add(ep); }
public static void Unregister(SerialBridgeEndpoint ep) { lock (_lock) _eps.Remove(ep); }
public static SerialBridgeEndpoint[] All { get { lock (_lock) return _eps.ToArray(); } }
public static SerialBridgeEndpoint? Find(string name) =>
All.FirstOrDefault(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase));
}
+112
View File
@@ -0,0 +1,112 @@
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using ETTerms.Infrastructure;
namespace ETTerms.Sessions;
/// <summary>
/// 本機 named pipe server:把 GUI 持有的 serial session 橋接給 MCP clientETTerms.SerialMcp)。
/// 協議=換行分隔 JSONlist / attach / write / detach),RX 由本端主動以 {"op":"rx"} 推送。
/// 一次服務一個 client(單一 AI agent 已足夠),斷線後自動回到等待下一個連線。
/// </summary>
public sealed class SerialBridgeServer : IDisposable
{
public const string PipeName = "etterms-serial";
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
public void Start()
{
if (_loop != null) return;
_loop = Task.Run(AcceptLoop);
AppLogger.Info($"SerialBridge server started on pipe \\\\.\\pipe\\{PipeName}");
}
private async Task AcceptLoop()
{
while (!_cts.IsCancellationRequested)
{
try
{
var pipe = new NamedPipeServerStream(PipeName, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
await pipe.WaitForConnectionAsync(_cts.Token);
await ServeClient(pipe);
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { AppLogger.LogWarning($"SerialBridge accept error: {ex.Message}"); }
}
}
private async Task ServeClient(NamedPipeServerStream pipe)
{
using (pipe)
{
var writer = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true };
var reader = new StreamReader(pipe, new UTF8Encoding(false));
var wlock = new object();
void Send(object o) { lock (wlock) { try { writer.WriteLine(JsonSerializer.Serialize(o)); } catch { } } }
SerialBridgeEndpoint? attached = null;
Action<byte[]>? rx = null;
void Detach() { if (attached != null && rx != null) attached.Rx -= rx; attached = null; rx = null; }
try
{
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
Req? req;
try { req = JsonSerializer.Deserialize<Req>(line); } catch { continue; }
if (req == null) continue;
switch (req.op)
{
case "list":
Send(new { sessions = SerialBridge.All.Select(e => new { name = e.Name, baud = e.BaudRate }) });
break;
case "attach":
Detach();
attached = SerialBridge.Find(req.session ?? "");
if (attached == null) { Send(new { ok = false, error = $"no open serial session '{req.session}' in GUI" }); break; }
rx = data => Send(new { op = "rx", data = Encoding.UTF8.GetString(data) });
attached.Rx += rx;
Send(new { ok = true, name = attached.Name });
break;
case "write":
if (attached == null) { Send(new { ok = false, error = "not attached" }); break; }
attached.Write(req.data ?? "", req.newline);
Send(new { ok = true });
break;
case "detach":
Detach();
Send(new { ok = true });
break;
default:
Send(new { ok = false, error = $"unknown op '{req.op}'" });
break;
}
}
}
catch (Exception ex) { AppLogger.LogWarning($"SerialBridge client error: {ex.Message}"); }
finally { Detach(); }
}
}
private sealed class Req
{
public string? op { get; set; }
public string? session { get; set; }
public string? data { get; set; }
public bool newline { get; set; }
}
public void Dispose()
{
try { _cts.Cancel(); } catch { }
_cts.Dispose();
}
}
+6
View File
@@ -17,6 +17,12 @@ public sealed class SerialChannel : ISessionChannel
public string LogName => _portName;
/// <summary>目前傳輸速率(供 MCP 橋接列出 session 用)。</summary>
public int BaudRate => _port.BaudRate;
/// <summary>送出換行序列(AI 寫入時附加,與手打 Enter 一致)。</summary>
public string NewLine => _port.NewLine;
public SerialChannel(SerialSettings s)
{
_portName = s.PortName;
+17
View File
@@ -24,6 +24,7 @@ public sealed class SessionPage : UserControl
private readonly Button _run, _stop, _log;
private bool _started;
private SessionLogger? _logger;
private SerialBridgeEndpoint? _bridge; // Serial 才有:供 MCP 橋接
/// <summary>底層連線通道。</summary>
public ISessionChannel Channel => _channel;
@@ -216,6 +217,11 @@ public sealed class SessionPage : UserControl
{
_channel.Open();
SessionManager.Register(_channel);
if (_channel is SerialChannel sc)
{
_bridge = new SerialBridgeEndpoint(sc.LogName, sc.BaudRate, WriteFromAi);
SerialBridge.Register(_bridge);
}
}
catch (Exception ex)
{
@@ -228,17 +234,28 @@ public sealed class SessionPage : UserControl
{
// 側錄不依賴 UI thread,直接在背景緒寫檔(SessionLogger 內部自鎖)。
_logger?.Write(data);
_bridge?.FeedRx(data); // 轉發給 MCP(若有 attach
if (IsDisposed || !IsHandleCreated) return;
if (InvokeRequired) BeginInvoke(() => _term.Feed(data));
else _term.Feed(data);
}
/// <summary>MCP 來源寫入:送出實體 port,並把內容以 [AI] 標色 echo 回終端機,使用者即時可見。</summary>
private void WriteFromAi(string text, bool appendNewline)
{
if (_channel is not SerialChannel sc) return;
_channel.Write(System.Text.Encoding.UTF8.GetBytes(appendNewline ? text + sc.NewLine : text));
var echo = System.Text.Encoding.UTF8.GetBytes($"\x1b[35m[AI]\x1b[0m {text}\r\n");
Ui(() => _term.Feed(echo));
}
protected override void Dispose(bool disposing)
{
if (disposing && _started)
{
_runner.Cancel();
_channel.DataReceived -= OnDataReceived;
if (_bridge != null) { SerialBridge.Unregister(_bridge); _bridge = null; }
_logger?.Dispose();
_logger = null;
SessionManager.Unregister(_channel);
+30 -4
View File
@@ -3,7 +3,7 @@ using System.Drawing;
namespace ETTerms.Terminal;
[Flags]
public enum CellAttr : byte { None = 0, Bold = 1, Underline = 2, Inverse = 4 }
public enum CellAttr : byte { None = 0, Bold = 1, Underline = 2, Inverse = 4, Wide = 8, WideTrail = 16 }
public struct Cell
{
@@ -80,11 +80,37 @@ public sealed class ScreenBuffer
// ── 輸出字元 ─────────────────────────────────────────────
public void Print(char ch)
{
int w = CharWidth(ch);
if (_wrapPending) { _wrapPending = false; CursorCol = 0; LineFeed(); }
if (CursorRow < 0 || CursorRow >= Rows) CursorRow = Math.Clamp(CursorRow, 0, Rows - 1);
_screen[CursorRow][CursorCol] = new Cell { Ch = ch, Fg = PenFg, Bg = PenBg, Attr = PenAttr };
if (CursorCol >= Cols - 1) { if (AutoWrap) _wrapPending = true; }
else CursorCol++;
// 寬字(全形 CJK)占 2 格;行尾剩 1 格放不下 → 先換行
if (w == 2 && CursorCol == Cols - 1) { CursorCol = 0; LineFeed(); }
_screen[CursorRow][CursorCol] = new Cell { Ch = ch, Fg = PenFg, Bg = PenBg, Attr = w == 2 ? PenAttr | CellAttr.Wide : PenAttr };
if (w == 2 && CursorCol + 1 < Cols)
_screen[CursorRow][CursorCol + 1] = new Cell { Ch = '\0', Fg = PenFg, Bg = PenBg, Attr = PenAttr | CellAttr.WideTrail };
if (CursorCol + w >= Cols) { if (AutoWrap) _wrapPending = true; else CursorCol = Cols - 1; }
else CursorCol += w;
}
/// <summary>東亞全形 / 寬字回傳 2,其餘 1(BMP 範圍,足夠涵蓋常見中日韓)。</summary>
private static int CharWidth(char ch)
{
if (ch < 0x1100) return 1;
return (ch <= 0x115F) // Hangul Jamo
|| (ch >= 0x2E80 && ch <= 0x303E) // CJK 部首 / 康熙 / 符號
|| (ch >= 0x3041 && ch <= 0x33FF) // 平假名 / 片假名 / CJK 符號
|| (ch >= 0x3400 && ch <= 0x4DBF) // CJK 擴充 A
|| (ch >= 0x4E00 && ch <= 0x9FFF) // CJK 基本
|| (ch >= 0xA000 && ch <= 0xA4CF) // 彝文
|| (ch >= 0xAC00 && ch <= 0xD7A3) // 韓文音節
|| (ch >= 0xF900 && ch <= 0xFAFF) // CJK 相容表意
|| (ch >= 0xFE30 && ch <= 0xFE4F) // CJK 相容形式
|| (ch >= 0xFF00 && ch <= 0xFF60) // 全形 ASCII
|| (ch >= 0xFFE0 && ch <= 0xFFE6) // 全形符號
? 2 : 1;
}
// ── 游標 / 換行 ──────────────────────────────────────────
+109 -7
View File
@@ -1,4 +1,5 @@
using System.Drawing;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;
@@ -30,12 +31,13 @@ public sealed class TerminalView : UserControl
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Color.FromArgb(18, 18, 22);
ImeMode = ImeMode.NoControl; // 容器控制項預設關 IME,這裡明確開啟讓使用者可切中文
_font = new Font(profile.FontFamily, profile.FontSize);
using (var g = CreateGraphics())
{
var sz = TextRenderer.MeasureText(g, "W", _font, Size.Empty, TextFormatFlags.NoPadding);
_cellW = Math.Max(1, sz.Width);
_cellH = Math.Max(1, _font.Height);
_cellH = Math.Max(1, _font.Height + 2); // +2 行距,避免 g/y 下緣與中文底部被裁切
}
_buf = new ScreenBuffer(profile.Cols, profile.Rows,
Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines);
@@ -90,13 +92,26 @@ public sealed class TerminalView : UserControl
while (c < line.Length)
{
var cell = line[c];
if ((cell.Attr & CellAttr.WideTrail) != 0) { c++; continue; } // 寬字第二格,由前格覆蓋
ResolveColors(cell, out var fg, out var bg, abs, c);
// 合併同屬性連續格
// 寬字(全形 CJK):單獨繪製,占 2 格寬
if ((cell.Attr & CellAttr.Wide) != 0)
{
var wr = new Rectangle(c * _cellW, y, _cellW * 2, _cellH);
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, wr);
DrawRun(g, cell.Ch == '\0' ? " " : cell.Ch.ToString(), cell.Attr, wr, fg);
c++;
continue;
}
// 合併同屬性連續的窄字
int start = c;
var sb = new StringBuilder();
while (c < line.Length)
{
var cur = line[c];
if ((cur.Attr & (CellAttr.Wide | CellAttr.WideTrail)) != 0) break;
ResolveColors(cur, out var f2, out var b2, abs, c);
if (f2 != fg || b2 != bg || (cur.Attr & CellAttr.Bold) != (cell.Attr & CellAttr.Bold)) break;
sb.Append(cur.Ch == '\0' ? ' ' : cur.Ch);
@@ -104,14 +119,19 @@ public sealed class TerminalView : UserControl
}
var rect = new Rectangle(start * _cellW, y, (c - start) * _cellW, _cellH);
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, rect);
var style = (cell.Attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular;
if ((cell.Attr & CellAttr.Underline) != 0) style |= FontStyle.Underline;
using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style);
TextRenderer.DrawText(g, sb.ToString(), fnt, rect, fg,
TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left);
DrawRun(g, sb.ToString(), cell.Attr, rect, fg);
}
}
private void DrawRun(Graphics g, string text, CellAttr attr, Rectangle rect, Color fg)
{
var style = (attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular;
if ((attr & CellAttr.Underline) != 0) style |= FontStyle.Underline;
using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style);
TextRenderer.DrawText(g, text, fnt, rect, fg,
TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left);
}
private void ResolveColors(Cell cell, out Color fg, out Color bg, int abs, int col)
{
fg = cell.Fg.A == 0 ? _buf.DefaultFg : cell.Fg;
@@ -223,7 +243,10 @@ public sealed class TerminalView : UserControl
int from = abs == a.row ? a.col : 0;
int to = abs == b.row ? b.col : line.Length;
for (int c = from; c < Math.Min(to, line.Length); c++)
{
if ((line[c].Attr & CellAttr.WideTrail) != 0) continue;
sb.Append(line[c].Ch == '\0' ? ' ' : line[c].Ch);
}
if (abs < b.row) sb.Append("\r\n");
}
var text = sb.ToString();
@@ -239,4 +262,83 @@ public sealed class TerminalView : UserControl
}
catch { }
}
// ── IME(中文 / 日文 / 韓文輸入)─────────────────────────
// 自繪控制項預設不處理 IME 組字,故攔截 WM_IME_COMPOSITION 取「結果字串」直接送出 UTF-8。
private const int WM_IME_STARTCOMPOSITION = 0x010D;
private const int WM_IME_COMPOSITION = 0x010F;
private const int GCS_RESULTSTR = 0x0800;
private const int CFS_POINT = 0x0002;
private const int IACE_DEFAULT = 0x0010;
// UserControl(容器)預設不啟用 IME,使用者無法切中文。把預設 IME context 綁回此視窗,
// 並在取得焦點後(WinForms 可能於 OnGotFocus 內關掉)再綁一次,確保可切換輸入法。
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
ImmAssociateContextEx(Handle, IntPtr.Zero, IACE_DEFAULT);
}
protected override void OnGotFocus(EventArgs e)
{
base.OnGotFocus(e);
ImmAssociateContextEx(Handle, IntPtr.Zero, IACE_DEFAULT);
}
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_IME_STARTCOMPOSITION) MoveImeWindowToCursor();
else if (m.Msg == WM_IME_COMPOSITION && ((long)m.LParam & GCS_RESULTSTR) != 0)
{
var s = ReadImeResult();
if (!string.IsNullOrEmpty(s))
{
SendData?.Invoke(Encoding.UTF8.GetBytes(s));
return; // 消費此訊息,避免預設再轉成 WM_CHAR 造成重複輸入
}
}
base.WndProc(ref m);
}
private string ReadImeResult()
{
IntPtr hImc = ImmGetContext(Handle);
if (hImc == IntPtr.Zero) return "";
try
{
int len = ImmGetCompositionStringW(hImc, GCS_RESULTSTR, null, 0);
if (len <= 0) return "";
var buf = new byte[len];
ImmGetCompositionStringW(hImc, GCS_RESULTSTR, buf, len);
return Encoding.Unicode.GetString(buf);
}
finally { ImmReleaseContext(Handle, hImc); }
}
private void MoveImeWindowToCursor()
{
IntPtr hImc = ImmGetContext(Handle);
if (hImc == IntPtr.Zero) return;
try
{
int vr = _buf.CursorRow + _scrollOffset;
var cf = new COMPOSITIONFORM
{
dwStyle = CFS_POINT,
ptCurrentPos = new POINT { x = _buf.CursorCol * _cellW, y = vr * _cellH }
};
ImmSetCompositionWindow(hImc, ref cf);
}
finally { ImmReleaseContext(Handle, hImc); }
}
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int x, y; }
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int left, top, right, bottom; }
[StructLayout(LayoutKind.Sequential)] private struct COMPOSITIONFORM { public int dwStyle; public POINT ptCurrentPos; public RECT rcArea; }
[DllImport("imm32.dll")] private static extern IntPtr ImmGetContext(IntPtr hWnd);
[DllImport("imm32.dll")] private static extern bool ImmAssociateContextEx(IntPtr hWnd, IntPtr hIMC, int dwFlags);
[DllImport("imm32.dll")] private static extern bool ImmReleaseContext(IntPtr hWnd, IntPtr hIMC);
[DllImport("imm32.dll", CharSet = CharSet.Unicode)] private static extern int ImmGetCompositionStringW(IntPtr hIMC, int dwIndex, byte[]? lpBuf, int dwBufLen);
[DllImport("imm32.dll")] private static extern bool ImmSetCompositionWindow(IntPtr hIMC, ref COMPOSITIONFORM lpCompForm);
}