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) 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.