Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified doc/source/_static/tutorials/clipboard.mp4
Binary file not shown.
Binary file modified doc/source/_static/tutorials/context_menus.mp4
Binary file not shown.
Binary file modified doc/source/_static/tutorials/delete_and_select.mp4
Binary file not shown.
11 changes: 6 additions & 5 deletions doc/source/tutorials/clipboard.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ selects it and focuses the canvas — then sends a real Ctrl chord:

post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = "D")))

``set_user_control(false)`` hands IO to the synthetic timeline so the real OS cursor can't
race the synth and steal the canvas focus the chord needs. The headless regression
(``test_clipboard_tutorial.das``) drives the same real chords — distinct from
``test_shortcuts`` / ``test_clipboard``, which exercise ``shader_graph`` through the
``ne_shortcut`` injection rail.
The recording app holds ``set_user_control(false)`` for the whole run, so the real OS
cursor can't race the synth and steal the canvas focus the chord needs. It also overlays
the ``imgui_key_hud`` keycap strip, so each ``Ctrl`` chord is visible on screen as it
fires. The headless regression (``test_clipboard_tutorial.das``) drives the same real
chords — distinct from ``test_shortcuts`` / ``test_clipboard``, which exercise
``shader_graph`` through the ``ne_shortcut`` injection rail.
6 changes: 6 additions & 0 deletions doc/source/tutorials/context_menus.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ Walkthrough

.. video:: context_menus.mp4

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

.. literalinclude:: ../../../examples/tutorial/context_menus.das
:language: das
:linenos:
Expand Down
5 changes: 5 additions & 0 deletions doc/source/tutorials/delete_and_select.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ Walkthrough

.. video:: delete_and_select.mp4

The recording is voiced and self-verifying: it pulses both links with ``flow()``
to show the chain is live, then a real synthetic click MUST select ``B`` and a
real Delete key MUST remove ``B`` *and* cascade both links on its pins (a no-op
aborts at teardown).

.. literalinclude:: ../../../examples/tutorial/delete_and_select.das
:language: das
:linenos:
Expand Down
94 changes: 56 additions & 38 deletions tests/integration/record_clipboard.das
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@ require imgui/imgui_editor_playwright public
require daslib/json public
require daslib/json_boost public

//! Driver: record clipboard.apng — click a node and Ctrl+D to duplicate it, then click
//! another and Ctrl+C / Ctrl+V to copy + paste. Narrated, with synthetic clicks + real
//! Ctrl chords. Run with `daslang tests/integration/record_clipboard.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 canvas never gains focus, so the editor's
//! shortcut gate (IsFocused) stays shut and the chords no-op. Shortcuts are Ctrl on every
//! platform. The headless regression is test_clipboard_tutorial.das.
//! Driver: record clipboard.apng - click a node and Ctrl+D to duplicate, then click
//! another and Ctrl+C / Ctrl+V to copy + paste. Voiced and self-verifying: each click
//! MUST select its node, Ctrl+D MUST spawn the duplicate, and Ctrl+V MUST spawn the
//! pasted node (Ctrl+C is verified transitively through the paste), or the recording
//! aborts at teardown. The keycap + modifier HUD (imgui_key_hud) is on, and each chord
//! fires partway INTO its voice line so the keycap + Ctrl pill pop while it is named.
//! Shortcuts are Ctrl on every platform; the click focuses the canvas the gate needs.
//! The headless regression is test_clipboard_tutorial.das.

let CANVAS = "MAIN_WIN/graph"

def title_point(var snap : JsonValue?; ident : string) : tuple<float; float> {
// The node's title row (top edge) — clicking it selects the node AND focuses the canvas.
// The node's title row - clicking it selects the node AND focuses the canvas (the gate
// the Ctrl shortcuts need); the center would hit the output pin.
let b = find_widget(snap, ident)?["bbox"]
return (((b?["x"] ?? 0.0f) + (b?["z"] ?? 0.0f)) * 0.5f, (b?["y"] ?? 0.0f) + 10.0f)
}
Expand All @@ -33,47 +33,65 @@ def click_node(app : ImguiApp; pt : tuple<float; float>) {
wait_for_mouse_idle(app)
}

def chord_under_voice(app : ImguiApp; dwell : uint; key : string) {
// Fire the Ctrl chord partway into the already-posted voice line, so the keycap + Ctrl
// pill pop while the line names it, then hold out the rest of the dwell.
let lead = dwell > 1200u ? 700u : dwell / 3u
sleep(lead)
post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = key)))
wait_for_key_idle(app, 4.0f)
sleep(dwell > lead ? dwell - lead : 0u)
}

