feat: v0.5.0 terminal search, keyword alerts, TeraTerm-compatible TTL

Search (Ctrl+F):
- in-terminal search bar over full scrollback + screen; all hits
  highlighted, current hit emphasized; Enter searches upward,
  Shift+Enter downward, F3/Esc shortcuts
- hits anchored via ScreenBuffer.DroppedLines so positions stay
  correct as the ring buffer drops old lines

Keyword highlighting & tab alerts:
- new Settings -> Highlight page: user-defined keyword list with
  per-rule enable and a global toggle (AppSettings.KeywordRules)
- keywords highlighted red in every terminal (visible rows only,
  case-insensitive); background tabs flash a red dot when a keyword
  appears, cleared when the tab is opened

TTL engine (TeraTerm macro compatibility):
- new TtlExpression parser: parens, and/or/xor/not, comparisons,
  * / % + -, hex literals (0x/$), string/int values; legacy fallback
  keeps old scripts working
- control flow: goto, call/return (inline, usable inside loops),
  for/next, do/loop [while|until], until/enduntil, break, continue,
  end, exit, include, mpause; one-line "if <expr> <statement>"
- waits: waitln, waitregex (matchstr/groupmatchstr1-9), recvln,
  multi-string wait (TeraTerm semantics), mtimeout
- strings: strlen strcompare strconcat strcopy strinsert strremove
  strmatch strscan strreplace strtrim strsplit strjoin tolower
  toupper str2int int2str code2str str2code sprintf expandenv
- files: fileopen filereadln filewrite(ln) fileclose filecreate
  filedelete filesearch basename dirname makepath foldercreate
  folderdelete foldersearch getdir setdir
- misc: beep getdate gettime getenv setenv random exec getver
  getttdir uptime ifdefined clipb2var var2clipb inputbox yesnobox
  crc32 checksum8/16/32 dispstr
- serial: sendbreak setbaud setdtr setrts sendfile (SerialChannel
  gains SendBreak/SetBaudRate/SetDtr/SetRts)
- script Output (incl. dispstr) now echoed gray into the terminal
- quote-aware comment stripping; case-insensitive variables

Docs:
- docs/ttl-script-reference.md rewritten: ETTerms-only commands
  first, then the TeraTerm-shared set, with examples

Misc:
- version 0.5.0; About changelog; CLAUDE.md v0.5.0 notes
- restore ETTerms.PduCore ProjectReference in ETTerms/PduMcp csproj
  (was dropped in the working tree; required to compile)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:47:06 +08:00
