Files
zhaowei.huangandCursor 158d770c54 docs: 添加 cyclegui-app-development 项目技能
将 CycleGUI 开发技能(含 Duplicated id 防碰撞规范)纳入 .cursor/skills,并调整 gitignore 仅放行 skills 目录可提交。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 22:38:18 +08:00

311 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 面板、控件、工具栏和菜单栏
## 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 暂只注册一个 HoverMenumanager 内部按 `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`