# 面板、控件、工具栏和菜单栏 ## Panel 创建方式 - `GUI.PromptPanel(handler)`:立即显示面板。 - `GUI.DeclarePanel()`:先创建 `Panel`,后续 `Define(handler)` 绑定内容。 - `GUI.PromptAndWaitPanel(handler)`:阻塞式模态面板。 - `GUI.PromptOrBringToFront(handler, instancingObject: key)`:单实例面板。 ```csharp var panel = GUI.DeclarePanel() .ShowTitle("Settings") .InitSize(400, 300) .InitPos(false, 100, 100) .SetDefaultDocking(Panel.Docking.Left); panel.Define(pb => { pb.Label("Ready"); if (pb.Closing()) panel.Exit(); }); ``` ## Panel 配置 - `ShowTitle(null)` 隐藏标题栏;传字符串设置标题。 - `InitSize(w, h)` 设置初始大小。 - `InitPos(pin, left, top, myPivotX, myPivotY, screenPivotX, screenPivotY)` 设置屏幕相对位置。 - `InitPosRelative(panel, left, top, relPivotX, relPivotY, myPivotX, myPivotY)` 设置相对位置。 - `SetDefaultDocking(Panel.Docking.Left/Top/Right/Bottom/None/Full)` 设置默认停靠。 - `AutoSize(true)`、`Modal(true)`、`TopMost(true)` 分别控制自适应大小、模态、置顶。 `InitPosRelative` 没有 `offsetX` / `offsetY` 命名参数。 ## Panel 生命周期 - `panel.Repaint()` / `pb.Panel.Repaint()`:请求重绘;handler 会在约 `repaintTimeMs`(默认 30ms)后再次执行。 - `panel.Repaint(true)`:丢弃当前帧并立即重绘(数据刚被用户编辑、需要同步其它面板时用)。 - `panel.Repaint()` 也可从其它线程调用,用于后台数据推送。 - `panel.BringToFront()`:置前。 - `panel.Exit()`:关闭。 - `panel.Freeze()` / `panel.UnFreeze()`:冻结/解冻。 - `pb.Closing()`:用户点击关闭时返回 true,通常随后调用 `panel.Exit()`。 ### 立即模式与 Repaint 策略(重要) CycleGUI 是立即模式:**handler 只在「被请求重绘」时才重新执行**。若 handler 末尾没有 `Repaint()`,面板通常只在控件返回 true(用户点击、输入等)那一帧重绘。 #### 默认原则:不要无条件 Repaint **不要在每个面板 handler 末尾无条件写 `pb.Panel.Repaint()`**,除非该面板确实有实时变化的数据。静态配置页、Actions 按钮组、Properties 表格等在无交互时不应持续刷新——否则 handler 会以 ~30ms 间隔空转,浪费 CPU。 | 面板内容 | 是否持续 Repaint | |---------|-----------------| | 静态 Properties / Actions / 配置表单 | 否,交互时自动重绘 | | Status 字段、实时指标、MiniPlot / RealtimePlot | 是 | | 后台线程推送的数据(遥测、诊断日志等) | 是(线程侧或对应 Tab 内 Repaint) | | 延迟回调改完 ListBox / Table 数据 | 仅回调内 Repaint 一次 | #### 何时必须显式 Repaint 1. **延迟回调改数据**:`PopMenuButton` / `MenuItem`、`UITools.Input` 等对话框确认回调发生时不在正常渲染帧内,`ListBox` / `Table` 不会自动反映新数据。 ```csharp pb.PopMenuButton("Add", types.Select(t => new MenuItem(t.Name, () => { if (UITools.Input("Name", def, out var name, ...)) { list.Add(Create(t, name)); pb.Panel.Repaint(); // 否则上面的 ListBox 不刷新 } })).ToArray()); var sel = pb.ListBox("Existing", list.Select(x => x.name).ToArray()); ``` 2. **用户编辑后需同步其它面板**:`pb.Panel.Repaint(true)`,并可 `otherPanel?.Repaint()`。 3. **后台线程更新 UI 数据**:在更新完共享数据后 `panel.Repaint()`(见 `examples.md` 实时图表示例)。 #### 复合面板:按 Tab / 区块决定是否 Repaint 多 Tab 面板(`TabButtons`)应只在**当前 Tab 或区块有实时数据**时 Repaint,而不是在 handler 末尾一刀切: ```csharp var tab = pb.TabButtons("edit", new[] { "Properties", "Status", "Actions" }); if (tab == 0) { // 静态配置,交互时控件自身触发重绘,无需 Repaint } else if (tab == 1) { pb.Table("Status", headers, rows, (row, i) => { row.Label(liveValues[i]); }); pb.Panel.Repaint(); // Status 实时刷新 } else if (tab == 2) { if (pb.ButtonGroup("Actions", actionNames, out var sel)) actions[sel](); // Actions 静态按钮,不 Repaint } ``` 若 handler 上半部还有**独立的实时区块**(例如 Locator 的 `DrawUI` 显示 `Status` / `Working`),下半部 Tab 可能是静态的——用标志位或虚属性标记「上半部需要 live 刷新」,仅在该标志为 true 时 Repaint: ```csharp inst.DrawUI(pb); // 子类可 override DrawUILive => true ShowPSA(pb, ...); // Status Tab 内部自行 Repaint if (inst.DrawUILive) pb.Panel.Repaint(); ``` #### 典型实时面板 状态栏、诊断条、带 `MiniPlot` / `RealtimePlot` 的监控面板——handler 末尾 **可以** 无条件 `pb.Panel.Repaint()`,因为内容每帧都在变。 ```csharp return pb => { pb.Label($"Pos: {state.x:F1}, {state.y:F1}"); pb.MiniPlot("Loop ms", state.loopMs); pb.Panel.Repaint(); }; ``` ## PanelBuilder 常用控件 ```csharp pb.Label("Status"); pb.Separator(); pb.SeparatorText("Section"); pb.SameLine(); if (pb.Button("Click", hint: "tooltip")) { } var (text, done) = pb.TextInput("Name", defaultText: "hello", hintText: "placeholder"); pb.CheckBox("Enable", ref enabled); pb.Toggle("Dark Mode", ref darkMode); pb.RadioButtons("Choice", new[] { "A", "B" }, ref selected); pb.DropdownBox("Type", new[] { "X", "Y" }, ref selectedIdx); pb.SliderFloat("Volume", ref volume, 0, 1); pb.SliderInt("Count", ref count, 0, 100); pb.DragFloat("Speed", ref speed, 0.1f, 0, 10); pb.DragVector2("Position", ref pos, 0.01f); pb.ColorEdit("Color", ref color); ``` 布局约束:没有 `BeginChild` / `ChildWindow` / `BeginTabBar` / `BeginTabItem`。分区列表用 `Table(..., height: N)`,折叠区域用 `CollapsingHeaderStart/End`,Tab 切换用 `TabButtons`。 空壳控件不要依赖:`Progress`、`BulletText`、`Indent` / `UnIndent`、`QuestionMark`、`ToolTip`。进度用 `RealtimePlot` 或 `Label`,tooltip 用 `Button(hint:)` 等入口。 ## 控件 id 与 Duplicated id(重要) CycleGUI 给每个**带状态的控件**用标签算一个 id(`ImHashStr`)。同一面板(同一次 handler 执行)内出现两个**相同 id** 就会抛异常并使该面板崩溃: ``` (Exception): Duplicated id `终点筛选(站点ID或名称)` at CycleGUI.PanelBuilder.TextInput(...) ``` ### 根因:id 用 ASCII 算哈希 `ImHashStr` 内部是 `Encoding.ASCII.GetBytes(label)` 再做 CRC32。**所有非 ASCII 字符(中文、全角括号()【】、全角空格等)都会被折叠成 `?`**,于是不同的中文可能折叠出相同的字节序列: - 两个**同字数的纯中文**标签 → 折叠后完全相同 → 同 id → 崩。 例:`筛选条件`(TextInput) 与 `设为类型`(DropdownBox) 都是 4 个汉字 → 都折叠成 `????`。 - 两个仅中文字符不同、ASCII 部分相同的标签 → 同样相撞。 例:`起点筛选(站点ID或名称)` 与 `终点筛选(站点ID或名称)` → 折叠后只剩 `ID` 等 ASCII 一致 → 相撞。 ### 哪些控件受影响 凡是用「第一个标签参数」算 id 的都受影响:`TextInput` / `DropdownBox` / `CheckBox` / `Toggle` / `ButtonGroup` / `ListBox` / `RadioButtons` / `Table` / `TabButtons` / `ColorEdit` / `SliderInt` / `SliderFloat` / `DragFloat` / `DragVector2` / `RealtimePlot` / `ChatBox` / `CollapsingHeaderStart` / `MiniPlot` 等;`Button` 的 id = `text + "-" + distinct`。 不受影响:`Label` / `SeparatorText` / `Separator`(无 id);`SelectableText` 用 `content`(不是 prompt)算 id。 ### 修法(三选一,优先 ①) ① **加 `###` 唯一后缀**:`ImHashStr` 实现了 ImGui 的 `###` 约定——遇到 `###` 会重置哈希,只用 `###` 之后的 ASCII 算 id;显示文字仍是 `###` 之前的部分。后缀必须**纯 ASCII** 且在本面板内唯一。 ```csharp // 错:两个 4 字纯中文标签 → Duplicated id pb.TextInput("筛选条件", ...); pb.DropdownBox("设为类型", ...); // 对:显示不变,id 唯一 pb.TextInput("筛选条件###flt-keyword", ...); pb.DropdownBox("设为类型###set-kind", ...); ``` ② **按钮用唯一 `distinct:`**:给每个 `Button` 一个唯一的纯 ASCII `distinct`,即使文案是纯中文也不撞。 ```csharp if (pb.Button("保存", distinct: "cfg-save")) { } if (pb.Button("删除", distinct: "cfg-del")) { } ``` ③ **加 ASCII 前缀**:表单字段统一用 `1. ` / `2. ` / `3. ` 等数字前缀(数字是 ASCII,天然区分),既排版整齐又避免相撞。 ```csharp pb.TextInput("1. 名称", ...); pb.TextInput("2. 备注", ...); ``` ### 作用域与循环 - id 唯一性是**「每面板每帧」**判定:不同面板之间不会相撞(panelId 已混入哈希种子),只需保证同一 handler 内不重复。 - **循环里渲染同一标签的带状态控件**也会自撞(第二次迭代即重复 id)。给每项拼上唯一 ASCII(索引或主键): ```csharp foreach (var item in items) pb.CheckBox($"启用###en-{item.Id}", ref item.Enabled); // 不要写死 "启用" ``` > 表格行内的 `row.Label` / `row.ButtonGroup` / `row.Checkbox` 由 `Table` 在原生侧按单元格分配 id,不走 `ImHashStr`,不受此限制。 ## 表格和标签页 ```csharp pb.Table("data", new[] { "Name", "Value", "Action" }, rowCount, (row, i) => { row.Label(names[i]); row.Label(values[i].ToString()); if (row.ButtonGroup("", new[] { "Edit", "Del" }, out int act)) { } }, height: 12, enableSearch: true); int tab = pb.TabButtons("Tabs", new[] { "General", "Advanced" }); ``` ### 程序化导航(选中同步 Tab / 列表滚动) `TabButtons` 与 `Table` 支持声明式导航参数,**变化检测在 API 内部完成**,调用方每帧传入当前目标即可: ```csharp // 根据业务选中态每帧推导目标 Tab;仅当目标 Tab 变化时才自动切换 var nav = pb.TabButtons("cmp-nav", navLabels, forceSelect: (int)targetNavKind); // 根据当前选中对象在 rows 中的索引每帧传入;仅当索引变化时才滚动列表 var scrollRow = FindSelectedRowIndex(rows); pb.Table("cmp-scn-all", headers, rows.Count, (row, i) => { ... }, height: visibleRows, scrollToRow: scrollRow); ``` - `forceSelect` / `scrollToRow` 传 `-1` 表示无目标;会复位 API 内部持久态,便于「取消选中后再选同一项」重新触发。 - 用户手动切 Tab 或滚动列表后,只要 `forceSelect` / `scrollToRow` 不变,就不会被每帧拉回。 - 面板置顶仍用 `panel.BringToFront()`(一次性,由应用在选中变更时自行触发)。 ## 工具栏 ```csharp var toolbar = GUI.DeclarePanel() .ShowTitle(null) .AsToolbarPanel(40, Panel.ToolbarAnchor.RightTop); toolbar.Define(pb => { var tpb = new ToolbarPanelBuilder(pb); tpb.Label("Tools"); tpb.Separator(); if (tpb.Button("Run", distinct: "run")) { } tpb.PopMenuButton("More", new[] { new MenuItem("Option A", () => { }), new MenuItem("-"), new MenuItem("Option B", () => { }), }); }); ``` 规则:高度建议 `>= 40`;工具栏无自动换行/横向滚动;项目过多时放进 `PopMenuButton`;要让 toolbar 避让旁边面板,旁边面板必须真的 dock 进 dockspace。 ## HoverMenu `HoverMenu` 用于 3D Workspace 对象 hover 后出现的可点击浮窗。它不是 tooltip:默认按对象屏幕锚点定位,鼠标移向浮窗按钮时不会跟随鼠标逃逸。 ```csharp var menu = GUI.DeclareHoverMenu(); menu.TargetObjectName = "site_a"; menu.OffsetX = 14; menu.OffsetY = 14; menu.CloseDelayMs = 180; menu.Build = pb => { pb.Label("Site A"); pb.Separator(); if (pb.Button("Move", distinct: "site_a")) { } if (pb.Button("Properties", distinct: "site_a")) { } }; menu.StartOnTerminal(terminal); ``` 规则:一个 object 暂只注册一个 HoverMenu;manager 内部按 `TargetObjectName` 建字典索引,几千个对象时匹配不是线性扫描。`Placement` 默认 `ObjectScreenAnchor`;`Cursor` 只适合一次性定位类场景,不要作为可点击菜单默认模式;`ObjectBounds` 预留,暂不实现。关闭菜单或移除对象时调用 `menu.Stop()` / `Dispose()`。 ## 菜单栏 - `pb.MenuBar(...)`:handler 内的一次性菜单。 - `new SetMainMenuBar { menu = items, show = true }.IssueToDefault()`:主窗口菜单。 - `new SetWorkspaceMenuBar { viewport = vp, menu = items, show = true }.Issue()`:viewport 菜单。 - `new SetPanelMenuBar { panel = p, menu = items }.Issue()`:面板持久菜单。 ```csharp pb.MenuBar(new List { new("File", subItems: new() { new("Open", () => { }), new("-"), new("Exit", () => panel.Exit()), }), }); ``` 完整签名见 `api-signatures.md`。