[export]
def main {
with_node_editor_recording_app("examples/tutorial/clipboard.das",
"clipboard.apng", 55) $(app) {
var snap = wait_for_widget(app, "{CANVAS}/node_1", 15.0f)
"clipboard.apng", 60) $(app) {
let T_A = "{CANVAS}/node_1"
let T_B = "{CANVAS}/node_2"
let T_DUP = "{CANVAS}/node_200"
let T_PASTE = "{CANVAS}/node_210"

var snap = wait_for_widget(app, T_A, 15.0f)
if (snap == null) {
panic("{CANVAS}/node_1 never rendered wrong app running?")
panic("{T_A} never rendered - wrong app running?")
}
// Synthetic IO only — no real-cursor race (see header note).
post_command(app, "set_user_control", JV((enabled = false)))
// Keyboard helper: the bottom-center keycap + modifier strip, so the Ctrl chords
// are visible on screen as they fire (this tutorial is all about Ctrl+D/C/V). Bump
// the keycap linger to 2.5s so [Ctrl][D] stays readable through its voice line.
post_command(app, "imgui_key_hud", JV((enabled = true, show_modifiers = true, keycap_fade_s = 2.5f)))

let pA = title_point(snap, "{CANVAS}/node_1")
let pB = title_point(snap, "{CANVAS}/node_2")
let pA = title_point(snap, T_A)
let pB = title_point(snap, T_B)

// ---- duplicate A ----
// ---- Beat 1: click A -> select + focus the canvas ----
move_to(app, pA, 600)
wait_for_mouse_idle(app)
say(app, "Click a node to select it - this also focuses the canvas.", "{CANVAS}/node_1")
click_node(app, pA)
say(app, "Ctrl+D duplicates the selection - accept_duplicate fires; the app clones it.", "{CANVAS}/node_1")
post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = "D")))
wait_for_key_idle(app, 4.0f)
var dupSnap = wait_for_widget(app, "{CANVAS}/node_200", 6.0f)
print("duplicate -> {dupSnap != null}\n")
record_check_value(app, T_A, "selected", true)
say(app, "click a node to select it", T_A,
[voice = "Click a node to select it - the click also focuses the canvas, which the edit shortcuts need."])

// ---- Beat 2: Ctrl+D -> duplicate (chord pops under the voice) ----
chord_under_voice(app, say_begin(app, "Ctrl+D duplicates", T_DUP,
[voice = "Ctrl+D duplicates the selection - accept_duplicate fires and the app clones it next to the original."]), "D")
record_check_rendered(app, T_DUP, true)

// ---- copy + paste B ----
// ---- Beat 3: click B -> select ----
move_to(app, pB, 600)
wait_for_mouse_idle(app)
say(app, "Select another node, then Ctrl+C to copy.", "{CANVAS}/node_2")
click_node(app, pB)
post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = "C")))
wait_for_key_idle(app, 4.0f)
say(app, "Ctrl+V pastes it - accept_paste fires; the app recreates the copied node.", "{CANVAS}/node_2")
post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = "V")))
wait_for_key_idle(app, 4.0f)
var pasteSnap = wait_for_widget(app, "{CANVAS}/node_210", 6.0f)
print("paste -> {pasteSnap != null}\n")
if (pasteSnap != null) {
say(app, "The app owns the clipboard - the editor only reports the chord through accept_copy / accept_paste.", "{CANVAS}/node_210")
} else {
say(app, "(the paste did not land - see test_clipboard_tutorial for the sequence)", "{CANVAS}/node_2")
}
record_check_value(app, T_B, "selected", true)
say(app, "select another node", T_B,
[voice = "Now select a different node to copy."])

// ---- Beat 4: Ctrl+C -> copy (internal; proven by the paste below) ----
chord_under_voice(app, say_begin(app, "Ctrl+C copies", T_B,
[voice = "Ctrl+C copies it - the editor only reports the chord; the app serializes the selection into its own clipboard."]), "C")

// ---- Beat 5: Ctrl+V -> paste ----
chord_under_voice(app, say_begin(app, "Ctrl+V pastes", T_PASTE,
[voice = "Ctrl+V pastes - accept_paste fires and the app recreates the copied node from its clipboard."]), "V")
record_check_rendered(app, T_PASTE, true)
}
}
103 changes: 59 additions & 44 deletions tests/integration/record_context_menus.das
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,13 @@ require imgui/imgui_editor_playwright public
require daslib/json public
require daslib/json_boost public

//! Driver: record context_menus.apng — right-click a node for its "Delete node" menu
//! (delete it, link cascades), then right-click the empty canvas for the background
//! "Add a node" menu (create one at the cursor). Narrated, synthetic right-clicks.
//! Run with `daslang tests/integration/record_context_menus.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 consumes the menu clicks (a popup-close
//! swallows the next right-click). The right-click must hit the node BODY (its center);
//! a click on a pin opens the pin menu instead.
//! Driver: record context_menus.apng - right-click a node for its "Delete node" menu
//! (delete it, the link cascades), then right-click empty canvas for the background
//! "Add a node" menu (create one at the cursor). Voiced and self-verifying: each
//! right-click MUST open the matching menu, the delete MUST remove the node + cascade
//! its link, and the create MUST spawn a node, or the recording aborts at teardown.
//! Pulses the A -> B link first so the cascade reads as cutting a live connection.
//! The headless regression for the same gestures is test_context_menus.das.

