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) returns true on a right-click that landed on a node, writing the node id out. show_link_context_menu is its link counterpart.

  • show_background_context_menu(ed, pos) returns true for 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.