# 面板、控件、工具栏和菜单栏

## 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<MenuItem>
{
    new("File", subItems: new()
    {
        new("Open", () => { }),
        new("-"),
        new("Exit", () => panel.Exit()),
    }),
});
```

完整签名见 `api-signatures.md`。
