docs: 添加 cyclegui-app-development 项目技能
将 CycleGUI 开发技能(含 Duplicated id 防碰撞规范)纳入 .cursor/skills,并调整 gitignore 仅放行 skills 目录可提交。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
# 面板、控件、工具栏和菜单栏
|
||||
|
||||
## 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`。
|
||||
Reference in New Issue
Block a user