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

13 KiB
Raw Permalink Blame History

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

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

  1. 延迟回调改数据PopMenuButton / MenuItemUITools.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());
  1. 用户编辑后需同步其它面板pb.Panel.Repaint(true),并可 otherPanel?.Repaint()

  2. 后台线程更新 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/EndTab 切换用 TabButtons

空壳控件不要依赖:ProgressBulletTextIndent / UnIndentQuestionMarkToolTip。进度用 RealtimePlotLabeltooltip 用 Button(hint:) 等入口。

控件 id 与 Duplicated id(重要)

CycleGUI 给每个带状态的控件用标签算一个 idImHashStr)。同一面板(同一次 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);SelectableTextcontent(不是 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.CheckboxTable 在原生侧按单元格分配 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 / 列表滚动)

TabButtonsTable 支持声明式导航参数,变化检测在 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 暂只注册一个 HoverMenumanager 内部按 TargetObjectName 建字典索引,几千个对象时匹配不是线性扫描。Placement 默认 ObjectScreenAnchorCursor 只适合一次性定位类场景,不要作为可点击菜单默认模式;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