diff --git a/daslib/imgui_node_editor_boost_v2.das b/daslib/imgui_node_editor_boost_v2.das index 519a201..799e3b4 100644 --- a/daslib/imgui_node_editor_boost_v2.das +++ b/daslib/imgui_node_editor_boost_v2.das @@ -261,9 +261,10 @@ def flow(ctx : imgui_node_editor::EditorContext?; link_id : LinkId; // SCALE_TO_MONITOR/DPI workaround — see shader_graph.das). def center_node_on_screen(ctx : imgui_node_editor::EditorContext?; id : NodeId) : bool { - //! Pan the viewport so `id` is centered. Transient view action (no zoom change); - //! no-op for a null ctx. (May share NavigateToContent's DPI quirk under - //! SCALE_TO_MONITOR — call inside the draw loop if the bracketed form mis-centers.) + //! MOVES node `id` to the center of the current view - despite the name, upstream + //! CenterNodeOnScreen translates the node's bounds (and marks a user position change); + //! it does NOT pan the view. Use it to recall a stray node, not to navigate. No-op for + //! a null ctx. (NavigateToContent / navigate_to_selection are the view-only ops.) return false if (ctx == null) imgui_node_editor::SetCurrentEditor(ctx) imgui_node_editor::CenterNodeOnScreen(id) diff --git a/doc/source/_static/tutorials/navigation.mp4 b/doc/source/_static/tutorials/navigation.mp4 index 261c311..70e2493 100644 Binary files a/doc/source/_static/tutorials/navigation.mp4 and b/doc/source/_static/tutorials/navigation.mp4 differ diff --git a/doc/source/_static/tutorials/styling.mp4 b/doc/source/_static/tutorials/styling.mp4 index 2cb42e7..9e032c7 100644 Binary files a/doc/source/_static/tutorials/styling.mp4 and b/doc/source/_static/tutorials/styling.mp4 differ diff --git a/doc/source/tutorials/navigation.rst b/doc/source/tutorials/navigation.rst index c936e22..5e8954c 100644 --- a/doc/source/tutorials/navigation.rst +++ b/doc/source/tutorials/navigation.rst @@ -5,8 +5,9 @@ Navigation ########## The editor separates the **graph** (node positions, in canvas space) from the **view** -(the pan + zoom that maps canvas to screen). These three ops change only the view — no -node moves: fit the whole graph, frame the selection, or center one node. +(the pan + zoom that maps canvas to screen). Two of these ops change only the view — fit +the whole graph, or frame the selection. The third, ``center_node_on_screen``, is a +**footgun**: despite the name it *moves* the node to the view center. .. code-block:: das @@ -40,13 +41,15 @@ Walkthrough :language: das :linenos: -The three view ops -================== +Two view ops and one node move +============================== -* ``NavigateToContent(duration)`` — fit the whole graph to the viewport. ``0.0`` snaps - instantly; a positive duration (seconds) animates a fly-to. -* ``navigate_to_selection(ed, zoomIn, duration)`` — frame just the selected nodes. -* ``center_node_on_screen(ed, nodeId)`` — pan one node to the viewport center. +* ``NavigateToContent(duration)`` — **view**: fit the whole graph to the viewport. ``0.0`` + snaps instantly; a positive duration (seconds) animates a fly-to. +* ``navigate_to_selection(ed, zoomIn, duration)`` — **view**: frame just the selected nodes. +* ``center_node_on_screen(ed, nodeId)`` — **moves the node**, not the view. Despite the + name, upstream ``CenterNodeOnScreen`` translates the node's bounds to the view center (and + marks a user position change). Reach for it to recall a stray node, not to navigate. Placement: inside vs outside the block ====================================== @@ -61,9 +64,11 @@ wrappers that set the editor current themselves, so the toolbar buttons — whic Driving it from a test ====================== -The view ops carry no observable state of their own, but they move the view, so a node's -**screen-space** bbox shifts even though its canvas position is unchanged. The graph is -seeded wide (node 3 far to the bottom-right); ``test_navigation.das`` records node 3's -screen center, clicks "Fit All", and asserts the center moved — proof the view changed. -``set_user_control(false)`` hands IO to the synthetic timeline so the real OS cursor can't -race the synth and eat the button click. +A view op shifts a node's **screen-space** bbox while its **canvas** position holds; +``center_node_on_screen`` is the mirror image — it leaves the view alone and changes the +canvas position. The recording (``record_navigation.das``) and the headless regression +(``test_navigation.das``) exploit both: they assert Fit All and Frame Selection move the +screen bbox but leave the canvas bbox put (``record_check_changed`` + ``record_check_unchanged``), +then assert Center #1 moves the **canvas** bbox — proof it relocates the node, not the view. +The recording app holds ``set_user_control(false)`` so the real OS cursor can't race the +synth and eat a click. diff --git a/examples/tutorial/navigation.das b/examples/tutorial/navigation.das index d5389b6..1c56315 100644 --- a/examples/tutorial/navigation.das +++ b/examples/tutorial/navigation.das @@ -5,18 +5,20 @@ require imgui/imgui_node_editor_boost_v2 require imgui/imgui_node_editor_live // ============================================================================= -// TUTORIAL: navigation — move the VIEW, not the nodes. +// TUTORIAL: navigation — the view ops, and the one that fools you. // -// NavigateToContent(duration) -- fit the whole graph to the viewport -// navigate_to_selection(ed, zoomIn, dur) -- frame just the selected nodes -// center_node_on_screen(ed, nodeId) -- pan a node to the viewport center +// NavigateToContent(duration) -- fit the whole graph to the viewport (VIEW) +// navigate_to_selection(ed, zoomIn, dur) -- frame just the selected nodes (VIEW) +// center_node_on_screen(ed, nodeId) -- MOVES the node to the view center (GRAPH!) // // The editor separates the GRAPH (node positions, in canvas space) from the VIEW -// (the pan + zoom that maps canvas to screen). These three ops change only the -// view — no node moves. Placement matters: NavigateToContent must run INSIDE the -// node_editor block with the editor current, so the app raises a flag and services -// it there; navigate_to_selection / center_node_on_screen are bracketed wrappers -// that set the editor current themselves, so the toolbar buttons call them directly. +// (the pan + zoom that maps canvas to screen). Fit All and Frame Selection change only +// the view — no node moves. center_node_on_screen is the footgun: despite the name, +// upstream CenterNodeOnScreen MOVES the node to the view center, it does not pan. +// Placement also matters: NavigateToContent must run INSIDE the node_editor block with +// the editor current, so the app raises a flag and services it there; +// navigate_to_selection / center_node_on_screen are bracketed wrappers that set the +// editor current themselves, so the toolbar buttons call them directly. // // STANDALONE: daslang.exe modules/dasImguiNodeEditor/examples/tutorial/navigation.das // LIVE: daslang-live modules/dasImguiNodeEditor/examples/tutorial/navigation.das @@ -47,7 +49,7 @@ def toolbar() { } same_line() if (button(CENTER_FIRST, (text = "Center #1"))) { - center_node_on_screen(g_ed, 1) + center_node_on_screen(g_ed, 1) // NB: MOVES node 1 to the view center - not a view op } } diff --git a/tests/integration/record_navigation.das b/tests/integration/record_navigation.das index fcc9e4c..a298145 100644 --- a/tests/integration/record_navigation.das +++ b/tests/integration/record_navigation.das @@ -8,43 +8,104 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record navigation.apng — the three view ops, each fired by a real synthetic -//! click on its toolbar button: Fit All (NavigateToContent), Frame Selection -//! (navigate_to_selection), Center #1 (center_node_on_screen). The recording narrates each -//! and lets the view settle between clicks. Run with -//! `daslang tests/integration/record_navigation.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 button clicks are eaten. The headless regression is -//! test_navigation.das. +//! Driver: record navigation.apng - two genuine view ops (Frame Selection / Fit All) and +//! one footgun (Center #1), each fired by a real synthetic click on its toolbar button. +//! Voiced and self-verifying: Fit All and Frame Selection MUST move a node's SCREEN bbox +//! while its CANVAS position holds (record_check_changed + record_check_unchanged) - the +//! proof they move the VIEW. Center #1 is the opposite: it MUST move node 1's CANVAS bbox +//! (record_check_changed) - despite the name, center_node_on_screen relocates the node, +//! not the view. Fit All must also REVEAL node 3 (off-view at the start). A no-op aborts at +//! teardown. The headless regression is test_navigation.das. let CANVAS = "MAIN_WIN/graph" +def widget_center(var snap : JsonValue?; ident : string) : tuple { + 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) +} + +def title_point(var snap : JsonValue?; ident : string) : tuple { + // Click the title row, not the center - node 1's center sits on its 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) +} + +def field_json(var snap : JsonValue?; ident : string; field : string) : string { + return write_json(widget_payload_field(snap, ident, field)) +} + +def click_node(app : ImguiApp; pt : tuple) { + var ev : array + ev |> click_at(0, pt, 250, 0) + post_command(app, "imgui_mouse_play", JV((events = ev))) + wait_for_mouse_idle(app) +} + [export] def main { with_node_editor_recording_app("examples/tutorial/navigation.das", - "navigation.apng", 50) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_3", 15.0f) + "navigation.apng", 60) $(app) { + let T_NODE1 = "{CANVAS}/node_1" + let T_NODE3 = "{CANVAS}/node_3" + let FRAME = "MAIN_WIN/FRAME_SEL" + let FIT = "MAIN_WIN/FIT_ALL" + let CENTER = "MAIN_WIN/CENTER_FIRST" + + var snap = wait_for_widget(app, T_NODE3, 15.0f) if (snap == null) { - panic("{CANVAS}/node_3 never rendered — wrong app running?") + panic("{T_NODE3} never rendered - wrong app running?") } - post_command(app, "set_user_control", JV((enabled = false))) + // At the default view: node 1 full-size top-left, node 3 far off the bottom-right. + // Capture the before-view screen bboxes and the canvas positions that must never move. + let n1_bbox0 = field_json(snap, T_NODE1, "bbox") + let n1_canvas0 = field_json(snap, T_NODE1, "canvas_bbox") + let n3_bbox0 = field_json(snap, T_NODE3, "bbox") + let n3_canvas0 = field_json(snap, T_NODE3, "canvas_bbox") + let n1_title = title_point(snap, T_NODE1) - say(app, "The graph is spread wide - node 3 sits far to the bottom-right.", "{CANVAS}/node_3") + // ---- Beat 1: graph vs view ---- + move_to(app, (840.0f, 470.0f), 700) wait_for_mouse_idle(app) + say(app, "graph vs view", "", + [voice = "The editor splits the graph - the node positions in canvas space - from the view, the pan and zoom onto it. The graph is spread wide, so node three sits far off the bottom-right, out of view."]) - say(app, "Fit All -> NavigateToContent zooms out so the whole graph fits.", "MAIN_WIN/FIT_ALL") - click(app, "MAIN_WIN/FIT_ALL") + // ---- Beat 2: Frame Selection -> select a real node, then frame just it ---- + click_node(app, n1_title) + record_check_value(app, T_NODE1, "selected", true) + click(app, FRAME) wait_for_mouse_idle(app) + record_check_changed(app, T_NODE1, "bbox", n1_bbox0) + record_check_unchanged(app, T_NODE1, "canvas_bbox", n1_canvas0) + say(app, "Frame Selection: frame the selected", FRAME, + [voice = "Select a node, then Frame Selection calls navigate_to_selection to frame just that node - it is a bracketed wrapper, safe to call from the toolbar out here. The view zooms onto node one; its canvas position does not move."]) - say(app, "Frame Selection -> navigate_to_selection frames just the selected nodes.", "MAIN_WIN/FRAME_SEL") - click(app, "MAIN_WIN/FRAME_SEL") + // ---- Beat 3: Fit All -> NavigateToContent reveals + reframes the whole graph ---- + click(app, FIT) wait_for_mouse_idle(app) + record_check_rendered(app, T_NODE3, true) + record_check_changed(app, T_NODE3, "bbox", n3_bbox0) + record_check_unchanged(app, T_NODE3, "canvas_bbox", n3_canvas0) + say(app, "Fit All: fit the whole graph", FIT, + [voice = "Fit All raises a flag the editor block services with NavigateToContent, because that one must run with the editor current. It zooms out so the whole graph fits and node three swings into view - again only the view moved."]) - say(app, "Center #1 -> center_node_on_screen pans node 1 to the middle.", "MAIN_WIN/CENTER_FIRST") - click(app, "MAIN_WIN/CENTER_FIRST") + // Node 1 is back on screen - capture its fit-view screen bbox for the Center check. + var snap1 = snapshot(app) + let n1_bbox1 = field_json(snap1, T_NODE1, "bbox") + + // ---- Beat 4: Center #1 -> the footgun: it MOVES node 1, not the view ---- + click(app, CENTER) wait_for_mouse_idle(app) - say(app, "Each op moves the VIEW only - no node's canvas position changed.", "{CANVAS}/node_1") + record_check_changed(app, T_NODE1, "canvas_bbox", n1_canvas0) + record_check_changed(app, T_NODE1, "bbox", n1_bbox1) + say(app, "Center #1: MOVES node 1 (gotcha)", CENTER, + [voice = "Center number one is the odd one out. Despite the name, center_node_on_screen moves node one to the view center - watch its canvas position change. Fit All and Frame Selection only moved the view; this one moves the node."]) + + // ---- Beat 5: the closer - view ops vs the node move ---- + move_to(app, widget_center(snapshot(app), T_NODE3), 600) wait_for_mouse_idle(app) + record_check_unchanged(app, T_NODE3, "canvas_bbox", n3_canvas0) + say(app, "view ops vs node move", T_NODE3, + [voice = "So node three never moved - Fit All and Frame Selection only ever touched the view. Node one moved only because center_node_on_screen relocates it. The name fools you: it centers by moving the node, not by panning."]) } } diff --git a/tests/integration/record_styling.das b/tests/integration/record_styling.das index 3d992a2..911613a 100644 --- a/tests/integration/record_styling.das +++ b/tests/integration/record_styling.das @@ -8,31 +8,95 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record styling.apng — a static tour of the styling layers. There is no -//! interaction to drive; the recording just narrates the three styled nodes (tinted -//! source, default passthrough, rounded + amber-bordered output with an accent stripe) -//! while the synthetic cursor points at each. Run with -//! `daslang tests/integration/record_styling.das`, then ffmpeg the .apng to .mp4. +//! Driver: record styling.apng - a guided, self-verifying tour of the editor's styling +//! layers (canvas theme, per-node tint, scoped style var/color, pin pivot, background +//! draw list), closing with a data-flow pulse along the link. Voiced (terse caption + +//! natural voice); every node, pin and the link is asserted rendered, so a broken +//! example aborts the recording at teardown. Styling is declarative - there is nothing +//! to click; the cursor sweeps left to right, pointing at each layer as it is named. let CANVAS = "MAIN_WIN/graph" +def widget_center(var snap : JsonValue?; ident : string) : tuple { + 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) +} + [export] def main { with_node_editor_recording_app("examples/tutorial/styling.das", - "styling.apng", 50) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_3", 15.0f) + "styling.apng", 60) $(app) { + let T_NODE1 = "{CANVAS}/node_1" + let T_NODE2 = "{CANVAS}/node_2" + let T_NODE3 = "{CANVAS}/node_3" + let T_OUT1 = "{CANVAS}/node_1/pin_12" + 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_NODE3, 15.0f) if (snap == null) { - panic("{CANVAS}/node_3 never rendered — wrong app running?") + panic("{T_NODE3} never rendered - wrong app running?") } - post_command(app, "set_user_control", JV((enabled = false))) - say(app, "node(id, color=...) tints a node's background - green for this source.", "{CANVAS}/node_1") + let c_node1 = widget_center(snap, T_NODE1) + let c_node3 = widget_center(snap, T_NODE3) + let c_out1 = widget_center(snap, T_OUT1) + // The accent stripe sits on node 3's left edge - point just inside it. + let b3 = find_widget(snap, T_NODE3)?["bbox"] + let c_stripe : tuple = ((b3?["x"] ?? 0.0f) + 4.0f, + ((b3?["y"] ?? 0.0f) + (b3?["w"] ?? 0.0f)) * 0.5f) + + // ---- Beat 1: the canvas theme (broadest layer) ---- + move_to(app, (500.0f, 300.0f), 600) wait_for_mouse_idle(app) - say(app, "No tint, square corners: the editor's default node look.", "{CANVAS}/node_2") + record_check_rendered(app, CANVAS, true) + say(app, "theme: warm-dark canvas", "", + [voice = "Styling is layered. The broadest layer is the theme - apply_daslang_node_editor_style paints the whole canvas, grid and links warm-dark with amber, to match daslang's ImGui look."]) + + // ---- Beat 2: per-node tint (and the default node for contrast) ---- + move_to(app, c_node1, 700) wait_for_mouse_idle(app) - say(app, "with_style_var (rounding) + with_style_color (amber border) compose around this output node.", "{CANVAS}/node_3") + record_check_rendered(app, T_NODE1, true) + record_check_rendered(app, T_NODE2, true) + say(app, "node(color=...): tint", T_NODE1, + [voice = "The color arg on a node fills just its background - node one is a green source. Node two passes no color and keeps the editor default, square and untinted, for contrast."]) + + // ---- Beat 3: pin pivot (adjacent to node 1) ---- + move_to(app, c_out1, 600) wait_for_mouse_idle(app) - say(app, "The red accent stripe is custom art on the node's background draw list.", "{CANVAS}/node_3") + record_check_rendered(app, T_OUT1, true) + say(app, "pin pivot -> node edge", T_OUT1, + [voice = "A pin's pivot_alignment moves where its link attaches. Node one's output pivots to the right edge, so the link leaves the node cleanly instead of springing from the pin label's center."]) + + // ---- Beat 4: scoped style var + color ---- + move_to(app, c_node3, 800) + wait_for_mouse_idle(app) + record_check_rendered(app, T_NODE3, true) + say(app, "with_style_var + with_style_color", T_NODE3, + [voice = "For properties that aren't a node arg, wrap the node in scoped brackets. with_style_var rounds node three's corners and with_style_color gives it an amber border - the two compose, and each restores its previous value when the block ends."]) + + // ---- Beat 5: background draw list (custom art, lowest layer) ---- + move_to(app, c_stripe, 600) wait_for_mouse_idle(app) + record_check_rendered(app, T_NODE3, true) + say(app, "background drawlist: accent", T_NODE3, + [voice = "The lowest layer is raw drawing. with_node_background_drawlist hands you a draw list behind the node - here a red accent stripe down node three's left edge, drawn from the editor-owned geometry read back after the node."]) + + // ---- Beat 6: pulse the link to show the styled graph is live ---- + let dwell = say_begin(app, "the link is live", T_LINK, + [voice = "The styling is only skin deep - the graph still runs. We can pulse the link to watch data flow from the green source into the styled output."]) + 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/test_navigation.das b/tests/integration/test_navigation.das index c6c70a2..8e31d1e 100644 --- a/tests/integration/test_navigation.das +++ b/tests/integration/test_navigation.das @@ -9,12 +9,12 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Headless regression for the navigation tutorial. The view ops (NavigateToContent, -//! navigate_to_selection, center_node_on_screen) carry no observable state of their own, -//! but they move the VIEW — so a node's SCREEN-space bbox shifts even though its canvas -//! position is unchanged. The graph is seeded wide (node 3 at canvas 1180,560), so a real -//! click on the "Fit All" button zooms out + recenters and node 3's screen center moves -//! measurably. That shift is the assertion. +//! Headless regression for the navigation tutorial. NavigateToContent (Fit All) and +//! navigate_to_selection (Frame Selection) are VIEW ops — a node's SCREEN-space bbox shifts +//! while its canvas position holds. center_node_on_screen is NOT a view op: despite the +//! name it MOVES the node to the view center (upstream CenterNodeOnScreen translates the +//! node bounds). This test asserts both: Fit All moves node 3's screen center (the graph is +//! seeded wide, node 3 at canvas 1180,560), and Center #1 moves node 1's CANVAS bbox. let SUBJECT = "modules/dasImguiNodeEditor/examples/tutorial/navigation.das" @@ -47,5 +47,18 @@ def test_navigation(t : T?) { return dx * dx + dy * dy > 400.0f // shifted > 20px in screen space } t |> success(moved != null, "Fit All moved the view (node_3 screen center shifted > 20px)") + + // The footgun: center_node_on_screen MOVES node 1 (not the view). Assert its + // canvas-space bbox changes after a real "Center #1" click. + var snap2 = ne_wait_widget(s, "node_1", 5.0f) + t |> success(snap2 != null, "node_1 present before Center #1") + if (snap2 == null) return + let n1_canvas_before = write_json(widget_payload_field(snap2, "{s.canvas}/node_1", "canvas_bbox")) + click(d, "MAIN_WIN/CENTER_FIRST") + var node_moved = wait_until_sec(d, 6.0f) $(var sn) { + let now = widget_payload_field(sn, "{s.canvas}/node_1", "canvas_bbox") + return now != null && write_json(now) != n1_canvas_before + } + t |> success(node_moved != null, "Center #1 MOVED node 1's canvas bbox (it relocates the node, not the view)") } }