Context menus
Right-click the canvas or a node and the editor tells you what was hit so you can pop
the matching menu (links have one too — see show_link_context_menu below). The catch:
the editor draws in canvas space
(panned and zoomed), but ImGui popups are plain windows in screen space. So the
hit-test and the popup windows live inside a Suspend/Resume island —
with_suspended — which steps out of the canvas transform for the duration.
with_suspended() { // step into screen space
var hit_node = 0
if (show_node_context_menu(g_ed, hit_node)) { // right-click landed on a node
g_ctx_node = hit_node
open_popup("ne_node_menu")
} elif (show_background_context_menu(g_ed, g_ctx_pos)) { // ...on empty canvas
open_popup("ne_bg_menu")
}
popup_window(NODE_MENU, (str_id = "ne_node_menu")) {
if (menu_label(DEL_ITEM, (text = "Delete node"))) {
enqueue_delete_node(g_ed, g_ctx_node) // routes through begin_delete
}
}
popup_window(BG_MENU, (str_id = "ne_bg_menu")) {
text("Add a node")
if (menu_label(ADD_ITEM, (text = "Node"))) {
add_menu_node(g_ctx_pos) // create at the click point
}
}
}
The background menu creates a node where you clicked; the node menu enqueues a delete
(enqueue_delete_node). That and the delete tutorial’s native Delete key flow through the same
begin_delete accept loop.
Source: examples/tutorial/context_menus.das.
Walkthrough
The recording is voiced and self-verifying: it pulses the A→B link with flow()
to show it is live, then each synthetic right-click MUST open the matching menu —
the node menu’s Delete node MUST remove A and cascade its link, and the
background menu’s Node MUST spawn a node at the click point (a no-op aborts at
teardown).
1options gen2
2
3require imgui/imgui_harness
4require imgui/imgui_node_editor_boost_v2
5require imgui/imgui_node_editor_live
6
7// =============================================================================
8// TUTORIAL: context_menus — right-click menus on the canvas or a node.
9//
10// with_suspended() { -- enter screen space for popups
11// if (show_node_context_menu(ed, nid)) { -- right-click landed on a node
12// open_popup("...") -- (remember nid, open its menu)
13// } elif (show_background_context_menu(ed, p)) -- right-click on empty canvas
14// open_popup("...") -- (remember canvas pos p)
15// popup_window(IDENT, (str_id = "...")) { ... menu_label(...) ... }
16// }
17//
18// The editor draws in canvas space; popups are plain ImGui in SCREEN space, so the
19// detection + the popup windows live inside a Suspend/Resume island (with_suspended).
20// The background menu creates a node at the click point; the node menu enqueues a
21// delete (enqueue_delete_node) that drains through the same begin_delete accept loop
22// the delete tutorial uses — there fed by the native Delete key.
23// =============================================================================
24
25struct Nd {
26 id : int
27 title : string
28 in_pin : int
29 out_pin : int
30 pos : float2
31}
32
33struct Lk {
34 id : int
35 from_pin : int
36 to_pin : int
37}
38
39var g_nodes : table<int; Nd>
40var g_links : table<int; Lk>
41var g_ed : imgui_node_editor::EditorContext? = null
42var g_seeded : bool = false
43var g_next_id : int = 200 // ids for menu-created nodes (pins follow: id+1, id+2)
44var g_ctx_node : int = 0 // node under a right-click (node menu target)
45var g_ctx_pos : float2 // canvas pos of a background right-click (where to spawn)
46
47var NODE_TITLE : table<int; NarrativeState> // per-id title slot (data-driven node idiom)
48
49def seed() {
50 g_nodes[1] = Nd(id = 1, title = "A", in_pin = 0, out_pin = 11, pos = float2(120.0, 190.0))
51 g_nodes[2] = Nd(id = 2, title = "B", in_pin = 21, out_pin = 0, pos = float2(480.0, 190.0))
52 g_links[100] = Lk(id = 100, from_pin = 11, to_pin = 21)
53}
54
55def add_menu_node(pos : float2) {
56 let nid = g_next_id
57 g_next_id += 10
58 g_nodes[nid] = Nd(id = nid, title = "New", in_pin = nid + 1, out_pin = nid + 2, pos = pos)
59 imgui_node_editor::SetNodePosition(nid, pos) // inside the editor block → raw SetNodePosition
60}
61
62def remove_node(nid : int) {
63 return if (!key_exists(g_nodes, nid))
64 let n = g_nodes[nid]
65 var dead <- [for (lk in keys(g_links)); lk]
66 for (lk in dead) {
67 let l = g_links[lk]
68 if (l.from_pin == n.in_pin || l.from_pin == n.out_pin ||
69 l.to_pin == n.in_pin || l.to_pin == n.out_pin) {
70 g_links |> erase(lk)
71 }
72 }
73 delete dead
74 g_nodes |> erase(nid)
75}
76
77def draw_editor() {
78 node_editor("graph", (editor = g_ed)) {
79 if (!g_seeded) {
80 for (n in values(g_nodes)) {
81 imgui_node_editor::SetNodePosition(n.id, n.pos)
82 }
83 g_seeded = true
84 }
85 for (n in values(g_nodes)) {
86 node(n.id) {
87 text(NODE_TITLE[n.id], (text = n.title))
88 if (n.in_pin != 0) {
89 pin(n.in_pin, PinKind.Input) {
90 text("-> in")
91 }
92 }
93 if (n.out_pin != 0) {
94 pin(n.out_pin, PinKind.Output) {
95 text("out ->")
96 }
97 }
98 }
99 }
100 for (l in values(g_links)) {
101 link(l.id, l.from_pin, l.to_pin)
102 }
103 // The node menu deletes through the same enqueue / begin_delete rail as the
104 // delete tutorial; drain it here.
105 begin_delete(g_ed) {
106 var lid = 0
107 while (query_deleted_link(g_ed, lid)) {
108 if (accept_deleted_link(g_ed)) {
109 g_links |> erase(lid)
110 }
111 }
112 var nid = 0
113 while (query_deleted_node(g_ed, nid)) {
114 if (accept_deleted_node(g_ed)) {
115 remove_node(nid)
116 }
117 }
118 }
119 // Detection + popups run in a Suspend/Resume island (screen space).
120 with_suspended() {
121 var hit_node = 0
122 if (show_node_context_menu(g_ed, hit_node)) {
123 g_ctx_node = hit_node
124 open_popup("ne_node_menu")
125 } elif (show_background_context_menu(g_ed, g_ctx_pos)) {
126 open_popup("ne_bg_menu")
127 }
128 popup_window(NODE_MENU, (str_id = "ne_node_menu", flags = ImGuiWindowFlags.None)) {
129 if (menu_label(DEL_ITEM, (text = "Delete node"))) {
130 enqueue_delete_node(g_ed, g_ctx_node)
131 }
132 }
133 popup_window(BG_MENU, (str_id = "ne_bg_menu", flags = ImGuiWindowFlags.None)) {
134 text("Add a node")
135 if (menu_label(ADD_ITEM, (text = "Node"))) {
136 add_menu_node(g_ctx_pos)
137 }
138 }
139 }
140 }
141}
142
143[export]
144def init() {
145 harness_init("Context menus", 1000, 600)
146 g_ed = create_node_editor()
147 seed()
148}
149
150[export]
151def update() {
152 if (!harness_begin_frame()) return
153 harness_new_frame()
154 let io & = unsafe(GetIO())
155 SetNextWindowPos(float2(0.0, 0.0), ImGuiCond.Always)
156 SetNextWindowSize(io.DisplaySize, ImGuiCond.Always)
157 let flags = (ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize |
158 ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoScrollbar |
159 ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoSavedSettings |
160 ImGuiWindowFlags.NoBringToFrontOnFocus)
161 window(MAIN_WIN, (text = "Context menus", closable = false, flags = flags)) {
162 draw_editor()
163 }
164 harness_end_frame()
165}
166
167[export]
168def shutdown() {
169 destroy_node_editor(g_ed)
170 harness_shutdown()
171}
172
173[export]
174def main() {
175 init()
176 while (!exit_requested()) {
177 update()
178 }
179 shutdown()
180}
The suspend island
with_suspended() { ... } brackets the body in imgui_node_editor::Suspend() /
Resume() and, crucially, pushes an identity item-transform for the duration so
any widget rendered inside reports its bounding box directly in screen space. Without
that, a popup drawn while the canvas transform is still on the stack would be
double-mapped — its on-screen position and its recorded bbox would disagree, and a
click resolved from the bbox would miss. Everything that is plain ImGui rather than
canvas geometry — the hit-test calls and the popup windows — belongs in here.
Which menu fired
show_node_context_menu(ed, nid)returnstrueon a right-click that landed on a node, writing the node id out.show_link_context_menuis its link counterpart.show_background_context_menu(ed, pos)returnstruefor a right-click on empty canvas, writing the canvas-space position — stash it so the menu’s Node item can create a node exactly where the click happened.
Because the queries clear the other targets when one fires, the editor also exposes
last_context_kind in its telemetry (background / node / link) — handy
for a headless assertion that the right kind of menu opened.
Driving it from a test
The recording above is produced by synthetic right-clicks (see
tests/integration/record_context_menus.das): right-click the node body for its
menu, right-click empty canvas for the background menu. Because with_suspended
captures each popup item’s bbox in screen space, the menu pick is an ordinary
click resolved from that bbox — the same real synthetic click any widget gets —
which lands on Delete node / Node and fires the delete or create.
The right-click must hit the node body; a click on a pin opens the pin menu
instead. set_user_control(false) hands IO fully to the synthetic timeline so the
real OS cursor can’t race the synth and swallow a menu click.