diff --git a/.gitignore b/.gitignore index be16d53..af4b96d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,13 @@ doc/source/stdlib/generated/ doc/build/ doc/source/__pycache__/ -# Tutorial recordings: raw .apng drivers drop are intermediate; ffmpeg-converted -# .mp4s under doc/source/_static/tutorials/ are tracked. +# Tutorial recordings: the .apng capture plus the voiceover/music intermediates +# are transient; only the ffmpeg-converted .mp4s under +# doc/source/_static/tutorials/ are tracked. doc/source/_static/tutorials/*.apng +doc/source/_static/tutorials/*_music.wav +doc/source/_static/tutorials/*.mp4.ffmpeg.txt +doc/source/_static/tutorials/voiceover/ # Runtime artifacts dropped next to features / at repo root. imgui.ini diff --git a/daslib/imgui_editor_playwright.das b/daslib/imgui_editor_playwright.das index 61b2558..a2cff19 100644 --- a/daslib/imgui_editor_playwright.das +++ b/daslib/imgui_editor_playwright.das @@ -62,6 +62,10 @@ def public ne_delete_link(s : EditorSession; id : int) : JsonValue? { return post_command(s.app, "delete_link_cmd", JV((editor = s.handle, id = id))) } +def public ne_flow(s : EditorSession; link_id : int; backward : bool = false) : JsonValue? { + return post_command(s.app, "flow_cmd", JV((editor = s.handle, id = link_id, backward = backward))) +} + def public ne_move_node(s : EditorSession; id : int; x : float; y : float) : JsonValue? { return post_command(s.app, "move_node", JV((editor = s.handle, id = id, x = x, y = y))) } diff --git a/doc/source/_static/tutorials/connect_by_drag.mp4 b/doc/source/_static/tutorials/connect_by_drag.mp4 index 61d10a8..405a87e 100644 Binary files a/doc/source/_static/tutorials/connect_by_drag.mp4 and b/doc/source/_static/tutorials/connect_by_drag.mp4 differ diff --git a/doc/source/_static/tutorials/create_by_drag.mp4 b/doc/source/_static/tutorials/create_by_drag.mp4 index cc61d3c..263c176 100644 Binary files a/doc/source/_static/tutorials/create_by_drag.mp4 and b/doc/source/_static/tutorials/create_by_drag.mp4 differ diff --git a/doc/source/_static/tutorials/first_graph.mp4 b/doc/source/_static/tutorials/first_graph.mp4 index 160dd8a..e9213f9 100644 Binary files a/doc/source/_static/tutorials/first_graph.mp4 and b/doc/source/_static/tutorials/first_graph.mp4 differ diff --git a/doc/source/tutorials/connect_by_drag.rst b/doc/source/tutorials/connect_by_drag.rst index a3860b7..05a87d5 100644 --- a/doc/source/tutorials/connect_by_drag.rst +++ b/doc/source/tutorials/connect_by_drag.rst @@ -34,6 +34,11 @@ Walkthrough .. video:: connect_by_drag.mp4 +The recording is voiced and self-verifying: a real synthetic pin-drag commits +the link, the recording asserts it committed (a no-op aborts at teardown), then +pulses the new link with ``flow()`` to show data running output pin 11 -> input +pin 21. + .. literalinclude:: ../../../examples/tutorial/connect_by_drag.das :language: das :linenos: diff --git a/doc/source/tutorials/create_by_drag.rst b/doc/source/tutorials/create_by_drag.rst index 06f2851..5d03533 100644 --- a/doc/source/tutorials/create_by_drag.rst +++ b/doc/source/tutorials/create_by_drag.rst @@ -45,6 +45,11 @@ Walkthrough .. video:: create_by_drag.mp4 +The recording is voiced and self-verifying: a real synthetic pin-drag released +in empty canvas must open the create menu, and the menu pick must spawn the node +and commit the auto-link (a no-op aborts at teardown). It closes by pulsing the +freshly enqueued link with ``flow()`` to show the editor-made wire is live. + .. literalinclude:: ../../../examples/tutorial/create_by_drag.das :language: das :linenos: diff --git a/doc/source/tutorials/first_graph.rst b/doc/source/tutorials/first_graph.rst index a131779..eb49520 100644 --- a/doc/source/tutorials/first_graph.rst +++ b/doc/source/tutorials/first_graph.rst @@ -32,6 +32,10 @@ Walkthrough .. video:: first_graph.mp4 +The recording is voiced and self-verifying: it tours each construct in turn and +asserts every one rendered, then closes by pulsing the link with ``flow()`` to +watch data travel from output pin 11 to input pin 21. + .. literalinclude:: ../../../examples/tutorial/first_graph.das :language: das :linenos: @@ -58,3 +62,8 @@ Links ``link(id, from_pin, to_pin)`` draws an edge from an output pin to an input pin. This graph hard-codes the one link; :ref:`tutorial_ne_connect_by_drag` makes links with the mouse. + +``flow(ctx, link_id)`` fires a one-shot data-flow pulse along a link — a marker +that travels the edge and fades over ``style.FlowDuration``. The walkthrough +re-pulses it so the link visibly carries data; from a live host it is the +``flow`` command (``ne_flow`` in the playwright helpers). diff --git a/tests/integration/record_connect_by_drag.das b/tests/integration/record_connect_by_drag.das index 4f5beca..5ad0db6 100644 --- a/tests/integration/record_connect_by_drag.das +++ b/tests/integration/record_connect_by_drag.das @@ -8,12 +8,11 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record connect_by_drag.apng — the signature gesture, narrated and -//! driven by a SLOW synthetic mouse drag (with the cursor sprite + trail on). -//! Press on the output pin, drag across, release on the input pin → link. -//! Run with `daslang tests/integration/record_connect_by_drag.das`, then ffmpeg -//! the .apng to .mp4. The headless regression for the same gesture is -//! test_connect_drag.das. +//! Driver: record connect_by_drag.apng - the signature gesture, voiced and +//! self-verifying. A REAL synthetic pin-drag (press on the output pin, travel +//! across, release on the input pin) commits a link; the recording asserts the +//! link committed (a no-op aborts at teardown) and pulses it to show data flow. +//! The headless regression for the same gesture is test_connect_drag.das. let CANVAS = "MAIN_WIN/graph" @@ -27,53 +26,58 @@ def widget_center(var snap : JsonValue?; ident : string) : tuple { def main { with_node_editor_recording_app("examples/tutorial/connect_by_drag.das", "connect_by_drag.apng", 50) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_1/pin_11", 15.0f) + let T_OUT = "{CANVAS}/node_1/pin_11" + let T_IN = "{CANVAS}/node_2/pin_21" + let T_LINK = "{CANVAS}/link_100" + + let s = ne_open(app, CANVAS) + if (s.handle == uint64(0)) { + panic("{CANVAS} never rendered / no editor handle - wrong app running?") + } + var snap = wait_for_widget(app, T_OUT, 15.0f) if (snap == null) { - panic("{CANVAS}/node_1/pin_11 never rendered — wrong app running?") + panic("{T_OUT} never rendered - wrong app running?") } - let out_c = widget_center(snap, "{CANVAS}/node_1/pin_11") - let in_c = widget_center(snap, "{CANVAS}/node_2/pin_21") + let out_c = widget_center(snap, T_OUT) + let in_c = widget_center(snap, T_IN) - // Park the cursor on the output pin, then narrate. wait_for_mouse_idle polls - // imgui_mouse_status until the scripted move finishes (the cursor reaches its - // target) — event-driven, no blind sleep. + // ---- Beat 1: the setup ---- move_to(app, out_c, 700) wait_for_mouse_idle(app) - say(app, "begin_create opens the create scope. Press on an output pin and start dragging...", "{CANVAS}/node_1/pin_11") + record_check_rendered(app, T_OUT, true) + record_check_rendered(app, T_IN, true) + say(app, "drag output -> input", T_OUT, + [voice = "To connect two nodes you drag from an output pin to an input pin. begin_create opens the create scope."]) - // The hero gesture. Timing is VERBATIM from test_connect_drag (the proven - // committing sequence): park ON the output pin through the press, travel the - // full distance (~900ms) with the button held, park ON the input pin through - // the release. (drag_along presses ~10% into the travel — off-pin for a small - // target — so we hand-build the timeline.) + // ---- Beat 2: the hero drag (proven committing timeline) ---- + // Press ON the output pin, travel the full distance with the button held, release + // ON the input pin. Verbatim from test_connect_drag (drag_along presses ~10% into + // the travel - off-pin for a small target - so the timeline is hand-built). var events <- [ - JV((t_ms = 0, kind = "move", x = out_c._0, y = out_c._1)), - JV((t_ms = 350, kind = "move", x = out_c._0, y = out_c._1)), - JV((t_ms = 400, kind = "button", button = 0, action = "press")), - JV((t_ms = 500, kind = "move", x = out_c._0, y = out_c._1)), - JV((t_ms = 1400, kind = "move", x = in_c._0, y = in_c._1)), - JV((t_ms = 1550, kind = "move", x = in_c._0, y = in_c._1)), - JV((t_ms = 1650, kind = "button", button = 0, action = "release")), - JV((t_ms = 1800, kind = "move", x = in_c._0, y = in_c._1)) + JV((t_ms = 0, kind = "move", x = out_c._0, y = out_c._1)), + JV((t_ms = 350, kind = "move", x = out_c._0, y = out_c._1)), + JV((t_ms = 400, kind = "button", button = 0, action = "press")), + JV((t_ms = 500, kind = "move", x = out_c._0, y = out_c._1)), + JV((t_ms = 1400, kind = "move", x = in_c._0, y = in_c._1)), + JV((t_ms = 1550, kind = "move", x = in_c._0, y = in_c._1)), + JV((t_ms = 1650, kind = "button", button = 0, action = "release")), + JV((t_ms = 1800, kind = "move", x = in_c._0, y = in_c._1)) ] post_command(app, "imgui_mouse_play", JV((events = events))) - - // Let the synth gesture FULLY drain (cursor arrives + button releases) before - // touching the snapshot API. wait_for_widget below snapshot-polls every frame; - // a snapshot landing mid-drag during an active recording (APNG capture already - // contends for each frame) disrupts the release — accept_new_item never fires - // and the link silently fails to commit. Waiting for mouse-idle first closes that - // window. (The non-recording test tolerates the concurrent polling — without - // frame-capture contention the loop keeps up — which is why it always passed and - // masked this.) wait_for_mouse_idle(app) - // Now that the gesture is done, poll for the committed link and narrate the result. - var snapL = wait_for_widget(app, "{CANVAS}/link_100", 10.0f) - if (snapL != null) { - say(app, "...release on a compatible input pin. query_new_link reports the pair; accept_new_item commits the link.", "{CANVAS}/link_100") - } else { - say(app, "(the drag did not land - see test_connect_drag for the geometry)", "{CANVAS}/node_2/pin_21") + // Hard self-verify: the drag MUST commit the link, or the recording aborts at teardown. + record_check_rendered(app, T_LINK, true) + + // ---- Beat 3: the committed link + data-flow pulse ---- + let dwell = say_begin(app, "link 11 -> 21 created", T_LINK, + [voice = "query_new_link reports the pair, accept_new_item commits it, and now the link carries data from the output to the input."]) + var pulses = int(dwell) / 500 + if (pulses < 4) { pulses = 4 } + let interval = dwell / uint(pulses) + for (_i in range(pulses)) { + ne_flow(s, 100) + sleep(interval) } } } diff --git a/tests/integration/record_create_by_drag.das b/tests/integration/record_create_by_drag.das index 4552ce6..dd0f37c 100644 --- a/tests/integration/record_create_by_drag.das +++ b/tests/integration/record_create_by_drag.das @@ -8,15 +8,12 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record create_by_drag.apng — drag from the source output pin into EMPTY +//! Driver: record create_by_drag.apng - drag from the source output pin into EMPTY //! canvas, pick a kind from the create-node menu, and watch the new node spawn at the -//! drop point already wired to the source. Narrated, with a slow synthetic pin-drag then -//! a real synthetic click on the menu. Run with -//! `daslang tests/integration/record_create_by_drag.das`, then ffmpeg the .apng to .mp4. -//! -//! NB: set_user_control(false) hands IO fully to the synthetic timeline — without it the -//! real OS cursor races the synth and the drag / menu click are eaten. The headless -//! regression for the same gesture is test_create_by_drag.das. +//! drop point already wired to the source. Voiced and self-verifying: the drag MUST open +//! the create menu and the menu pick MUST spawn the node + auto-link, or the recording +//! aborts at teardown. Closes by pulsing the freshly enqueued link to show it is live. +//! The headless regression for the same gesture is test_create_by_drag.das. let CANVAS = "MAIN_WIN/graph" @@ -29,49 +26,69 @@ def widget_center(var snap : JsonValue?; ident : string) : tuple { [export] def main { with_node_editor_recording_app("examples/tutorial/create_by_drag.das", - "create_by_drag.apng", 50) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_1/pin_11", 15.0f) + "create_by_drag.apng", 60) $(app) { + let T_OUT = "{CANVAS}/node_1/pin_11" + let T_MENU = "{CANVAS}/CREATE_MENU/ADD_MUL" + let T_NODE = "{CANVAS}/node_200" + let T_LINK = "{CANVAS}/link_100" + + let s = ne_open(app, CANVAS) + if (s.handle == uint64(0)) { + panic("{CANVAS} never rendered / no editor handle - wrong app running?") + } + var snap = wait_for_widget(app, T_OUT, 15.0f) if (snap == null) { - panic("{CANVAS}/node_1/pin_11 never rendered — wrong app running?") + panic("{T_OUT} never rendered - wrong app running?") } - // Synthetic IO only — no real-cursor race (see header note). - post_command(app, "set_user_control", JV((enabled = false))) - - let out_c = widget_center(snap, "{CANVAS}/node_1/pin_11") - let drop : tuple = (680.0, 430.0) // empty canvas - let add_mul = "{CANVAS}/CREATE_MENU/ADD_MUL" + let out_c = widget_center(snap, T_OUT) + let drop : tuple = (680.0f, 430.0f) // empty canvas + // ---- Beat 1: the setup ---- move_to(app, out_c, 700) wait_for_mouse_idle(app) - say(app, "Drag from an output pin - but release in EMPTY canvas instead of on a pin.", "{CANVAS}/node_1/pin_11") + record_check_rendered(app, T_OUT, true) + say(app, "drag output -> EMPTY canvas", T_OUT, + [voice = "Drag from an output pin again - but this time release in empty canvas, not on a pin."]) - // The hero gesture. Timing VERBATIM from connect_by_drag (the proven committing - // sequence): park ON the pin through the press, travel ~900ms with the button held, - // park at the drop point through the release. + // ---- Beat 2: the hero drag into empty canvas (proven committing timeline) ---- + // Press ON the output pin, travel the full distance with the button held, release + // in empty canvas. Verbatim timing from connect_by_drag. var events <- [ - JV((t_ms = 0, kind = "move", x = out_c._0, y = out_c._1)), - JV((t_ms = 350, kind = "move", x = out_c._0, y = out_c._1)), - JV((t_ms = 400, kind = "button", button = 0, action = "press")), - JV((t_ms = 500, kind = "move", x = out_c._0, y = out_c._1)), - JV((t_ms = 1400, kind = "move", x = drop._0, y = drop._1)), - JV((t_ms = 1550, kind = "move", x = drop._0, y = drop._1)), - JV((t_ms = 1650, kind = "button", button = 0, action = "release")), - JV((t_ms = 1800, kind = "move", x = drop._0, y = drop._1)) + JV((t_ms = 0, kind = "move", x = out_c._0, y = out_c._1)), + JV((t_ms = 350, kind = "move", x = out_c._0, y = out_c._1)), + JV((t_ms = 400, kind = "button", button = 0, action = "press")), + JV((t_ms = 500, kind = "move", x = out_c._0, y = out_c._1)), + JV((t_ms = 1400, kind = "move", x = drop._0, y = drop._1)), + JV((t_ms = 1550, kind = "move", x = drop._0, y = drop._1)), + JV((t_ms = 1650, kind = "button", button = 0, action = "release")), + JV((t_ms = 1800, kind = "move", x = drop._0, y = drop._1)) ] post_command(app, "imgui_mouse_play", JV((events = events))) wait_for_mouse_idle(app) - var menuSnap = wait_for_widget(app, add_mul, 6.0f) - print("create menu opened -> {menuSnap != null}\n") - say(app, "show_new_node_drag reports the source pin + drop point; the app opens its create menu.", add_mul) - click(app, add_mul) + // Hard self-verify: a drop in empty canvas MUST open the create menu (show_new_node_drag + // fired), or the recording aborts at teardown. + record_check_rendered(app, T_MENU, true) + say(app, "show_new_node_drag -> create menu", T_MENU, + [voice = "Releasing in empty canvas fires show_new_node_drag - it reports the source pin and the drop point, so the app opens its create-node menu right there."]) + + // ---- Beat 3: pick a kind -> spawn + auto-connect ---- + click(app, T_MENU) + // Hard self-verify: the pick MUST spawn the node AND the auto-link, or abort. + record_check_rendered(app, T_NODE, true) + record_check_rendered(app, T_LINK, true) + say(app, "spawned + auto-wired", T_NODE, + [voice = "Picking Multiply spawns the node at the drop point, and enqueue_new_link wires it straight back to the source pin."]) - var nSnap = wait_for_widget(app, "{CANVAS}/link_100", 6.0f) - print("node + auto-link created -> {nSnap != null}\n") - if (nSnap != null) { - say(app, "Picked Multiply: spawned at the drop point and enqueue_new_link wired it to the source.", "{CANVAS}/node_200") - } else { - say(app, "(the create did not land - see test_create_by_drag for the geometry)", "{CANVAS}/node_1") + // ---- Beat 4: pulse the new link to show it is live ---- + let dwell = say_begin(app, "the new link is live", T_LINK, + [voice = "The link the editor just made carries data exactly like a hand-dragged one - we can pulse it to watch it flow from the source into the node we just created."]) + var pulses = int(dwell) / 500 + if (pulses < 4) { pulses = 4 } + let interval = dwell / uint(pulses) + for (_i in range(pulses)) { + ne_flow(s, 100) + sleep(interval) } } } diff --git a/tests/integration/record_first_graph.das b/tests/integration/record_first_graph.das index 72d20c4..ed2aa57 100644 --- a/tests/integration/record_first_graph.das +++ b/tests/integration/record_first_graph.das @@ -8,9 +8,10 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record first_graph.apng — a guided tour of the four constructs -//! (node_editor / node / pin / link). Narration only, no interaction. Run with -//! `daslang tests/integration/record_first_graph.das`, then ffmpeg the .apng to .mp4. +//! Driver: record first_graph.apng - a guided, self-verifying tour of the four +//! node-editor constructs (node_editor / node / pin / link), closing with a +//! data-flow pulse along the link. Voiced (terse caption + natural voice); every +//! construct is asserted rendered, so a missing one aborts the recording at teardown. let CANVAS = "MAIN_WIN/graph" @@ -23,34 +24,74 @@ def widget_center(var snap : JsonValue?; ident : string) : tuple { [export] def main { with_node_editor_recording_app("examples/tutorial/first_graph.das", - "first_graph.apng", 50) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_1", 15.0f) + "first_graph.apng", 55) $(app) { + let T_NODE1 = "{CANVAS}/node_1" + let T_PIN11 = "{CANVAS}/node_1/pin_11" + let T_NODE2 = "{CANVAS}/node_2" + let T_PIN21 = "{CANVAS}/node_2/pin_21" + let T_LINK = "{CANVAS}/link_100" + + let s = ne_open(app, CANVAS) + if (s.handle == uint64(0)) { + panic("{CANVAS} never rendered / no editor handle - wrong app running?") + } + var snap = wait_for_widget(app, T_NODE1, 15.0f) if (snap == null) { - panic("{CANVAS}/node_1 never rendered — wrong app running?") + panic("{T_NODE1} never rendered - wrong app running?") } - let c_node1 = widget_center(snap, "{CANVAS}/node_1") - let c_pin11 = widget_center(snap, "{CANVAS}/node_1/pin_11") - let c_pin21 = widget_center(snap, "{CANVAS}/node_2/pin_21") - let c_link = widget_center(snap, "{CANVAS}/link_100") - move_to(app, c_node1, 600) + let c_node1 = widget_center(snap, T_NODE1) + let c_pin11 = widget_center(snap, T_PIN11) + let c_pin21 = widget_center(snap, T_PIN21) + let c_link = widget_center(snap, T_LINK) + + // ---- Beat 1: the canvas ---- + move_to(app, (500.0f, 300.0f), 600) wait_for_mouse_idle(app) - say(app, "node_editor - the canvas. create_node_editor() owns pan, zoom and selection; everything inside node_editor(...) is drawn in canvas space.", CANVAS) + record_check_rendered(app, CANVAS, true) + say(app, "node_editor: the canvas", "", + [voice = "The node editor is the canvas. create_node_editor owns pan, zoom and selection, and everything inside is drawn in canvas space."]) - move_to(app, c_node1, 700) + // ---- Beat 2: a node ---- + move_to(app, c_node1, 600) wait_for_mouse_idle(app) - say(app, "node(1) - a box. Its body is plain ImGui (here just a text label). Nodes are identified by an integer id you choose.", "{CANVAS}/node_1") + record_check_rendered(app, T_NODE1, true) + say(app, "node(1): a box", T_NODE1, + [voice = "A node is just a box. Its body is plain ImGui, here a text label. Every node has an integer id you choose."]) + // ---- Beat 3: an output pin ---- move_to(app, c_pin11, 700) wait_for_mouse_idle(app) - say(app, "pin(11, PinKind.Output) - an output connection point on node 1.", "{CANVAS}/node_1/pin_11") + record_check_rendered(app, T_PIN11, true) + say(app, "pin(11): output", T_PIN11, + [voice = "Node one carries pin eleven, an output connection point."]) - move_to(app, c_pin21, 900) + // ---- Beat 4: an input pin ---- + move_to(app, c_pin21, 800) wait_for_mouse_idle(app) - say(app, "node(2) carries pin(21, PinKind.Input) - the destination a link feeds into.", "{CANVAS}/node_2/pin_21") + record_check_rendered(app, T_NODE2, true) + record_check_rendered(app, T_PIN21, true) + say(app, "pin(21): input", T_PIN21, + [voice = "Node two carries pin twenty-one, the input a link feeds into."]) + // ---- Beat 5: the link ---- move_to(app, c_link, 800) wait_for_mouse_idle(app) - say(app, "link(100, 11, 21) - one edge joining output pin 11 to input pin 21. The model is id-only: the editor tracks geometry, you own what the ids mean.", "{CANVAS}/link_100") + record_check_rendered(app, T_LINK, true) + say(app, "link(100): 11 -> 21", T_LINK, + [voice = "A link is one edge joining output pin eleven to input pin twenty-one. The model is id-only: the editor tracks geometry, you own what the ids mean."]) + + // ---- Beat 6: pulse the link to show data flow ---- + // A single flow() fades in about a second, so re-pulse across the whole + // voice dwell to keep the marker train traveling continuously. + let dwell = say_begin(app, "data flows 11 -> 21", T_LINK, + [voice = "And we can pulse the link to watch data flow from the output, along the edge, into the input."]) + var pulses = int(dwell) / 500 + if (pulses < 4) { pulses = 4 } + let interval = dwell / uint(pulses) + for (_i in range(pulses)) { + ne_flow(s, 100) + sleep(interval) + } } } diff --git a/utils/node_editor2rst.das b/utils/node_editor2rst.das index cb9b709..14cf9a6 100644 --- a/utils/node_editor2rst.das +++ b/utils/node_editor2rst.das @@ -45,7 +45,7 @@ def document_module_imgui_editor_playwright() { var mod = find_module("imgui_editor_playwright") var groups <- array( group_by_regex("Session", mod, %regex~^ne_open$%%), - group_by_regex("Actions", mod, %regex~^(ne_select_node|ne_select_link|ne_clear_selection|ne_shortcut|ne_add_link|ne_delete_node|ne_delete_link|ne_move_node|ne_new_node_drag)$%%), + group_by_regex("Actions", mod, %regex~^(ne_select_node|ne_select_link|ne_clear_selection|ne_shortcut|ne_add_link|ne_delete_node|ne_delete_link|ne_move_node|ne_new_node_drag|ne_flow)$%%), group_by_regex("Snapshots & queries", mod, %regex~^(ne_snapshot|ne_node_count|ne_payload|ne_node|ne_node_exists|ne_node_selected)$%%), group_by_regex("Polling / await", mod, %regex~^(ne_wait_widget|ne_wait_selected|ne_wait_payload_str|ne_wait_shortcut)$%%), hide_group(group_by_regex("Internal", mod, %regex~^(_|priv_).*%%))