diff --git a/doc/source/_static/tutorials/groups.mp4 b/doc/source/_static/tutorials/groups.mp4 index 2d1fd3a..59bf8d6 100644 Binary files a/doc/source/_static/tutorials/groups.mp4 and b/doc/source/_static/tutorials/groups.mp4 differ diff --git a/doc/source/tutorials/groups.rst b/doc/source/tutorials/groups.rst index d07ade8..e53e609 100644 --- a/doc/source/tutorials/groups.rst +++ b/doc/source/tutorials/groups.rst @@ -4,9 +4,10 @@ Groups ###### -A **group** (or comment box) is just a node with no pins and an explicit content size. -Drag nodes over it and they move *with* the group — the editor handles the corral for -free. It is the editor's tool for visually organizing a large graph. +A **group** (or comment box) is a node with no pins and an explicit content size — the +editor's tool for visually organizing a large graph. Membership is purely **spatial**: +drag a node *inside* the group's bounds and it travels with the group from then on; drag +it back *out* and the group leaves it behind. There is no "add to group" call. .. code-block:: das @@ -26,6 +27,11 @@ Walkthrough .. video:: groups.mp4 +The recording is voiced and self-verifying: Texture starts inside the group, Tint outside. +It drags Tint *in* (a group move then carries both nodes), then drags Tint back *out* (a +group move then leaves it behind) — asserting at each step that the right nodes moved and, +crucially, that the outside node stayed put (a no-op aborts at teardown). + .. literalinclude:: ../../../examples/tutorial/groups.das :language: das :linenos: @@ -38,6 +44,18 @@ pin-less node whose body is its label. It shares the **same id space** as ``node keep the ids disjoint (the tutorial uses 1 for the group, 10 / 20 for the nodes). Draw groups **first** so the regular nodes paint on top of them. +Membership is spatial +===================== + +The editor decides what a group carries at the **moment you drag it**: it moves every node +whose bounds fall inside the group's content box (``FindNodesInRect`` over ``m_GroupBounds`` +in the C++). There is no membership list and no join API — drop a node inside the bounds and +the next group move takes it along; drag it back outside and the next move leaves it. + +To move the group itself, grab its **header** — the title strip *above* the content box. +Pressing the content box instead starts a rubber-band selection (it moves nothing), which +is why the recording aims its group-drag at the title. + Editor-owned geometry ===================== diff --git a/examples/tutorial/groups.das b/examples/tutorial/groups.das index f629d5c..ab4a815 100644 --- a/examples/tutorial/groups.das +++ b/examples/tutorial/groups.das @@ -39,9 +39,9 @@ let NODE_B = 20 def draw_editor() { node_editor("graph", (editor = g_ed)) { if (!g_seeded) { - imgui_node_editor::SetNodePosition(GROUP_ID, float2(60.0, 60.0)) - imgui_node_editor::SetNodePosition(NODE_A, float2(110.0, 130.0)) - imgui_node_editor::SetNodePosition(NODE_B, float2(110.0, 250.0)) + imgui_node_editor::SetNodePosition(GROUP_ID, float2(60.0, 80.0)) + imgui_node_editor::SetNodePosition(NODE_A, float2(130.0, 170.0)) // INSIDE the group + imgui_node_editor::SetNodePosition(NODE_B, float2(560.0, 200.0)) // OUTSIDE the group g_seeded = true } // The group, drawn first so the two nodes sit on top of it. `size` is the @@ -56,7 +56,9 @@ def draw_editor() { let mn = imgui_node_editor::GetGroupMin() fg |> add_text(float2(mn.x + 4.0, mn.y - 18.0), rgba(232u, 226u, 210u, 255u), "Inputs") } - // Two ordinary nodes seeded inside the group. Drag the group and they follow. + // Two ordinary nodes: Texture starts INSIDE the group, Tint OUTSIDE. Membership is + // spatial - dragging the group carries whatever sits within its bounds at that moment + // (the editor's FindNodesInRect), so drag Tint in and it joins, drag it out and it leaves. node(NODE_A) { text("Texture") pin(11, (kind = PinKind.Output, pivot_alignment = float2(1.0, 0.5))) { diff --git a/tests/integration/record_groups.das b/tests/integration/record_groups.das index ddbefe4..8a00819 100644 --- a/tests/integration/record_groups.das +++ b/tests/integration/record_groups.das @@ -8,57 +8,116 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record groups.apng — a group/comment box with two nodes seeded on top of it, -//! then a drag of the GROUP that carries both nodes with it (the editor's group-move). The -//! recording narrates the box and demonstrates the corral behavior with a slow synthetic -//! drag on the group's title bar. Run with `daslang tests/integration/record_groups.das`, -//! then ffmpeg the .apng to .mp4. -//! -//! NB: set_user_control(false) hands IO to the synthetic timeline — without it the real OS -//! cursor races the synth and the drag is eaten. The headless regression is test_groups.das. +//! Driver: record groups.apng - group membership is spatial. Texture starts INSIDE the +//! group, Tint OUTSIDE. Drag Tint in and a group move carries it; drag Tint back out and +//! a group move leaves it behind. Voiced and self-verifying: each drag/move MUST move the +//! nodes it should (record_check_changed) and the out-node MUST stay put when the group +//! moves (record_check_unchanged), or the recording aborts at teardown. This tutorial has +//! no links, so there is no flow pulse. The headless regression is test_groups.das. let CANVAS = "MAIN_WIN/graph" -def widget_center(var snap : JsonValue?; ident : string) : tuple { +def bbox(var snap : JsonValue?; ident : string) : float4 { let b = find_widget(snap, ident)?["bbox"] - return (((b?["x"] ?? 0.0f) + (b?["z"] ?? 0.0f)) * 0.5f, - ((b?["y"] ?? 0.0f) + (b?["w"] ?? 0.0f)) * 0.5f) + return float4(b?["x"] ?? 0.0f, b?["y"] ?? 0.0f, b?["z"] ?? 0.0f, b?["w"] ?? 0.0f) +} + +def center(var snap : JsonValue?; ident : string) : tuple { + let b = bbox(snap, ident) + return ((b.x + b.z) * 0.5f, (b.y + b.w) * 0.5f) +} + +def title_point(var snap : JsonValue?; ident : string) : tuple { + // A node's title row - grabbing the center would hit the right-aligned pin and start a + // link drag; the title strip reliably grabs the node body for a move. + let b = bbox(snap, ident) + return ((b.x + b.z) * 0.5f, b.y + 10.0f) +} + +def cbbox_json(var snap : JsonValue?; ident : string) : string { + let v = widget_payload_field(snap, ident, "canvas_bbox") + return v != null ? write_json(v) : "" +} + +def do_drag(app : ImguiApp; from, to : tuple) { + // Press on `from`, travel held to `to`, release - the proven committing timeline. + var events <- [ + JV((t_ms = 0, kind = "move", x = from._0, y = from._1)), + JV((t_ms = 300, kind = "move", x = from._0, y = from._1)), + JV((t_ms = 380, kind = "button", button = 0, action = "press")), + JV((t_ms = 500, kind = "move", x = from._0, y = from._1)), + JV((t_ms = 1400, kind = "move", x = to._0, y = to._1)), + JV((t_ms = 1550, kind = "move", x = to._0, y = to._1)), + JV((t_ms = 1650, kind = "button", button = 0, action = "release")), + JV((t_ms = 1800, kind = "move", x = to._0, y = to._1)) + ] + post_command(app, "imgui_mouse_play", JV((events = events))) + wait_for_mouse_idle(app) } [export] def main { with_node_editor_recording_app("examples/tutorial/groups.das", - "groups.apng", 50) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_group_1", 15.0f) + "groups.apng", 65) $(app) { + let T_GROUP = "{CANVAS}/node_group_1" + let T_TITLE = "{CANVAS}/node_group_1/GROUP_TITLE" + let T_TEX = "{CANVAS}/node_10" + let T_TINT = "{CANVAS}/node_20" + + var snap = wait_for_widget(app, T_GROUP, 15.0f) if (snap == null) { - panic("{CANVAS}/node_group_1 never rendered — wrong app running?") + panic("{T_GROUP} never rendered - wrong app running?") } - post_command(app, "set_user_control", JV((enabled = false))) - say(app, "node_group(id, size=...) is a pin-less node - a comment box that corrals what's on it.", "{CANVAS}/node_group_1") - wait_for_mouse_idle(app) - say(app, "Two ordinary nodes seeded inside the box.", "{CANVAS}/node_10") - wait_for_mouse_idle(app) + // ---- Beat 1: the setup - one node in, one out ---- + record_check_rendered(app, T_GROUP, true) + record_check_rendered(app, T_TEX, true) + record_check_rendered(app, T_TINT, true) + say(app, "Texture is inside, Tint is outside", T_GROUP, + [voice = "A group owns the nodes inside its bounds - membership is spatial. Texture sits inside the box; Tint is outside it."]) + + // ---- Beat 2: drag Tint INTO the group ---- + snap = snapshot(app) + let before_tint_in = cbbox_json(snap, T_TINT) + do_drag(app, title_point(snap, T_TINT), center(snap, T_GROUP)) + record_check_changed(app, T_TINT, "canvas_bbox", before_tint_in) + say(app, "drag Tint into the group", T_TINT, + [voice = "Drag Tint into the group's bounds and it becomes a member - no API call, just position."]) + + // ---- Beat 3: move the group -> both nodes follow ---- + snap = snapshot(app) + let before_g1 = cbbox_json(snap, T_GROUP) + let before_tex1 = cbbox_json(snap, T_TEX) + let before_tint1 = cbbox_json(snap, T_TINT) + let grab1 = center(snap, T_TITLE) + do_drag(app, grab1, (grab1._0 + 170.0f, grab1._1 + 90.0f)) + record_check_changed(app, T_GROUP, "canvas_bbox", before_g1) + record_check_changed(app, T_TEX, "canvas_bbox", before_tex1) + record_check_changed(app, T_TINT, "canvas_bbox", before_tint1) + say(app, "move the group -> both travel with it", T_GROUP, + [voice = "Move the group now and both nodes travel with it - Texture and the freshly added Tint."]) + + // ---- Beat 4: drag Tint back OUT of the group ---- + snap = snapshot(app) + let before_tint_out = cbbox_json(snap, T_TINT) + let gb = bbox(snap, T_GROUP) + do_drag(app, title_point(snap, T_TINT), (gb.z + 150.0f, (gb.y + gb.w) * 0.5f)) + record_check_changed(app, T_TINT, "canvas_bbox", before_tint_out) + say(app, "drag Tint back out", T_TINT, + [voice = "Drag Tint back outside the bounds and it leaves the group again."]) - // Drag the group by its title strip (just inside the top edge). The two nodes - // move with it — the editor handles the corral for free. - let g_top = widget_center(snap, "{CANVAS}/node_group_1") - let grab : tuple = (g_top._0, g_top._1 - 120.0) // up near the title bar - let dest : tuple = (grab._0 + 220.0, grab._1 + 60.0) - say(app, "Drag the group's title bar - the nodes on it come along.", "{CANVAS}/node_group_1") - var events <- [ - JV((t_ms = 0, kind = "move", x = grab._0, y = grab._1)), - JV((t_ms = 300, kind = "move", x = grab._0, y = grab._1)), - JV((t_ms = 380, kind = "button", button = 0, action = "press")), - JV((t_ms = 500, kind = "move", x = grab._0, y = grab._1)), - JV((t_ms = 1400, kind = "move", x = dest._0, y = dest._1)), - JV((t_ms = 1550, kind = "move", x = dest._0, y = dest._1)), - JV((t_ms = 1650, kind = "button", button = 0, action = "release")), - JV((t_ms = 1800, kind = "move", x = dest._0, y = dest._1)) - ] - post_command(app, "imgui_mouse_play", JV((events = events))) - wait_for_mouse_idle(app) - say(app, "The whole group moved together.", "{CANVAS}/node_group_1") - wait_for_mouse_idle(app) + // ---- Beat 5: move the group -> Texture follows, Tint stays ---- + snap = snapshot(app) + let before_g2 = cbbox_json(snap, T_GROUP) + let before_tex2 = cbbox_json(snap, T_TEX) + let before_tint2 = cbbox_json(snap, T_TINT) + let grab2 = center(snap, T_TITLE) + do_drag(app, grab2, (grab2._0 - 130.0f, grab2._1 + 70.0f)) + record_check_changed(app, T_GROUP, "canvas_bbox", before_g2) + record_check_changed(app, T_TEX, "canvas_bbox", before_tex2) + // Hard self-verify the membership rule: Tint is outside now, so it MUST NOT move. + record_check_unchanged(app, T_TINT, "canvas_bbox", before_tint2) + say(app, "move again -> Texture aboard, Tint left behind", T_GROUP, + [voice = "Move the group one more time - Texture stays aboard, but Tint, now outside, is left behind."]) } }