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
2options _comment_hygiene = true
3
4require imgui/imgui_harness
5require imgui/imgui_node_editor_boost_v2
6require imgui/imgui_node_editor_live
7
8// =============================================================================
9// TUTORIAL: context_menus — right-click menus on the canvas or a node.
10//
11// with_suspended() { -- enter screen space for popups
12// if (show_node_context_menu(ed, nid)) { -- right-click landed on a node
13// open_popup("...") -- (remember nid, open its menu)
14// } elif (show_background_context_menu(ed, p)) -- right-click on empty canvas
15// open_popup("...") -- (remember canvas pos p)
16// popup_window(IDENT, (str_id = "...")) { ... menu_label(...) ... }
17// }
18//
19// The editor draws in canvas space; popups are plain ImGui in SCREEN space, so the
20// detection + the popup windows live inside a Suspend/Resume island (with_suspended).
21// The background menu creates a node at the click point; the node menu enqueues a
22// delete (enqueue_delete_node) that drains through the same begin_delete accept loop
23// the delete tutorial uses — there fed by the native Delete key.
24// =============================================================================
25
26struct Nd {
27 id : int
28 title : string
29 in_pin : int
30 out_pin : int
31 pos : float2
32}
33
34struct Lk {
35 id : int
36 from_pin : int
37 to_pin : int
38}
39
40var g_nodes : table<int; Nd>
41var g_links : table<int; Lk>
42var g_ed : imgui_node_editor::EditorContext? = null
43var g_seeded : bool = false
44var g_next_id : int = 200 // ids for menu-created nodes (pins follow: id+1, id+2)
45var g_ctx_node : int = 0 // node under a right-click (node menu target)
46var g_ctx_pos : float2 // canvas pos of a background right-click (where to spawn)
47
48var NODE_TITLE : table<int; NarrativeState> // per-id title slot (data-driven node idiom)
49
50def seed() {
51 g_nodes[1] = Nd(id = 1, title = "A", in_pin = 0, out_pin = 11, pos = float2(120.0, 190.0))
52 g_nodes[2] = Nd(id = 2, title = "B", in_pin = 21, out_pin = 0, pos = float2(480.0, 190.0))
53 g_links[100] = Lk(id = 100, from_pin = 11, to_pin = 21)
54}
55
56def add_menu_node(pos : float2) {
57 let nid = g_next_id
58 g_next_id += 10
59 g_nodes[nid] = Nd(id = nid, title = "New", in_pin = nid + 1, out_pin = nid + 2, pos = pos)
60 imgui_node_editor::SetNodePosition(nid, pos) // inside the editor block → raw SetNodePosition
61}
62
63def remove_node(nid : int) {
64 return if (!key_exists(g_nodes, nid))
65 let n = g_nodes[nid]
66 var dead <- [for (lk in keys(g_links)); lk]
67 for (lk in dead) {
68 let l = g_links[lk]
69 if (l.from_pin == n.in_pin || l.from_pin == n.out_pin ||
70 l.to_pin == n.in_pin || l.to_pin == n.out_pin) {
71 g_links |> erase(lk)
72 }
73 }
74 delete dead
75 g_nodes |> erase(nid)
76}
77
78def draw_editor() {
79 node_editor("graph", (editor = g_ed)) {
80 if (!g_seeded) {
81 for (n in values(g_nodes)) {
82 imgui_node_editor::SetNodePosition(n.id, n.pos)
83 }
84 g_seeded = true
85 }
86 for (n in values(g_nodes)) {
87 node(n.id) {
88 text(NODE_TITLE[n.id], (text = n.title))
89 if (n.in_pin != 0) {
90 pin(n.in_pin, PinKind.Input) {
91 text("-> in")
92 }
93 }
94 if (n.out_pin != 0) {
95 pin(n.out_pin, PinKind.Output) {
96 text("out ->")
97 }
98 }
99 }
100 }
101 for (l in values(g_links)) {
102 link(l.id, l.from_pin, l.to_pin)
103 }
104 // The node menu deletes through the same enqueue / begin_delete rail as the
105 // delete tutorial; drain it here.
106 begin_delete(g_ed) {
107 var lid = 0
108 while (query_deleted_link(g_ed, lid)) {
109 if (accept_deleted_link(g_ed)) {
110 g_links |> erase(lid)
111 }
112 }
113 var nid = 0
114 while (query_deleted_node(g_ed, nid)) {
115 if (accept_deleted_node(g_ed)) {
116 remove_node(nid)
117 }
118 }
119 }
120 // Detection + popups run in a Suspend/Resume island (screen space).
121 with_suspended() {
122 var hit_node = 0
123 if (show_node_context_menu(g_ed, hit_node)) {
124 g_ctx_node = hit_node
125 open_popup("ne_node_menu")
126 } elif (show_background_context_menu(g_ed, g_ctx_pos)) {
127 open_popup("ne_bg_menu")
128 }
129 popup_window(NODE_MENU, (str_id = "ne_node_menu", flags = ImGuiWindowFlags.None)) {
130 if (menu_label(DEL_ITEM, (text = "Delete node"))) {
131 enqueue_delete_node(g_ed, g_ctx_node)
132 }
133 }
134 popup_window(BG_MENU, (str_id = "ne_bg_menu", flags = ImGuiWindowFlags.None)) {
135 text("Add a node")
136 if (menu_label(ADD_ITEM, (text = "Node"))) {
137 add_menu_node(g_ctx_pos)
138 }
139 }
140 }
141 }
142}
143
144[export]
145def init() {
146 harness_init("Context menus", 1000, 600)
147 g_ed = create_node_editor()
148 seed()
149}
150
151[export]
152def update() {
153 if (!harness_begin_frame()) return
154 harness_new_frame()
155 let io & = unsafe(GetIO())
156 SetNextWindowPos(float2(0.0, 0.0), ImGuiCond.Always)
157 SetNextWindowSize(io.DisplaySize, ImGuiCond.Always)
158 let flags = (ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize |
159 ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoScrollbar |
160 ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoSavedSettings |
161 ImGuiWindowFlags.NoBringToFrontOnFocus)
162 window(MAIN_WIN, (text = "Context menus", closable = false, flags = flags)) {
163 draw_editor()
164 }
165 harness_end_frame()
166}
167
168[export]
169def shutdown() {
170 destroy_node_editor(g_ed)
171 harness_shutdown()
172}
173
174[export]
175def main() {
176 init()
177 while (!exit_requested()) {
178 update()
179 }
180 shutdown()
181}
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.