将 CycleGUI 开发技能(含 Duplicated id 防碰撞规范)纳入 .cursor/skills,并调整 gitignore 仅放行 skills 目录可提交。 Co-authored-by: Cursor <cursoragent@cursor.com>
13 KiB
面板、控件、工具栏和菜单栏
Panel 创建方式
GUI.PromptPanel(handler):立即显示面板。GUI.DeclarePanel():先创建Panel,后续Define(handler)绑定内容。GUI.PromptAndWaitPanel(handler):阻塞式模态面板。GUI.PromptOrBringToFront(handler, instancingObject: key):单实例面板。
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
- 延迟回调改数据:
PopMenuButton/MenuItem、UITools.Input等对话框确认回调发生时不在正常渲染帧内,ListBox/Table不会自动反映新数据。
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());
-
用户编辑后需同步其它面板:
pb.Panel.Repaint(true),并可otherPanel?.Repaint()。 -
后台线程更新 UI 数据:在更新完共享数据后
panel.Repaint()(见examples.md实时图表示例)。
复合面板:按 Tab / 区块决定是否 Repaint
多 Tab 面板(TabButtons)应只在当前 Tab 或区块有实时数据时 Repaint,而不是在 handler 末尾一刀切:
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:
inst.DrawUI(pb); // 子类可 override DrawUILive => true
ShowPSA(pb, ...); // Status Tab 内部自行 Repaint
if (inst.DrawUILive)
pb.Panel.Repaint();
典型实时面板
状态栏、诊断条、带 MiniPlot / RealtimePlot 的监控面板——handler 末尾 可以 无条件 pb.Panel.Repaint(),因为内容每帧都在变。
return pb =>
{
pb.Label($"Pos: {state.x:F1}, {state.y:F1}");
pb.MiniPlot("Loop ms", state.loopMs);
pb.Panel.Repaint();
};
PanelBuilder 常用控件
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 且在本面板内唯一。
// 错:两个 4 字纯中文标签 → Duplicated id
pb.TextInput("筛选条件", ...);
pb.DropdownBox("设为类型", ...);
// 对:显示不变,id 唯一
pb.TextInput("筛选条件###flt-keyword", ...);
pb.DropdownBox("设为类型###set-kind", ...);
② 按钮用唯一 distinct::给每个 Button 一个唯一的纯 ASCII distinct,即使文案是纯中文也不撞。
if (pb.Button("保存", distinct: "cfg-save")) { }
if (pb.Button("删除", distinct: "cfg-del")) { }
③ 加 ASCII 前缀:表单字段统一用 1. / 2. / 3. 等数字前缀(数字是 ASCII,天然区分),既排版整齐又避免相撞。
pb.TextInput("1. 名称", ...);
pb.TextInput("2. 备注", ...);
作用域与循环
- id 唯一性是**「每面板每帧」**判定:不同面板之间不会相撞(panelId 已混入哈希种子),只需保证同一 handler 内不重复。
- 循环里渲染同一标签的带状态控件也会自撞(第二次迭代即重复 id)。给每项拼上唯一 ASCII(索引或主键):
foreach (var item in items)
pb.CheckBox($"启用###en-{item.Id}", ref item.Enabled); // 不要写死 "启用"
表格行内的
row.Label/row.ButtonGroup/row.Checkbox由Table在原生侧按单元格分配 id,不走ImHashStr,不受此限制。
表格和标签页
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 内部完成,调用方每帧传入当前目标即可:
// 根据业务选中态每帧推导目标 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()(一次性,由应用在选中变更时自行触发)。
工具栏
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:默认按对象屏幕锚点定位,鼠标移向浮窗按钮时不会跟随鼠标逃逸。
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():面板持久菜单。
pb.MenuBar(new List<MenuItem>
{
new("File", subItems: new()
{
new("Open", () => { }),
new("-"),
new("Exit", () => panel.Exit()),
}),
});
完整签名见 api-signatures.md。