7 Commits
Author SHA1 Message Date
etwenandClaude Opus 4.8 c49ccd2cb2 merge: v0.7.2 — the whole UI now scales correctly at 125% / 150% display scaling
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 13:46:42 +08:00
etwenandClaude Opus 4.8 3e9e375d49 docs: add v0.7.2 release notes (and bump version to 0.7.2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 13:46:29 +08:00
etwenandClaude Opus 4.8 46cb7a5662 fix(ui): Scale every hardcoded pixel size to the actual display DPI
At Windows display scaling above 100% (125%/150%, common on laptops), fonts
grew with the scale but hardcoded pixel sizes did not: the Log/Script/Stop
buttons on each session were truncated, the AI Chat toolbar button lost its
bottom edge at 150%, and absolute-coordinate dialogs (Quick Connect / New
Connection) could clip or overlap their fields.

Root cause: MainForm's AutoScaleMode.Dpi scales only the controls that exist
when the form initializes. Almost all of this app's UI is created at runtime
(SessionPage on open, views built in constructors after InitializeComponent),
so nothing was ever scaled — verified at 125%: the 250px sidebar stayed 250px.

Fix, in order of preference:
- New Theme.Dpi(Control, px) extension (px * DeviceDpi/96, same approach as
  the existing ActivityRail.ItemSize) applied to every hardcoded dimension:
  sidebar, toolbars, tab strips, Settings inputs and grids, PDU status grid,
  AI chat pane, Ctrl+F search bar, DarkScrollBar width.
- Buttons whose text changes at runtime (Log <-> Logging, Log All <->
  Logging All) now size from measured text width (SessionPage.BarButtonWidth)
  or plain AutoSize, so they can never truncate at any scale.
- Multi-line instruction labels switch to AutoSize instead of fixed frames.
- Absolute-coordinate dialogs get DarkDialog.ApplyDpiScale(): one recursive
  Control.Scale(DeviceDpi/96) at the end of each constructor.
- MainForm moves to AutoScaleMode.None with ClientSize/MinimumSize scaled
  manually — we own scaling explicitly, so a future .NET change to auto-scale
  semantics cannot double-scale us.

At 100% the conversion is the identity, so existing setups are unchanged.
Known limitation: changing the system scale while running needs an app
restart (no WM_DPICHANGED re-layout).

Also rides along in AboutView: the v0.7.2 changelog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 13:46:29 +08:00
etwenandClaude Opus 4.8 90c1c45a56 merge: v0.7.1 — sendlnretry: stop losing commands to a booting device
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 10:13:06 +08:00
etwenandClaude Opus 4.8 6d84de819f docs(about): Add v0.7.1 to the in-app changelog
The About page changelog is a hardcoded array, so it did not list v0.7.1
even though the title bar and About header already read 0.7.1 (those come
from the assembly version).

Also rework docs/release-notes/v0.7.1.md to the house format: New features
before Bug fixes, bold group headings, and the standard Downloads table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 10:12:59 +08:00
etwenandClaude Opus 4.8 3c90df0e83 docs: add v0.7.1 release notes (and bump version to 0.7.1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 09:22:49 +08:00
etwenandClaude Opus 4.8 fae5ca7337 fix(ttl): add sendlnretry — resend until the device confirms it ran the command
A device that is still booting can silently discard console input the moment its
shell takes over the tty (tty reopen / termios flush). The line sent by `sendln`
vanishes — the device neither echoes nor runs it — so the following `wait` blocks
forever and the rig sits dead.

This is a race, not a delay: `pause` before `sendln` only lowers the odds of hitting
the window, it can never close it. Evidence from a 45-cycle overnight power-cycle run
(For_AI/[COM121]_20260715): 3 cycles hung this way, and the swallowed sends landed at
the same DUT uptime (31.8-34.8s) as the 42 that worked. The `random: crng init done`
line those hangs share is a symptom, not the cause — it only appears because a hung
script stops power-cycling, letting the DUT reach uptime 89.7s it never otherwise sees.

Single-string `wait` aborts the script on timeout (deliberate, see CLAUDE.md), so the
TeraTerm idiom `wait` -> `if result = 0 then goto retry` cannot express a resend here.
Hence a new command rather than a semantics change:

    sendlnretry '<text>' '<confirm keyword>' [max attempts]

Sends, then waits for proof the device actually ran it, and resends if that proof does
not arrive. Attempts omitted = retry until it gets through. On success result=1; when
attempts run out result=0 and the script continues so it can handle the failure.

Semantics: clears the receive buffer before each send (a match can only come from this
send); on a hit consumes only up to the first occurrence, leaving the rest for the next
`wait`; deliberately no settle (the keyword appearing is itself proof). Per-attempt
timeout follows timeout/mtimeout when set, else 3s — unlike `wait`, 0 cannot mean
"forever" here since that would mean never retrying.

Not yet run against hardware: compiles clean (cross-built win-x64 on Linux), but the
DUT power-cycle rig is the first real execution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 09:22:49 +08:00
20 changed files with 377 additions and 101 deletions
+4
View File
@@ -10,6 +10,10 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。 **進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。
**v0.7.2** **高 DPI125%/150%)全面修正** —— 字型以點數指定會隨 DPI 放大、寫死像素不會,導致 user 機器(筆電常見 125%/150%)上 SessionPage 的 Log/Script/Stop 被截字、150% 時工具列 AI Chat 被裁、Quick Connect 對話框疊字。**根因**`MainForm``AutoScaleMode.Dpi` 只在 Form 初始化當下對「已存在」的控制項縮放一次,本專案 UI 幾乎全是執行期才 new 的(SessionPage 開分頁才建),完全吃不到(實測 125% 下 sidebar 250px 原封不動)。**作法**(1) `Theme.cs``Dpi()` 擴充(`px × DeviceDpi/96`,同 `ActivityRail.ItemSize` 的既有做法),全 app ~70 處裸像素尺寸(sidebar/工具列/tab 列/Settings 輸入框/表格/Ctrl+F 搜尋列/DarkScrollBar 寬)全部包上;(2) 會換字的按鈕(⏺ Log↔Logging、⏺ Log All↔Logging All)改 `TextRenderer.MeasureText` 量最長字串取寬(`SessionPage.BarButtonWidth`)/AutoSize(3) 多行說明 Label 改 `AutoSize = true` 不再固定框;(4) 絕對座標對話框(`Dialogs/`)在 `DarkDialog` 基底加 `ApplyDpiScale()`(建構子最後 `Control.Scale(DeviceDpi/96f)` 一次縮放全樹);(5) `MainForm``AutoScaleMode.None` + `ClientSize/MinimumSize` 手動 `Dpi()`(明確自己管,避免未來 .NET 行為改變造成雙重縮放)。⚠️ **新增 UI 的鐵則:任何寫死像素尺寸都要 `this.Dpi(n)`100% 看起來正常不代表對(`Dpi()` 在 96 DPI 是恆等)**。已知限制:執行中改系統縮放需重啟。通用規範已沉淀到 `create-architecture` skill 的「WinApp DPI Scaling Pattern」。
**v0.7.1** 新增 TTL 指令 **`sendlnretry '文字' '確認關鍵字' [最多送出次數]`**`TTLInterpreter.SendLnRetry` + `WaitConfirm`)——送出後等確認關鍵字,沒等到就**重送**;次數省略 = 無限重送,命中 `result=1`、用完次數 `result=0` 且**繼續執行**(不中止腳本)。**動機**:裝置開機、console 剛被 shell 接手的瞬間會丟棄輸入(tty 重開 / termios flush),`sendln` 送出的整行無聲消失(裝置不回顯、不執行),後面的 `wait` 就永遠卡住;這是機率性的,`pause` 只能調機率、無法根治(實測 45 輪 power-cycle 中 3 輪中招,且**中招時間點與成功時完全重疊**)。**為何需要新指令而非用 `wait` 重試**:單字串 `wait` 逾時是 throw 中止腳本(刻意設計,見下方「注意事項」),TeraTerm 的 `wait``if result = 0 then goto retry` 重送寫法在 ETTerms 寫不出來。**語意**:每次送出前清空 `_recv`(確保比對到的是這次送出的回應);命中只消費到**第一次**出現處,後續輸出留給接下來的 `wait`;刻意**不套 `SettleAndConsume` 的 settle**(關鍵字出現本身就證明裝置收到了);每次等待逾時 `timeout`/`mtimeout` 有設就用設定值、**沒設預設 3 秒**(此處 0=無限等於永不重送,故不沿用 `wait` 的 0=無限)。⚠️ 確認關鍵字要挑「命令真的有跑」的輸出,**不要挑指令回顯**——tty echo 由核心產生,不保證命令被 shell 讀走。文件:[docs/ttl-script-reference.md](docs/ttl-script-reference.md#送出並確認sendlnretry)。
**v0.7.0** **AI Chat 改用 WebView2 渲染**(真氣泡 + Markdown + thinking 動畫泡)。訊息區從 RichTextBox 換成 `WebView2`user 右泡 / AI 左泡、AI 回覆走 **Markdig** markdown→HTML(程式碼區塊/表格/清單)、送出後顯示會動的 thinking「…」泡(`AgentHost.Status "thinking"``showThinking()`,收到 `AssistantText``hideThinking()` 再加 AI 泡)。HTML 模板全內嵌於 `Ai/ChatHtml.cs``NavigateToString`,無外部依賴),C# 經 `ExecuteScriptAsync` 呼叫 JS 函式;WebView2 async 初始化,就緒前的呼叫先入 `_pending` 佇列、`NavigationCompleted` 後 flush。使用者資料夾 `%LocalAppData%\ETTerms\WebView2`(避開 Program Files 唯讀)。底部控制列(輸入框/模型下拉/Send)仍 WinForms。新增 NuGet `Microsoft.Web.WebView2` + `Markdig`;依賴 WebView2 RuntimeWin11 內建,缺時 hint 顯示錯誤)。⚠️ **publish 要確認 WebView2 native`runtimes/win-x64/native/WebView2Loader.dll`)有進產物**。**工具呼叫上限可設定**`AppSettings.AiMaxToolRounds`,Settings → AI Assistant,`AgentHost` 建構子傳入):**0 = 無上限**(自動化長跑;每輪燒 token,聊天視窗 Send 鈕在執行中變 **Stop**,經 `CancellationToken` 中止),預設 30。 **v0.7.0** **AI Chat 改用 WebView2 渲染**(真氣泡 + Markdown + thinking 動畫泡)。訊息區從 RichTextBox 換成 `WebView2`user 右泡 / AI 左泡、AI 回覆走 **Markdig** markdown→HTML(程式碼區塊/表格/清單)、送出後顯示會動的 thinking「…」泡(`AgentHost.Status "thinking"``showThinking()`,收到 `AssistantText``hideThinking()` 再加 AI 泡)。HTML 模板全內嵌於 `Ai/ChatHtml.cs``NavigateToString`,無外部依賴),C# 經 `ExecuteScriptAsync` 呼叫 JS 函式;WebView2 async 初始化,就緒前的呼叫先入 `_pending` 佇列、`NavigationCompleted` 後 flush。使用者資料夾 `%LocalAppData%\ETTerms\WebView2`(避開 Program Files 唯讀)。底部控制列(輸入框/模型下拉/Send)仍 WinForms。新增 NuGet `Microsoft.Web.WebView2` + `Markdig`;依賴 WebView2 RuntimeWin11 內建,缺時 hint 顯示錯誤)。⚠️ **publish 要確認 WebView2 native`runtimes/win-x64/native/WebView2Loader.dll`)有進產物**。**工具呼叫上限可設定**`AppSettings.AiMaxToolRounds`,Settings → AI Assistant,`AgentHost` 建構子傳入):**0 = 無上限**(自動化長跑;每輪燒 token,聊天視窗 Send 鈕在執行中變 **Stop**,經 `CancellationToken` 中止),預設 30。
**v0.6.0** **內建 AI AssistantPhase 10** — 不經 Claude / KiroGUI 內建 ✨ AI 聊天**分頁**,自然語言驅動 serial + PDU。**AI 是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟,`WorkspaceView.Session` 抽象化為可容納 `SessionPage``AiChatView``Content`),可用 Layout 與 serial 分頁**並排同時用**(像 Claude/Kiro);AI 分頁無 Group/Log/Script。**UI 走乾淨逐字稿風**user 靠右 accent、AI 靠左、工具灰字、thinking 收進 Send 按鈕不洗版;與終端機美學一致,非氣泡——真氣泡+Markdown 的 WebView2 版列 v0.7.0)。**模型下拉**(右下角)打端點 `/v1/models` 列可選模型、即時切換並記住;**Settings 只留 Base URL / API Key / 系統提示詞,不再設 model**(`configured` 只看 Base URL)。**BYO endpoint**`Settings → AI Assistant` 填 Base URL / Model / API Key(任意 OpenAI 相容 gateway:本機 Ollama、LiteLLM、公司 gateway、OpenAI…),**預設全空白=功能停用**;🚫 **發佈版不含任何私人端點**API key 存 Windows Credential Manager`ETTerms/AiApiKey`)、Base URL/Model 存 settings.json,程式碼範例一律 `localhost`/假 IP。**架構(in-process,不經 MCP**`Ai/OpenAiChatClient`(極簡 OpenAI 相容 `/chat/completions`HttpClient 非串流)+ `Ai/AgentHost`(手寫 agent looptool_calls→執行→role=tool 餵回→迴圈上限 8 輪)+ `Ai/AiTools`serial list/attach/write/read 經 `SerialBridge`——與 MCP 同一路徑、AI 的 TX 照樣 `[AI]` 標色顯示在終端機;PDU connect/status/set_port/power_cycle 經 `ETTerms.PduCore`)。**安全**:破壞性 PDU 動作(關插座 / power-cycle)一律 `AiTools.ConfirmAsync` → GUI MessageBox(預設 No)確認,AI 不能自己斷電;每筆工具呼叫寫 AppLogger(`[AI tool] …`)。**刻意不引入 `Microsoft.Extensions.AI`**(手寫 client+loop,依賴最小、對任意 gateway 相容性自己掌控)。既有 SerialMcp/PduMcpSettings → AI MCP**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agentin-processvs 外部 AIMCP 跨行程)」的分工。新增 `src/ETTerms/Ai/`3 檔)+ `App/AiChatView.cs``ActivityRail``Ai` view`SettingsView` 加 AI Assistant 分頁,`AppSettings``AiBaseUrl/AiModel/AiSystemPrompt` **v0.6.0** **內建 AI AssistantPhase 10** — 不經 Claude / KiroGUI 內建 ✨ AI 聊天**分頁**,自然語言驅動 serial + PDU。**AI 是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟,`WorkspaceView.Session` 抽象化為可容納 `SessionPage``AiChatView``Content`),可用 Layout 與 serial 分頁**並排同時用**(像 Claude/Kiro);AI 分頁無 Group/Log/Script。**UI 走乾淨逐字稿風**user 靠右 accent、AI 靠左、工具灰字、thinking 收進 Send 按鈕不洗版;與終端機美學一致,非氣泡——真氣泡+Markdown 的 WebView2 版列 v0.7.0)。**模型下拉**(右下角)打端點 `/v1/models` 列可選模型、即時切換並記住;**Settings 只留 Base URL / API Key / 系統提示詞,不再設 model**(`configured` 只看 Base URL)。**BYO endpoint**`Settings → AI Assistant` 填 Base URL / Model / API Key(任意 OpenAI 相容 gateway:本機 Ollama、LiteLLM、公司 gateway、OpenAI…),**預設全空白=功能停用**;🚫 **發佈版不含任何私人端點**API key 存 Windows Credential Manager`ETTerms/AiApiKey`)、Base URL/Model 存 settings.json,程式碼範例一律 `localhost`/假 IP。**架構(in-process,不經 MCP**`Ai/OpenAiChatClient`(極簡 OpenAI 相容 `/chat/completions`HttpClient 非串流)+ `Ai/AgentHost`(手寫 agent looptool_calls→執行→role=tool 餵回→迴圈上限 8 輪)+ `Ai/AiTools`serial list/attach/write/read 經 `SerialBridge`——與 MCP 同一路徑、AI 的 TX 照樣 `[AI]` 標色顯示在終端機;PDU connect/status/set_port/power_cycle 經 `ETTerms.PduCore`)。**安全**:破壞性 PDU 動作(關插座 / power-cycle)一律 `AiTools.ConfirmAsync` → GUI MessageBox(預設 No)確認,AI 不能自己斷電;每筆工具呼叫寫 AppLogger(`[AI tool] …`)。**刻意不引入 `Microsoft.Extensions.AI`**(手寫 client+loop,依賴最小、對任意 gateway 相容性自己掌控)。既有 SerialMcp/PduMcpSettings → AI MCP**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agentin-processvs 外部 AIMCP 跨行程)」的分工。新增 `src/ETTerms/Ai/`3 檔)+ `App/AiChatView.cs``ActivityRail``Ai` view`SettingsView` 加 AI Assistant 分頁,`AppSettings``AiBaseUrl/AiModel/AiSystemPrompt`
+59
View File
@@ -0,0 +1,59 @@
# v0.7.1 — Scripts stop losing commands to a booting device
## ✨ New features
**`sendlnretry` — send a command, confirm it ran, resend if it didn't**
* New TTL command: `sendlnretry '<text>' '<confirm keyword>' [max attempts]`.
It sends the text, then **waits for proof the device actually ran it**, and **resends** if that
proof never arrives. Leave the attempt count out and it retries until it gets through.
* Drop it in wherever you send a command right after a boot — the fragile
`pause` + `sendln` + `wait` dance becomes one line:
```
wait 'root@(none):/#'
sendlnretry 'tpm2' 'TPM 2p0' ; retry until the device really runs it
wait 'PASS'
```
* Cap the attempts and handle the failure yourself — on success `result` is **1**;
when the attempts run out `result` is **0** and **the script carries on** instead of dying:
```
sendlnretry 'tpm2' 'TPM 2p0' 3
if result = 0 then
dispstr 'tpm2 got no response after 3 tries'
endif
```
* Pick the confirm keyword from the command's **own output** (`TPM 2p0`), not its echo (`tpm2`) —
an echo is produced by the device's tty and doesn't prove the shell ever read the line.
* Each attempt waits **3 seconds** by default, or your `timeout` / `mtimeout` if you've set one.
(Unlike `wait`, `0` doesn't mean "wait forever" here — that would mean never retrying.)
## 🐛 Bug fixes
**Scripts no longer hang forever when a booting device swallows a command**
* Fixed: a device that is still booting can **silently discard console input** the moment its shell
takes over the tty. The line you sent is gone — the device never echoes it and never runs it —
so the `wait` that follows blocks forever and the test rig sits dead until someone notices.
* This is a race, not a delay: adding `pause` before `sendln` only lowers the odds of hitting the
window, it can never close it. In one overnight 45-cycle power-cycle run, **3 cycles hung this
way** — and the swallowed sends landed at exactly the same moment as the 42 that worked.
* Use the new `sendlnretry` above to make these sends reliable.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.7.1`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.7.1_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.7.1.exe`.
## 🔗 Links
* TTL reference (see **送出並確認 / sendlnretry**):
[docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
* Architecture:
[ARCHITECTURE.md](https://github.com/ETWen/ETTerms/blob/main/ARCHITECTURE.md)
**Full changelog:** [v0.7.0...v0.7.1](https://github.com/ETWen/ETTerms/compare/v0.7.0...v0.7.1)
+39
View File
@@ -0,0 +1,39 @@
# v0.7.2 — The whole UI now scales correctly at 125% / 150% display scaling
## 🐛 Bug fixes
**Buttons and panels no longer get clipped at Windows display scaling above 100%**
* Fixed: on machines set to 125% or 150% scaling (most laptops), text grew with the scale but
many controls didn't — the **Log / Script / Stop** buttons on each session tab were truncated
or hidden, the **✨ AI Chat** toolbar button lost its bottom edge at 150%, and dialogs like
**Quick Connect** could clip or overlap their fields.
* Every fixed pixel size in the app now converts to your actual display scale, the same way
text always did: the sidebar, toolbars, tab strips, all Settings inputs and tables, the PDU
status grid, the AI chat pane, dialogs, the Ctrl+F search bar — even the scrollbar width.
* Buttons that change their text (like **⏺ Log → ⏺ Logging**) now size themselves from the
actual rendered text width, so they can never truncate again at any scale.
* At 100% scaling nothing changes — the conversion is exact there, so existing setups look
identical to before.
**Known limitation**
* If you change the Windows display scaling while ETTerms is running, restart the app to
re-layout at the new scale.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.7.2`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.7.2_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.7.2.exe`.
## 🔗 Links
* TTL reference:
[docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
* Architecture:
[ARCHITECTURE.md](https://github.com/ETWen/ETTerms/blob/main/ARCHITECTURE.md)
**Full changelog:** [v0.7.1...v0.7.2](https://github.com/ETWen/ETTerms/compare/v0.7.1...v0.7.2)
+37 -1
View File
@@ -43,6 +43,42 @@
# 一、ETTerms 獨有指令 # 一、ETTerms 獨有指令
## 送出並確認(sendlnretry
| 指令 | 語法 | 說明 |
|------|------|------|
| `sendlnretry` | `sendlnretry '文字' '確認關鍵字' [最多送出次數]` | 送出 `文字` + `\r\n`,然後等 `確認關鍵字`;沒等到就**重送**。次數省略 = 一直重送到收到為止。命中 `result=1`;用完次數 `result=0` 且**繼續執行**(不中止腳本)。 |
**為什麼需要它**:裝置在開機、console 剛被 shell 接手的瞬間,可能把收到的輸入直接丟掉
tty 重開 / termios flush)。此時 `sendln` 送出的整行會**無聲無息地消失**——裝置不會回顯、
也不會執行,後面的 `wait` 就永遠等不到,腳本整個卡死。這是**機率性**的:
`sendln` 前面加 `pause` 只是降低撞上那個窗口的機率,**不可能根治**。
`sendlnretry` 的作法是送完就確認對方真的有反應,沒反應就再送一次。
```
; 開機後下 tpm2,沒跑到就自動重送(無限重送)
wait 'root@(none):/#'
sendlnretry 'tpm2' 'TPM 2p0'
wait 'PASS'
; 最多送 3 次,還是不行就自己處置
sendlnretry 'tpm2' 'TPM 2p0' 3
if result = 0 then
dispstr 'tpm2 送了 3 次都沒反應'
endif
```
使用要點:
- **確認關鍵字要挑「命令真的有跑」才會出現的輸出**(例:`TPM 2p0`),
**不要挑指令回顯**(例:`tpm2`)——tty 的 echo 由核心產生,不保證命令有被 shell 讀走。
- **每次送出前會清空接收緩衝**,確保比對到的是「這次送出」的回應而不是殘留輸出。
命中後只消費到**第一次**出現處,後面的輸出留在緩衝裡給接下來的 `wait`
(所以上例的 `wait 'PASS'` 照常會等到)。
- 每次送出後等確認的逾時:`timeout`/`mtimeout` 有設就用設定值,**沒設預設 3 秒**
(注意這與 `wait` 不同——`wait` 的 0 是無限等,但無限等在這裡等於永不重送)。
- **指令最好是可重複執行的**:若逾時設得太短、而裝置其實只是回應慢,會造成同一個指令送兩次。
## Group 同步(多分頁協同) ## Group 同步(多分頁協同)
以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1-3`)。 以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1-3`)。
@@ -89,7 +125,7 @@ wait 'login:'
| 項目 | ETTerms 行為 | | 項目 | ETTerms 行為 |
|------|--------------| |------|--------------|
| `wait`(**單字串**) | 命中後需裝置**安靜 300ms**(無新資料)才接受,並取「最後一次」出現——排除輸出中途的指令回顯(如 `SVOS> help`)造成腳本搶跑。**逾時會中止腳本**(TeraTerm 是 `result=0` 繼續)。 | | `wait`(**單字串**) | 命中後需裝置**安靜 300ms**(無新資料)才接受,並取「最後一次」出現——排除輸出中途的指令回顯(如 `SVOS> help`)造成腳本搶跑。**逾時會中止腳本**(TeraTerm 是 `result=0` 繼續),因此 TeraTerm 常見的 `wait``if result = 0 then goto retry` 重送寫法在單字串 `wait` 上做不到;要「送出後確認、沒回應就重送」請用 [`sendlnretry`](#送出並確認sendlnretry)。 |
| `wait`**多字串** | TeraTerm 相容:任一命中即繼續,`result` = 第幾個字串(1 起算);逾時 `result=0` **繼續執行**、無 settle。 | | `wait`**多字串** | TeraTerm 相容:任一命中即繼續,`result` = 第幾個字串(1 起算);逾時 `result=0` **繼續執行**、無 settle。 |
| `goto` / 跨區塊跳轉 | `goto` 跳出 `if`/`while` 區塊後,該區塊的迴圈控制即結束(同層繼續直行)。避免 goto 跳「進」區塊中間。 | | `goto` / 跨區塊跳轉 | `goto` 跳出 `if`/`while` 區塊後,該區塊的迴圈控制即結束(同層繼續直行)。避免 goto 跳「進」區塊中間。 |
| `call` | 可以在迴圈 / if 內使用(行內執行,返回後迴圈續跑)。 | | `call` | 可以在迴圈 / if 內使用(行內執行,返回後迴圈續跑)。 |
+21 -5
View File
@@ -20,7 +20,7 @@ public sealed class AboutView : UserControl
// ── Main layout: left fixed + right scrollable ── // ── Main layout: left fixed + right scrollable ──
var left = new FlowLayoutPanel var left = new FlowLayoutPanel
{ {
Dock = DockStyle.Left, Width = 380, FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Left, Width = this.Dpi(380), FlowDirection = FlowDirection.TopDown,
WrapContents = false, AutoScroll = true, Padding = new Padding(20), WrapContents = false, AutoScroll = true, Padding = new Padding(20),
BackColor = Theme.WorkspaceBack BackColor = Theme.WorkspaceBack
}; };
@@ -105,10 +105,10 @@ public sealed class AboutView : UserControl
{ {
FlowDirection = FlowDirection.TopDown, WrapContents = false, FlowDirection = FlowDirection.TopDown, WrapContents = false,
AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(width, height),
Margin = new Padding(0, 0, 0, 12), Margin = new Padding(0, 0, 0, 12),
BackColor = Theme.TabBack, Padding = new Padding(12) BackColor = Theme.TabBack, Padding = new Padding(12)
}; };
card.MinimumSize = new Size(card.Dpi(width), card.Dpi(height));
build(card); build(card);
return card; return card;
} }
@@ -118,9 +118,10 @@ public sealed class AboutView : UserControl
{ {
var card = new Panel var card = new Panel
{ {
Width = 340, Height = 300, Margin = new Padding(0, 0, 0, 12), Margin = new Padding(0, 0, 0, 12),
BackColor = Theme.TabBack, Padding = new Padding(12) BackColor = Theme.TabBack, Padding = new Padding(12)
}; };
card.Size = new Size(card.Dpi(340), card.Dpi(300));
var flow = new FlowLayoutPanel var flow = new FlowLayoutPanel
{ {
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
@@ -133,7 +134,7 @@ public sealed class AboutView : UserControl
{ {
flow.Controls.Add(new PictureBox flow.Controls.Add(new PictureBox
{ {
Width = 312, Height = 268, Margin = new Padding(0, 4, 0, 4), Width = card.Dpi(312), Height = card.Dpi(268), Margin = new Padding(0, 4, 0, 4),
SizeMode = PictureBoxSizeMode.Zoom, SizeMode = PictureBoxSizeMode.Zoom,
BackColor = Theme.TabBack, Image = iconImg.ToBitmap() BackColor = Theme.TabBack, Image = iconImg.ToBitmap()
}); });
@@ -164,7 +165,7 @@ public sealed class AboutView : UserControl
}; };
row.Controls.Add(new Label row.Controls.Add(new Label
{ {
Text = label, AutoSize = true, MinimumSize = new Size(70, 0), Text = label, AutoSize = true, MinimumSize = new Size(row.Dpi(70), 0),
ForeColor = Theme.TextDim, Font = Theme.UiFont, ForeColor = Theme.TextDim, Font = Theme.UiFont,
TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 0, 8, 0) TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 0, 8, 0)
}); });
@@ -183,6 +184,21 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = private static readonly ChangelogEntry[] Changelog =
[ [
new("0.7.2", new DateOnly(2026, 7, 16), "The whole UI now scales correctly at 125% / 150% display scaling",
[
"Fixed: at Windows display scaling above 100%, text grew with the scale but many buttons and panels didn't — the Log / Script / Stop buttons on each session got clipped, the AI Chat toolbar button lost its bottom edge at 150%, and dialogs like Quick Connect could overlap their fields.",
"Every fixed pixel size in the app — sidebars, toolbars, tab strips, settings inputs, tables, dialogs, the Ctrl+F search bar, even the scrollbar width — now converts to your actual display scale, the same way a button's text does.",
"Buttons that show changing text (like ⏺ Log / ⏺ Logging) now size themselves from the actual rendered text width, so they can never truncate again at any scale.",
"Note: if you change the Windows scaling while ETTerms is running, restart the app to re-layout at the new scale.",
]),
new("0.7.1", new DateOnly(2026, 7, 16), "Scripts no longer hang when a booting device swallows a command",
[
"Fixed: a device that is still booting can silently throw away what you send it, the moment its shell takes over the console — the line never echoes and never runs, so the wait after it sat there forever and your test rig was dead until someone noticed. Adding a pause before the send only made it rarer: in one overnight 45-cycle power-cycle run, 3 cycles still hung this way.",
"New scripting command: sendlnretry '<text>' '<confirm keyword>' [max attempts] it sends, waits for proof the device actually ran the command, and sends again if that proof never arrives. Leave the attempt count out and it keeps trying until it gets through.",
"Use it anywhere you currently send a command right after a boot: sendlnretry 'tpm2' 'TPM 2p0' followed by wait 'PASS'. Pick the confirm keyword from the command's own output, not its echo — an echo comes from the device's tty and doesn't prove the command was ever read.",
"If it runs out of attempts, result is 0 and the script carries on, so you can handle the failure yourself. Each attempt waits 3 seconds, or your timeout / mtimeout if you've set one.",
"See docs/ttl-script-reference.md for the full details.",
]),
new("0.7.0", new DateOnly(2026, 7, 5), "AI chat gets real bubbles, Markdown & a thinking indicator", new("0.7.0", new DateOnly(2026, 7, 5), "AI chat gets real bubbles, Markdown & a thinking indicator",
[ [
"The AI Chat pane now renders proper chat bubbles (your messages on the right, the AI's on the left) with full Markdown — code blocks, tables, lists and inline `code` all display nicely.", "The AI Chat pane now renders proper chat bubbles (your messages on the right, the AI's on the left) with full Markdown — code blocks, tables, lists and inline `code` all display nicely.",
+6 -6
View File
@@ -48,7 +48,7 @@ public sealed class AiChatView : UserControl
_web = new WebView2 { Dock = DockStyle.Fill }; _web = new WebView2 { Dock = DockStyle.Fill };
// ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ── // ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ──
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 96, BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) }; var bottom = new Panel { Dock = DockStyle.Bottom, Height = this.Dpi(96), BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) };
_input = new TextBox _input = new TextBox
{ {
@@ -60,9 +60,9 @@ public sealed class AiChatView : UserControl
if (e.KeyCode == Keys.Enter && !e.Shift && !_running) { e.Handled = e.SuppressKeyPress = true; OnSend(); } if (e.KeyCode == Keys.Enter && !e.Shift && !_running) { e.Handled = e.SuppressKeyPress = true; OnSend(); }
}; };
var rightCol = new Panel { Dock = DockStyle.Right, Width = 178, BackColor = Theme.RailBack, Padding = new Padding(8, 0, 0, 0) }; var rightCol = new Panel { Dock = DockStyle.Right, Width = this.Dpi(178), BackColor = Theme.RailBack, Padding = new Padding(8, 0, 0, 0) };
var modelWrap = new Panel { Dock = DockStyle.Top, Height = 24, BackColor = Theme.RailBack }; var modelWrap = new Panel { Dock = DockStyle.Top, Height = this.Dpi(26), BackColor = Theme.RailBack };
_modelBox = new ComboBox _modelBox = new ComboBox
{ {
Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList, Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList,
@@ -78,7 +78,7 @@ public sealed class AiChatView : UserControl
}; };
var refreshBtn = new Button var refreshBtn = new Button
{ {
Text = "↻", Dock = DockStyle.Right, Width = 24, FlatStyle = FlatStyle.Flat, Text = "↻", Dock = DockStyle.Right, Width = this.Dpi(24), FlatStyle = FlatStyle.Flat,
ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
}; };
refreshBtn.FlatAppearance.BorderColor = Theme.Border; refreshBtn.FlatAppearance.BorderColor = Theme.Border;
@@ -86,7 +86,7 @@ public sealed class AiChatView : UserControl
modelWrap.Controls.Add(_modelBox); // Fill modelWrap.Controls.Add(_modelBox); // Fill
modelWrap.Controls.Add(refreshBtn); // Right modelWrap.Controls.Add(refreshBtn); // Right
var gap = new Panel { Dock = DockStyle.Top, Height = 6, BackColor = Theme.RailBack }; var gap = new Panel { Dock = DockStyle.Top, Height = this.Dpi(6), BackColor = Theme.RailBack };
_send = new Button _send = new Button
{ {
@@ -105,7 +105,7 @@ public sealed class AiChatView : UserControl
_hint = new Label _hint = new Label
{ {
Dock = DockStyle.Top, Height = 40, BackColor = Color.FromArgb(60, 50, 30), ForeColor = Theme.Text, Dock = DockStyle.Top, Height = this.Dpi(40), BackColor = Color.FromArgb(60, 50, 30), ForeColor = Theme.Text,
Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleCenter, Visible = false, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleCenter, Visible = false,
Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL 與 API Key。" Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL 與 API Key。"
}; };
+20 -20
View File
@@ -44,7 +44,7 @@ public sealed class ConnectionSidebar : UserControl
public ConnectionSidebar() public ConnectionSidebar()
{ {
Width = 250; Width = this.Dpi(250);
Dock = DockStyle.Left; Dock = DockStyle.Left;
BackColor = Theme.SidebarBack; BackColor = Theme.SidebarBack;
@@ -52,7 +52,7 @@ public sealed class ConnectionSidebar : UserControl
// 高度 40 與右側 Workspace 工具列對齊,讓「CONNECTIONS / 連線清單」與右側分頁列同一條基準線 // 高度 40 與右側 Workspace 工具列對齊,讓「CONNECTIONS / 連線清單」與右側分頁列同一條基準線
var tabBar = new FlowLayoutPanel var tabBar = new FlowLayoutPanel
{ {
Dock = DockStyle.Top, Height = 40, BackColor = Theme.RailBack, Dock = DockStyle.Top, Height = this.Dpi(40), BackColor = Theme.RailBack,
Padding = new Padding(4, 7, 4, 0), WrapContents = false Padding = new Padding(4, 7, 4, 0), WrapContents = false
}; };
@@ -65,7 +65,7 @@ public sealed class ConnectionSidebar : UserControl
var b = new Button var b = new Button
{ {
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(70, 24), Padding = new Padding(8, 2, 8, 2), MinimumSize = new Size(this.Dpi(70), this.Dpi(24)), Padding = new Padding(8, 2, 8, 2),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
@@ -90,7 +90,7 @@ public sealed class ConnectionSidebar : UserControl
tabBar.Controls.Add(sftpBtn); tabBar.Controls.Add(sftpBtn);
// ── Sessions panel content ── // ── Sessions panel content ──
var header = new Panel { Dock = DockStyle.Top, Height = 34, BackColor = Theme.SidebarBack }; var header = new Panel { Dock = DockStyle.Top, Height = this.Dpi(34), BackColor = Theme.SidebarBack };
var title = new Label var title = new Label
{ {
Text = "CONNECTIONS", Text = "CONNECTIONS",
@@ -108,7 +108,7 @@ public sealed class ConnectionSidebar : UserControl
header.Controls.Add(btnNewFolder); header.Controls.Add(btnNewFolder);
header.Controls.Add(btnNewConn); header.Controls.Add(btnNewConn);
var searchHost = new Panel { Dock = DockStyle.Top, Height = 32, BackColor = Theme.SidebarBack, Padding = new Padding(8, 2, 8, 4) }; var searchHost = new Panel { Dock = DockStyle.Top, Height = this.Dpi(32), BackColor = Theme.SidebarBack, Padding = new Padding(8, 2, 8, 4) };
_search = new TextBox _search = new TextBox
{ {
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
@@ -125,7 +125,7 @@ public sealed class ConnectionSidebar : UserControl
{ {
Text = "▷ Quick Connect", Text = "▷ Quick Connect",
Dock = DockStyle.Top, Dock = DockStyle.Top,
Height = 34, Height = this.Dpi(34),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Color.White, ForeColor = Color.White,
BackColor = Theme.Accent, BackColor = Theme.Accent,
@@ -136,22 +136,22 @@ public sealed class ConnectionSidebar : UserControl
quick.FlatAppearance.BorderSize = 0; quick.FlatAppearance.BorderSize = 0;
quick.FlatAppearance.MouseOverBackColor = Theme.AccentDim; quick.FlatAppearance.MouseOverBackColor = Theme.AccentDim;
quick.Click += (_, _) => QuickConnect(); quick.Click += (_, _) => QuickConnect();
var quickHost = new Panel { Dock = DockStyle.Top, Height = 42, BackColor = Theme.SidebarBack, Padding = new Padding(8, 4, 8, 4) }; var quickHost = new Panel { Dock = DockStyle.Top, Height = this.Dpi(42), BackColor = Theme.SidebarBack, Padding = new Padding(8, 4, 8, 4) };
quickHost.Controls.Add(quick); quickHost.Controls.Add(quick);
var shellBtn = new Button var shellBtn = new Button
{ {
Text = "🖥 Local Shell", Dock = DockStyle.Top, Height = 30, Text = "🖥 Local Shell", Dock = DockStyle.Top, Height = this.Dpi(30),
FlatStyle = FlatStyle.Flat, ForeColor = Theme.Text, BackColor = Theme.TabBack, FlatStyle = FlatStyle.Flat, ForeColor = Theme.Text, BackColor = Theme.TabBack,
Font = Theme.UiFont, Cursor = Cursors.Hand Font = Theme.UiFont, Cursor = Cursors.Hand
}; };
shellBtn.FlatAppearance.BorderColor = Theme.Border; shellBtn.FlatAppearance.BorderColor = Theme.Border;
shellBtn.FlatAppearance.MouseOverBackColor = Theme.Hover; shellBtn.FlatAppearance.MouseOverBackColor = Theme.Hover;
shellBtn.Click += (_, _) => OpenLocalShell(); shellBtn.Click += (_, _) => OpenLocalShell();
var shellHost = new Panel { Dock = DockStyle.Top, Height = 36, BackColor = Theme.SidebarBack, Padding = new Padding(8, 2, 8, 4) }; var shellHost = new Panel { Dock = DockStyle.Top, Height = this.Dpi(36), BackColor = Theme.SidebarBack, Padding = new Padding(8, 2, 8, 4) };
shellHost.Controls.Add(shellBtn); shellHost.Controls.Add(shellBtn);
var toolbar = new Panel { Dock = DockStyle.Top, Height = 28, BackColor = Theme.SidebarBack }; var toolbar = new Panel { Dock = DockStyle.Top, Height = this.Dpi(28), BackColor = Theme.SidebarBack };
var btnExpand = IconButton("⊞", "Expand All", (_, _) => SetAllExpanded(true)); var btnExpand = IconButton("⊞", "Expand All", (_, _) => SetAllExpanded(true));
var btnCollapse = IconButton("⊟", "Collapse All", (_, _) => SetAllExpanded(false)); var btnCollapse = IconButton("⊟", "Collapse All", (_, _) => SetAllExpanded(false));
btnExpand.Dock = DockStyle.Right; btnExpand.Dock = DockStyle.Right;
@@ -171,8 +171,8 @@ public sealed class ConnectionSidebar : UserControl
ShowRootLines = true, ShowRootLines = true,
ShowPlusMinus = true, ShowPlusMinus = true,
FullRowSelect = true, FullRowSelect = true,
ItemHeight = 26, ItemHeight = this.Dpi(26),
Indent = 18, Indent = this.Dpi(18),
AllowDrop = true AllowDrop = true
}; };
_tree.NodeMouseDoubleClick += OnNodeDoubleClick; _tree.NodeMouseDoubleClick += OnNodeDoubleClick;
@@ -213,15 +213,15 @@ public sealed class ConnectionSidebar : UserControl
// Connection row // Connection row
var connRow = new FlowLayoutPanel var connRow = new FlowLayoutPanel
{ {
Dock = DockStyle.Top, Height = 30, BackColor = Theme.SidebarBack, Dock = DockStyle.Top, Height = this.Dpi(30), BackColor = Theme.SidebarBack,
Padding = new Padding(4, 4, 4, 0), WrapContents = false Padding = new Padding(4, 4, 4, 0), WrapContents = false
}; };
var sshLabel = new Label { Text = "SSH:", AutoSize = true, ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 4, 4, 0) }; var sshLabel = new Label { Text = "SSH:", AutoSize = true, ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 4, 4, 0) };
var sshCombo = new ComboBox { Width = 120, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont }; var sshCombo = new ComboBox { Width = this.Dpi(120), DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont };
var connectBtn = new Button var connectBtn = new Button
{ {
Text = "Connect", AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Text = "Connect", AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(64, 24), Padding = new Padding(8, 2, 8, 2), MinimumSize = new Size(this.Dpi(64), this.Dpi(24)), Padding = new Padding(8, 2, 8, 2),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand, Margin = new Padding(4, 0, 0, 0) ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand, Margin = new Padding(4, 0, 0, 0)
}; };
@@ -233,7 +233,7 @@ public sealed class ConnectionSidebar : UserControl
// Path bar // Path bar
_sftpPath = new TextBox _sftpPath = new TextBox
{ {
Dock = DockStyle.Top, Height = 24, Text = "/", Dock = DockStyle.Top, Height = this.Dpi(24), Text = "/",
BackColor = Theme.TabBack, ForeColor = Theme.Accent, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle BackColor = Theme.TabBack, ForeColor = Theme.Accent, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle
}; };
_sftpPath.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter && _sftp?.IsConnected == true) SftpNavigate(_sftpPath.Text); }; _sftpPath.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter && _sftp?.IsConnected == true) SftpNavigate(_sftpPath.Text); };
@@ -245,8 +245,8 @@ public sealed class ConnectionSidebar : UserControl
BackColor = Theme.SidebarBack, ForeColor = Theme.Text, Font = Theme.UiFont, BackColor = Theme.SidebarBack, ForeColor = Theme.Text, Font = Theme.UiFont,
BorderStyle = BorderStyle.None, HeaderStyle = ColumnHeaderStyle.Nonclickable BorderStyle = BorderStyle.None, HeaderStyle = ColumnHeaderStyle.Nonclickable
}; };
_sftpList.Columns.Add("Name", 150); _sftpList.Columns.Add("Name", this.Dpi(150));
_sftpList.Columns.Add("Size", 60, HorizontalAlignment.Right); _sftpList.Columns.Add("Size", this.Dpi(60), HorizontalAlignment.Right);
_sftpList.DoubleClick += (_, _) => _sftpList.DoubleClick += (_, _) =>
{ {
if (_sftpList.SelectedItems.Count == 0 || _sftp?.IsConnected != true) return; if (_sftpList.SelectedItems.Count == 0 || _sftp?.IsConnected != true) return;
@@ -651,8 +651,8 @@ public sealed class ConnectionSidebar : UserControl
var b = new Button var b = new Button
{ {
Text = glyph, Text = glyph,
Width = 34, Width = this.Dpi(34),
Height = 34, Height = this.Dpi(34),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.TextDim, ForeColor = Theme.TextDim,
BackColor = Theme.SidebarBack, BackColor = Theme.SidebarBack,
@@ -59,6 +59,7 @@ public sealed class ConnectionEditDialog : DarkDialog
_type.SelectedIndex = (existing?.Type ?? ConnectionType.Ssh) == ConnectionType.Ssh ? 0 : 1; _type.SelectedIndex = (existing?.Type ?? ConnectionType.Ssh) == ConnectionType.Ssh ? 0 : 1;
ToggleType(); ToggleType();
ApplyDpiScale();
} }
private void ToggleType() private void ToggleType()
+12
View File
@@ -12,6 +12,7 @@ public abstract class DarkDialog : Form
BackColor = Theme.SidebarBack; BackColor = Theme.SidebarBack;
ForeColor = Theme.Text; ForeColor = Theme.Text;
Font = Theme.UiFont; Font = Theme.UiFont;
AutoScaleMode = AutoScaleMode.None; // 縮放由 ApplyDpiScale() 統一處理,避免雙重縮放
FormBorderStyle = FormBorderStyle.FixedDialog; FormBorderStyle = FormBorderStyle.FixedDialog;
StartPosition = FormStartPosition.CenterParent; StartPosition = FormStartPosition.CenterParent;
MaximizeBox = false; MaximizeBox = false;
@@ -19,6 +20,17 @@ public abstract class DarkDialog : Form
ShowInTaskbar = false; ShowInTaskbar = false;
} }
/// <summary>
/// 依目前 DPI 一次縮放整個對話框(含所有子控制項的絕對座標),
/// 於衍生類別建構子「最後」呼叫。對話框以 96-DPI 座標排版,
/// 字型是點數會自己隨 DPI 放大,座標不縮放在 125%/150% 就會裁切、疊字。
/// </summary>
protected void ApplyDpiScale()
{
float f = DeviceDpi / 96f;
if (Math.Abs(f - 1f) > 0.01f) Scale(new SizeF(f, f));
}
protected override void OnHandleCreated(EventArgs e) protected override void OnHandleCreated(EventArgs e)
{ {
base.OnHandleCreated(e); base.OnHandleCreated(e);
@@ -30,6 +30,7 @@ public sealed class TextPromptDialog : DarkDialog
Controls.AddRange(new Control[] { lbl, _input, ok, cancel }); Controls.AddRange(new Control[] { lbl, _input, ok, cancel });
AcceptButton = ok; AcceptButton = ok;
CancelButton = cancel; CancelButton = cancel;
ApplyDpiScale();
} }
/// <summary>顯示對話框;回傳輸入值,取消或空白回傳 null。</summary> /// <summary>顯示對話框;回傳輸入值,取消或空白回傳 null。</summary>
+7 -6
View File
@@ -18,12 +18,13 @@ partial class MainForm
{ {
SuspendLayout(); SuspendLayout();
// 用 Dpi 自動縮放:尺寸縮放比例 = 螢幕 DPI 比例,與點數字型的實際渲染比例一致, // DPI 縮放由我們自己管(Theme.Dpi() + AutoSize),不靠 Form 的自動縮放:
// 避免「字放大了、容器沒放大」造成的高 DPI 文字/按鈕被裁切。 // AutoScaleMode.Dpi 只在 Form 初始化當下對「已存在」的控制項做一次,
AutoScaleDimensions = new SizeF(96F, 96F); // 執行期才建立的 SessionPage / 各 view 完全吃不到(實測 125%/150% 皆未縮放),
AutoScaleMode = AutoScaleMode.Dpi; // 留著反而有「哪天開始生效 → 與 Dpi() 雙重縮放」的風險。
ClientSize = new Size(1100, 700); AutoScaleMode = AutoScaleMode.None;
MinimumSize = new Size(720, 480); ClientSize = new Size(this.Dpi(1100), this.Dpi(700));
MinimumSize = new Size(this.Dpi(720), this.Dpi(480));
BackColor = Theme.WorkspaceBack; BackColor = Theme.WorkspaceBack;
ForeColor = Theme.Text; ForeColor = Theme.Text;
Font = Theme.UiFont; Font = Theme.UiFont;
+33 -32
View File
@@ -16,7 +16,7 @@ public sealed class SettingsView : UserControl
// Use a custom tab strip + panel swapping instead of TabControl to avoid white borders // Use a custom tab strip + panel swapping instead of TabControl to avoid white borders
var tabBar = new FlowLayoutPanel var tabBar = new FlowLayoutPanel
{ {
Dock = DockStyle.Top, Height = 32, BackColor = Theme.RailBack, Dock = DockStyle.Top, Height = this.Dpi(32), BackColor = Theme.RailBack,
Padding = new Padding(4, 4, 4, 0), WrapContents = false Padding = new Padding(4, 4, 4, 0), WrapContents = false
}; };
@@ -34,7 +34,7 @@ public sealed class SettingsView : UserControl
var b = new Button var b = new Button
{ {
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(70, 26), Padding = new Padding(10, 2, 10, 2), MinimumSize = new Size(this.Dpi(70), this.Dpi(26)), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
@@ -76,18 +76,18 @@ public sealed class SettingsView : UserControl
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
}; };
var font = new ComboBox { Width = 200, DropDownStyle = ComboBoxStyle.DropDown, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat }; var font = new ComboBox { Width = this.Dpi(200), DropDownStyle = ComboBoxStyle.DropDown, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat };
font.Items.AddRange(new object[] { "Cascadia Mono", "Consolas", "Courier New", "Lucida Console", "JetBrains Mono" }); font.Items.AddRange(new object[] { "Cascadia Mono", "Consolas", "Courier New", "Lucida Console", "JetBrains Mono" });
font.Text = s.FontFamily; font.Text = s.FontFamily;
var fontSize = new NumericUpDown { Width = 80, Minimum = 8, Maximum = 24, DecimalPlaces = 1, Increment = 0.5m, Value = (decimal)s.FontSize, BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle }; var fontSize = new NumericUpDown { Width = this.Dpi(80), Minimum = 8, Maximum = 24, DecimalPlaces = 1, Increment = 0.5m, Value = (decimal)s.FontSize, BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle };
var scrollback = new NumericUpDown { Width = 100, Minimum = 500, Maximum = 50000, Increment = 500, Value = s.ScrollbackLines, BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle }; var scrollback = new NumericUpDown { Width = this.Dpi(100), Minimum = 500, Maximum = 50000, Increment = 500, Value = s.ScrollbackLines, BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle };
var scheme = new ComboBox { Width = 150, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat }; var scheme = new ComboBox { Width = this.Dpi(150), DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat };
scheme.Items.AddRange(new object[] { "Dark", "Solarized Dark", "Monokai" }); scheme.Items.AddRange(new object[] { "Dark", "Solarized Dark", "Monokai" });
scheme.Text = s.ColorScheme; scheme.Text = s.ColorScheme;
var newline = new ComboBox { Width = 120, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat }; var newline = new ComboBox { Width = this.Dpi(120), DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat };
newline.Items.AddRange(new object[] { "\\r\\n", "\\r", "\\n" }); newline.Items.AddRange(new object[] { "\\r\\n", "\\r", "\\n" });
newline.Text = s.DefaultNewLine; newline.Text = s.DefaultNewLine;
@@ -109,17 +109,17 @@ public sealed class SettingsView : UserControl
// Shell settings // Shell settings
flow.Controls.Add(new Label { Text = "Shell Settings", AutoSize = true, ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4) }); flow.Controls.Add(new Label { Text = "Shell Settings", AutoSize = true, ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4) });
var shellType = new ComboBox { Width = 150, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat }; var shellType = new ComboBox { Width = this.Dpi(150), DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat };
shellType.Items.AddRange(new object[] { "PowerShell", "Bash", "Cmd" }); shellType.Items.AddRange(new object[] { "PowerShell", "Bash", "Cmd" });
shellType.Text = s.ShellType; shellType.Text = s.ShellType;
flow.Controls.Add(MakeRow("Terminal Shell", shellType)); flow.Controls.Add(MakeRow("Terminal Shell", shellType));
// 用一個帶邊框的容器包住「輸入框 + 瀏覽鈕」,讓兩者看起來像同一個欄位 // 用一個帶邊框的容器包住「輸入框 + 瀏覽鈕」,讓兩者看起來像同一個欄位
var dirPanel = new Panel { Width = InputWidth, Height = 24, BackColor = Theme.TabBack, BorderStyle = BorderStyle.FixedSingle }; var dirPanel = new Panel { Width = this.Dpi(InputWidth), Height = this.Dpi(24), BackColor = Theme.TabBack, BorderStyle = BorderStyle.FixedSingle };
var shellDir = new TextBox { BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, Text = s.ShellStartupDir, BorderStyle = BorderStyle.None }; var shellDir = new TextBox { BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, Text = s.ShellStartupDir, BorderStyle = BorderStyle.None };
var browseBtn = new Button var browseBtn = new Button
{ {
Text = "…", Width = 26, FlatStyle = FlatStyle.Flat, Text = "…", Width = this.Dpi(26), FlatStyle = FlatStyle.Flat,
ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
}; };
// 無邊框、暗色文字、與輸入框同底色 → 不再突兀 // 無邊框、暗色文字、與輸入框同底色 → 不再突兀
@@ -143,7 +143,7 @@ public sealed class SettingsView : UserControl
flow.Controls.Add(MakeSpacer(12)); flow.Controls.Add(MakeSpacer(12));
// ── Live Preview ── // ── Live Preview ──
var preview = new Panel { Width = 460, Height = 100, BackColor = Color.FromArgb(20, 20, 24), Margin = new Padding(0, 0, 0, 8) }; var preview = new Panel { Width = this.Dpi(460), Height = this.Dpi(100), BackColor = Color.FromArgb(20, 20, 24), Margin = new Padding(0, 0, 0, 8) };
var previewLabel = new Label var previewLabel = new Label
{ {
Dock = DockStyle.Fill, BackColor = Color.FromArgb(20, 20, 24), ForeColor = Color.FromArgb(200, 200, 200), Dock = DockStyle.Fill, BackColor = Color.FromArgb(20, 20, 24), ForeColor = Color.FromArgb(200, 200, 200),
@@ -204,7 +204,7 @@ public sealed class SettingsView : UserControl
{ {
Text = "Keywords below are highlighted in red in every terminal (case-insensitive).\n" + 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.", "When a keyword appears in a background tab, that tab's dot turns red until you open it.",
AutoSize = false, Width = 520, Height = 34, AutoSize = true,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8) ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
}); });
@@ -218,7 +218,7 @@ public sealed class SettingsView : UserControl
// 規則清單:每列 = 啟用勾選 + 關鍵字(可直接編輯) // 規則清單:每列 = 啟用勾選 + 關鍵字(可直接編輯)
var grid = new DataGridView var grid = new DataGridView
{ {
Width = 420, Height = 240, Width = this.Dpi(420), Height = this.Dpi(240),
BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border, BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border,
BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text }, DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text },
@@ -227,9 +227,9 @@ public sealed class SettingsView : UserControl
EnableHeadersVisualStyles = false, RowHeadersVisible = false, EnableHeadersVisualStyles = false, RowHeadersVisible = false,
AllowUserToAddRows = false, AllowUserToDeleteRows = false, AllowUserToAddRows = false, AllowUserToDeleteRows = false,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect, AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
Font = Theme.UiFont, RowTemplate = { Height = 24 }, Margin = new Padding(0, 0, 0, 8) Font = Theme.UiFont, RowTemplate = { Height = this.Dpi(24) }, Margin = new Padding(0, 0, 0, 8)
}; };
grid.Columns.Add(new DataGridViewCheckBoxColumn { Name = "On", HeaderText = "On", Width = 44 }); grid.Columns.Add(new DataGridViewCheckBoxColumn { Name = "On", HeaderText = "On", Width = this.Dpi(44) });
grid.Columns.Add(new DataGridViewTextBoxColumn grid.Columns.Add(new DataGridViewTextBoxColumn
{ {
Name = "Keyword", HeaderText = "Keyword", Name = "Keyword", HeaderText = "Keyword",
@@ -239,7 +239,7 @@ public sealed class SettingsView : UserControl
flow.Controls.Add(grid); flow.Controls.Add(grid);
// 新增 / 移除 // 新增 / 移除
var newKw = new TextBox { Width = 220, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle }; var newKw = new TextBox { Width = this.Dpi(220), BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle };
var addBtn = MakeButton("Add", Theme.Accent); var addBtn = MakeButton("Add", Theme.Accent);
var removeBtn = MakeButton("Remove Selected", Color.FromArgb(210, 120, 120)); var removeBtn = MakeButton("Remove Selected", Color.FromArgb(210, 120, 120));
@@ -315,33 +315,33 @@ public sealed class SettingsView : UserControl
"Leave blank to keep the AI Assistant disabled. Pick the model from the dropdown inside the\n" + "Leave blank to keep the AI Assistant disabled. Pick the model from the dropdown inside the\n" +
"AI Chat pane (it lists what your endpoint offers). The model must support function calling.\n" + "AI Chat pane (it lists what your endpoint offers). The model must support function calling.\n" +
"The API key is stored in Windows Credential Manager, never in settings.json or the app.", "The API key is stored in Windows Credential Manager, never in settings.json or the app.",
AutoSize = false, Width = 620, Height = 68, AutoSize = true,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 10) ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 10)
}); });
var baseUrl = new TextBox var baseUrl = new TextBox
{ {
Width = 380, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, Width = this.Dpi(380), BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont,
BorderStyle = BorderStyle.FixedSingle, Text = s.AiBaseUrl, BorderStyle = BorderStyle.FixedSingle, Text = s.AiBaseUrl,
PlaceholderText = "http://localhost:11434/v1" PlaceholderText = "http://localhost:11434/v1"
}; };
var apiKey = new TextBox var apiKey = new TextBox
{ {
Width = 380, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, Width = this.Dpi(380), BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont,
BorderStyle = BorderStyle.FixedSingle, UseSystemPasswordChar = true, BorderStyle = BorderStyle.FixedSingle, UseSystemPasswordChar = true,
Text = CredentialVault.Get("ETTerms/AiApiKey") ?? "", Text = CredentialVault.Get("ETTerms/AiApiKey") ?? "",
PlaceholderText = "(stored in Credential Manager)" PlaceholderText = "(stored in Credential Manager)"
}; };
var sysPrompt = new TextBox var sysPrompt = new TextBox
{ {
Width = 560, Height = 70, Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text, Width = this.Dpi(560), Height = this.Dpi(70), Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text,
Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle, Text = s.AiSystemPrompt, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle, Text = s.AiSystemPrompt,
PlaceholderText = "(optional) override the assistant persona / system prompt" PlaceholderText = "(optional) override the assistant persona / system prompt"
}; };
var maxRounds = new NumericUpDown var maxRounds = new NumericUpDown
{ {
Width = 100, Minimum = 0, Maximum = 100000, Increment = 10, Value = s.AiMaxToolRounds, Width = this.Dpi(100), Minimum = 0, Maximum = 100000, Increment = 10, Value = s.AiMaxToolRounds,
BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle
}; };
@@ -352,7 +352,7 @@ public sealed class SettingsView : UserControl
{ {
Text = "How many tool calls the assistant may chain per message before it stops (a runaway-loop guard).\n" + Text = "How many tool calls the assistant may chain per message before it stops (a runaway-loop guard).\n" +
"0 = unlimited — for long automation runs. Every round costs tokens; press Stop in the chat to abort.", "0 = unlimited — for long automation runs. Every round costs tokens; press Stop in the chat to abort.",
AutoSize = false, Width = 620, Height = 34, AutoSize = true,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6) ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6)
}); });
flow.Controls.Add(MakeSpacer(4)); flow.Controls.Add(MakeSpacer(4));
@@ -368,7 +368,7 @@ public sealed class SettingsView : UserControl
{ {
Text = "Tools the assistant can call: serial send/read (via the GUI's open Serial session, shown as [AI]),\n" + Text = "Tools the assistant can call: serial send/read (via the GUI's open Serial session, shown as [AI]),\n" +
"and PDU control over SNMP. Turning an outlet off / power-cycling always asks you to confirm.", "and PDU control over SNMP. Turning an outlet off / power-cycling always asks you to confirm.",
AutoSize = false, Width = 620, Height = 36, AutoSize = true,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8) ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
}); });
@@ -416,7 +416,7 @@ public sealed class SettingsView : UserControl
"user-level config. Serial: ETTerms owns the COM port, the AI drives it through a\n" + "user-level config. Serial: ETTerms owns the COM port, the AI drives it through a\n" +
"local named pipe (open a Serial session first). PDU: the AI controls outlets\n" + "local named pipe (open a Serial session first). PDU: the AI controls outlets\n" +
"directly over SNMP — no GUI session required.", "directly over SNMP — no GUI session required.",
AutoSize = false, Width = 600, Height = 64, AutoSize = true,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8) ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
}); });
@@ -426,7 +426,7 @@ public sealed class SettingsView : UserControl
flow.Controls.Add(new Label flow.Controls.Add(new Label
{ {
Text = $"{name}: {exe}", Text = $"{name}: {exe}",
AutoSize = false, Width = 600, Height = 20, AutoSize = true,
ForeColor = exists ? Theme.SerialColor : Color.FromArgb(210, 150, 120), ForeColor = exists ? Theme.SerialColor : Color.FromArgb(210, 150, 120),
Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2) Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2)
}); });
@@ -436,7 +436,7 @@ public sealed class SettingsView : UserControl
flow.Controls.Add(new Label flow.Controls.Add(new Label
{ {
Text = "⚠ Some servers not built yet — publish the app (or build the MCP projects). Setup still writes the expected paths.", Text = "⚠ Some servers not built yet — publish the app (or build the MCP projects). Setup still writes the expected paths.",
AutoSize = false, Width = 600, Height = 20, AutoSize = true,
ForeColor = Color.FromArgb(210, 150, 120), Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 4) ForeColor = Color.FromArgb(210, 150, 120), Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 4)
}); });
} }
@@ -455,7 +455,7 @@ public sealed class SettingsView : UserControl
{ {
var card = new Panel var card = new Panel
{ {
Width = 600, Height = 196, BackColor = Theme.TabBack, Width = this.Dpi(600), Height = this.Dpi(196), BackColor = Theme.TabBack,
Padding = new Padding(14), Margin = new Padding(0, 0, 0, 4) Padding = new Padding(14), Margin = new Padding(0, 0, 0, 4)
}; };
var col = new FlowLayoutPanel var col = new FlowLayoutPanel
@@ -473,7 +473,7 @@ public sealed class SettingsView : UserControl
var pathLbl = new Label var pathLbl = new Label
{ {
Text = $"Config: {McpRegistrar.ConfigPath(target)}", Text = $"Config: {McpRegistrar.ConfigPath(target)}",
AutoSize = false, Width = 560, Height = 18, AutoSize = true,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6) ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6)
}; };
@@ -495,7 +495,7 @@ public sealed class SettingsView : UserControl
}; };
var verifyBox = new TextBox var verifyBox = new TextBox
{ {
Multiline = true, ReadOnly = true, Width = 560, Height = 56, Multiline = true, ReadOnly = true, Width = this.Dpi(560), Height = this.Dpi(56),
BackColor = Color.FromArgb(20, 20, 24), ForeColor = Color.FromArgb(200, 200, 200), BackColor = Color.FromArgb(20, 20, 24), ForeColor = Color.FromArgb(200, 200, 200),
BorderStyle = BorderStyle.FixedSingle, Font = new Font("Cascadia Mono", 9f), BorderStyle = BorderStyle.FixedSingle, Font = new Font("Cascadia Mono", 9f),
Text = McpRegistrar.VerifyHint(target) Text = McpRegistrar.VerifyHint(target)
@@ -571,11 +571,11 @@ public sealed class SettingsView : UserControl
}; };
var lbl = new Label var lbl = new Label
{ {
Text = label, AutoSize = true, MinimumSize = new Size(LabelWidth, 0), Text = label, AutoSize = true, MinimumSize = new Size(row.Dpi(LabelWidth), 0),
ForeColor = Theme.Text, Font = Theme.UiFont, ForeColor = Theme.Text, Font = Theme.UiFont,
TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 6, 8, 0) TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 6, 8, 0)
}; };
if (control.Width <= 0) control.Width = InputWidth; if (control.Width <= 0) control.Width = row.Dpi(InputWidth);
row.Controls.Add(lbl); row.Controls.Add(lbl);
row.Controls.Add(control); row.Controls.Add(control);
return row; return row;
@@ -586,11 +586,12 @@ public sealed class SettingsView : UserControl
var b = new Button var b = new Button
{ {
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(90, 28), Padding = new Padding(10, 2, 10, 2), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0) Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0)
}; };
b.MinimumSize = new Size(b.Dpi(90), b.Dpi(28));
b.FlatAppearance.BorderColor = borderColor; b.FlatAppearance.BorderColor = borderColor;
b.FlatAppearance.MouseOverBackColor = Theme.Hover; b.FlatAppearance.MouseOverBackColor = Theme.Hover;
return b; return b;
+8 -7
View File
@@ -19,7 +19,7 @@ public sealed class StatusView : UserControl
var tabBar = new FlowLayoutPanel var tabBar = new FlowLayoutPanel
{ {
Dock = DockStyle.Top, Height = 32, BackColor = Theme.RailBack, Dock = DockStyle.Top, Height = this.Dpi(32), BackColor = Theme.RailBack,
Padding = new Padding(4, 4, 4, 0), WrapContents = false Padding = new Padding(4, 4, 4, 0), WrapContents = false
}; };
@@ -37,7 +37,7 @@ public sealed class StatusView : UserControl
var b = new Button var b = new Button
{ {
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(70, 26), Padding = new Padding(10, 2, 10, 2), MinimumSize = new Size(this.Dpi(70), this.Dpi(26)), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
@@ -75,11 +75,11 @@ public sealed class StatusView : UserControl
}; };
// Connection row // Connection row
var ipBox = new TextBox { Width = 160, Text = "192.168.1.21", BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont }; var ipBox = new TextBox { Width = this.Dpi(160), Text = "192.168.1.21", BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont };
var connectBtn = MakeButton("Connect", Theme.SerialColor); var connectBtn = MakeButton("Connect", Theme.SerialColor);
var statusLabel = new Label { AutoSize = true, Text = "Disconnected", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(8, 8, 0, 0) }; var statusLabel = new Label { AutoSize = true, Text = "Disconnected", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(8, 8, 0, 0) };
var connRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 500, Height = 36, WrapContents = false, Margin = new Padding(0, 0, 0, 8) }; var connRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, AutoSize = true, WrapContents = false, Margin = new Padding(0, 0, 0, 8) };
connRow.Controls.Add(new Label { Text = "PDU IP:", AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) }); connRow.Controls.Add(new Label { Text = "PDU IP:", AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) });
connRow.Controls.Add(ipBox); connRow.Controls.Add(ipBox);
connRow.Controls.Add(connectBtn); connRow.Controls.Add(connectBtn);
@@ -89,7 +89,7 @@ public sealed class StatusView : UserControl
// Port status grid // Port status grid
var grid = new DataGridView var grid = new DataGridView
{ {
Width = 480, Height = 310, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, Width = this.Dpi(480), Height = this.Dpi(310), AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border, BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border,
BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text }, DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text },
@@ -99,7 +99,7 @@ public sealed class StatusView : UserControl
AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true, AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect, AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
ScrollBars = ScrollBars.None, Font = Theme.UiFont, ScrollBars = ScrollBars.None, Font = Theme.UiFont,
RowTemplate = { Height = 24 }, RowTemplate = { Height = this.Dpi(24) },
Margin = new Padding(0, 8, 0, 8) Margin = new Padding(0, 8, 0, 8)
}; };
grid.Columns.Add("Port", "Port"); grid.Columns.Add("Port", "Port");
@@ -274,11 +274,12 @@ public sealed class StatusView : UserControl
var b = new Button var b = new Button
{ {
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(90, 28), Padding = new Padding(10, 2, 10, 2), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0) Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0)
}; };
b.MinimumSize = new Size(b.Dpi(90), b.Dpi(28));
b.FlatAppearance.BorderColor = borderColor; b.FlatAppearance.BorderColor = borderColor;
b.FlatAppearance.MouseOverBackColor = Theme.Hover; b.FlatAppearance.MouseOverBackColor = Theme.Hover;
return b; return b;
+9
View File
@@ -1,4 +1,5 @@
using System.Drawing; using System.Drawing;
using System.Windows.Forms;
namespace ETTerms.App; namespace ETTerms.App;
@@ -27,4 +28,12 @@ public static class Theme
public static readonly Font UiFont = new("Segoe UI", 9.5f); public static readonly Font UiFont = new("Segoe UI", 9.5f);
public static readonly Font UiFontBold = new("Segoe UI", 9.5f, FontStyle.Bold); public static readonly Font UiFontBold = new("Segoe UI", 9.5f, FontStyle.Bold);
/// <summary>
/// 96-DPI 設計像素 → 控制項目前 DPI 的實際像素。
/// 執行期建立的控制項不會被 Form 的自動縮放處理(那只發生在 Form 初始化當下),
/// 字型以點數指定會自己隨 DPI 放大,因此所有寫死的像素尺寸都必須經過這裡換算,
/// 否則 125%/150% 時就是「字大了、框沒大」的裁切(作法同 ActivityRail.ItemSize)。
/// </summary>
public static int Dpi(this Control c, int px) => (int)Math.Round(px * (c.DeviceDpi / 96.0));
} }
+15 -13
View File
@@ -44,9 +44,10 @@ public sealed class WorkspaceView : UserControl
private bool _dragging; // 是否已進入拖曳 private bool _dragging; // 是否已進入拖曳
private const int DragThreshold = 5; private const int DragThreshold = 5;
private const int StripH = 30; // Tab 列尺寸:owner-draw 不會被自動縮放,需依 DPI 換算(同 ActivityRail.ItemSize
private const int TabW = 180; private int StripH => this.Dpi(30);
private const int CloseSz = 14; private int TabW => this.Dpi(180);
private int CloseSz => this.Dpi(14);
private static readonly (string label, int r, int c)[] Presets = private static readonly (string label, int r, int c)[] Presets =
{ ("1×1", 1, 1), ("1×2", 1, 2), ("2×1", 2, 1), ("2×2", 2, 2), ("2×3", 2, 3), ("3×3", 3, 3) }; { ("1×1", 1, 1), ("1×2", 1, 2), ("2×1", 2, 1), ("2×2", 2, 2), ("2×3", 2, 3), ("3×3", 3, 3) };
@@ -58,7 +59,7 @@ public sealed class WorkspaceView : UserControl
// 頂部工具列容器:左側為 Layout/Run 群組(Fill),右側為 Log AllDock Right // 頂部工具列容器:左側為 Layout/Run 群組(Fill),右側為 Log AllDock Right
// 高度 40 與左側 ConnectionSidebar 的 tab bar 對齊,且容得下 AutoSize 按鈕(不被裁切) // 高度 40 與左側 ConnectionSidebar 的 tab bar 對齊,且容得下 AutoSize 按鈕(不被裁切)
var topBar = new Panel { Dock = DockStyle.Top, Height = 40, BackColor = Theme.RailBack }; var topBar = new Panel { Dock = DockStyle.Top, Height = this.Dpi(40), BackColor = Theme.RailBack };
_toolbar = new FlowLayoutPanel _toolbar = new FlowLayoutPanel
{ {
@@ -268,7 +269,7 @@ public sealed class WorkspaceView : UserControl
: $"{icon} {s.Title} [{glabel}]"; : $"{icon} {s.Title} [{glabel}]";
var lbl = new Label var lbl = new Label
{ {
Dock = DockStyle.Bottom, Height = 22, Dock = DockStyle.Bottom, Height = this.Dpi(22),
Text = labelText, Text = labelText,
ForeColor = s == _active ? Theme.Text : Theme.TextDim, ForeColor = s == _active ? Theme.Text : Theme.TextDim,
BackColor = Theme.RailBack, Font = Theme.UiFont, BackColor = Theme.RailBack, Font = Theme.UiFont,
@@ -293,7 +294,7 @@ public sealed class WorkspaceView : UserControl
foreach (var s in _sessions) foreach (var s in _sessions)
{ {
s.TabBounds = new Rectangle(x, 0, TabW, StripH); s.TabBounds = new Rectangle(x, 0, TabW, StripH);
s.CloseRect = new Rectangle(s.TabBounds.Right - CloseSz - 6, (StripH - CloseSz) / 2, CloseSz, CloseSz); s.CloseRect = new Rectangle(s.TabBounds.Right - CloseSz - this.Dpi(6), (StripH - CloseSz) / 2, CloseSz, CloseSz);
x += TabW; x += TabW;
} }
} }
@@ -400,9 +401,11 @@ public sealed class WorkspaceView : UserControl
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除。AI 分頁用 accent 紫。 // 警示中的背景分頁:型別圓點改紅色,切過去看時清除。AI 分頁用 accent 紫。
Color dotColor = s.Alert ? Color.FromArgb(235, 85, 85) Color dotColor = s.Alert ? Color.FromArgb(235, 85, 85)
: s.IsAi ? Theme.Accent : s.IsSsh ? Theme.SshColor : Theme.SerialColor; : s.IsAi ? Theme.Accent : s.IsSsh ? Theme.SshColor : Theme.SerialColor;
int dotSz = this.Dpi(8);
using (var dot = new SolidBrush(dotColor)) using (var dot = new SolidBrush(dotColor))
g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8); g.FillEllipse(dot, s.TabBounds.Left + this.Dpi(9), (StripH - dotSz) / 2, dotSz, dotSz);
var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH); int textL = this.Dpi(22);
var tr = new Rectangle(s.TabBounds.Left + textL, s.TabBounds.Top, s.TabBounds.Width - textL - CloseSz - this.Dpi(10), StripH);
TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim, TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, "✕", Theme.UiFont, s.CloseRect, hover ? Theme.Text : Theme.TextDim, TextRenderer.DrawText(g, "✕", Theme.UiFont, s.CloseRect, hover ? Theme.Text : Theme.TextDim,
@@ -416,7 +419,7 @@ public sealed class WorkspaceView : UserControl
var b = new Button var b = new Button
{ {
Text = label, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Text = label, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(40, 26), Padding = new Padding(8, 2, 8, 2), MinimumSize = new Size(this.Dpi(40), this.Dpi(26)), Padding = new Padding(8, 2, 8, 2),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(3, 0, 3, 0), Cursor = Cursors.Hand Margin = new Padding(3, 0, 3, 0), Cursor = Cursors.Hand
@@ -427,12 +430,12 @@ public sealed class WorkspaceView : UserControl
return b; return b;
} }
private static Button MakeActionButton(string text, int minWidth, int leftMargin, EventHandler onClick) private Button MakeActionButton(string text, int minWidth, int leftMargin, EventHandler onClick)
{ {
var b = new Button var b = new Button
{ {
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(minWidth, 26), Padding = new Padding(10, 2, 10, 2), MinimumSize = new Size(this.Dpi(minWidth), this.Dpi(26)), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(leftMargin, 0, 3, 0), Cursor = Cursors.Hand Margin = new Padding(leftMargin, 0, 3, 0), Cursor = Cursors.Hand
@@ -474,8 +477,7 @@ public sealed class WorkspaceView : UserControl
private void SetLogAllActive(bool on) private void SetLogAllActive(bool on)
{ {
_logAll.Text = on ? "⏺ Logging All" : "⏺ Log All"; _logAll.Text = on ? "⏺ Logging All" : "⏺ Log All"; // AutoSize 按鈕,寬度隨字自動調
_logAll.Width = on ? 110 : 96;
_logAll.ForeColor = on ? Theme.SerialColor : Theme.Text; _logAll.ForeColor = on ? Theme.SerialColor : Theme.Text;
} }
+1 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.7.0</Version> <Version>0.7.2</Version>
<Product>ETTerms</Product> <Product>ETTerms</Product>
<Company>ETTerms Project</Company> <Company>ETTerms Project</Company>
+88
View File
@@ -33,6 +33,7 @@ public sealed class TTLInterpreter : IDisposable
private const int SettleMs = 300; // wait 命中關鍵字後,需連續安靜這麼久(無新資料)才接受,避免比對到輸出中途的回顯 private const int SettleMs = 300; // wait 命中關鍵字後,需連續安靜這麼久(無新資料)才接受,避免比對到輸出中途的回顯
private const int MaxRecvChars = 1_000_000; // _recv 上限:長時間無 wait 消費時避免無限成長 private const int MaxRecvChars = 1_000_000; // _recv 上限:長時間無 wait 消費時避免無限成長
private const int SendRetryTimeoutMs = 3000; // sendlnretry 每次送出後等確認關鍵字的預設逾時(timeout/mtimeout 有設就以設定值為準)
private GroupSyncContext? _groupSync; private GroupSyncContext? _groupSync;
private string _groupMemberLabel = ""; private string _groupMemberLabel = "";
@@ -193,6 +194,7 @@ public sealed class TTLInterpreter : IDisposable
// ── 通訊 ── // ── 通訊 ──
case "send": Send(args, false); break; case "send": Send(args, false); break;
case "sendln": Send(args, true); break; case "sendln": Send(args, true); break;
case "sendlnretry": SendLnRetry(TokenizeArgs(args)); break;
case "wait": DispatchWait(args); break; case "wait": DispatchWait(args); break;
case "waitln": WaitLn(TokenizeArgs(args)); break; case "waitln": WaitLn(TokenizeArgs(args)); break;
case "waitregex": WaitRegex(TokenizeArgs(args)); break; case "waitregex": WaitRegex(TokenizeArgs(args)); break;
@@ -740,6 +742,92 @@ public sealed class TTLInterpreter : IDisposable
Output?.Invoke($">> {text}"); Output?.Invoke($">> {text}");
} }
/// <summary>
/// <c>sendlnretry '文字' '確認關鍵字' [最多送出次數]</c>
/// —— 送出後確認裝置真的收到;沒等到確認關鍵字就重送。次數省略 = 一直重送到收到為止。
/// 命中 <c>result = 1</c>;用完次數 <c>result = 0</c> 且**繼續執行**(不中止腳本,讓腳本自行處置)。
/// <para>存在理由:裝置在開機/console 剛接手的瞬間可能丟棄輸入(tty 重開 / flush),
/// 此時 <c>sendln</c> 送出的整行會被吃掉、後面的 <c>wait</c> 便永遠等不到,
/// 而任何固定長度的 <c>pause</c> 都只是在調機率、無法根治。</para>
/// <para>每次送出前會清空接收緩衝,確保比對到的是「這次送出」的回應而非殘留輸出;
/// 命中後只消費到**第一次**出現處為止,後續輸出留在緩衝裡給接下來的 wait 用。</para>
/// <para>確認關鍵字請挑「命令真的有跑」才會出現的輸出,不要挑指令回顯——
/// tty 的 echo 不保證命令有被 shell 讀走。</para>
/// </summary>
private void SendLnRetry(List<string> tokens)
{
if (tokens.Count < 2)
{
Output?.Invoke("[ttl] sendlnretry: 用法 sendlnretry '文字' '確認關鍵字' [最多送出次數]");
return;
}
string text = ValS(tokens[0]);
string confirm = ValS(tokens[1]);
if (confirm.Length == 0)
{
Output?.Invoke("[ttl] sendlnretry: 確認關鍵字不可為空");
return;
}
int maxAttempts = tokens.Count > 2 ? ValI(tokens[2]) : 0; // 省略 / <= 0 = 無限
int perTryMs = EffectiveTimeoutMs > 0 ? EffectiveTimeoutMs : SendRetryTimeoutMs;
for (int attempt = 1; maxAttempts <= 0 || attempt <= maxAttempts; attempt++)
{
ThrowIfCancelled();
lock (_recv) _recv.Clear(); // 只認這次送出之後的回應
_channel.Write(_enc.GetBytes(text + "\r\n"));
_logWriter?.WriteLine($">> {text}");
Output?.Invoke(attempt == 1 ? $">> {text}" : $">> {text} (attempt {attempt})");
if (WaitConfirm(confirm, perTryMs))
{
_result = 1;
if (attempt > 1) _logWriter?.WriteLine($"[sendlnretry] '{text}' 於第 {attempt} 次送出後確認");
return;
}
Output?.Invoke($"[sendlnretry] {perTryMs}ms 內未見 '{confirm}' — 重送");
_logWriter?.WriteLine($"[sendlnretry] no '{confirm}' after attempt {attempt}: {text}");
}
_result = 0;
Output?.Invoke($"[sendlnretry] 送出 {maxAttempts} 次仍未確認 '{confirm}' — 放棄(result = 0");
_logWriter?.WriteLine($"[sendlnretry] gave up after {maxAttempts} attempt(s): {text}");
}
/// <summary>等確認關鍵字出現,逾時回 false(不丟例外、不中止腳本)。
/// 刻意不套 <see cref="SettleAndConsume"/> 的 settle:關鍵字「有出現」本身就證明裝置收到了,
/// 且只消費到第一次出現處,後面的輸出要留給後續的 wait。</summary>
private bool WaitConfirm(string text, int timeoutMs)
{
int elapsed = 0, lastLen = -1;
while (elapsed < timeoutMs)
{
ThrowIfCancelled();
lock (_recv)
{
if (_recv.Length != lastLen)
{
lastLen = _recv.Length;
string buf = _recv.ToString();
int idx = buf.IndexOf(text, StringComparison.Ordinal);
if (idx >= 0)
{
_recv.Clear();
_recv.Append(buf[(idx + text.Length)..]);
return true;
}
}
}
Thread.Sleep(50);
elapsed += 50;
}
return false;
}
private int EffectiveTimeoutMs => _timeout + _mtimeout; // 0 = 無限 private int EffectiveTimeoutMs => _timeout + _mtimeout; // 0 = 無限
private void DispatchWait(string args) private void DispatchWait(string args)
+8 -4
View File
@@ -73,7 +73,7 @@ public sealed class SessionPage : UserControl
Dock = DockStyle.Fill; Dock = DockStyle.Fill;
// ── 頂部腳本列 ── // ── 頂部腳本列 ──
var bar = new Panel { Dock = DockStyle.Top, Height = 24, BackColor = Theme.RailBack }; var bar = new Panel { Dock = DockStyle.Top, Height = this.Dpi(24), BackColor = Theme.RailBack };
_status = new Label _status = new Label
{ {
Dock = DockStyle.Fill, Text = "Idle", Dock = DockStyle.Fill, Text = "Idle",
@@ -84,7 +84,7 @@ public sealed class SessionPage : UserControl
_stop = MakeBarButton("■ Stop", (_, _) => _runner.Cancel()); _stop = MakeBarButton("■ Stop", (_, _) => _runner.Cancel());
_stop.Enabled = false; _stop.Enabled = false;
_log = MakeBarButton("⏺ Log", OnToggleLog); _log = MakeBarButton("⏺ Log", OnToggleLog);
_log.Width = 92; // 容納 "⏺ Logging" 不被截字 _log.Width = BarButtonWidth("⏺ Logging"); // 取切換後較長的字,不被截字
bar.Controls.Add(_status); // Fill 先加 bar.Controls.Add(_status); // Fill 先加
bar.Controls.Add(_log); // Right(最左:Log bar.Controls.Add(_log); // Right(最左:Log
bar.Controls.Add(_run); // Right(中:Script bar.Controls.Add(_run); // Right(中:Script
@@ -185,11 +185,15 @@ public sealed class SessionPage : UserControl
else action(); else action();
} }
private static Button MakeBarButton(string text, EventHandler onClick) /// <summary>依實際字寬(隨 DPI 放大)算按鈕寬度,固定像素在 125%/150% 會截字。</summary>
private int BarButtonWidth(string text) =>
TextRenderer.MeasureText(text, Theme.UiFont).Width + this.Dpi(18);
private Button MakeBarButton(string text, EventHandler onClick)
{ {
var b = new Button var b = new Button
{ {
Text = text, Dock = DockStyle.Right, Width = 72, FlatStyle = FlatStyle.Flat, Text = text, Dock = DockStyle.Right, Width = BarButtonWidth(text), FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
}; };
b.FlatAppearance.BorderColor = Theme.Border; b.FlatAppearance.BorderColor = Theme.Border;
+2 -1
View File
@@ -1,6 +1,7 @@
using System.Drawing; using System.Drawing;
using System.Drawing.Drawing2D; using System.Drawing.Drawing2D;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.App;
namespace ETTerms.Terminal; namespace ETTerms.Terminal;
@@ -44,7 +45,7 @@ public sealed class DarkScrollBar : Control
// 導致 TerminalView 收不到鍵盤輸入(打字 / Enter 全失效),需重開 session 才恢復。 // 導致 TerminalView 收不到鍵盤輸入(打字 / Enter 全失效),需重開 session 才恢復。
SetStyle(ControlStyles.Selectable, false); SetStyle(ControlStyles.Selectable, false);
TabStop = false; TabStop = false;
Width = 12; Width = this.Dpi(12);
BackColor = TrackColor; BackColor = TrackColor;
} }
+6 -5
View File
@@ -3,6 +3,7 @@ using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.App;
using ETTerms.Infrastructure; using ETTerms.Infrastructure;
namespace ETTerms.Terminal; namespace ETTerms.Terminal;
@@ -440,7 +441,7 @@ public sealed class TerminalView : UserControl
if (_searchPanel != null) return; if (_searchPanel != null) return;
var back = Color.FromArgb(32, 32, 38); var back = Color.FromArgb(32, 32, 38);
_searchPanel = new Panel { Size = new Size(268, 30), BackColor = back, Visible = false }; _searchPanel = new Panel { Size = new Size(this.Dpi(268), this.Dpi(30)), BackColor = back, Visible = false };
_searchPanel.Paint += (_, pe) => _searchPanel.Paint += (_, pe) =>
{ {
using var pen = new Pen(Color.FromArgb(58, 58, 66)); using var pen = new Pen(Color.FromArgb(58, 58, 66));
@@ -449,7 +450,7 @@ public sealed class TerminalView : UserControl
_searchBox = new TextBox _searchBox = new TextBox
{ {
Bounds = new Rectangle(6, 5, 130, 20), BorderStyle = BorderStyle.None, Bounds = new Rectangle(this.Dpi(6), this.Dpi(5), this.Dpi(130), this.Dpi(20)), BorderStyle = BorderStyle.None,
BackColor = back, ForeColor = Color.FromArgb(222, 222, 226), Font = new Font("Segoe UI", 9.5f) BackColor = back, ForeColor = Color.FromArgb(222, 222, 226), Font = new Font("Segoe UI", 9.5f)
}; };
_searchBox.TextChanged += (_, _) => RunSearch(); _searchBox.TextChanged += (_, _) => RunSearch();
@@ -463,7 +464,7 @@ public sealed class TerminalView : UserControl
_searchCount = new Label _searchCount = new Label
{ {
Bounds = new Rectangle(138, 7, 56, 16), Text = "", Bounds = new Rectangle(this.Dpi(138), this.Dpi(7), this.Dpi(56), this.Dpi(16)), Text = "",
ForeColor = Color.FromArgb(150, 150, 158), BackColor = back, ForeColor = Color.FromArgb(150, 150, 158), BackColor = back,
Font = new Font("Segoe UI", 8.5f), TextAlign = ContentAlignment.MiddleRight Font = new Font("Segoe UI", 8.5f), TextAlign = ContentAlignment.MiddleRight
}; };
@@ -472,7 +473,7 @@ public sealed class TerminalView : UserControl
{ {
var b = new Button var b = new Button
{ {
Bounds = new Rectangle(x, 4, 22, 22), Text = text, FlatStyle = FlatStyle.Flat, Bounds = new Rectangle(this.Dpi(x), this.Dpi(4), this.Dpi(22), this.Dpi(22)), Text = text, FlatStyle = FlatStyle.Flat,
ForeColor = Color.FromArgb(180, 180, 188), BackColor = back, ForeColor = Color.FromArgb(180, 180, 188), BackColor = back,
Font = new Font("Segoe UI", 8.5f), TabStop = false, Cursor = Cursors.Hand Font = new Font("Segoe UI", 8.5f), TabStop = false, Cursor = Cursors.Hand
}; };
@@ -493,7 +494,7 @@ public sealed class TerminalView : UserControl
private void PositionSearchPanel() private void PositionSearchPanel()
{ {
if (_searchPanel == null) return; if (_searchPanel == null) return;
_searchPanel.Location = new Point(Math.Max(0, ContentWidth - _searchPanel.Width - 8), 6); _searchPanel.Location = new Point(Math.Max(0, ContentWidth - _searchPanel.Width - this.Dpi(8)), this.Dpi(6));
} }
/// <summary>重掃整個 bufferscrollback + 畫面)建立命中清單,並跳到最靠近底部的命中。</summary> /// <summary>重掃整個 bufferscrollback + 畫面)建立命中清單,並跳到最靠近底部的命中。</summary>