let CANVAS = "MAIN_WIN/graph"

Expand All @@ -37,50 +34,68 @@ def right_click(app : ImguiApp; pt : tuple<float; float>) {
[export]
def main {
with_node_editor_recording_app("examples/tutorial/context_menus.das",
"context_menus.apng", 55) $(app) {
var snap = wait_for_widget(app, "{CANVAS}/node_1", 15.0f)
"context_menus.apng", 60) $(app) {
let T_A = "{CANVAS}/node_1"
let T_B = "{CANVAS}/node_2"
let T_LINK = "{CANVAS}/link_100"
let DEL_ITEM = "{CANVAS}/NODE_MENU/DEL_ITEM"
let ADD_ITEM = "{CANVAS}/BG_MENU/ADD_ITEM"
let NEW_NODE = "{CANVAS}/node_200"

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_A, 15.0f)
if (snap == null) {
panic("{CANVAS}/node_1 never rendered wrong app running?")
panic("{T_A} 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 cA = center(snap, T_A)
let bg_pt : tuple<float; float> = (720.0f, 430.0f) // empty canvas

let del_item = "{CANVAS}/NODE_MENU/DEL_ITEM"
let add_item = "{CANVAS}/BG_MENU/ADD_ITEM"
let new_node = "{CANVAS}/node_200"
let cA = center(snap, "{CANVAS}/node_1")
let bg_pt : tuple<float; float> = (720.0, 430.0) // empty canvas
// ---- Beat 1: the live link ----
record_check_rendered(app, T_A, true)
let dwell = say_begin(app, "A -> B: a live link", T_A,
[voice = "A and B share a live link - we can pulse it to see data flow before we touch the menus."])
Comment on lines +56 to +59
Comment on lines +56 to +59
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)
}

// ---- node menu: right-click node A's body ----
say(app, "Right-click a node for its own context menu.", "{CANVAS}/node_1")
// ---- Beat 2: right-click A -> node menu ----
move_to(app, cA, 700)
wait_for_mouse_idle(app)
right_click(app, cA)
var nodeMenuSnap = wait_for_widget(app, del_item, 6.0f)
print("node menu opened -> {nodeMenuSnap != null}\n")
say(app, "show_node_context_menu reports which node was hit; 'Delete node' enqueues it.", del_item)
click(app, del_item)
var goneSnap = wait_until_sec(app, 6.0f) $(var sn) {
return !widget_exists(sn, "{CANVAS}/node_1")
}
print("node A deleted via node menu -> {goneSnap != null}\n")
say(app, "Deleted through begin_delete - the link on its pin cascaded away too.", "{CANVAS}/node_2")
// Hard self-verify: the right-click MUST open the node menu.
record_check_rendered(app, DEL_ITEM, true)
say(app, "right-click a node -> its menu", DEL_ITEM,
[voice = "Right-click a node and the editor pops its own context menu - here it offers Delete node."])

// ---- background menu: right-click empty canvas ----
say(app, "Right-click empty canvas for the background menu.", "{CANVAS}/node_2")
// ---- Beat 3: Delete node -> A + link cascade ----
click(app, DEL_ITEM)
// Hard self-verify: A is gone AND its link cascaded out.
record_check_rendered(app, T_A, false)
record_check_rendered(app, T_LINK, false)
say(app, "Delete node -> A + link cascade", T_B,
[voice = "Delete node enqueues A through begin_delete - the same path the Delete key uses - so A and the link on its pin cascade away."])

// ---- Beat 4: right-click empty canvas -> background menu ----
move_to(app, bg_pt, 700)
wait_for_mouse_idle(app)
right_click(app, bg_pt)
var bgSnap = wait_for_widget(app, add_item, 6.0f)
print("bg menu opened -> {bgSnap != null}\n")
say(app, "show_background_context_menu opens the menu at the cursor; 'Node' creates one here.", add_item)
click(app, add_item)
var nSnap = wait_for_widget(app, new_node, 5.0f)
print("node spawned via bg menu -> {nSnap != null}\n")
if (nSnap != null) {
say(app, "A new node, created at the click point.", new_node)
} else {
say(app, "(the menu pick did not land)", "{CANVAS}/node_2")
}
// Hard self-verify: the right-click MUST open the background menu.
record_check_rendered(app, ADD_ITEM, true)
say(app, "right-click empty -> background menu", ADD_ITEM,
[voice = "Right-click empty canvas instead and you get the background menu, opened right at the cursor."])

// ---- Beat 5: Node -> spawned at the click point ----
click(app, ADD_ITEM)
// Hard self-verify: the pick MUST spawn the node.
record_check_rendered(app, NEW_NODE, true)
say(app, "Node -> created at the click point", NEW_NODE,
[voice = "Picking Node creates a fresh node exactly where you clicked."])
}
}
Loading
Loading