co-authored by Claude Fable 5
parent c9671c819f
commit 98fbd310d2
13 changed files with 2255 additions and 229 deletions
+9
View File
@@ -183,6 +183,15 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog =
[
new("0.5.0", new DateOnly(2026, 7, 2), "Search, keyword alerts & a much bigger scripting language",
[
"Press Ctrl+F in any terminal to search everything you've scrolled past — all hits are highlighted, Enter jumps between them.",
"New Settings → Highlight page: add your own keywords (like ERROR or FAIL) and they light up in red wherever they appear.",
"When a keyword shows up in a tab you're not looking at, that tab's dot turns red so you don't miss it.",
"TTL scripting grew from ~15 commands to 90+, matching Tera Term macros: loops (for / do / until), goto and subroutines, waitln / waitregex with regex capture, string and file operations, input dialogs, and serial line control (sendbreak, setbaud).",
"Scripts now show their progress in gray right inside the terminal, so you can watch what they're doing.",
"See the full command table with examples in docs/ttl-script-reference.md.",
]),
new("0.4.0", new DateOnly(2026, 7, 2), "Performance & stability overhaul",
[
"The terminal is much smoother during heavy output (long boot logs, big file dumps) — drawing and scroll history were reworked to stay fast no matter how much text has scrolled by.",
+110
View File
@@ -52,6 +52,7 @@ public sealed class SettingsView : UserControl
var termBtn = MakeTab("Terminal", BuildTerminalTab());
tabBar.Controls.Add(termBtn);
tabBar.Controls.Add(MakeTab("Highlight", BuildHighlightTab()));
tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab()));
Controls.Add(body);
@@ -171,6 +172,115 @@ public sealed class SettingsView : UserControl
return page;
}
// ═══ Highlight Tab ═══
private Panel BuildHighlightTab()
{
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var s = AppSettings.Instance;
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
};
flow.Controls.Add(new Label
{
Text = "Keyword Highlighting", AutoSize = true,
ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4)
});
flow.Controls.Add(new Label
{
Text = "Keywords below are highlighted in red in every terminal (case-insensitive).\n" +
"When a keyword appears in a background tab, that tab's dot turns red until you open it.",
AutoSize = false, Width = 520, Height = 34,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
});
var enable = new CheckBox
{
Text = "Enable keyword highlighting", Checked = s.KeywordHighlightEnabled,
AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
};
flow.Controls.Add(enable);
// 規則清單:每列 = 啟用勾選 + 關鍵字(可直接編輯)
var grid = new DataGridView
{
Width = 420, Height = 240,
BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border,
BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text },
ColumnHeadersDefaultCellStyle = { BackColor = Theme.RailBack, ForeColor = Theme.Accent, Font = Theme.UiFontBold },
ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single,
EnableHeadersVisualStyles = false, RowHeadersVisible = false,
AllowUserToAddRows = false, AllowUserToDeleteRows = false,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
Font = Theme.UiFont, RowTemplate = { Height = 24 }, Margin = new Padding(0, 0, 0, 8)
};
grid.Columns.Add(new DataGridViewCheckBoxColumn { Name = "On", HeaderText = "On", Width = 44 });
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Keyword", HeaderText = "Keyword",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
});
foreach (var r in s.KeywordRules) grid.Rows.Add(r.Enabled, r.Text);
flow.Controls.Add(grid);
// 新增 / 移除
var newKw = new TextBox { Width = 220, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle };
var addBtn = MakeButton("Add", Theme.Accent);
var removeBtn = MakeButton("Remove Selected", Color.FromArgb(210, 120, 120));
void AddKeyword()
{
var t = newKw.Text.Trim();
if (t.Length == 0) return;
grid.Rows.Add(true, t);
newKw.Clear();
newKw.Focus();
}
addBtn.Click += (_, _) => AddKeyword();
newKw.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) { AddKeyword(); e.Handled = e.SuppressKeyPress = true; } };
removeBtn.Click += (_, _) =>
{
foreach (DataGridViewRow row in grid.SelectedRows) grid.Rows.Remove(row);
};
var editRow = new FlowLayoutPanel
{
FlowDirection = FlowDirection.LeftToRight, AutoSize = true,
WrapContents = false, BackColor = Theme.WorkspaceBack, Margin = new Padding(0, 0, 0, 12)
};
editRow.Controls.Add(newKw);
editRow.Controls.Add(addBtn);
editRow.Controls.Add(removeBtn);
flow.Controls.Add(editRow);
var save = MakeButton("Save", Theme.Accent);
save.Margin = new Padding(0);
save.Click += (_, _) =>
{
grid.EndEdit();
s.KeywordHighlightEnabled = enable.Checked;
s.KeywordRules = grid.Rows.Cast<DataGridViewRow>()
.Select(r => new KeywordRule
{
Enabled = r.Cells["On"].Value is true,
Text = r.Cells["Keyword"].Value?.ToString()?.Trim() ?? ""
})
.Where(r => r.Text.Length > 0)
.ToList();
s.Save();
MessageBox.Show(this, "Highlight settings saved.\nThey apply immediately to all open sessions.",
"Highlight", MessageBoxButtons.OK, MessageBoxIcon.Information);
};
flow.Controls.Add(save);
page.Controls.Add(flow);
return page;
}
// ═══ AI MCP Tab ═══
private Panel BuildAiMcpTab()
{
+10 -1
View File
@@ -20,6 +20,7 @@ public sealed class WorkspaceView : UserControl
public required SessionPage Page;
public Rectangle TabBounds;
public Rectangle CloseRect;
public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除
}
private readonly FlowLayoutPanel _toolbar;
@@ -114,6 +115,12 @@ public sealed class WorkspaceView : UserControl
var page = BuildPage(conn);
var s = new Session { Title = conn.Name, IsSsh = conn.IsSsh, Page = page };
page.ConnectFailed += msg => OnConnectFailed(s, msg);
page.KeywordAlert += _ =>
{
if (s == _active || s.Alert) return;
s.Alert = true;
_tabStrip.Invalidate();
};
_sessions.Add(s);
_active = s;
Relayout();
@@ -191,6 +198,7 @@ public sealed class WorkspaceView : UserControl
}
_empty.Visible = false;
_active ??= _sessions[0];
_active.Alert = false; // 使用者正在看這個分頁,警示清除
int cells = _rows * _cols;
int activeIdx = _sessions.IndexOf(_active);
@@ -360,7 +368,8 @@ public sealed class WorkspaceView : UserControl
if (drag)
using (var pen = new Pen(Theme.Accent, 1))
g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1));
using (var dot = new SolidBrush(s.IsSsh ? Theme.SshColor : Theme.SerialColor))
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除
using (var dot = new SolidBrush(s.Alert ? Color.FromArgb(235, 85, 85) : s.IsSsh ? Theme.SshColor : Theme.SerialColor))
g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8);
var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH);
TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim,
+3 -3
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 -->
<Version>0.4.0</Version>
<Version>0.5.0</Version>
<Product>ETTerms</Product>
<Company>ETTerms Project</Company>
@@ -51,9 +51,9 @@
<_McpSelfContained Condition="'$(SelfContained)' != ''">$(SelfContained)</_McpSelfContained>
<_McpSelfContained Condition="'$(SelfContained)' == ''">false</_McpSelfContained>
</PropertyGroup>
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.SerialMcp -> $(PublishDir)ETTerms.SerialMcp (self-contained=$(_McpSelfContained))" />
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.SerialMcp -&gt; $(PublishDir)ETTerms.SerialMcp (self-contained=$(_McpSelfContained))" />
<Exec Command="dotnet publish &quot;$(MSBuildThisFileDirectory)..\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj&quot; -c $(Configuration) -r $(_McpRid) --self-contained $(_McpSelfContained) -o &quot;$(PublishDir)ETTerms.SerialMcp&quot;" />
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.PduMcp -> $(PublishDir)ETTerms.PduMcp (self-contained=$(_McpSelfContained))" />
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.PduMcp -&gt; $(PublishDir)ETTerms.PduMcp (self-contained=$(_McpSelfContained))" />
<Exec Command="dotnet publish &quot;$(MSBuildThisFileDirectory)..\ETTerms.PduMcp\ETTerms.PduMcp.csproj&quot; -c $(Configuration) -r $(_McpRid) --self-contained $(_McpSelfContained) -o &quot;$(PublishDir)ETTerms.PduMcp&quot;" />
</Target>
+11
View File
@@ -27,6 +27,10 @@ public sealed class AppSettings
public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd
public string ShellStartupDir { get; set; } = "";
// ── Keyword highlight(終端機關鍵字標色 + 分頁警示;Settings → Highlight 分頁設定)──
public bool KeywordHighlightEnabled { get; set; } = true;
public List<KeywordRule> KeywordRules { get; set; } = new();
// ── Window ──
public int WindowX { get; set; } = -1;
public int WindowY { get; set; } = -1;
@@ -83,3 +87,10 @@ public sealed class AppSettings
return new();
}
}
/// <summary>一條關鍵字高亮規則:關鍵字文字 + 是否啟用(比對不分大小寫)。</summary>
public sealed class KeywordRule
{
public string Text { get; set; } = "";
public bool Enabled { get; set; } = true;
}
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
using System.Globalization;
namespace ETTerms.Scripting;
/// <summary>
/// TTL 運算式解析 / 求值器(v0.5.0,供 if / elseif / while / until / for / 變數指派使用)。
///
/// 支援:
/// - 整數:十進位、<c>0x1F</c>、<c>$1F</c>TeraTerm 十六進位)
/// - 字串常值:<c>'...'</c> 或 <c>"..."</c>
/// - 變數(由呼叫端 resolver 解析;未定義視為 0)
/// - 括號、一元 <c>-</c> / <c>not</c> / <c>!</c> / <c>~</c>
/// - 算術 <c>* / % + -</c>
/// - 比較 <c>= == &lt;&gt; != &gt; &lt; &gt;= &lt;=</c>(兩邊皆可為整數時用數值比較,否則字串比較)
/// - 邏輯 <c>and or xor</c>(同義:<c>&amp;&amp; ||</c>
///
/// 值為 <see cref="object"/>int 或 string;比較 / 邏輯運算結果為 int 1/0。
/// </summary>
public static class TtlExpression
{
/// <summary>求值整段文字;結尾若有多餘內容視為錯誤(FormatException)。</summary>
public static object Evaluate(string text, Func<string, object?> resolve)
{
int pos = 0;
var v = ParseOr(text, ref pos, resolve);
SkipWs(text, ref pos);
if (pos < text.Length) throw new FormatException($"unexpected '{text[pos..]}'");
return v;
}
/// <summary>從 <paramref name="pos"/> 起解析一個運算式,pos 停在運算式之後(供單行 if 判斷剩餘 statement)。</summary>
public static object Parse(string text, ref int pos, Func<string, object?> resolve)
=> ParseOr(text, ref pos, resolve);
public static bool Truthy(object? v) => ToInt(v) != 0;
public static int ToInt(object? v) => v switch
{
int i => i,
string s when TryParseInt(s.Trim(), out int r) => r,
_ => 0
};
public static string ToStr(object? v) => v switch
{
null => "",
string s => s,
_ => v.ToString() ?? ""
};
// ── 文法(優先序低 → 高)─────────────────────────────────
private static object ParseOr(string s, ref int p, Func<string, object?> r)
{
var v = ParseAnd(s, ref p, r);
while (true)
{
SkipWs(s, ref p);
if (MatchWord(s, ref p, "or") || MatchOp(s, ref p, "||"))
v = (Truthy(v) | Truthy(ParseAnd(s, ref p, r))) ? 1 : 0;
else if (MatchWord(s, ref p, "xor"))
v = (Truthy(v) ^ Truthy(ParseAnd(s, ref p, r))) ? 1 : 0;
else return v;
}
}
private static object ParseAnd(string s, ref int p, Func<string, object?> r)
{
var v = ParseCompare(s, ref p, r);
while (true)
{
SkipWs(s, ref p);
if (MatchWord(s, ref p, "and") || MatchOp(s, ref p, "&&"))
v = (Truthy(v) & Truthy(ParseCompare(s, ref p, r))) ? 1 : 0;
else return v;
}
}
private static object ParseCompare(string s, ref int p, Func<string, object?> r)
{
var l = ParseAdd(s, ref p, r);
SkipWs(s, ref p);
string? op =
MatchOp(s, ref p, ">=") ? ">=" :
MatchOp(s, ref p, "<=") ? "<=" :
MatchOp(s, ref p, "<>") ? "!=" :
MatchOp(s, ref p, "==") ? "==" :
MatchOp(s, ref p, "!=") ? "!=" :
MatchOp(s, ref p, "=") ? "==" :
MatchOp(s, ref p, ">") ? ">" :
MatchOp(s, ref p, "<") ? "<" : null;
if (op == null) return l;
var rt = ParseAdd(s, ref p, r);
// 兩邊皆可為整數 → 數值比較;否則字串比較(僅 == / !=;大小比較退回數值 0)
bool numeric = l is int || rt is int
|| (TryParseInt(ToStr(l).Trim(), out _) && TryParseInt(ToStr(rt).Trim(), out _));
if (numeric)
{
int a = ToInt(l), b = ToInt(rt);
return op switch
{
"==" => a == b ? 1 : 0,
"!=" => a != b ? 1 : 0,
">=" => a >= b ? 1 : 0,
"<=" => a <= b ? 1 : 0,
">" => a > b ? 1 : 0,
_ => a < b ? 1 : 0
};
}
int cmp = string.CompareOrdinal(ToStr(l), ToStr(rt));
return op switch
{
"==" => cmp == 0 ? 1 : 0,
"!=" => cmp != 0 ? 1 : 0,
">=" => cmp >= 0 ? 1 : 0,
"<=" => cmp <= 0 ? 1 : 0,
">" => cmp > 0 ? 1 : 0,
_ => cmp < 0 ? 1 : 0
};
}
private static object ParseAdd(string s, ref int p, Func<string, object?> r)
{
var v = ParseMul(s, ref p, r);
while (true)
{
SkipWs(s, ref p);
if (MatchOp(s, ref p, "+")) v = ToInt(v) + ToInt(ParseMul(s, ref p, r));
else if (MatchOp(s, ref p, "-")) v = ToInt(v) - ToInt(ParseMul(s, ref p, r));
else return v;
}
}
private static object ParseMul(string s, ref int p, Func<string, object?> r)
{
var v = ParseUnary(s, ref p, r);
while (true)
{
SkipWs(s, ref p);
if (MatchOp(s, ref p, "*")) v = ToInt(v) * ToInt(ParseUnary(s, ref p, r));
else if (MatchOp(s, ref p, "/"))
{
int d = ToInt(ParseUnary(s, ref p, r));
v = d != 0 ? ToInt(v) / d : 0;
}
else if (MatchOp(s, ref p, "%"))
{
int d = ToInt(ParseUnary(s, ref p, r));
v = d != 0 ? ToInt(v) % d : 0;
}
else return v;
}
}
private static object ParseUnary(string s, ref int p, Func<string, object?> r)
{
SkipWs(s, ref p);
if (MatchOp(s, ref p, "-")) return -ToInt(ParseUnary(s, ref p, r));
if (MatchOp(s, ref p, "~")) return ~ToInt(ParseUnary(s, ref p, r));
if (MatchOp(s, ref p, "!")) return Truthy(ParseUnary(s, ref p, r)) ? 0 : 1;
if (MatchWord(s, ref p, "not")) return Truthy(ParseUnary(s, ref p, r)) ? 0 : 1;
return ParsePrimary(s, ref p, r);
}
private static object ParsePrimary(string s, ref int p, Func<string, object?> r)
{
SkipWs(s, ref p);
if (p >= s.Length) throw new FormatException("unexpected end of expression");
char c = s[p];
if (c == '(')
{
p++;
var v = ParseOr(s, ref p, r);
SkipWs(s, ref p);
if (p >= s.Length || s[p] != ')') throw new FormatException("missing ')'");
p++;
return v;
}
if (c is '\'' or '"')
{
char q = c;
int start = ++p;
while (p < s.Length && s[p] != q) p++;
if (p >= s.Length) throw new FormatException("unterminated string");
var str = s[start..p];
p++;
return str;
}
if (c == '$') // TeraTerm 十六進位 $1F
{
int start = ++p;
while (p < s.Length && Uri.IsHexDigit(s[p])) p++;
if (p == start) throw new FormatException("invalid hex literal");
return int.Parse(s[start..p], NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
if (char.IsDigit(c))
{
if (c == '0' && p + 1 < s.Length && (s[p + 1] is 'x' or 'X'))
{
int hs = p + 2, hp = hs;
while (hp < s.Length && Uri.IsHexDigit(s[hp])) hp++;
if (hp == hs) throw new FormatException("invalid hex literal");
p = hp;
return int.Parse(s[hs..hp], NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
int ds = p;
while (p < s.Length && char.IsDigit(s[p])) p++;
return int.Parse(s[ds..p], CultureInfo.InvariantCulture);
}
if (char.IsLetter(c) || c == '_')
{
int ws = p;
while (p < s.Length && (char.IsLetterOrDigit(s[p]) || s[p] == '_')) p++;
return r(s[ws..p]) ?? 0; // 未定義變數 → 0
}
throw new FormatException($"unexpected character '{c}'");
}
// ── 小工具 ───────────────────────────────────────────────
private static void SkipWs(string s, ref int p) { while (p < s.Length && char.IsWhiteSpace(s[p])) p++; }
private static bool MatchOp(string s, ref int p, string op)
{
if (p + op.Length > s.Length || !s.AsSpan(p, op.Length).SequenceEqual(op)) return false;
// "=" 不可吃掉 "=="、"<" 不可吃掉 "<=" / "<>"(呼叫端已按長度優先排序,這裡防呆單字元誤判)
if (op == "=" && p + 1 < s.Length && s[p + 1] == '=') return false;
if (op == ">" && p + 1 < s.Length && s[p + 1] == '=') return false;
if (op == "<" && p + 1 < s.Length && (s[p + 1] == '=' || s[p + 1] == '>')) return false;
p += op.Length;
return true;
}
private static bool MatchWord(string s, ref int p, string word)
{
if (p + word.Length > s.Length) return false;
if (!s.AsSpan(p, word.Length).Equals(word, StringComparison.OrdinalIgnoreCase)) return false;
int after = p + word.Length;
if (after < s.Length && (char.IsLetterOrDigit(s[after]) || s[after] == '_')) return false; // 是識別字的一部分
p = after;
return true;
}
private static bool TryParseInt(string s, out int v)
{
if (s.StartsWith("0x") || s.StartsWith("0X"))
return int.TryParse(s[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out v);
if (s.StartsWith("$"))
return int.TryParse(s[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out v);
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v);
}
}
+16
View File
@@ -72,6 +72,22 @@ public sealed class SerialChannel : ISessionChannel
if (_port.IsOpen) _port.Write(data, 0, data.Length);
}
// ── TTL serial 控制指令用(sendbreak / setbaud / setdtr / setrts)──
/// <summary>送出 serial break(拉 break 狀態約 300ms)。</summary>
public void SendBreak()
{
if (!_port.IsOpen) return;
try { _port.BreakState = true; Thread.Sleep(300); }
finally { try { _port.BreakState = false; } catch { } }
}
/// <summary>執行中變更 baud rateTTL setbaud)。</summary>
public void SetBaudRate(int baud) => _port.BaudRate = baud;
public void SetDtr(bool on) => _port.DtrEnable = on;
public void SetRts(bool on) => _port.RtsEnable = on;
public void Resize(int cols, int rows) { /* serial 無 PTY size */ }
public void Close()
+7
View File
@@ -63,6 +63,9 @@ public sealed class SessionPage : UserControl
/// <summary>同步開啟失敗(主要是 Serial 連不上 / 被占用)時觸發,附帶訊息。</summary>
public event Action<string>? ConnectFailed;
/// <summary>輸出中出現啟用的高亮關鍵字(UI thread),供 WorkspaceView 標分頁警示。</summary>
public event Action<string>? KeywordAlert;
public SessionPage(ISessionChannel channel, string title)
{
_channel = channel;
@@ -90,12 +93,16 @@ public sealed class SessionPage : UserControl
_term = new TerminalView(new TerminalProfile()) { Dock = DockStyle.Fill };
_term.SendData += data => _channel.Write(data);
_term.Resized += (cols, rows) => _channel.Resize(cols, rows);
_term.KeywordAlert += k => KeywordAlert?.Invoke(k);
Controls.Add(_term); // Fill 先加
Controls.Add(bar); // Top
_runner.StatusChanged += (_, line, cmd) => Ui(() => _status.Text = $"line {line}: {Trunc(cmd)}");
_runner.Finished += (_, msg) => Ui(() => { _status.Text = msg; SetRunning(false); });
// 腳本輸出(dispstr / [wait] 進度 / 錯誤)以灰色 echo 到終端機,執行過程看得見
_runner.Output += m => Ui(() =>
_term.Feed(System.Text.Encoding.UTF8.GetBytes($"\x1b[90m{m}\x1b[0m\r\n")));
}
private async void OnRunScript(object? sender, EventArgs e)
+6 -2
View File
@@ -62,6 +62,10 @@ public sealed class ScreenBuffer
public int TotalRows => _sbCount + Rows;
public Cell[] LineAt(int abs) => abs < _sbCount ? _sb[(_sbHead + abs) % _sb.Length]! : _screen[abs - _sbCount];
/// <summary>scrollback 滿後被丟棄的總行數。搜尋以「絕對行號 = DroppedLines + abs」錨定命中,
/// 舊行被丟棄時命中位置不會漂移。</summary>
public long DroppedLines { get; private set; }
// ── 內部建構工具 ─────────────────────────────────────────
private Cell BlankPen() => new() { Ch = ' ', Fg = PenFg, Bg = PenBg, Attr = CellAttr.None };
private Cell BlankDefault() => new() { Ch = ' ', Fg = DefaultFg, Bg = DefaultBg, Attr = CellAttr.None };
@@ -78,7 +82,7 @@ public sealed class ScreenBuffer
{
if (_sb.Length == 0) return;
if (_sbCount < _sb.Length) _sb[(_sbHead + _sbCount++) % _sb.Length] = line;
else { _sb[_sbHead] = line; _sbHead = (_sbHead + 1) % _sb.Length; } // 滿了:覆蓋最舊一行
else { _sb[_sbHead] = line; _sbHead = (_sbHead + 1) % _sb.Length; DroppedLines++; } // 滿了:覆蓋最舊一行
}
// ── 輸出字元 ─────────────────────────────────────────────
@@ -233,7 +237,7 @@ public sealed class ScreenBuffer
public void EraseInDisplay(int mode)
{
if (mode == 3) { Array.Clear(_sb); _sbHead = 0; _sbCount = 0; return; }
if (mode == 3) { DroppedLines += _sbCount; Array.Clear(_sb); _sbHead = 0; _sbCount = 0; return; }
if (mode == 2) { for (int r = 0; r < Rows; r++) _screen[r] = BlankLine(); return; }
if (mode == 0)
{
+293
View File
@@ -1,7 +1,9 @@
using System.Drawing;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using ETTerms.Infrastructure;
namespace ETTerms.Terminal;
@@ -33,6 +35,31 @@ public sealed class TerminalView : UserControl
private (int row, int col) _selStart, _selEnd;
private bool _hasSel;
// ── 搜尋(Ctrl+F)──
private Panel? _searchPanel;
private TextBox _searchBox = null!;
private Label _searchCount = null!;
private readonly List<(long line, int col, int len)> _matches = new(); // line = DroppedLines + abs
private int _matchIdx = -1;
// ── 高亮標記(搜尋命中 + 關鍵字),每次 OnPaint 對可見行重建 ──
private enum MarkKind { Keyword, Search, SearchCurrent }
private readonly Dictionary<int, List<(int col, int len, MarkKind kind)>> _rowMarks = new();
private readonly StringBuilder _lineSb = new();
private readonly List<int> _lineColMap = new();
// ── 關鍵字警示(Feed 偵測,分頁標紅點用)──
private static readonly Regex AnsiStrip = new(
@"\x1B\][^\x07\x1B]*(\x07|\x1B\\)|\x1B[@-Z\\-_]|\x1B\[[0-?]*[ -/]*[@-~]",
RegexOptions.Compiled);
private readonly Decoder _alertDec = Encoding.UTF8.GetDecoder();
private string _alertCarry = "";
private readonly Dictionary<string, long> _alertLastFired = new(StringComparer.OrdinalIgnoreCase);
private const int AlertCooldownMs = 2000;
/// <summary>啟用的關鍵字在輸出中出現(UI thread 觸發),供分頁標警示。</summary>
public event Action<string>? KeywordAlert;
public TerminalView(TerminalProfile profile)
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
@@ -63,6 +90,7 @@ public sealed class TerminalView : UserControl
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
public void Feed(byte[] data)
{
DetectKeywords(data);
int before = _buf.ScrollbackCount;
_parser.Feed(data);
// 已貼底才跟隨新輸出;使用者往回看歷史時,補償 scrollback 增量讓畫面停在原處,
@@ -88,6 +116,8 @@ public sealed class TerminalView : UserControl
if (ClientSize.Width < _cellW || ClientSize.Height < _cellH) return;
if (FindForm() is { WindowState: FormWindowState.Minimized }) return;
PositionSearchPanel();
int cols = VisibleCols, rows = VisibleRows;
if (cols == _lastCols && rows == _lastRows) return;
_lastCols = cols; _lastRows = rows;
@@ -106,6 +136,8 @@ public sealed class TerminalView : UserControl
int top = _buf.ScrollbackCount - _scrollOffset; // 視窗第一列的絕對 index
if (top < 0) top = 0;
CollectMarks(top, rows);
for (int vr = 0; vr < rows; vr++)
{
int abs = top + vr;
@@ -179,6 +211,27 @@ public sealed class TerminalView : UserControl
fg = cell.Fg.A == 0 ? _buf.DefaultFg : cell.Fg;
bg = cell.Bg.A == 0 ? _buf.DefaultBg : cell.Bg;
if ((cell.Attr & CellAttr.Inverse) != 0) (fg, bg) = (bg, fg);
// 標記優先序:目前搜尋命中 > 其他搜尋命中 > 關鍵字(選取反白最後蓋上)
if (_rowMarks.TryGetValue(abs, out var marks))
{
var best = MarkKind.Keyword; bool hit = false;
foreach (var (c0, len, kind) in marks)
{
if (col < c0 || col >= c0 + len) continue;
if (!hit || kind > best) { best = kind; hit = true; }
}
if (hit)
{
switch (best)
{
case MarkKind.SearchCurrent: bg = Color.FromArgb(215, 160, 40); fg = Color.Black; break;
case MarkKind.Search: bg = Color.FromArgb(120, 100, 25); break;
case MarkKind.Keyword: bg = Color.FromArgb(150, 45, 45); break;
}
}
}
if (_hasSel && InSelection(abs, col)) (fg, bg) = (bg, Color.FromArgb(70, 90, 140));
}
@@ -199,6 +252,9 @@ public sealed class TerminalView : UserControl
{
if (e.Control && e.KeyCode == Keys.C && _hasSel) { CopySelection(); e.Handled = e.SuppressKeyPress = true; return; }
if ((e.Control && e.KeyCode == Keys.V) || (e.Shift && e.KeyCode == Keys.Insert)) { Paste(); e.Handled = e.SuppressKeyPress = true; return; }
if (e.Control && e.KeyCode == Keys.F) { OpenSearch(); e.Handled = e.SuppressKeyPress = true; return; }
if (e.KeyCode == Keys.F3 && _searchPanel is { Visible: true }) { StepMatch(e.Shift ? +1 : -1); e.Handled = e.SuppressKeyPress = true; return; }
if (e.KeyCode == Keys.Escape && _searchPanel is { Visible: true }) { CloseSearch(); e.Handled = e.SuppressKeyPress = true; return; }
var bytes = TerminalInput.Map(e, _parser.AppCursorKeys);
if (bytes != null) { SendData?.Invoke(bytes); e.Handled = e.SuppressKeyPress = true; }
@@ -358,6 +414,243 @@ public sealed class TerminalView : UserControl
catch { }
}
// ── 搜尋(Ctrl+F)─────────────────────────────────────────
private void OpenSearch()
{
EnsureSearchUi();
_searchPanel!.Visible = true;
PositionSearchPanel();
_searchBox.SelectAll();
_searchBox.Focus();
if (_searchBox.Text.Length > 0) RunSearch();
}
private void CloseSearch()
{
if (_searchPanel == null) return;
_searchPanel.Visible = false;
_matches.Clear();
_matchIdx = -1;
Focus();
Invalidate();
}
private void EnsureSearchUi()
{
if (_searchPanel != null) return;
var back = Color.FromArgb(32, 32, 38);
_searchPanel = new Panel { Size = new Size(268, 30), BackColor = back, Visible = false };
_searchPanel.Paint += (_, pe) =>
{
using var pen = new Pen(Color.FromArgb(58, 58, 66));
pe.Graphics.DrawRectangle(pen, 0, 0, _searchPanel.Width - 1, _searchPanel.Height - 1);
};
_searchBox = new TextBox
{
Bounds = new Rectangle(6, 5, 130, 20), BorderStyle = BorderStyle.None,
BackColor = back, ForeColor = Color.FromArgb(222, 222, 226), Font = new Font("Segoe UI", 9.5f)
};
_searchBox.TextChanged += (_, _) => RunSearch();
_searchBox.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Enter || e.KeyCode == Keys.F3)
{ StepMatch(e.Shift ? +1 : -1); e.Handled = e.SuppressKeyPress = true; } // Enter 往上找(較舊),Shift 往下
else if (e.KeyCode == Keys.Escape)
{ CloseSearch(); e.Handled = e.SuppressKeyPress = true; }
};
_searchCount = new Label
{
Bounds = new Rectangle(138, 7, 56, 16), Text = "",
ForeColor = Color.FromArgb(150, 150, 158), BackColor = back,
Font = new Font("Segoe UI", 8.5f), TextAlign = ContentAlignment.MiddleRight
};
Button MakeBtn(string text, int x, Action onClick)
{
var b = new Button
{
Bounds = new Rectangle(x, 4, 22, 22), Text = text, FlatStyle = FlatStyle.Flat,
ForeColor = Color.FromArgb(180, 180, 188), BackColor = back,
Font = new Font("Segoe UI", 8.5f), TabStop = false, Cursor = Cursors.Hand
};
b.FlatAppearance.BorderSize = 0;
b.FlatAppearance.MouseOverBackColor = Color.FromArgb(60, 60, 70);
b.Click += (_, _) => { onClick(); _searchBox.Focus(); };
return b;
}
var up = MakeBtn("▲", 196, () => StepMatch(-1)); // 往上(較舊)
var down = MakeBtn("▼", 218, () => StepMatch(+1)); // 往下(較新)
var close = MakeBtn("✕", 240, CloseSearch);
_searchPanel.Controls.AddRange(new Control[] { _searchBox, _searchCount, up, down, close });
Controls.Add(_searchPanel);
_searchPanel.BringToFront();
}
private void PositionSearchPanel()
{
if (_searchPanel == null) return;
_searchPanel.Location = new Point(Math.Max(0, ContentWidth - _searchPanel.Width - 8), 6);
}
/// <summary>重掃整個 bufferscrollback + 畫面)建立命中清單,並跳到最靠近底部的命中。</summary>
private void RunSearch()
{
_matches.Clear();
_matchIdx = -1;
string q = _searchBox.Text;
if (q.Length > 0)
{
long dropped = _buf.DroppedLines;
for (int abs = 0; abs < _buf.TotalRows; abs++)
{
BuildLineText(_buf.LineAt(abs), _lineSb, _lineColMap);
string s = _lineSb.ToString();
int at = 0;
while (s.Length >= q.Length && (at = s.IndexOf(q, at, StringComparison.OrdinalIgnoreCase)) >= 0)
{
int colStart = _lineColMap[at];
int colEnd = at + q.Length < _lineColMap.Count
? _lineColMap[at + q.Length]
: _buf.LineAt(abs).Length;
_matches.Add((dropped + abs, colStart, Math.Max(1, colEnd - colStart)));
at++;
}
}
if (_matches.Count > 0) { _matchIdx = _matches.Count - 1; ScrollToMatch(); }
}
UpdateSearchCount();
Invalidate();
}
/// <summary>dir = -1 往上(較舊)、+1 往下(較新),循環。</summary>
private void StepMatch(int dir)
{
if (_matches.Count == 0) { RunSearch(); if (_matches.Count == 0) return; }
else
{
_matchIdx = (_matchIdx + dir + _matches.Count) % _matches.Count;
ScrollToMatch();
}
UpdateSearchCount();
Invalidate();
}
private void ScrollToMatch()
{
if (_matchIdx < 0 || _matchIdx >= _matches.Count) return;
int abs = (int)(_matches[_matchIdx].line - _buf.DroppedLines);
if (abs < 0) return; // 該行已被 scrollback 丟棄
int top = Math.Max(0, abs - VisibleRows / 2);
_scrollOffset = Math.Clamp(_buf.ScrollbackCount - top, 0, _buf.ScrollbackCount);
UpdateScrollBar();
}
private void UpdateSearchCount() =>
_searchCount.Text = _matches.Count == 0
? (_searchBox.Text.Length > 0 ? "0" : "")
: $"{_matchIdx + 1}/{_matches.Count}";
// ── 高亮標記收集(每次重繪對可見行執行)──────────────────
private void CollectMarks(int top, int rows)
{
_rowMarks.Clear();
// 關鍵字(Settings → Highlight):只掃可見行,成本固定
var settings = AppSettings.Instance;
bool kwOn = settings.KeywordHighlightEnabled && settings.KeywordRules.Count > 0;
if (kwOn)
{
for (int vr = 0; vr < rows; vr++)
{
int abs = top + vr;
if (abs >= _buf.TotalRows) break;
BuildLineText(_buf.LineAt(abs), _lineSb, _lineColMap);
string s = _lineSb.ToString();
foreach (var rule in settings.KeywordRules)
{
if (!rule.Enabled || rule.Text.Length == 0) continue;
int at = 0;
while (s.Length >= rule.Text.Length &&
(at = s.IndexOf(rule.Text, at, StringComparison.OrdinalIgnoreCase)) >= 0)
{
int colStart = _lineColMap[at];
int colEnd = at + rule.Text.Length < _lineColMap.Count
? _lineColMap[at + rule.Text.Length]
: _buf.LineAt(abs).Length;
AddMark(abs, colStart, Math.Max(1, colEnd - colStart), MarkKind.Keyword);
at++;
}
}
}
}
// 搜尋命中(RunSearch 已算好,換算回目前 abs)
if (_matches.Count > 0)
{
long dropped = _buf.DroppedLines;
for (int i = 0; i < _matches.Count; i++)
{
int abs = (int)(_matches[i].line - dropped);
if (abs < top || abs >= top + rows) continue;
AddMark(abs, _matches[i].col, _matches[i].len,
i == _matchIdx ? MarkKind.SearchCurrent : MarkKind.Search);
}
}
}
private void AddMark(int abs, int col, int len, MarkKind kind)
{
if (!_rowMarks.TryGetValue(abs, out var list)) _rowMarks[abs] = list = new();
list.Add((col, len, kind));
}
/// <summary>把一行 cell 轉成緊湊字串(略過 WideTrail),並記錄字元 index → 欄位 col 的對應。</summary>
private static void BuildLineText(Cell[] line, StringBuilder sb, List<int> colMap)
{
sb.Clear();
colMap.Clear();
for (int c = 0; c < line.Length; c++)
{
var cell = line[c];
if ((cell.Attr & CellAttr.WideTrail) != 0) continue;
sb.Append(cell.Ch == '\0' ? ' ' : cell.Ch);
colMap.Add(c);
}
}
// ── 關鍵字警示(Feed 路徑偵測,供分頁標紅點)──────────────
private void DetectKeywords(byte[] data)
{
var settings = AppSettings.Instance;
if (!settings.KeywordHighlightEnabled || settings.KeywordRules.Count == 0 || KeywordAlert == null)
{ _alertCarry = ""; return; }
var chars = new char[data.Length];
int n = _alertDec.GetChars(data, 0, data.Length, chars, 0);
if (n == 0) return;
string text = _alertCarry + AnsiStrip.Replace(new string(chars, 0, n), "");
int maxKw = 1;
foreach (var rule in settings.KeywordRules)
{
if (!rule.Enabled || rule.Text.Length == 0) continue;
maxKw = Math.Max(maxKw, rule.Text.Length);
if (text.IndexOf(rule.Text, StringComparison.OrdinalIgnoreCase) < 0) continue;
long now = Environment.TickCount64;
if (_alertLastFired.TryGetValue(rule.Text, out var last) && now - last < AlertCooldownMs) continue;
_alertLastFired[rule.Text] = now;
KeywordAlert.Invoke(rule.Text);
}
// 保留尾巴(最長關鍵字 - 1),跨 chunk 的關鍵字下一輪才接得上
int keep = maxKw - 1;
_alertCarry = text.Length <= keep ? text : text[^keep..];
}
// ── IME(中文 / 日文 / 韓文輸入)─────────────────────────
// 自繪控制項預設不處理 IME 組字,故攔截 WM_IME_COMPOSITION 取「結果字串」直接送出 UTF-8。
private const int WM_IME_STARTCOMPOSITION = 0x010D;