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
7 changes: 4 additions & 3 deletions daslib/imgui_node_editor_boost_v2.das
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Binary file modified doc/source/_static/tutorials/navigation.mp4
Binary file not shown.
Binary file modified doc/source/_static/tutorials/styling.mp4
Binary file not shown.
33 changes: 19 additions & 14 deletions doc/source/tutorials/navigation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
======================================
Expand All @@ -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.
22 changes: 12 additions & 10 deletions examples/tutorial/navigation.das
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down
103 changes: 82 additions & 21 deletions tests/integration/record_navigation.das
Original file line number Diff line number Diff line change
Expand Up @@ -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<float; float> {
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<float; float> {
// 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<float; float>) {
var ev : array<JsonValue?>
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,
Comment on lines +78 to +80
[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."])
}
}
90 changes: 77 additions & 13 deletions tests/integration/record_styling.das
Original file line number Diff line number Diff line change
Expand Up @@ -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<float; float> {
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<float; float> = ((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)
}
}
}
Loading
Loading