Widget 模式深入
第一個 Plugin 4 你已經做出一個有按鈕與數值的面板。這一章把 Widget 模式的規則講完整: 你的版面檔寫什麼、主程式怎麼排、使用者操作之後你會收到什麼、什麼時候畫面會重建。 共有 15 種控制項,各自的欄位與寫法在下一章:Widget 型別詳解。

版面與狀態是兩份東西
每個控制項有兩半:
| 內容 | 誰擁有 | 什麼時候變 | |
|---|---|---|---|
版面(.nxlay) | 型別、列 y、min_w/max_w/weight、高度 h、標籤、選項數、數值範圍、旗標 | 你用 LoadLayout 交給 Core | 很少變:版面結構 |
| 狀態(屬性值) | 文字框裡的字、勾選與否、選了第幾項、進度多少、選項文字、表格列 | Core | 隨時:你用 SetWidgetProp 改,或使用者操作 |
狀態的唯一真相在 Core,主程式不自己記。 畫面上的控制項只是把 Core 的快照畫出來。 這就是 Widget 模式不必擔心繪圖執行緒與逾時的原因:繪圖時完全不會呼叫你的程式。
版面的規則:
- 一個面板最多 256 個控制項(超過
LoadLayout回ERR_FULL)。 - 每個控制項有你自訂的
widget_id(.nxlay裡[w.N]的 N,0~65534),同一面板內不可重複。之後所有事件與SetWidgetProp都用它辨認, 不用陣列索引。 - 在
init裡呼叫host->LoadLayout(ctx, 0, "資源名稱", 0)。第二個參數是面板編號, 目前每個掛載只有一個邏輯面板,編號恆為0,傳別的值回ERR_INVALID_PARAM。.nxlay以RCDATA資源嵌進 DLL (開發時可用NX_LAYOUT_F_FILE讀檔,沒有資源可用時用LoadLayoutData吃記憶體內容),見第一個 Plugin 4。 - 版面違反規則(例如
y遞減、widget_id重複、METER沒有num_max、label超過 31 位元組)時,LoadLayout回ERR_INVALID_PARAM; 含主程式不認得或尚未實作的type=時回ERR_NOT_SUPPORTED,整份載入失敗,不會載一半。 驗證失敗的原因(規則編號與widget_id)會寫進你的 Plugin 的 Log(WARN),回傳碼本身只有錯誤類別。所以要檢查回傳值並自己LogWrite, 並且在存檔前用nxlayout --solve或視覺化編輯器先驗證好版面。 .nxlay是版面的唯一來源;要在 C 裡用 widget 編號時,nxlayout --emit-header從它產生只含WIDGET_ID_*常數的.h,.c用#include引進,改了.nxlay一定要重新產生。- 版面載入一次就好。內容逐位元組相同的重新載入是 no-op;內容真的不同則整棵樹重建,已存在的控制項的值不保留。
版面怎麼決定:列、寬度、高度
你不寫座標。你只說:這個控制項在第幾列、至少要多寬、多出來的空間怎麼分。主程式依面板大小算出實際位置。 單位是抽象格:終端機版 1 格就是 1 個字元格,視窗版 1 格約等於一個平均字元寬。
列:y
y是列號(0 起算),不是像素、也不是終端機座標。y相同的控制項在同一列並排。 宣告順序中,y必須不遞減(不會自動幫你排序)。y可以跳號:中間就留一列空白。- 同一列的控制項,
x是「這個控制項左邊界」,後一個必須放在前一個的x + min_w之後。 兩個相鄰控制項之間的空隙就是你宣告的差距:後一個的 x−前一個的 x + min_w。慣例留 1 格,所以第二個的x通常是「前一個的x+min_w+ 1」(widget_demo全部這樣寫)。
寬度:min_w、weight、max_w
主程式對每一列這樣算:
- 先給每個控制項它的
min_w。 - 一列的
min_w總和加上空隙超過面板寬度時,容不下,見下面「空間不夠怎麼辦」。 - 有剩餘寬度時,依
weight的比例分給weight > 0的控制項;weight = 0的永遠維持min_w。 max_w非 0 時是該控制項長大的上限(必須>= min_w)。- 分不整除的餘數給宣告順序最前面的那個加權控制項,所以兩個版本算出來逐格相同。
兩個常見的新手錯誤:
SEPARATOR若min_w = 0又沒有weight,寬度就是 0,什麼都畫不出來。分隔線請給weight=1。- 該長大的控制項(文字框、數值)記得給
weight,否則面板再寬它也只有min_w。
高度:h
- 大多數控制項一列高,
h留 0(自動)即可:自動=「到下一個有控制項的y為止」,最後一列則延伸到面板底。 GROUPBOX一定要明確給h(h = 0會被拒絕),涵蓋它要框住的列數。TABLE的高度不是宣告的h:主程式一律給它「到下一列y的距離」,且至少 3 列(標題列、分隔列、至少一列資料); 最後一列的話延伸到面板底。所以想讓清單多高,靠把下一列的y排在多遠來控制,而不是靠h。
空間不夠怎麼辦
「完整容得下才可見」。 一個控制項只有整個矩形都放得進面板才會畫出來,沒有畫一半的情況—— 半個輸入框無法閱讀,也無法正確操作。放不下就整個隱藏。
隱藏的順序:
- 寬度不夠:先把這一列控制項之間的空隙全部擠成 0(不隱藏任何東西,一旦擠了就用零空隙排完這一列);還是不夠,由後往前隱藏標了
OPTIONAL的控制項;再不夠,由後往前讓控制項不可見;連零空隙都放不下就整列不可見。 - 高度不夠:由後往前先隱藏「整列都標了
OPTIONAL」的列;還是不夠,再由後往前讓整列不可見。
因此宣告順序就是重要性順序:愈後面的愈先被犧牲。把重要的放前面,把可有可無的(例如次要說明、進階選項)標 OPTIONAL 並放後面。
你不能假設自己一定看得到:面板窄到某個程度,你的控制項就是不在。設計時用編輯器的預覽或 nxlayout --solve --w 40 --h 10 之類的參數確認窄面板下什麼還在。
還有一種隱藏是你主動要求的:SetWidgetProp(ctx, 0, widget_id, NX_WP_HIDDEN, &v)(v 是 NX_WKIND_I64,1 隱藏、0 顯示)。
被隱藏的控制項仍保留它的版面空間(不畫、不能聚焦、不能點)。它與「空間不夠」是兩件事,互不覆寫。
欄導引線(進階)
一個面板可以再宣告幾條欄導引線(最多 8 條),把不同列的控制項對齊到同樣的欄位,並讓各欄各自決定要不要長大、長大到多寬。
在 .nxlay 的 [panel] 區段宣告,控制項用 x_guide 綁到某一條線;執行期則是設在面板根(NX_WIDGET_ROOT)的 NX_WP_GUIDES 屬性。
只有在你需要「上下兩列對齊」時才用;沒有 [panel] 區段的版面照常排。
如何在編輯器裡設定導引線,見nxlayout 工具與視覺化編輯器的 Inspector 一節。
使用者操作之後:on_widget_event
使用者操作控制項,Core 先把狀態更新好並立刻返回,然後把 on_widget_event(ctx, panel_id, widget_id, evt) 排到 I2C Worker Thread 上呼叫你
(不是 UI Thread,見生命週期與執行緒)。所以你的處理程式裡可以直接 ExecSync 讀寫硬體,這正是設計目的。
事件是非同步交付的(操作當下你還沒被呼叫),且只有兩種 evt->kind(不認得的 kind 一律忽略):
evt->kind | 意思 | evt->val |
|---|---|---|
NX_WEVT_CHANGED | 使用者改了值(evt->prop 是 NX_WP_VALUE) | 新值 |
NX_WEVT_ACTIVATED | 使用者觸發了它(按鈕被按下,或帶 ACTIVATABLE 旗標的唯讀控制項被點) | 一律是 NX_WKIND_U64 的點擊位置封裝 |
evt->val 怎麼讀:
| 型別 | 怎麼讀 |
|---|---|
TEXTBOX、FILEPICK | evt->val.u.str.ptr/len:新的字串。只在提交時才送:按 Enter,或把焦點移到別處;不是每打一個字送一次 |
CHECKBOX | evt->val.u.i64:0 或 1 |
DROPDOWN、CHOICE | evt->val.u.i64:選項索引(從 0 起算) |
SPIN | evt->val.u.i64:新數值,是有號的,下限可以是負的 |
BUTTON(ACTIVATED) | 點擊位置封裝(用不到就忽略);按鈕沒有持續的狀態 |
帶 ACTIVATABLE 的 METER/CHART/IMAGE/LABEL/VALUE(ACTIVATED) | 點擊位置封裝 (v << 16) | h:高 16 位是垂直比例、低 16 位是水平比例(各 0~65535),鍵盤觸發時固定 NX_WPOS_KEYBOARD(0x80008000)。是無號值,先用 (pos >> 16) & 0xFFFF 與 pos & 0xFFFF 拆開 |
字串值 evt->val.u.str.ptr 只在這次呼叫期間有效,要留著請自己複製,不要存指標。
事件的順序與合併:
- 同一個 widget 連續的
NX_WEVT_CHANGED只在佇列尾端合併(只有最後一個值有意義);中間夾了別的事件就依序都送,順序與使用者操作一致。 - 面板重新載入(內容不同的
LoadLayout)或DestroyWidget之前排入的事件作廢,不會送達。 evt->user_data是排入佇列當下該 widget 的NX_WP_USER_DATA。
事件回報用的是 widget_id,所以你的處理程式通常是一串 if (widget_id == WIDGET_ID_xxx && evt->kind == ...)。
改值:SetWidgetProp
host->SetWidgetProp(ctx, 0, widget_id, NX_WP_VALUE, &v) 改某個控制項的顯示值;v 是 NxPropVal,先把整個結構清成 0,再填 SizeOfStruct 與 kind。
kind必須符合那個屬性,否則回ERR_INVALID_PARAM(這個欄位存在就是讓你自我檢查):
| 型別 | kind 與內容 |
|---|---|
TEXTBOX、VALUE、FILEPICK | NX_WKIND_STR(u.str.ptr/len,UTF-8) |
CHECKBOX | NX_WKIND_I64(非 0 就是勾選) |
DROPDOWN | NX_WKIND_I64=索引,必須小於選項數 |
CHOICE | NX_WKIND_I64=索引,必須小於宣告的 item_count |
SPIN、METER | NX_WKIND_I64,超出 num_min~num_max 會被夾到範圍內 |
其他型別(BUTTON、LABEL、SEPARATOR、TABLE、GROUPBOX、CHART、IMAGE)沒有可以這樣寫入的 NX_WP_VALUE,回 ERR_INVALID_PARAM;
它們各有自己的屬性,見Widget 型別詳解。
文字欄位上限:NX_WP_LABEL、NX_WP_AUX 每段最多 31 位元組(NX_WLABEL_MAX_BYTES),超過時 SetWidgetProp 回 ERR_FULL(容量不足,舊值保留);
NX_WP_ITEMS 每個選項、NX_WP_ROWS 每列的名稱與值各最多 255 位元組,超過回 ERR_INVALID_PARAM(舊值保留)。影子表另有文字配額(每面板 512 KiB、全體 4 MiB),用盡是 ERR_FULL。
SetWidgetProp 不會觸發你自己的 on_widget_event:那個事件只描述使用者的行為。所以你不會因此陷入「改值 → 事件 → 又改值」的迴圈
(唯一例外:把 TEXTBOX 的 NX_WP_MAX_LENGTH 調小而截短了目前的字,會送一次 CHANGED,且在 SetWidgetProp 返回之後才交付)。
要讀回目前的值用 GetWidgetProp(任何執行緒都可呼叫,SetWidgetProp 返回後立刻讀得到新值;字串型屬性可先以 buf == NULL、buf_cap == 0 問大小)。
你可以在 init(UI Thread)設初值,也可以在 Worker 的任何 callback 裡更新;主程式下一次更新畫面時就會讀到新值;Widget 模式不需要另外呼叫 NotifyUiRefresh(第一個 Plugin 第 4 步就沒有呼叫)。
什麼時候畫面會重建
視窗版對「版面有沒有變」很在意:版面沒變,畫面只是就地更新內容;版面一變,正在編輯的文字框會先把打到一半的字提交(你會收到一次 NX_WEVT_CHANGED)再重新開始,輸入法的組字狀態會被取消。
你需要知道的規則:
- 內容逐位元組相同的重新載入是 no-op。 所以「每秒重新載入一次相同版面」不會害使用者打不完字。
但更好的做法仍是
init載入一次就不再載入。 - 改變結構的是
LoadLayout/LoadLayoutData(內容真的不同)、CreateWidget、DestroyWidget;SetWidgetProp(值、選項、表格列、圖表樣本、隱藏等)都是狀態,不重建面板。 DestroyWidget與內容不同的LoadLayout會使該面板在途的事件作廢(widget_id之後可能被重用成別的型別);CreateWidget不會。
實際的建議:不要在輪詢裡反覆呼叫會改變結構的函式。 要更新的是值,就用 SetWidgetProp。
視窗版的自繪 widget
視窗版的 widget 全部自繪(沒有任何系統原生控件),兩個版本畫出來的東西不同,但版面與行為一致(排版是同一份程式算的):
| 型別 | 視窗版 | 終端機版 |
|---|---|---|
TEXTBOX、CHECKBOX、DROPDOWN、BUTTON、SPIN、CHOICE、FILEPICK | 自繪(可互動) | 字元自繪 |
LABEL、VALUE、TABLE、SEPARATOR、METER、GROUPBOX、IMAGE | 自繪 | 字元自繪 |
CHART | 沒有繪圖器,畫成 [?] | 字元長條圖 |
對你的影響:
- 面板放不下時,放不下的 widget 整個不顯示(不會被裁成半個)。
- 視窗版的
on_key_press在 widget 吃掉的鍵不會送給你(文字框有鍵盤焦點時所有鍵都由它處理),見生命週期與執行緒。 - 頻繁
SetWidgetProp相同的值不會讓畫面閃;使用者正在輸入的文字框不會被你的SetWidgetProp覆寫(提交時以使用者輸入為準)。 - 想在視窗版畫圖表,目前不行:
CHART在視窗版是[?]。要在兩個版本都能看的圖,請改用Canvas 模式,或用METER/VALUE表達。
絕對不要做的事
- 不要假設座標、格子大小或是哪個版本。 你在
on_widget_event裡不知道也不該知道控制項被畫在哪裡。 - 不要把「我看得到它」當前提。 面板變窄,
OPTIONAL與後面的控制項就消失了。重要資訊放前面。 - 不要在事件處理裡想做很久的事。 它在 Worker 上,佔住它就佔住這個後端的所有硬體存取。
逐支函式的精確契約見 API 參考的 Widget 一節與Widget 詞彙;同一份文件也隨 PDK 附在 docs\NEXUS_TUI_PLUGIN_API_REFERENCE.md。