fix(terminal): dark scrollbar, bracketed paste, clear selection on copy (v0.3.1)
- Add owner-drawn DarkScrollBar (slim, dark, rounded thumb) to TerminalView; content width excludes the bar so text isn't covered; synced on feed/wheel/resize. - Support bracketed paste (DEC mode 2004): wrap multi-line paste in ESC[200~..ESC[201~ when the app enables it (PSReadLine/Kiro CLI), so pasted lines aren't each submitted immediately. - Clear text selection after right-click copy so the user knows it worked. - Bump version to 0.3.1; rewrite v0.3.0 changelog in plain language and add v0.3.1 entry; update ARCHITECTURE.md / CLAUDE.md.
This commit is contained in:
+5
-1
@@ -131,7 +131,8 @@ ETTerms/
|
|||||||
│
|
│
|
||||||
├── Terminal/ # ── 終端機渲染 ──
|
├── Terminal/ # ── 終端機渲染 ──
|
||||||
│ ├── TerminalView.cs # 自繪 VT100 控制項 (owner-drawn)
|
│ ├── TerminalView.cs # 自繪 VT100 控制項 (owner-drawn)
|
||||||
│ ├── AnsiParser.cs # ANSI/VT100 escape 解析狀態機
|
│ ├── DarkScrollBar.cs # 自繪深色垂直捲軸 (細長/圓角滑塊, 配深色主題)
|
||||||
|
│ ├── AnsiParser.cs # ANSI/VT100 escape 解析狀態機 (含 DEC 2004 bracketed paste)
|
||||||
│ ├── ScreenBuffer.cs # 字格緩衝 (rows×cols, 屬性/顏色)
|
│ ├── ScreenBuffer.cs # 字格緩衝 (rows×cols, 屬性/顏色)
|
||||||
│ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵)
|
│ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵)
|
||||||
│
|
│
|
||||||
@@ -295,6 +296,9 @@ ETTerms 是單視窗多分頁,沒有「路由」,以下以**功能面板**
|
|||||||
- 接收 channel bytes → `AnsiParser` → `ScreenBuffer` → 繪製
|
- 接收 channel bytes → `AnsiParser` → `ScreenBuffer` → 繪製
|
||||||
- 鍵盤輸入 → `TerminalInput` → byte 序列 → channel
|
- 鍵盤輸入 → `TerminalInput` → byte 序列 → channel
|
||||||
- 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile`)
|
- 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile`)
|
||||||
|
- **捲動:** 滑鼠滾輪或右側自繪深色捲軸 `DarkScrollBar`(拖曳滑塊 / 點軌道翻頁);`ContentWidth` 扣掉捲軸寬避免文字被蓋,捲軸範圍 / 位置在 `Feed` / 滾輪 / resize 時同步
|
||||||
|
- **貼上:** 對方啟用 bracketed paste(DEC mode 2004,如 PSReadLine / Kiro CLI)時,整段以 `ESC[200~ … ESC[201~` 包夾送出,避免多行貼上被逐行 Enter 立即送出;未啟用則逐字送出
|
||||||
|
- **複製:** 右鍵複製選取內容後自動清除反白(提示已複製)
|
||||||
|
|
||||||
### Script Editor / Runner(Scripts 檢視)
|
### Script Editor / Runner(Scripts 檢視)
|
||||||
- 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm)
|
- 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
|
|||||||
|
|
||||||
**進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server:**GUI 持有 COM port,MCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)。打包待指示。
|
**進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server:**GUI 持有 COM port,MCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)。打包待指示。
|
||||||
|
|
||||||
|
**v0.3.1:** 終端機體驗修正(皆在 `src/ETTerms/Terminal/`)。**(1) 深色垂直捲軸** — 新增自繪 `DarkScrollBar`(細長、無箭頭、圓角滑塊,配合 KKTerm 深色主題),`TerminalView` 右側 `Dock=Right` 掛上,`ContentWidth` 扣掉捲軸寬避免文字被蓋,`UpdateScrollBar()` 在 `Feed` / 滾輪 / resize 時同步滑塊範圍與位置,滾輪與拖曳互通。**(2) 多行貼上修正** — `AnsiParser` 新增 `BracketedPaste`(DEC mode 2004);`TerminalView.Paste()` 在對方啟用 bracketed paste(PSReadLine / Kiro CLI 等)時以 `ESC[200~ … ESC[201~` 包夾整段,視為「單次貼上」而非逐行 Enter 立即送出;未啟用時退回原本逐字送出。**(3) 右鍵複製清反白** — 右鍵複製後清掉 `_hasSel` 並重繪,讓使用者知道已複製。
|
||||||
|
|
||||||
**v0.3.0:** 新增 **`ETTerms.PduMcp`**(stdio MCP server):讓 AI agent 直接控制 SNMP PDU 插座。與 serial 不同,PDU 走 SNMP(UDP) **非獨佔**,故 PduMcp **直接打 SNMP、不經 GUI 橋接**(內含精簡版 `PduController`,OID 邏輯複製自 GUI 版,log 走 stderr),GUI 不開著也能用。工具:`pdu_connect` / `pdu_list` / `pdu_set_port` / `pdu_get_port` / `pdu_status` / `pdu_power_cycle` / `pdu_disconnect`,回傳統一 `{ok, result/error}` JSON;連線狀態以行程內單例 `PduRegistry`(IP→controller)保存。`McpRegistrar` 改為多 server,**Settings → AI MCP 一鍵同時註冊 `etterms-serial` 與 `etterms-pdu`**;`ETTerms.csproj` 的 publish target 更名 `PublishMcpServers`,GUI publish 會把兩個 MCP 各自帶到 `\ETTerms.SerialMcp\`、`\ETTerms.PduMcp\` 子資料夾。
|
**v0.3.0:** 新增 **`ETTerms.PduMcp`**(stdio MCP server):讓 AI agent 直接控制 SNMP PDU 插座。與 serial 不同,PDU 走 SNMP(UDP) **非獨佔**,故 PduMcp **直接打 SNMP、不經 GUI 橋接**(內含精簡版 `PduController`,OID 邏輯複製自 GUI 版,log 走 stderr),GUI 不開著也能用。工具:`pdu_connect` / `pdu_list` / `pdu_set_port` / `pdu_get_port` / `pdu_status` / `pdu_power_cycle` / `pdu_disconnect`,回傳統一 `{ok, result/error}` JSON;連線狀態以行程內單例 `PduRegistry`(IP→controller)保存。`McpRegistrar` 改為多 server,**Settings → AI MCP 一鍵同時註冊 `etterms-serial` 與 `etterms-pdu`**;`ETTerms.csproj` 的 publish target 更名 `PublishMcpServers`,GUI publish 會把兩個 MCP 各自帶到 `\ETTerms.SerialMcp\`、`\ETTerms.PduMcp\` 子資料夾。
|
||||||
|
|
||||||
**v0.2.1:** 新增 GUI **Settings → AI MCP** 分頁(`McpRegistrar`):對 Claude Code(`~/.claude.json`)與 Kiro(`~/.kiro/settings/mcp.json`)**一鍵 Setup / Remove** 註冊 `etterms-serial` MCP server,read-modify-write 保留檔內其他設定、原子寫回;卡片附 CLI 驗證指令。`ETTerms.csproj` 加 publish target(`AfterTargets=Publish`),GUI publish 會自動把 MCP server 帶到子資料夾,與 `McpRegistrar.ResolveServerExe()` 解析路徑對齊。
|
**v0.2.1:** 新增 GUI **Settings → AI MCP** 分頁(`McpRegistrar`):對 Claude Code(`~/.claude.json`)與 Kiro(`~/.kiro/settings/mcp.json`)**一鍵 Setup / Remove** 註冊 `etterms-serial` MCP server,read-modify-write 保留檔內其他設定、原子寫回;卡片附 CLI 驗證指令。`ETTerms.csproj` 加 publish target(`AfterTargets=Publish`),GUI publish 會自動把 MCP server 帶到子資料夾,與 `McpRegistrar.ResolveServerExe()` 解析路徑對齊。
|
||||||
|
|||||||
@@ -183,12 +183,18 @@ public sealed class AboutView : UserControl
|
|||||||
|
|
||||||
private static readonly ChangelogEntry[] Changelog =
|
private static readonly ChangelogEntry[] Changelog =
|
||||||
[
|
[
|
||||||
new("0.3.0", new DateOnly(2026, 6, 8), "feat: PDU MCP server",
|
new("0.3.1", new DateOnly(2026, 6, 8), "Bugfix — terminal usability",
|
||||||
[
|
[
|
||||||
"New ETTerms.PduMcp server: AI agents (Kiro / Claude CLI) can now control SNMP PDU outlets directly.",
|
"Added a scrollbar on the right of the terminal — just drag it to look back through long output, instead of spinning the mouse wheel for ages.",
|
||||||
"Tools: pdu_connect / pdu_list / pdu_set_port / pdu_get_port / pdu_status / pdu_power_cycle / pdu_disconnect.",
|
"Fixed pasting multiple lines into Kiro CLI (and similar tools): the whole block now pastes in one go, instead of each line being sent off immediately.",
|
||||||
"PDU runs over SNMP, so the AI can power-cycle a DUT without the GUI session being open.",
|
"After you right-click to copy, the highlight now clears so you can tell the copy actually worked.",
|
||||||
"Settings → AI MCP now registers both Serial and PDU MCP servers with one click.",
|
]),
|
||||||
|
new("0.3.0", new DateOnly(2026, 6, 8), "AI power control for PDU outlets",
|
||||||
|
[
|
||||||
|
"AI assistants (Kiro / Claude) can now switch your PDU power outlets on and off for you.",
|
||||||
|
"Great for automated testing — the AI can power-cycle (restart) a connected device by itself.",
|
||||||
|
"Works even when the main ETTerms window isn't open.",
|
||||||
|
"One click in Settings → AI MCP now sets up both the Serial and PDU AI helpers at the same time.",
|
||||||
]),
|
]),
|
||||||
new("0.2.2", new DateOnly(2026, 6, 5), "Bugfix — terminal stability",
|
new("0.2.2", new DateOnly(2026, 6, 5), "Bugfix — terminal stability",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<AssemblyName>ETTerms</AssemblyName>
|
<AssemblyName>ETTerms</AssemblyName>
|
||||||
|
|
||||||
<!-- 版本資訊 -->
|
<!-- 版本資訊 -->
|
||||||
<Version>0.3.0</Version>
|
<Version>0.3.1</Version>
|
||||||
<Product>ETTerms</Product>
|
<Product>ETTerms</Product>
|
||||||
<Company>ETTerms Project</Company>
|
<Company>ETTerms Project</Company>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ public sealed class AnsiParser
|
|||||||
/// <summary>DECCKM:application cursor keys(影響方向鍵送出序列)。</summary>
|
/// <summary>DECCKM:application cursor keys(影響方向鍵送出序列)。</summary>
|
||||||
public bool AppCursorKeys { get; private set; }
|
public bool AppCursorKeys { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>DEC mode 2004:bracketed paste。啟用時貼上內容須以 ESC[200~ / ESC[201~ 包夾,
|
||||||
|
/// 讓 PSReadLine / Kiro CLI 等把多行貼上視為單一輸入而非逐行立即送出。</summary>
|
||||||
|
public bool BracketedPaste { get; private set; }
|
||||||
|
|
||||||
public AnsiParser(ScreenBuffer buffer) => _b = buffer;
|
public AnsiParser(ScreenBuffer buffer) => _b = buffer;
|
||||||
|
|
||||||
public void Feed(byte[] data)
|
public void Feed(byte[] data)
|
||||||
@@ -138,6 +142,7 @@ public sealed class AnsiParser
|
|||||||
case 25: _b.CursorVisible = set; break;
|
case 25: _b.CursorVisible = set; break;
|
||||||
case 7: _b.AutoWrap = set; break;
|
case 7: _b.AutoWrap = set; break;
|
||||||
case 1: AppCursorKeys = set; break;
|
case 1: AppCursorKeys = set; break;
|
||||||
|
case 2004: BracketedPaste = set; break;
|
||||||
case 47: case 1047: case 1049:
|
case 47: case 1047: case 1049:
|
||||||
if (set) _b.EnterAlt(); else _b.ExitAlt(); break;
|
if (set) _b.EnterAlt(); else _b.ExitAlt(); break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ETTerms.Terminal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自繪深色垂直捲軸:細長、無箭頭按鈕、圓角滑塊,配合終端機深色主題。
|
||||||
|
/// API(Minimum / Maximum / Value / LargeChange / SmallChange / Scroll)對齊 <see cref="VScrollBar"/>,
|
||||||
|
/// 方便直接替換。<see cref="Scroll"/> 只在「使用者操作」造成值變動時觸發(程式設定 Value 不觸發)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DarkScrollBar : Control
|
||||||
|
{
|
||||||
|
public int Minimum { get; set; } = 0;
|
||||||
|
public int Maximum { get; set; } = 0;
|
||||||
|
public int LargeChange { get; set; } = 1;
|
||||||
|
public int SmallChange { get; set; } = 1;
|
||||||
|
|
||||||
|
private int _value;
|
||||||
|
public int Value
|
||||||
|
{
|
||||||
|
get => _value;
|
||||||
|
set { int v = Clamp(value); if (v != _value) { _value = v; Invalidate(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>使用者拖曳 / 點軌道 / 滾輪造成值變動時觸發。</summary>
|
||||||
|
public event EventHandler? Scroll;
|
||||||
|
|
||||||
|
// 配色(KKTerm 深色)
|
||||||
|
private static readonly Color TrackColor = Color.FromArgb(24, 24, 28);
|
||||||
|
private static readonly Color ThumbColor = Color.FromArgb(70, 70, 82);
|
||||||
|
private static readonly Color ThumbHover = Color.FromArgb(100, 100, 118);
|
||||||
|
private static readonly Color ThumbDrag = Color.FromArgb(130, 130, 152);
|
||||||
|
|
||||||
|
private bool _hover, _dragging;
|
||||||
|
private int _dragOffset;
|
||||||
|
|
||||||
|
public DarkScrollBar()
|
||||||
|
{
|
||||||
|
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
|
||||||
|
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||||
|
Width = 12;
|
||||||
|
BackColor = TrackColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 數值範圍工具 ─────────────────────────────────────────
|
||||||
|
/// <summary>可達的最大 Value(與 VScrollBar 相同:Maximum - LargeChange + 1)。</summary>
|
||||||
|
private int MaxValue => Math.Max(Minimum, Maximum - LargeChange + 1);
|
||||||
|
private int Clamp(int v) => Math.Clamp(v, Minimum, MaxValue);
|
||||||
|
private bool Scrollable => Enabled && MaxValue > Minimum;
|
||||||
|
|
||||||
|
// ── 滑塊幾何 ─────────────────────────────────────────────
|
||||||
|
private const int MinThumb = 24;
|
||||||
|
private const int Pad = 2;
|
||||||
|
|
||||||
|
private int TrackHeight => Math.Max(1, Height - Pad * 2);
|
||||||
|
|
||||||
|
private int ThumbHeight()
|
||||||
|
{
|
||||||
|
int total = Maximum - Minimum + 1;
|
||||||
|
if (total <= 0) return TrackHeight;
|
||||||
|
int h = (int)((long)TrackHeight * LargeChange / total);
|
||||||
|
return Math.Clamp(h, MinThumb, TrackHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ThumbTop()
|
||||||
|
{
|
||||||
|
int range = MaxValue - Minimum;
|
||||||
|
if (range <= 0) return Pad;
|
||||||
|
int travel = TrackHeight - ThumbHeight();
|
||||||
|
return Pad + (int)((long)travel * (_value - Minimum) / range);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rectangle ThumbRect() => new(Pad, ThumbTop(), Math.Max(1, Width - Pad * 2), ThumbHeight());
|
||||||
|
|
||||||
|
// ── 繪製 ─────────────────────────────────────────────────
|
||||||
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
|
{
|
||||||
|
var g = e.Graphics;
|
||||||
|
g.Clear(TrackColor);
|
||||||
|
if (!Scrollable) return; // 不可捲動時只畫底色(與停用態一致)
|
||||||
|
|
||||||
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
var r = ThumbRect();
|
||||||
|
var color = _dragging ? ThumbDrag : _hover ? ThumbHover : ThumbColor;
|
||||||
|
int radius = Math.Min(r.Width, 6);
|
||||||
|
using var path = RoundedRect(r, radius);
|
||||||
|
using var b = new SolidBrush(color);
|
||||||
|
g.FillPath(b, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GraphicsPath RoundedRect(Rectangle r, int radius)
|
||||||
|
{
|
||||||
|
int d = radius * 2;
|
||||||
|
var p = new GraphicsPath();
|
||||||
|
if (d <= 0 || d > r.Width || d > r.Height) { p.AddRectangle(r); return p; }
|
||||||
|
p.AddArc(r.X, r.Y, d, d, 180, 90);
|
||||||
|
p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
|
||||||
|
p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
|
||||||
|
p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
|
||||||
|
p.CloseFigure();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 滑鼠互動 ─────────────────────────────────────────────
|
||||||
|
protected override void OnMouseDown(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Button != MouseButtons.Left || !Scrollable) return;
|
||||||
|
var thumb = ThumbRect();
|
||||||
|
if (thumb.Contains(e.Location))
|
||||||
|
{
|
||||||
|
_dragging = true;
|
||||||
|
_dragOffset = e.Y - thumb.Top;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 點軌道:往點擊方向翻一頁
|
||||||
|
SetValueFromUser(e.Y < thumb.Top ? _value - LargeChange : _value + LargeChange);
|
||||||
|
}
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseMove(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
bool over = ThumbRect().Contains(e.Location);
|
||||||
|
if (over != _hover) { _hover = over; Invalidate(); }
|
||||||
|
|
||||||
|
if (!_dragging) return;
|
||||||
|
int travel = TrackHeight - ThumbHeight();
|
||||||
|
if (travel <= 0) return;
|
||||||
|
int range = MaxValue - Minimum;
|
||||||
|
int y = Math.Clamp(e.Y - _dragOffset - Pad, 0, travel);
|
||||||
|
SetValueFromUser(Minimum + (int)((long)y * range / travel));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseUp(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (_dragging) { _dragging = false; Invalidate(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseLeave(EventArgs e)
|
||||||
|
{
|
||||||
|
if (_hover) { _hover = false; Invalidate(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseWheel(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (!Scrollable) return;
|
||||||
|
int step = e.Delta / 120 * SmallChange * 3;
|
||||||
|
SetValueFromUser(_value - step);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetValueFromUser(int v)
|
||||||
|
{
|
||||||
|
int nv = Clamp(v);
|
||||||
|
if (nv == _value) return;
|
||||||
|
_value = nv;
|
||||||
|
Invalidate();
|
||||||
|
Scroll?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ public sealed class TerminalView : UserControl
|
|||||||
private readonly ScreenBuffer _buf;
|
private readonly ScreenBuffer _buf;
|
||||||
private readonly AnsiParser _parser;
|
private readonly AnsiParser _parser;
|
||||||
private readonly Font _font;
|
private readonly Font _font;
|
||||||
|
private readonly DarkScrollBar _vscroll; // 右側深色垂直捲軸(可拖曳捲動 scrollback)
|
||||||
|
private bool _suppressScroll; // 程式設定捲軸值時抑制回呼
|
||||||
private int _cellW, _cellH;
|
private int _cellW, _cellH;
|
||||||
private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback
|
private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback
|
||||||
private int _lastCols = -1, _lastRows = -1;
|
private int _lastCols = -1, _lastRows = -1;
|
||||||
@@ -42,6 +44,11 @@ public sealed class TerminalView : UserControl
|
|||||||
_buf = new ScreenBuffer(profile.Cols, profile.Rows,
|
_buf = new ScreenBuffer(profile.Cols, profile.Rows,
|
||||||
Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines);
|
Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines);
|
||||||
_parser = new AnsiParser(_buf);
|
_parser = new AnsiParser(_buf);
|
||||||
|
|
||||||
|
// 右側深色垂直捲軸:拖曳即捲動 scrollback。內容寬度會扣掉捲軸寬,故文字不會被蓋住。
|
||||||
|
_vscroll = new DarkScrollBar { Dock = DockStyle.Right, SmallChange = 1, Minimum = 0, Maximum = 0 };
|
||||||
|
_vscroll.Scroll += OnVScroll;
|
||||||
|
Controls.Add(_vscroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
|
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
|
||||||
@@ -49,11 +56,13 @@ public sealed class TerminalView : UserControl
|
|||||||
{
|
{
|
||||||
_parser.Feed(data);
|
_parser.Feed(data);
|
||||||
_scrollOffset = 0; // 新輸出貼底
|
_scrollOffset = 0; // 新輸出貼底
|
||||||
|
UpdateScrollBar();
|
||||||
Invalidate();
|
Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int ContentWidth => Math.Max(_cellW, ClientSize.Width - (_vscroll?.Width ?? 0));
|
||||||
private int VisibleRows => Math.Max(1, ClientSize.Height / _cellH);
|
private int VisibleRows => Math.Max(1, ClientSize.Height / _cellH);
|
||||||
private int VisibleCols => Math.Max(1, ClientSize.Width / _cellW);
|
private int VisibleCols => Math.Max(1, ContentWidth / _cellW);
|
||||||
|
|
||||||
// ── resize → 通知 PTY ────────────────────────────────────
|
// ── resize → 通知 PTY ────────────────────────────────────
|
||||||
protected override void OnSizeChanged(EventArgs e)
|
protected override void OnSizeChanged(EventArgs e)
|
||||||
@@ -71,6 +80,7 @@ public sealed class TerminalView : UserControl
|
|||||||
_lastCols = cols; _lastRows = rows;
|
_lastCols = cols; _lastRows = rows;
|
||||||
_buf.Resize(cols, rows);
|
_buf.Resize(cols, rows);
|
||||||
Resized?.Invoke(cols, rows);
|
Resized?.Invoke(cols, rows);
|
||||||
|
UpdateScrollBar();
|
||||||
Invalidate();
|
Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +198,47 @@ public sealed class TerminalView : UserControl
|
|||||||
{
|
{
|
||||||
int delta = e.Delta / 120 * 3;
|
int delta = e.Delta / 120 * 3;
|
||||||
_scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount);
|
_scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount);
|
||||||
|
UpdateScrollBar();
|
||||||
Invalidate();
|
Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 垂直捲軸 ─────────────────────────────────────────────
|
||||||
|
private void OnVScroll(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressScroll) return;
|
||||||
|
// 捲軸值 = 視窗第一列的絕對 index;_scrollOffset = 距底部的列數。
|
||||||
|
_scrollOffset = Math.Clamp(_buf.ScrollbackCount - _vscroll.Value, 0, _buf.ScrollbackCount);
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>依目前 scrollback / 捲動位置同步捲軸的範圍與滑塊位置。</summary>
|
||||||
|
private void UpdateScrollBar()
|
||||||
|
{
|
||||||
|
if (_vscroll is null) return;
|
||||||
|
_suppressScroll = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int visible = VisibleRows;
|
||||||
|
int total = _buf.TotalRows;
|
||||||
|
if (_buf.ScrollbackCount <= 0)
|
||||||
|
{
|
||||||
|
_vscroll.Enabled = false;
|
||||||
|
_vscroll.LargeChange = 1;
|
||||||
|
_vscroll.Maximum = 0;
|
||||||
|
_vscroll.Value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_vscroll.Enabled = true;
|
||||||
|
// 先設 LargeChange/Maximum 再設 Value,避免 Value 超出可達範圍被夾掉。
|
||||||
|
_vscroll.LargeChange = Math.Max(1, visible);
|
||||||
|
_vscroll.Maximum = Math.Max(0, total - 1);
|
||||||
|
int top = _buf.ScrollbackCount - _scrollOffset; // 視窗第一列絕對 index
|
||||||
|
int maxValue = Math.Max(0, _vscroll.Maximum - _vscroll.LargeChange + 1);
|
||||||
|
_vscroll.Value = Math.Clamp(top, 0, maxValue);
|
||||||
|
}
|
||||||
|
finally { _suppressScroll = false; }
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnMouseDown(MouseEventArgs e)
|
protected override void OnMouseDown(MouseEventArgs e)
|
||||||
{
|
{
|
||||||
Focus();
|
Focus();
|
||||||
@@ -202,7 +250,8 @@ public sealed class TerminalView : UserControl
|
|||||||
}
|
}
|
||||||
else if (e.Button == MouseButtons.Right)
|
else if (e.Button == MouseButtons.Right)
|
||||||
{
|
{
|
||||||
if (_hasSel) CopySelection(); else Paste();
|
if (_hasSel) { CopySelection(); _hasSel = false; Invalidate(); } // 複製後清掉反白,讓 user 知道已動作
|
||||||
|
else Paste();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,8 +313,17 @@ public sealed class TerminalView : UserControl
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (Clipboard.ContainsText())
|
if (!Clipboard.ContainsText()) return;
|
||||||
SendData?.Invoke(Encoding.UTF8.GetBytes(Clipboard.GetText().Replace("\r\n", "\r")));
|
// 換行統一成 CR:剪貼簿多為 \r\n,也處理單獨的 \n。
|
||||||
|
var text = Clipboard.GetText().Replace("\r\n", "\r").Replace('\n', '\r');
|
||||||
|
|
||||||
|
// 應用程式啟用 bracketed paste(PSReadLine / Kiro CLI 等)時,以 ESC[200~ … ESC[201~
|
||||||
|
// 包夾整段貼上內容,讓對方視為「單次貼上」而非逐行 Enter 立即送出;
|
||||||
|
// 未啟用時才退回原本逐字送出(一般 shell 的預期行為)。
|
||||||
|
var data = _parser.BracketedPaste
|
||||||
|
? Encoding.UTF8.GetBytes("\x1b[200~" + text + "\x1b[201~")
|
||||||
|
: Encoding.UTF8.GetBytes(text);
|
||||||
|
SendData?.Invoke(data);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user