diff --git a/CMakeLists.txt b/CMakeLists.txt index ff165ef..997b720 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(attolang STATIC src/atto/graph_index.cpp src/atto/shadow.cpp src/atto/symbol_table.cpp + src/atto/graph_builder.cpp ) target_include_directories(attolang PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -56,13 +57,35 @@ if(ATTOLANG_BUILD_EDITOR) set(ATTO_NEEDS_IMGUI ON) include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/AttoDeps.cmake) + # Embed Liberation Mono font as C array + set(FONT_TTF "${CMAKE_CURRENT_SOURCE_DIR}/src/attoflow/fonts/LiberationMono-Regular.ttf") + set(FONT_HDR "${CMAKE_CURRENT_BINARY_DIR}/generated/LiberationMono_Regular.h") + file(READ "${FONT_TTF}" FONT_HEX HEX) + string(LENGTH "${FONT_HEX}" FONT_HEX_LEN) + math(EXPR FONT_SIZE "${FONT_HEX_LEN} / 2") + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," FONT_BYTES "${FONT_HEX}") + file(WRITE "${FONT_HDR}" + "// Liberation Mono Regular - SIL Open Font License (auto-generated)\n" + "#pragma once\n" + "static const unsigned int LiberationMono_Regular_size = ${FONT_SIZE};\n" + "static const unsigned char LiberationMono_Regular_data[] = {\n" + "${FONT_BYTES}\n};\n" + ) + add_executable(attoflow src/attoflow/main.cpp - src/attoflow/editor.cpp + src/attoflow/window.cpp + src/attoflow/editor2.cpp + src/attoflow/nets_editor.cpp + src/attoflow/visual_editor.cpp + src/attoflow/node_renderer.cpp + src/attoflow/tooltip_renderer.cpp + src/attoflow/editor_style.cpp ) target_include_directories(attoflow PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/src/attoflow + ${CMAKE_CURRENT_BINARY_DIR}/generated ) if(WIN32) target_link_libraries(attoflow PRIVATE attolang SDL3::SDL3 imgui::imgui) diff --git a/docs/attolang.md b/docs/attolang.md index 61f58cd..3517e99 100644 --- a/docs/attolang.md +++ b/docs/attolang.md @@ -457,27 +457,31 @@ Nodes with input or output bangs: ## Inline Expressions -All non-declaration nodes support **inline expressions** in their arguments. Each space-separated arg token replaces the corresponding descriptor input. If an arg is an inline expression (a literal, symbol, or complex expression), that input slot is "filled" and does not require a pin connection. Only `$N` references within inline expressions create actual input pins. +All non-declaration nodes support **inline expressions** in their arguments. Each arg maps 1:1 to a descriptor input port. An arg can be: + +- **Net reference** (`$net-name`): connects to a named net — produces a visible input pin +- **Expression** (`sin($0)+1`): inline expression — displayed in node text, no pin +- **Literal** (`42`, `"hello"`, `true`): inline constant — displayed in node text, no pin +- **Symbol** (`oscs`, `sin`): bare identifier resolved via symbol table — displayed in text, no pin + +Only net references produce visible input pins. All other arg types fill the slot inline. ### Rules -1. Each arg token (space-separated, respecting parentheses and quotes) maps to a descriptor input left-to-right -2. The number of arg tokens must not exceed the node's descriptor input count (error otherwise) -3. `$N` references within inline args create input pins; symbol references (bare names) do not -4. Pin indices must be contiguous starting from 0 — gaps (e.g. `$0` and `$2` without `$1`) are errors -5. Descriptor inputs beyond the number of inline args remain as pin connections +1. Each arg maps to a descriptor input left-to-right +2. The number of args must not exceed the node's descriptor input count (plus va-args if applicable) +3. `$N` references within expressions create **remap pins** (mapped via the `remaps` array) +4. Remap indices must be contiguous starting from 0 — gaps produce errors +5. `$name` (non-numeric, starting with `$`) references a named net and produces a visible pin +6. Bare names (no `$` prefix) are symbols resolved via the symbol table, not pins -### Examples (store! has 2 descriptor inputs: target, value) +### Examples (store! has 3 descriptor inputs: bang_in, target, value) -| Node text | Pins | Explanation | -|-----------|------|-------------| -| `store!` | target, value | No inline args — both inputs are pins | -| `store! oscs` | value | target filled by symbol `oscs` (resolves via symbol table to `&T`) | -| `store! oscs 42` | (none) | Both filled inline | -| `store! oscs $0` | $0 | target = symbol, value = pin $0 | -| `store! $1 $0` | $0, $1 | Both inline but reference pins | -| `store! $0 $1 $2` | error | Too many args (store! takes 2) | -| `store! $0 $2` | error | Missing pin $1 | +| Args | Visible pins | Explanation | +|------|-------------|-------------| +| `["$bang-src", "$var-ref", "$val-net"]` | 3 pins | All net refs — all visible | +| `["$bang-src", "oscs", "$0"]` | 2 pins (bang + remap $0) | target filled by symbol `oscs` | +| `["$bang-src", "oscs", "42"]` | 1 pin (bang only) | Both target and value filled inline | ## Expression Language @@ -627,8 +631,7 @@ When a lambda's data dependency traces back to a node in the caller scope, that Bang pins represent `() -> void` callable connections for control flow: - **BangTrigger** (top square): The node's callable entry point. When invoked, the node executes. Typed as `() -> void`. Can be used as a value source — connecting a BangTrigger to a data Input passes the `() -> void` callable as a value (e.g., to store it in a variable). -- **BangNext** (bottom square): The node's continuation. After execution, the node calls whatever is connected here. Typed as `() -> void`. Links go FROM BangNext TO BangTrigger. -- **Post-bang** (side): Fires after the node's inline expressions are evaluated. Same semantics as BangNext. +- **BangNext** (bottom square): The node's continuation output. After execution, the node calls whatever is connected here. Typed as `() -> void`. Links go FROM BangNext TO BangTrigger. The first output pin on bang nodes is always a `BangNext` named `next` — this replaces the old `post_bang` pseudo-pin and is rendered at the same visual position. **Link direction:** BangNext → BangTrigger. The "next" pin calls the "trigger" pin. @@ -636,44 +639,205 @@ Bang pins represent `() -> void` callable connections for control flow: **Bidirectional BangTrigger:** A BangTrigger pin can be both a link destination (receiving bang chain flow from BangNext) and a link source (providing its `() -> void` value to a data Input pin). -## File Format (.atto) +## File Format (instrument@atto:0) -TOML-like format: +TOML-like format with named nets instead of explicit pin-to-pin connections. ``` -version = "attoprog@0" - -[viewport] -x = -500.0 -y = -200.0 -zoom = 1.5 +# version instrument@atto:0 [[node]] -guid = "a3f7c1b2e9d04856" +id = "$gen-expr" type = "expr" -args = ["$0+$1"] -position = [100, 200] -connections = ["a3f7c1b2e9d04856.out0->b4c8d9e0f1a23456.0"] +args = ["sin($0.p)*$1/32.f"] +remaps = ["$iter-item", "$iter-amp"] +position = [1866.25, 1443.11] + +[[node]] +id = "$store-p" +type = "store!" +args = ["$0.p"] +remaps = ["$iter-item"] +position = [2133.84, 1421.55] ``` -### Viewport Section +### Version Header + +First line: `# version instrument@atto:0` (comment-style). + +Legacy formats (`nanoprog@0`, `nanoprog@1`, `attoprog@0`, `attoprog@1`) are loaded via a legacy parser and auto-migrated. Saving always writes `instrument@atto:0`. + +### Node IDs and Net Names + +Node IDs and net names share the same namespace: +- Format: `$[a-zA-Z_-][a-zA-Z0-9_-]*` +- Auto-generated on import: `$a-0`, `$a-1`, ... `$a-f`, `$a-10`, ... (compact hex, migrated from old `$auto-`) +- `$0`, `$1`, ... `$N` are reserved for expression pin inputs (remaps) +- `$unconnected` is a reserved sentinel net for unconnected pins +- `$empty` is a reserved sentinel node for unassigned pin ownership +- The `$` prefix is stored in the file + +### Sentinel Entries + +| Sentinel | Type | Purpose | +|---|---|---| +| `$unconnected` | Net | Default wire for unconnected pins | +| `$empty` | Node | Default node owner for pins not yet assigned to a node | + +Both are pre-registered by `GraphBuilder::ensure_sentinels()`. Direct `find()` or `find_or_create_net()` calls with these names throw — use `gb->unconnected_net()` / `gb->empty_node()` instead. + +### Node Structure + +```toml +[[node]] +id = "$a-5" # compact hex identifier +type = "store!" # node type name +args = ["oscs", "$0"] # inline arguments (expressions, literals, net refs) +remaps = ["$a-3-out0"] # $N → net mapping for expression pin inputs +position = [100, 200] # canvas coordinates +``` -The optional `[viewport]` section stores the editor's camera state. It must appear after `version` and before any `[[node]]` entries. +### Node Kinds -| Field | Type | Description | -|--------|-------|--------------------------------| -| `x` | float | Horizontal scroll offset | -| `y` | float | Vertical scroll offset | -| `zoom` | float | Zoom level (1.0 = default) | +| Kind | Bang input | Bang output | Side-bang | Description | +|---|---|---|---|---| +| `Flow` | No | Yes (side-bang, right) | Yes | Dataflow node — all flow nodes have a side-bang | +| `Banged` | Yes (top) | Yes (bottom) | No | Imperative node with bang trigger | +| `Event` | No | Yes (bottom) | No | Event source | +| `Declaration` | Yes (top) | Yes (bottom) | No | Compile-time declaration | +| `Special` | No | No | No | Label or Error | + +Flow nodes always have `outputs[0]` as the side-bang (BangNext). It is rendered on the right side, not at the bottom. + +### Arguments (`args`) + +Each entry in the `args` array is a singular expression (space-delimited in the source, already split in the file). Arguments map 1:1 to the node's descriptor input ports. + +An argument can be: +- **Net reference** (`$name`): connects to a named net — produces a visible input pin +- **Expression** (`sin($0)+1`): inline expression with `$N` pin refs — displayed in node text, not a pin +- **Number** (`42`, `3.14f`): inline constant — displayed in node text +- **String** (`"hello"`): inline string literal — displayed in node text + +Only net reference entries produce visible input pins. Inline values are displayed in the node's label text. + +### Remaps (`remaps`) + +The `remaps` array maps `$N` expression pin inputs to named nets: + +```toml +remaps = ["$a-2-out0", "$a-2-out1"] +``` + +- `remaps[0]` = net for `$0`, `remaps[1]` = net for `$1`, etc. +- `$unconnected` for unconnected expression inputs +- Remaps are always net references + +### Pin Model + +Pins are graph entities (`FlowArg2` hierarchy: `ArgNet2`, `ArgNumber2`, `ArgString2`, `ArgExpr2`). Each pin has: +- **`node()`** — owning FlowNodeBuilder (always valid, `$empty` if unassigned) +- **`wire()`** / **`net()`** — associated NetBuilder (always valid, `$unconnected` if unassigned) +- **`port()`** — PortDesc2 descriptor (null for remaps) +- **`name()`** — computed: `"port_name"` or `"va_name[idx]"` or `"remaps[idx]"` +- **`is_remap()`** — true if port is null (remap pin) + +#### Input pins (top of node, left to right) + +Only net reference (`$name`) arguments produce visible pins. The visible pin count is: + +| Section | Visible pins | Source | +|---|---|---| +| **Base args** | Only net refs in `args` | 1:1 with descriptor input ports | +| **Input va-args** | Only net refs in va-args | Named `va_name[0]`, `va_name[1]`, ... | +| **+diamond** | Add button (if node has input va-args) | Rendered as ◇ with + | +| **Remaps** | All entries | `$0`, `$1`, ... from expressions | -### Connection Format +#### Output pins (bottom of node) -Connections use pin IDs: `".->."` +Fixed descriptor output ports are rendered at the bottom, EXCEPT for flow nodes where `outputs[0]` (side-bang) is rendered on the right side. Output va-args follow fixed outputs. + +| Section | Pins | Source | +|---|---|---| +| **Fixed outputs** | Descriptor output ports (skip side-bang for flow) | `outputs[skip_sb..]` | +| **Output va-args** | Dynamic outputs | `outputs_va_args[]` | + +Expr/expr! have output va-args sized to match expression count. Event! has output va-args for spillover outputs. + +#### Pin kinds + +| Kind | Visual | Description | +|---|---|---| +| `BangTrigger` | Square (top) | Trigger input | +| `Data` | Circle | Data value | +| `Lambda` | Down-pointing triangle | Lambda capture (accepts node refs) | +| `BangNext` | Square (bottom/right) | Bang continuation output | +| `Va-args` | Diamond (◇) | Variable-length input/output | +| `Optional` | Diamond with ? | Optional input (trailing) | + +#### Special pins + +| Pin | Position | Visual | Description | +|---|---|---|---| +| **Lambda grab** | Left center | Left-pointing triangle (purple) | Capture this node as lambda | +| **Side-bang** | Right center | Square (yellow) | Post-bang output (flow nodes only) | + +### Lambda Captures via Node ID + +When a `$id` in an argument resolves to a **node** (not a net), it is a lambda capture. The wire renders from the source node's lambda grab (left side) to the destination's lambda pin. + +- Node reference → lambda capture (wire from grab) +- Net reference → data wire (wire from output pin) +- `Lambda` pins accept only node refs +- `Data` pins can accept either + +### Input Va-args + +Some node types accept a variable number of additional inputs. The va-args template is defined on `NodeType2::input_ports_va_args`. + +| Node | Va-args template | Description | +|---|---|---| +| `new` | `field` | Constructor fields (`field[0]`, `field[1]`, ...) | +| `call` / `call!` | `arg` | Function arguments (`arg[0]`, `arg[1]`, ...) | +| `lock` / `lock!` | `param` | Lambda parameters (`param[0]`, `param[1]`, ...) | + +### Output Va-args + +Some node types have dynamic output counts. The template is defined on `NodeType2::output_ports_va_args`. + +| Node | Va-args template | Description | +|---|---|---| +| `expr` | `expr` | One output per expression (`expr[0]`, `expr[1]`, ...) | +| `expr!` | `expr` | Same, after the fixed `next` output | +| `event!` | `args` | Event argument outputs | + +### Optional Ports + +Optional ports are always trailing in the descriptor. They are split into separate `input_optional_ports` / `num_inputs_optional` on NodeType2. If not connected, they are omitted from `parsed_args` (shorter array). The editor shows absent optionals as ◇ with ? inside. + +Currently only `decl_var` has an optional port (`initial`). + +### Viewport (Meta File) + +Viewport state is stored in `.atto/.yaml`, not in the `.atto` file: + +```yaml +# Editor metadata for main.atto +viewport_x: -1504.32 +viewport_y: -551.573 +viewport_zoom: 4.17725 +``` + +The `.atto/` directory is gitignored. Node positions remain in the `.atto` file. + +### Labels and Errors + +```toml +[[node]] +id = "$lbl-types" +type = "label" +args = ["Types"] +position = [766, 335] +``` -Pin names: -- Data/lambda inputs: `0`, `1`, `2`, ... or named (e.g. `gen`, `stop`) -- Bang inputs: `bang_in0`, `bang_in1`, ... -- Data outputs: `out0`, `out1`, ... -- Bang outputs: `bang0`, `bang1`, ... -- Lambda grab: `as_lambda` -- Post-bang: `post_bang` +Labels have exactly 1 argument (the display text). Error nodes are the same — they display the original args when parsing failed. diff --git a/scenes/klavier/main.atto b/scenes/klavier/main.atto index fadeecc..105b641 100644 --- a/scenes/klavier/main.atto +++ b/scenes/klavier/main.atto @@ -59,7 +59,7 @@ position = [754.71, 742.669] [[node]] id = "$auto-831e483b4e4602dc" type = "append" -args = ["$oscs"] +args = ["oscs"] inputs = ["$auto-831e483b4e4602dc_s1-out0"] outputs = ["$auto-831e483b4e4602dc-out0"] position = [1762.05, 2099.45] @@ -76,7 +76,7 @@ id = "$auto-c81c38e5f70d7c98" type = "expr" args = ["sin($0.p)*$1/32.f"] inputs = ["$auto-2018c2b6134a0c05-out0", "$auto-2018c2b6134a0c05-out1"] -outputs = ["", "$auto-c81c38e5f70d7c98-post_bang"] +outputs = ["$auto-c81c38e5f70d7c98-out0", "$auto-c81c38e5f70d7c98-post_bang"] position = [1866.25, 1443.11] [[node]] @@ -90,6 +90,7 @@ position = [1784.69, 1675.87] id = "$auto-1d7ed7a2c6bd9465" type = "expr" args = ["0"] +outputs = ["$auto-1d7ed7a2c6bd9465-out0"] position = [1776.07, 1758.84] [[node]] @@ -97,12 +98,14 @@ id = "$auto-cc68627fd36abb94" type = "expr" args = ["2*pi/$0"] inputs = ["$auto-b475716d61845870-out1"] +outputs = ["$auto-cc68627fd36abb94-out0"] position = [1805.57, 1828.22] [[node]] id = "$auto-9786d74433799f1e" type = "expr" args = ["1"] +outputs = ["$auto-9786d74433799f1e-out0"] position = [1831.1, 1894.92] [[node]] @@ -131,6 +134,7 @@ position = [1872.74, 1347.96] id = "$auto-b64eb56b2a60eda2" type = "new" args = ["osc_res"] +outputs = ["", "", "$auto-b64eb56b2a60eda2-as_lambda"] position = [1766.95, 1565.73] [[node]] @@ -138,14 +142,15 @@ id = "$auto-18cbc672fb22e68e" type = "expr" args = ["$0<0.001f"] inputs = ["$auto-2018c2b6134a0c05-out1"] +outputs = ["$auto-18cbc672fb22e68e-out0"] position = [1923.26, 1493.73] [[node]] id = "$auto-e6a647578747ca01" type = "iterate" -args = ["$oscs"] +args = ["oscs"] inputs = ["$auto-a81d5e94c0631e58-as_lambda"] -outputs = ["$auto-e6a647578747ca01-as_lambda"] +outputs = ["", "$auto-e6a647578747ca01-as_lambda"] position = [3718.47, 1973.21] [[node]] @@ -161,7 +166,7 @@ id = "$auto-a81d5e94c0631e58" type = "select" args = ["$0.e", "$2", "$1"] inputs = ["$auto-5551d3ed2caa466f-out0", "$auto-b78d3f85fe8f654a-out0", "$auto-59970d1e2f56ca0f-out0"] -outputs = ["", "$auto-a81d5e94c0631e58-as_lambda"] +outputs = ["", "", "$auto-a81d5e94c0631e58-as_lambda"] position = [3781.35, 1891.39] [[node]] @@ -180,7 +185,7 @@ position = [3811.81, 1541.74] [[node]] id = "$auto-59970d1e2f56ca0f" type = "erase" -args = ["$oscs"] +args = ["oscs"] inputs = ["$auto-59970d1e2f56ca0f_s1-out0"] outputs = ["$auto-59970d1e2f56ca0f-out0"] position = [3839.65, 1835.09] @@ -240,21 +245,21 @@ position = [1106.59, 745.558] [[node]] id = "$auto-0a536bc07ab8e6ba" type = "expr" -args = ["$delay_line_pos", "mod($delay_line_pos+1,$delay_line_size)"] +args = ["delay_line_pos", "mod(delay_line_pos+1,delay_line_size)"] outputs = ["$auto-0a536bc07ab8e6ba-out0", "$auto-0a536bc07ab8e6ba-out1", "$auto-0a536bc07ab8e6ba-post_bang"] position = [3853.11, 2143.2] [[node]] id = "$auto-a34b262f215ea808" type = "store!" -args = ["$delay_line_pos"] +args = ["delay_line_pos"] inputs = ["$auto-0a536bc07ab8e6ba-post_bang", "$auto-0a536bc07ab8e6ba-out1"] position = [4447.05, 2205.49] [[node]] id = "$auto-694aaecf19c1f260" type = "store!" -args = ["$delay_line[$0]"] +args = ["delay_line[$0]"] inputs = ["$auto-a8da10815e6d03ff-bang0", "$auto-0a536bc07ab8e6ba-out0", "$auto-436d853ee8e34fa5-out0"] outputs = ["$auto-694aaecf19c1f260-bang0"] position = [3720.9, 2405.95] @@ -262,7 +267,7 @@ position = [3720.9, 2405.95] [[node]] id = "$auto-436d853ee8e34fa5" type = "expr" -args = ["$0+$delay_line[$1]*0.7f"] +args = ["$0+delay_line[$1]*0.7f"] inputs = ["$auto-e74cec1135c3a130-out0", "$auto-0a536bc07ab8e6ba-out1"] outputs = ["$auto-436d853ee8e34fa5-out0"] position = [3794.02, 2334.26] @@ -291,7 +296,7 @@ position = [771.53, 1196.46] [[node]] id = "$auto-45df349a6ae05f56" type = "store!" -args = ["$delay_line_size"] +args = ["delay_line_size"] inputs = ["$auto-ddaf4497489a54c2-bang0", "$auto-45df349a6ae05f56_s1-out0"] outputs = ["$auto-45df349a6ae05f56-bang0"] position = [785.327, 1265.73] @@ -306,7 +311,7 @@ position = [756.636, 792.818] [[node]] id = "$auto-a8da10815e6d03ff" type = "lock!" -args = ["$oscs_mutex"] +args = ["oscs_mutex"] inputs = ["$auto-e74cec1135c3a130-bang0", "$auto-e6a647578747ca01-as_lambda"] outputs = ["$auto-a8da10815e6d03ff-bang0"] position = [3500.01, 2030.75] @@ -327,7 +332,7 @@ position = [1683.32, 681.704] [[node]] id = "$auto-scan_keys_node" type = "call!" -args = ["$imgui_scan_piano_keys", "$klavie_down", "$klavie_up"] +args = ["imgui_scan_piano_keys", "klavie_down", "klavie_up"] inputs = ["$auto-vtick_void-post_bang"] outputs = ["$auto-scan_keys_node-bang0"] position = [4945.95, 1509.43] @@ -335,7 +340,7 @@ position = [4945.95, 1509.43] [[node]] id = "$auto-4320923f2a319682" type = "call!" -args = ["$imgui_begin_fullscreen"] +args = ["imgui_begin_fullscreen"] inputs = ["$auto-scan_keys_node-bang0"] outputs = ["$auto-4320923f2a319682-bang0"] position = [4945.95, 1579.43] @@ -343,7 +348,7 @@ position = [4945.95, 1579.43] [[node]] id = "$auto-gui_slider_node" type = "call!" -args = ["$imgui_slider_int", "\"Delay Size\"", "$delay_line_size", "1", "48000"] +args = ["imgui_slider_int", "\"Delay Size\"", "delay_line_size", "1", "48000"] inputs = ["$auto-4320923f2a319682-bang0"] outputs = ["$auto-gui_slider_node-bang0"] position = [4945.95, 1652.43] @@ -351,7 +356,7 @@ position = [4945.95, 1652.43] [[node]] id = "$auto-expr_delay_ref" type = "expr" -args = ["$delay_line"] +args = ["delay_line"] outputs = ["$auto-expr_delay_ref-out0"] position = [5122.24, 1727.07] @@ -366,7 +371,7 @@ position = [5041.65, 1803.23] [[node]] id = "$auto-plot_delay_node" type = "call!" -args = ["$imgui_plot_lines_fill", "\"Delay Line\"", "$0", "0", "$delay_line_size", "-1.0f", "1.0f", "\"\""] +args = ["imgui_plot_lines_fill", "\"Delay Line\"", "$0", "0", "delay_line_size", "-1.0f", "1.0f", "\"\""] inputs = ["$auto-gui_slider_node-bang0", "$auto-cast_delay_node-out0"] outputs = ["$auto-plot_delay_node-bang0"] position = [4945.95, 1869.43] @@ -374,7 +379,7 @@ position = [4945.95, 1869.43] [[node]] id = "$auto-gui_end_node" type = "call!" -args = ["$imgui_end"] +args = ["imgui_end"] inputs = ["$auto-plot_delay_node-bang0"] position = [4945.95, 1939.43] @@ -395,9 +400,9 @@ position = [761, 914] [[node]] id = "$auto-daa77173e91ec011" type = "lock" -args = ["$oscs_mutex"] +args = ["oscs_mutex"] inputs = ["$auto-a3cda7b2eaa0cc3c-as_lambda"] -outputs = ["$auto-daa77173e91ec011-as_lambda"] +outputs = ["", "$auto-daa77173e91ec011-as_lambda"] position = [1202.59, 2435.03] [[node]] @@ -410,7 +415,7 @@ position = [1284.42, 1561.27] [[node]] id = "$auto-f79f6fb421f321f0" type = "store!" -args = ["$klavie_down"] +args = ["klavie_down"] inputs = ["$auto-45df349a6ae05f56-bang0", "$auto-daa77173e91ec011-as_lambda"] outputs = ["$auto-f79f6fb421f321f0-bang0"] position = [1224.44, 1270.42] @@ -418,7 +423,7 @@ position = [1224.44, 1270.42] [[node]] id = "$auto-487ba455bf42a84c" type = "store!" -args = ["$klavie_up"] +args = ["klavie_up"] inputs = ["$auto-f79f6fb421f321f0-bang0", "$auto-c7381b7375adf0ce-as_lambda"] outputs = ["$auto-487ba455bf42a84c-bang0"] position = [2527.9, 1282.56] @@ -427,13 +432,13 @@ position = [2527.9, 1282.56] id = "$auto-c7381b7375adf0ce" type = "select" inputs = ["$auto-081b2e7c7b05405a-out0", "$auto-a4638623e82d8e17-out0", "$auto-7771f927d195eceb-out0"] -outputs = ["", "$auto-c7381b7375adf0ce-as_lambda"] +outputs = ["", "", "$auto-c7381b7375adf0ce-as_lambda"] position = [2734.55, 1619.72] [[node]] id = "$auto-a4638623e82d8e17" type = "expr" -args = ["$keys[$0].stop($keys[$0])"] +args = ["keys[$0].stop(keys[$0])"] inputs = ["$auto-d08c1c1fdad95ee4-out0"] outputs = ["$auto-a4638623e82d8e17-out0", "$auto-a4638623e82d8e17-post_bang"] position = [2750.22, 1521.28] @@ -441,7 +446,7 @@ position = [2750.22, 1521.28] [[node]] id = "$auto-8bbb6e2930a59a81" type = "erase!" -args = ["$keys"] +args = ["keys"] inputs = ["$auto-a4638623e82d8e17-post_bang", "$auto-d08c1c1fdad95ee4-out0"] position = [3070.95, 1557.71] @@ -455,7 +460,7 @@ position = [2731.42, 1400.77] [[node]] id = "$auto-081b2e7c7b05405a" type = "expr" -args = ["$keys?[$0]"] +args = ["keys?[$0]"] inputs = ["$auto-d08c1c1fdad95ee4-out0"] outputs = ["$auto-081b2e7c7b05405a-out0"] position = [2697.56, 1472.1] @@ -469,15 +474,15 @@ position = [2826.45, 1558.83] [[node]] id = "$auto-a3cda7b2eaa0cc3c" type = "select" -args = ["$keys?[$0]"] +args = ["keys?[$0]"] inputs = ["$auto-b475716d61845870-out0", "$auto-cb9a285934d2d07b-out0", "$auto-225009e132d0e7d5-out0"] -outputs = ["", "$auto-a3cda7b2eaa0cc3c-as_lambda"] +outputs = ["", "$auto-a3cda7b2eaa0cc3c-post_bang", "$auto-a3cda7b2eaa0cc3c-as_lambda"] position = [1297.72, 2337.6] [[node]] id = "$auto-cb9a285934d2d07b" type = "expr" -args = ["$keys[$0].stop($keys[$0])"] +args = ["keys[$0].stop(keys[$0])"] inputs = ["$auto-b475716d61845870-out0"] outputs = ["$auto-cb9a285934d2d07b-out0"] position = [1345.11, 2180.75] @@ -485,8 +490,8 @@ position = [1345.11, 2180.75] [[node]] id = "$auto-182cde3e88fc2aec" type = "store!" -args = ["$keys[$0]"] -inputs = ["", "$auto-b475716d61845870-out0", "$auto-831e483b4e4602dc-out0"] +args = ["keys[$0]"] +inputs = ["$auto-a3cda7b2eaa0cc3c-post_bang", "$auto-b475716d61845870-out0", "$auto-831e483b4e4602dc-out0"] position = [1727.27, 2235.78] [[node]] @@ -518,42 +523,42 @@ position = [766.535, 1028.41] [[node]] id = "$auto-store_atick" type = "store!" -args = ["$audio_tick"] -inputs = ["$auto-487ba455bf42a84c-bang0"] +args = ["audio_tick"] +inputs = ["$auto-487ba455bf42a84c-bang0", "$auto-atick_void-as_lambda"] outputs = ["$auto-store_atick-bang0"] position = [3340.42, 1265.93] [[node]] id = "$auto-atick_void" type = "void" -outputs = ["", "$auto-atick_void-post_bang"] +outputs = ["", "$auto-atick_void-post_bang", "$auto-atick_void-as_lambda"] position = [3496.36, 1376.98] [[node]] id = "$auto-store_vtick" type = "store!" -args = ["$video_tick"] -inputs = ["$auto-store_atick-bang0"] +args = ["video_tick"] +inputs = ["$auto-store_atick-bang0", "$auto-vtick_void-as_lambda"] outputs = ["$auto-store_vtick-bang0"] position = [4623.5, 1281.83] [[node]] id = "$auto-vtick_void" type = "void" -outputs = ["", "$auto-vtick_void-post_bang"] +outputs = ["", "$auto-vtick_void-post_bang", "$auto-vtick_void-as_lambda"] position = [4808.81, 1366.93] [[node]] id = "$auto-av_window_node" type = "call!" -args = ["$av_create_window", "\"Klavier\"", "$audio_tick", "48000", "1", "$video_tick", "800", "600", "$on_quit"] +args = ["av_create_window", "\"Klavier\"", "audio_tick", "48000", "1", "video_tick", "800", "600", "on_quit"] inputs = ["$auto-dcdb8bb52c9d14ef-bang0"] position = [4808.51, 2495.54] [[node]] id = "$auto-dcdb8bb52c9d14ef" type = "store!" -args = ["$on_quit"] +args = ["on_quit"] inputs = ["$auto-store_vtick-bang0", "$auto-56eb5d29abcb9fb0-as_lambda"] outputs = ["$auto-dcdb8bb52c9d14ef-bang0"] position = [4799.81, 2260.41] @@ -568,7 +573,7 @@ position = [766.535, 1080] [[node]] id = "$auto-56eb5d29abcb9fb0" type = "void" -outputs = ["", "$auto-56eb5d29abcb9fb0-as_lambda"] +outputs = ["", "", "$auto-56eb5d29abcb9fb0-as_lambda"] position = [4965.91, 2377.32] [[node]] @@ -642,6 +647,12 @@ type = "decl_var" inputs = ["", "$auto-90dd534c6a74519c-out0", "$auto-1680d2daeb0c8be1-out0"] position = [1832.42, 1132.81] +[[node]] +id = "$auto-123b4f69e60c60d4" +type = "expr" +args = ["f32"] +position = [1555.62, 880.995] + [[node]] id = "$auto-d0fb3eeaa045e214" type = "expr" @@ -675,6 +686,7 @@ id = "$auto-934e3b98bb914e95_s2" type = "expr" shadow = true args = ["e:bool"] +outputs = ["$auto-934e3b98bb914e95_s2-out0"] position = [550.93, 255.328] [[node]] @@ -689,24 +701,10 @@ position = [551.91, 422.962] id = "$auto-e073eb5950485587_s1" type = "expr" shadow = true -args = ["(osc:&osc_def)"] +args = ["(osc:&osc_def)->osc_res"] outputs = ["$auto-e073eb5950485587_s1-out0"] position = [551.91, 362.962] -[[node]] -id = "$auto-e073eb5950485587_s2" -type = "expr" -shadow = true -args = ["->"] -position = [551.91, 302.962] - -[[node]] -id = "$auto-e073eb5950485587_s3" -type = "expr" -shadow = true -args = ["osc_res"] -position = [551.91, 242.962] - [[node]] id = "$auto-fe155835bba6cd45_s0" type = "expr" @@ -719,24 +717,10 @@ position = [554.325, 467.55] id = "$auto-fe155835bba6cd45_s1" type = "expr" shadow = true -args = ["(osc:&osc_def)"] +args = ["(osc:&osc_def)->void"] outputs = ["$auto-fe155835bba6cd45_s1-out0"] position = [554.325, 407.55] -[[node]] -id = "$auto-fe155835bba6cd45_s2" -type = "expr" -shadow = true -args = ["->"] -position = [554.325, 347.55] - -[[node]] -id = "$auto-fe155835bba6cd45_s3" -type = "expr" -shadow = true -args = ["void"] -position = [554.325, 287.55] - [[node]] id = "$auto-09f161f1210cec4f_s0" type = "expr" @@ -758,6 +742,7 @@ id = "$auto-09f161f1210cec4f_s2" type = "expr" shadow = true args = ["stop:stop_fn"] +outputs = ["$auto-09f161f1210cec4f_s2-out0"] position = [554.46, 390.991] [[node]] @@ -765,6 +750,7 @@ id = "$auto-09f161f1210cec4f_s3" type = "expr" shadow = true args = ["p:f32"] +outputs = ["$auto-09f161f1210cec4f_s3-out0"] position = [554.46, 330.991] [[node]] @@ -772,6 +758,7 @@ id = "$auto-09f161f1210cec4f_s4" type = "expr" shadow = true args = ["pstep:f32"] +outputs = ["$auto-09f161f1210cec4f_s4-out0"] position = [554.46, 270.991] [[node]] @@ -779,6 +766,7 @@ id = "$auto-09f161f1210cec4f_s5" type = "expr" shadow = true args = ["a:f32"] +outputs = ["$auto-09f161f1210cec4f_s5-out0"] position = [554.46, 210.991] [[node]] @@ -786,6 +774,7 @@ id = "$auto-09f161f1210cec4f_s6" type = "expr" shadow = true args = ["astep:f32"] +outputs = ["$auto-09f161f1210cec4f_s6-out0"] position = [554.46, 150.991] [[node]] @@ -809,6 +798,7 @@ id = "$auto-c0fbc2b794fa65b4_s2" type = "expr" shadow = true args = ["^list_iterator>"] +outputs = ["$auto-c0fbc2b794fa65b4_s2-out0"] position = [554.64, 441.922] [[node]] @@ -935,24 +925,10 @@ position = [1467.98, 389.477] id = "$auto-20115e980dcd5b53_s1" type = "expr" shadow = true -args = ["(args:vector envs:vector)"] +args = ["(args:vector envs:vector)->void"] outputs = ["$auto-20115e980dcd5b53_s1-out0"] position = [1467.98, 329.477] -[[node]] -id = "$auto-20115e980dcd5b53_s2" -type = "expr" -shadow = true -args = ["->"] -position = [1467.98, 269.477] - -[[node]] -id = "$auto-20115e980dcd5b53_s3" -type = "expr" -shadow = true -args = ["void"] -position = [1467.98, 209.477] - [[node]] id = "$auto-0e02c497002f40c2_s0" type = "expr" @@ -989,24 +965,10 @@ position = [557.37, 851.95] id = "$auto-c5373cf3d77e7979_s1" type = "expr" shadow = true -args = ["(midi_key:u8 freq:f32)"] +args = ["(midi_key:u8 freq:f32)->void"] outputs = ["$auto-c5373cf3d77e7979_s1-out0"] position = [557.37, 791.95] -[[node]] -id = "$auto-c5373cf3d77e7979_s2" -type = "expr" -shadow = true -args = ["->"] -position = [557.37, 731.95] - -[[node]] -id = "$auto-c5373cf3d77e7979_s3" -type = "expr" -shadow = true -args = ["void"] -position = [557.37, 671.95] - [[node]] id = "$auto-48a2c13cec7e5013_s0" type = "expr" @@ -1019,24 +981,10 @@ position = [561, 914] id = "$auto-48a2c13cec7e5013_s1" type = "expr" shadow = true -args = ["(midi_key:u8)"] +args = ["(midi_key:u8)->void"] outputs = ["$auto-48a2c13cec7e5013_s1-out0"] position = [561, 854] -[[node]] -id = "$auto-48a2c13cec7e5013_s2" -type = "expr" -shadow = true -args = ["->"] -position = [561, 794] - -[[node]] -id = "$auto-48a2c13cec7e5013_s3" -type = "expr" -shadow = true -args = ["void"] -position = [561, 734] - [[node]] id = "$auto-9facb8e5368e52c0_s0" type = "expr" @@ -1057,24 +1005,10 @@ position = [563.229, 970.563] id = "$auto-c17ebd09a44700e1_s1" type = "expr" shadow = true -args = ["()"] +args = ["()->void"] outputs = ["$auto-c17ebd09a44700e1_s1-out0"] position = [563.229, 910.563] -[[node]] -id = "$auto-c17ebd09a44700e1_s2" -type = "expr" -shadow = true -args = ["->"] -position = [563.229, 850.563] - -[[node]] -id = "$auto-c17ebd09a44700e1_s3" -type = "expr" -shadow = true -args = ["void"] -position = [563.229, 790.563] - [[node]] id = "$auto-50417175624d2751_s0" type = "expr" @@ -1087,23 +1021,10 @@ position = [566.535, 1028.41] id = "$auto-50417175624d2751_s1" type = "expr" shadow = true -args = ["()"] +args = ["()->void"] outputs = ["$auto-50417175624d2751_s1-out0"] position = [566.535, 968.41] -[[node]] -id = "$auto-50417175624d2751_s2" -type = "expr" -shadow = true -args = ["->"] -position = [566.535, 908.41] - -[[node]] -id = "$auto-50417175624d2751_s3" -type = "expr" -shadow = true -args = ["void"] -position = [566.535, 848.41] [[node]] id = "$auto-decl_on_quit_s0" @@ -1117,24 +1038,10 @@ position = [566.535, 1080] id = "$auto-decl_on_quit_s1" type = "expr" shadow = true -args = ["()"] +args = ["()->void"] outputs = ["$auto-decl_on_quit_s1-out0"] position = [566.535, 1020] -[[node]] -id = "$auto-decl_on_quit_s2" -type = "expr" -shadow = true -args = ["->"] -position = [566.535, 960] - -[[node]] -id = "$auto-decl_on_quit_s3" -type = "expr" -shadow = true -args = ["void"] -position = [566.535, 900] - [[node]] id = "$auto-445319c565ebdaa8_s1" type = "expr" @@ -1249,14 +1156,3 @@ args = ["array"] outputs = ["$auto-7837ca36997a9a3d_s1-out0"] position = [1060.74, 944.346] -[[node]] -id = "$auto-206413c1566d4d28" -type = "decl_var" -position = [1385.62, 980.6] - -[[node]] -id = "$auto-960e1ea09eeca09e" -type = "expr" -args = ["1.0"] -position = [1512.24, 797.574] - diff --git a/src/atto/args.cpp b/src/atto/args.cpp index 34a403d..0851994 100644 --- a/src/atto/args.cpp +++ b/src/atto/args.cpp @@ -264,3 +264,79 @@ void FlowNode::parse_args() { inline_meta.ref_pin_count = (info.pin_slots.max_slot >= 0) ? (info.pin_slots.max_slot + 1) : 0; } } + +// ─── split_args: split string into singular expressions ─── + +SplitResult split_args(const std::string& args_str) { + std::vector result; + std::string current; + int paren_depth = 0; + int brace_depth = 0; + bool in_string = false; + bool escape = false; + + for (size_t i = 0; i < args_str.size(); i++) { + char c = args_str[i]; + + if (escape) { + current += c; + escape = false; + continue; + } + if (c == '\\' && in_string) { + escape = true; + current += c; + continue; + } + if (c == '"') { + in_string = !in_string; + current += c; + continue; + } + if (in_string) { + current += c; + continue; + } + + if (c == '(') { paren_depth++; current += c; continue; } + if (c == ')') { + paren_depth--; + if (paren_depth < 0) + return std::string("Mismatched ')' at position " + std::to_string(i)); + current += c; + continue; + } + if (c == '{') { brace_depth++; current += c; continue; } + if (c == '}') { + brace_depth--; + if (brace_depth < 0) + return std::string("Mismatched '}' at position " + std::to_string(i)); + current += c; + continue; + } + + if ((c == ' ' || c == '\t') && paren_depth == 0 && brace_depth == 0) { + if (!current.empty()) { + result.push_back(current); + current.clear(); + } + continue; + } + + current += c; + } + + if (in_string) + return std::string("Unterminated string literal"); + if (paren_depth > 0) + return std::string("Unclosed '(' — " + std::to_string(paren_depth) + " level(s) deep"); + if (brace_depth > 0) + return std::string("Unclosed '{' — " + std::to_string(brace_depth) + " level(s) deep"); + + if (!current.empty()) + result.push_back(current); + + return result; +} + +// (v2 types and functions moved to graph_builder.h/cpp) diff --git a/src/atto/args.h b/src/atto/args.h index d15a26a..01f5c61 100644 --- a/src/atto/args.h +++ b/src/atto/args.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -68,5 +69,10 @@ int find_max_port_ref(const std::string& s); // Parse a single token into a FlowArg FlowArg parse_token(const std::string& tok); -// Parse a full argument string +// Parse a full argument string (legacy wrapper) ParsedArgs parse_args(const std::string& args_str, bool is_expr = false); + +// Split an args string into singular expressions (space-delimited, aware of () {} "" nesting). +// Returns vector on success, or error string on failure (mismatched parens/braces/quotes). +using SplitResult = std::variant, std::string>; +SplitResult split_args(const std::string& args_str); diff --git a/src/atto/graph_builder.cpp b/src/atto/graph_builder.cpp new file mode 100644 index 0000000..9de4c41 --- /dev/null +++ b/src/atto/graph_builder.cpp @@ -0,0 +1,1525 @@ +#include "graph_builder.h" +#include "node_types2.h" +#include "expr.h" +#include +#include +#include + +// ─── TOML helpers ─── + +static std::string trim(std::string s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.erase(s.begin()); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back(); + return s; +} + +static std::string unescape_toml(const std::string& s) { + std::string result; + result.reserve(s.size()); + for (size_t i = 0; i < s.size(); i++) { + if (s[i] == '\\' && i + 1 < s.size()) { + switch (s[i + 1]) { + case '"': result += '"'; i++; break; + case '\\': result += '\\'; i++; break; + case 'n': result += '\n'; i++; break; + case 't': result += '\t'; i++; break; + case 'r': result += '\r'; i++; break; + default: result += s[i]; break; + } + } else { + result += s[i]; + } + } + return result; +} + +static std::string unquote(const std::string& s) { + if (s.size() >= 2 && s.front() == '"' && s.back() == '"') + return unescape_toml(s.substr(1, s.size() - 2)); + return s; +} + +static std::vector parse_toml_array(const std::string& val) { + std::vector result; + std::string s = trim(val); + if (s.empty() || s.front() != '[' || s.back() != ']') return result; + s = s.substr(1, s.size() - 2); + std::string item; + bool in_str = false, escaped = false; + for (char c : s) { + if (escaped) { item += c; escaped = false; continue; } + if (c == '\\' && in_str) { item += c; escaped = true; continue; } + if (c == '"') { in_str = !in_str; item += c; continue; } + if (c == ',' && !in_str) { result.push_back(unquote(trim(item))); item.clear(); continue; } + item += c; + } + if (!trim(item).empty()) result.push_back(unquote(trim(item))); + return result; +} + +// ─── FlowArg2 base ─── + +static void maybe_dirty(const std::shared_ptr& gb) { if (gb) gb->mark_dirty(); } + +FlowArg2::FlowArg2(ArgKind kind, const std::shared_ptr& owner) + : kind_(kind), owner_(owner) + , node_(owner ? owner->empty_node() : nullptr) + , net_(owner ? owner->unconnected_net() : nullptr) +{ + if (!owner_) throw std::logic_error("FlowArg2: owner must not be null"); + if (!node_) throw std::logic_error("FlowArg2: node must not be null"); + if (!net_) throw std::logic_error("FlowArg2: net must not be null"); +} + +void FlowArg2::mark_dirty() { + maybe_dirty(owner_); + // Only enqueue editor callbacks if editors are registered + if (!owner_->has_editors()) return; + // Enqueue type-specific arg editor callbacks + switch (kind_) { + case ArgKind::Net: { + auto self = std::dynamic_pointer_cast(shared_from_this()); + owner_->add_mutation_call(this, [self]() { + for (auto& we : self->editors_) + if (auto e = we.lock()) e->arg_net_mutated(self); + }); + break; + } + case ArgKind::Number: { + auto self = std::dynamic_pointer_cast(shared_from_this()); + owner_->add_mutation_call(this, [self]() { + for (auto& we : self->editors_) + if (auto e = we.lock()) e->arg_number_mutated(self); + }); + break; + } + case ArgKind::String: { + auto self = std::dynamic_pointer_cast(shared_from_this()); + owner_->add_mutation_call(this, [self]() { + for (auto& we : self->editors_) + if (auto e = we.lock()) e->arg_string_mutated(self); + }); + break; + } + case ArgKind::Expr: { + auto self = std::dynamic_pointer_cast(shared_from_this()); + owner_->add_mutation_call(this, [self]() { + for (auto& we : self->editors_) + if (auto e = we.lock()) e->arg_expr_mutated(self); + }); + break; + } + } + // Bubble up to node (structural change) + if (node_ && !node_->is_the_empty) + node_->mark_dirty(); +} + +const FlowNodeBuilderPtr& FlowArg2::node() const { + if (!node_) throw std::logic_error("FlowArg2::node(): node is null"); + return node_; +} +void FlowArg2::node(const FlowNodeBuilderPtr& n) { + if (!n) throw std::logic_error("FlowArg2::node(set): cannot set null, use empty_node()"); + node_ = n; +} + +const NetBuilderPtr& FlowArg2::net() const { + if (!net_) throw std::logic_error("FlowArg2::net(): net is null"); + return net_; +} +void FlowArg2::net(const NetBuilderPtr& w) { + if (!w) throw std::logic_error("FlowArg2::net(set): cannot set null, use unconnected_net()"); + net_ = w; +} + +const std::shared_ptr& FlowArg2::owner() const { + if (!owner_) throw std::logic_error("FlowArg2::owner(): owner is null"); + return owner_; +} + +std::shared_ptr FlowArg2::as_net() { + return kind_ == ArgKind::Net ? std::dynamic_pointer_cast(shared_from_this()) : nullptr; +} +std::shared_ptr FlowArg2::as_number() { + return kind_ == ArgKind::Number ? std::dynamic_pointer_cast(shared_from_this()) : nullptr; +} +std::shared_ptr FlowArg2::as_string() { + return kind_ == ArgKind::String ? std::dynamic_pointer_cast(shared_from_this()) : nullptr; +} +std::shared_ptr FlowArg2::as_expr() { + return kind_ == ArgKind::Expr ? std::dynamic_pointer_cast(shared_from_this()) : nullptr; +} + +std::string FlowArg2::name() const { + if (!is_remap()) { + std::string prefix = port_->name; + if (port_->va_args) { + if (port_->position == PortPosition2::Input) { + return prefix + "[" + std::to_string(input_pin_va_idx()) + "]"; + } else { + return prefix + "[" + std::to_string(output_pin_va_idx()) + "]"; + } + } else { + return prefix; + } + } else { + return "remaps[" + std::to_string(remap_idx()) + "]"; + } +} + +std::string FlowArg2::fq_name() const { + return node_->id() + "." + name(); +} + +unsigned FlowArg2::remap_idx() const { + if (!is_remap()) throw std::logic_error("FlowArg2::remap_idx(): not a remap (port is set)"); + auto n = node(); + auto self = const_cast(this)->shared_from_this(); + for (unsigned i = 0; i < n->remaps.size(); i++) { + if (n->remaps[i] == self) return i; + } + throw std::logic_error("FlowArg2::remap_idx(): arg not found in node remaps"); +} + +unsigned FlowArg2::input_pin_idx() const { + if (is_remap()) throw std::logic_error("FlowArg2::input_pin_idx(): is a remap (no port)"); + auto n = node(); + auto self = const_cast(this)->shared_from_this(); + if (n->parsed_args) { + for (unsigned i = 0; i < (unsigned)n->parsed_args->size(); i++) + if ((*n->parsed_args)[i] == self) return i; + } + if (n->parsed_va_args) { + unsigned base = n->parsed_args ? (unsigned)n->parsed_args->size() : 0; + for (unsigned i = 0; i < (unsigned)n->parsed_va_args->size(); i++) + if ((*n->parsed_va_args)[i] == self) return base + i; + } + throw std::logic_error("FlowArg2::input_pin_idx(): arg not found in node inputs"); +} + +unsigned FlowArg2::input_pin_va_idx() const { + if (is_remap()) throw std::logic_error("FlowArg2::input_pin_va_idx(): is a remap (no port)"); + auto n = node(); + auto self = const_cast(this)->shared_from_this(); + if (n->parsed_va_args) { + for (unsigned i = 0; i < (unsigned)n->parsed_va_args->size(); i++) + if ((*n->parsed_va_args)[i] == self) return i; + } + throw std::logic_error("FlowArg2::input_pin_va_idx(): arg not found in node inputs"); +} + +unsigned FlowArg2::output_pin_idx() const { + if (is_remap()) throw std::logic_error("FlowArg2::output_pin_idx(): is a remap (no port)"); + auto n = node(); + auto self = const_cast(this)->shared_from_this(); + for (unsigned i = 0; i < (unsigned)n->outputs.size(); i++) + if (n->outputs[i] == self) return i; + throw std::logic_error("FlowArg2::output_pin_idx(): arg not found in node outputs"); +} + + +unsigned FlowArg2::output_pin_va_idx() const { + if (is_remap()) throw std::logic_error("FlowArg2::output_pin_va_idx(): is a remap (no port)"); + auto n = node(); + auto self = const_cast(this)->shared_from_this(); + for (unsigned i = 0; i < (unsigned)n->outputs_va_args.size(); i++) + if (n->outputs_va_args[i] == self) return i; + throw std::logic_error("FlowArg2::output_pin_va_idx(): arg not found in node outputs"); +} + +// ─── Dirty-tracked setters ─── + +void ArgNet2::net_id(const NodeId& v) { + if (v.empty()) throw std::logic_error("ArgNet2::net_id: cannot set empty id"); + net_id_ = v; mark_dirty(); +} +void ArgNet2::entry(std::shared_ptr v) { + if (!v) throw std::logic_error("ArgNet2::entry: cannot set null entry"); + entry_ = std::move(v); mark_dirty(); +} +void ArgNumber2::value(double v) { value_ = v; mark_dirty(); } +void ArgNumber2::is_float(bool v) { is_float_ = v; mark_dirty(); } +void ArgString2::value(const std::string& v) { value_ = v; mark_dirty(); } +void ArgExpr2::expr(const std::string& v) { expr_ = v; mark_dirty(); } + +// ─── ParsedArgs2 ─── + +void ParsedArgs2::push_back(FlowArg2Ptr arg) { items_.push_back(std::move(arg)); maybe_dirty(owner); } +void ParsedArgs2::pop_back() { items_.pop_back(); maybe_dirty(owner); } +void ParsedArgs2::resize(int n) { + // Can't create default FlowArg2 (abstract), just truncate if shrinking + items_.resize(n); + maybe_dirty(owner); +} +void ParsedArgs2::insert(iterator pos, FlowArg2Ptr arg) { items_.insert(pos, std::move(arg)); maybe_dirty(owner); } +void ParsedArgs2::clear() { items_.clear(); maybe_dirty(owner); } +void ParsedArgs2::set(int i, FlowArg2Ptr arg) { items_[i] = std::move(arg); maybe_dirty(owner); } + +// ─── BuilderEntry ─── + +void BuilderEntry::id(const NodeId& v) { id_ = v; mark_dirty(); } +void BuilderEntry::mark_dirty() { + maybe_dirty(owner_); + if (!owner_ || !owner_->has_editors()) return; + if (is(IdCategory::Node)) { + auto self = std::dynamic_pointer_cast(shared_from_this()); + owner_->add_mutation_call(this, [self]() { + for (auto& we : self->editors_) + if (auto e = we.lock()) e->node_mutated(self); + }); + } else if (is(IdCategory::Net)) { + auto self = std::dynamic_pointer_cast(shared_from_this()); + owner_->add_mutation_call(this, [self]() { + for (auto& we : self->editors_) + if (auto e = we.lock()) e->net_mutated(self); + }); + } +} + +std::shared_ptr BuilderEntry::as_node() { + return std::dynamic_pointer_cast(shared_from_this()); +} +std::shared_ptr BuilderEntry::as_net() { + return std::dynamic_pointer_cast(shared_from_this()); +} + +// ─── NetBuilder ─── + +void NetBuilder::compact() { + destinations().erase( + std::remove_if(destinations().begin(), destinations().end(), [](auto& w) { return w.expired(); }), + destinations().end()); +} + +bool NetBuilder::unused() { + compact(); + return source().expired() && destinations().empty(); +} + +void NetBuilder::validate() const { +} + +// ─── v2 parse/reconstruct ─── + +static FlowArg2Ptr parse_token_v2(GraphBuilder& gb, const std::string& tok) { + if (tok.empty()) return gb.build_arg_string(""); + + // Net reference: $name (non-numeric) + if (tok[0] == '$' && tok.size() >= 2 && !std::isdigit(tok[1])) { + auto [id, entry] = gb.find_or_create_net(tok, false); + return gb.build_arg_net(NodeId(id), entry); + } + + // String literal + if (tok.front() == '"' && tok.back() == '"' && tok.size() >= 2) { + return gb.build_arg_string(tok.substr(1, tok.size() - 2)); + } + + // Number + bool is_float = false; + bool is_number = true; + for (size_t i = 0; i < tok.size(); i++) { + char c = tok[i]; + if (c == '.' && !is_float) { is_float = true; continue; } + if (c == 'f' && i == tok.size() - 1) { is_float = true; continue; } + if (c == '-' && i == 0) continue; + if (c < '0' || c > '9') { is_number = false; break; } + } + if (is_number && !tok.empty()) { + return gb.build_arg_number(std::stod(tok), is_float); + } + + // Expression (anything else) + return gb.build_arg_expr(tok); +} + +ParseResult parse_args_v2(const std::shared_ptr& gb, + const std::vector& exprs, bool is_expr) { + auto result = std::make_shared(); + + // Scan all expressions for $N refs to compute rewrite_input_count + std::set slot_indices; + for (auto& expr : exprs) { + for (size_t i = 0; i < expr.size(); i++) { + if (expr[i] == '$' && i + 1 < expr.size() && std::isdigit(expr[i + 1])) { + int n = 0; + size_t j = i + 1; + while (j < expr.size() && std::isdigit(expr[j])) { + n = n * 10 + (expr[j] - '0'); + j++; + } + slot_indices.insert(n); + } + } + } + + // Validate contiguous from 0 + if (!slot_indices.empty()) { + int max_slot = *slot_indices.rbegin(); + if ((int)slot_indices.size() != max_slot + 1) { + std::string missing; + for (int i = 0; i <= max_slot; i++) { + if (!slot_indices.count(i)) { + if (!missing.empty()) missing += ", "; + missing += "$" + std::to_string(i); + } + } + return std::string("Missing pin reference(s): " + missing); + } + result->rewrite_input_count = max_slot + 1; + } + + for (auto& expr : exprs) { + result->push_back(parse_token_v2(*gb, expr)); + } + return result; +} + +std::string reconstruct_args_str(const ParsedArgs2& args) { + std::string result; + for (auto& a : args) { + if (!a) continue; + if (!result.empty()) result += " "; + if (auto n = a->as_net()) result += n->first(); + else if (auto num = a->as_number()) { + if (num->is_float()) { + char buf[64]; + snprintf(buf, sizeof(buf), "%g", num->value()); + result += buf; + } else { + result += std::to_string((long long)num->value()); + } + } + else if (auto s = a->as_string()) result += "\"" + s->value() + "\""; + else if (auto e = a->as_expr()) result += e->expr(); + } + return result; +} + +// ─── FlowNodeBuilder ─── + +std::string FlowNodeBuilder::args_str() const { + std::string result; + if (parsed_args) result = reconstruct_args_str(*parsed_args); + if (parsed_va_args && !parsed_va_args->empty()) { + std::string va = reconstruct_args_str(*parsed_va_args); + if (!va.empty()) { + if (!result.empty()) result += " "; + result += va; + } + } + return result; +} + +// ─── GraphBuilder ─── + +std::shared_ptr GraphBuilder::add_node(NodeId id, NodeTypeID type, std::shared_ptr args) { + auto nb = std::make_shared(shared_from_this()); + nb->type_id = type; + nb->parsed_args = std::move(args); + nb->id(id); + entries[std::move(id)] = nb; + return nb; +} + +FlowNodeBuilderPtr GraphBuilder::empty_node() { + ensure_sentinels(); + return empty_; +} + +NetBuilderPtr GraphBuilder::unconnected_net() { + ensure_sentinels(); + return unconnected_; +} + +void GraphBuilder::ensure_sentinels() { + if (!unconnected_) { + unconnected_ = std::make_shared(shared_from_this()); + unconnected_->is_the_unconnected(true); + unconnected_->auto_wire(true); + unconnected_->id("$unconnected"); + entries["$unconnected"] = unconnected_; + } + if (!empty_) { + empty_ = std::make_shared(shared_from_this()); + empty_->is_the_empty = true; + empty_->id("$empty"); + entries["$empty"] = empty_; + } +} + +std::pair GraphBuilder::find_or_create_net(const NodeId& name, bool for_source) { + if (name == "$unconnected" || name == "$empty") + throw std::logic_error("find_or_create_net: use unconnected_net()/empty_node() for sentinel '" + name + "'"); + auto it = entries.find(name); + if (it != entries.end()) { + if (auto net = it->second->as_net()) { + if (for_source && !net->source().expired()) + throw std::logic_error("find_or_create_net(\"" + name + "\"): net already has a source"); + return {it->first, it->second}; + } + return {it->first, nullptr}; + } + auto net = std::make_shared(shared_from_this()); + net->auto_wire(name.size() >= 6 && name.substr(0, 6) == "$auto-"); + net->id(name); + entries[name] = net; + return {entries.find(name)->first, net}; +} + +BuilderEntryPtr GraphBuilder::find_or_null_node(const NodeId& id) { + auto it = entries.find(id); + return (it != entries.end()) ? it->second : nullptr; +} + +BuilderEntryPtr GraphBuilder::find(const NodeId& id) { + if (id == "$unconnected" || id == "$empty") + throw std::logic_error("find: use unconnected_net()/empty_node() for sentinel '" + id + "'"); + auto it = entries.find(id); + return (it != entries.end()) ? it->second : nullptr; +} + +FlowNodeBuilderPtr GraphBuilder::find_node(const NodeId& id) { + auto it = entries.find(id); + if (it == entries.end()) return nullptr; + return it->second->as_node(); +} + +NetBuilderPtr GraphBuilder::find_net(const NodeId& name) { + auto it = entries.find(name); + if (it == entries.end()) return nullptr; + return it->second->as_net(); +} + +void GraphBuilder::compact() { + for (auto it = entries.begin(); it != entries.end(); ) { + if (auto net = it->second->as_net()) { + if (!net->is_the_unconnected() && net->unused()) { + it = entries.erase(it); + continue; + } + } + ++it; + } +} + +NodeId GraphBuilder::next_id() { + for (int n = 0; ; n++) { + char buf[32]; + snprintf(buf, sizeof(buf), "$a-%x", n); + if (!entries.count(buf)) return buf; + } +} + +bool GraphBuilder::rename(const BuilderEntryPtr& entry, const NodeId& new_id) { + if (!entry) return false; + if (entries.count(new_id)) return false; // collision + + const NodeId old_id = entry->id(); + entries.erase(old_id); + entry->id(new_id); + entries[new_id] = entry; + mark_dirty(); + return true; +} + +FlowArg2Ptr GraphBuilder::build_arg_net(NodeId id, BuilderEntryPtr entry, const PortDesc2* port) { + auto p = std::shared_ptr(new ArgNet2{std::move(id), std::move(entry), shared_from_this()}); + if (port) p->port(port); + pins_.push_back(p); + return p; +} +FlowArg2Ptr GraphBuilder::build_arg_number(double value, bool is_float, const PortDesc2* port) { + auto p = std::shared_ptr(new ArgNumber2{value, is_float, shared_from_this()}); + if (port) p->port(port); + pins_.push_back(p); + return p; +} +FlowArg2Ptr GraphBuilder::build_arg_string(std::string value, const PortDesc2* port) { + auto p = std::shared_ptr(new ArgString2{std::move(value), shared_from_this()}); + if (port) p->port(port); + pins_.push_back(p); + return p; +} +FlowArg2Ptr GraphBuilder::build_arg_expr(std::string expr, const PortDesc2* port) { + auto p = std::shared_ptr(new ArgExpr2{std::move(expr), shared_from_this()}); + if (port) p->port(port); + pins_.push_back(p); + return p; +} + +// ─── Mutation batching ─── + +void GraphBuilder::edit_start() { + if (!mutations_.empty()) + throw std::logic_error("GraphBuilder::edit_start(): previous edit_commit() was missed (" + + std::to_string(mutations_.size()) + " pending mutations)"); +} + +void GraphBuilder::edit_commit() { + auto mutations = std::move(mutations_); + mutations_.clear(); + mutation_items_.clear(); + for (auto& fn : mutations) + fn(); +} + +void GraphBuilder::add_mutation_call(void* ptr, std::function&& fn) { + if (mutation_items_.count(ptr)) return; + mutation_items_.insert(ptr); + mutations_.push_back(std::move(fn)); +} + +// ─── Editor registration ─── + +// Helper: create arg editors for all args belonging to a node +static void register_arg_editors(const std::shared_ptr& node_editor, + const FlowNodeBuilderPtr& node) { + // Helper lambda to process a single arg + auto process_arg = [&](const FlowArg2Ptr& arg) { + if (!arg) return; + switch (arg->kind()) { + case ArgKind::Net: { + auto a = arg->as_net(); + auto ed = node_editor->create_arg_net_editor(a); + if (ed) a->editors_.push_back(ed); + break; + } + case ArgKind::Number: { + auto a = arg->as_number(); + auto ed = node_editor->create_arg_number_editor(a); + if (ed) a->editors_.push_back(ed); + break; + } + case ArgKind::String: { + auto a = arg->as_string(); + auto ed = node_editor->create_arg_string_editor(a); + if (ed) a->editors_.push_back(ed); + break; + } + case ArgKind::Expr: { + auto a = arg->as_expr(); + auto ed = node_editor->create_arg_expr_editor(a); + if (ed) a->editors_.push_back(ed); + break; + } + } + }; + + // Process all arg containers on the node + if (node->parsed_args) { + for (int i = 0; i < node->parsed_args->size(); i++) + process_arg((*node->parsed_args)[i]); + } + if (node->parsed_va_args) { + for (int i = 0; i < node->parsed_va_args->size(); i++) + process_arg((*node->parsed_va_args)[i]); + } + for (auto& arg : node->remaps) process_arg(arg); + for (auto& arg : node->outputs) process_arg(arg); + for (auto& arg : node->outputs_va_args) process_arg(arg); +} + +// Helper: enqueue initial mutated callbacks bottom-up for a node and its args +static void enqueue_initial_mutations(GraphBuilder* gb, const FlowNodeBuilderPtr& node) { + // Enqueue arg mutations first (innermost) + auto enqueue_arg = [&](const FlowArg2Ptr& arg) { + if (!arg) return; + switch (arg->kind()) { + case ArgKind::Net: { + auto a = arg->as_net(); + gb->add_mutation_call(a.get(), [a]() { + for (auto& we : a->editors_) + if (auto e = we.lock()) e->arg_net_mutated(a); + }); + break; + } + case ArgKind::Number: { + auto a = arg->as_number(); + gb->add_mutation_call(a.get(), [a]() { + for (auto& we : a->editors_) + if (auto e = we.lock()) e->arg_number_mutated(a); + }); + break; + } + case ArgKind::String: { + auto a = arg->as_string(); + gb->add_mutation_call(a.get(), [a]() { + for (auto& we : a->editors_) + if (auto e = we.lock()) e->arg_string_mutated(a); + }); + break; + } + case ArgKind::Expr: { + auto a = arg->as_expr(); + gb->add_mutation_call(a.get(), [a]() { + for (auto& we : a->editors_) + if (auto e = we.lock()) e->arg_expr_mutated(a); + }); + break; + } + } + }; + + if (node->parsed_args) + for (int i = 0; i < node->parsed_args->size(); i++) + enqueue_arg((*node->parsed_args)[i]); + if (node->parsed_va_args) + for (int i = 0; i < node->parsed_va_args->size(); i++) + enqueue_arg((*node->parsed_va_args)[i]); + for (auto& a : node->remaps) enqueue_arg(a); + for (auto& a : node->outputs) enqueue_arg(a); + for (auto& a : node->outputs_va_args) enqueue_arg(a); + + // Then enqueue node mutation (outer) + gb->add_mutation_call(node.get(), [node]() { + for (auto& we : node->editors_) + if (auto e = we.lock()) e->node_mutated(node); + }); +} + +void GraphBuilder::add_editor(const std::shared_ptr& editor) { + editors_.push_back(editor); + + // Register existing entries and fire initial mutations + edit_start(); + + for (auto& [id, entry] : entries) { + if (auto node = entry->as_node()) { + if (node->shadow || node->is_the_empty) continue; + auto node_ed = editor->node_added(id, node); + if (node_ed) { + node->editors_.push_back(node_ed); + register_arg_editors(node_ed, node); + enqueue_initial_mutations(this, node); + } + } else if (auto net = entry->as_net()) { + if (net->is_the_unconnected()) continue; + auto net_ed = editor->net_added(id, net); + if (net_ed) { + net->editors_.push_back(net_ed); + add_mutation_call(net.get(), [net]() { + for (auto& we : net->editors_) + if (auto e = we.lock()) e->net_mutated(net); + }); + } + } + } + + edit_commit(); +} + +void GraphBuilder::remove_editor(const std::shared_ptr& editor) { + editors_.erase( + std::remove_if(editors_.begin(), editors_.end(), + [&](const std::weak_ptr& w) { + auto s = w.lock(); + return !s || s == editor; + }), + editors_.end()); + // Note: per-item editor weak_ptrs will naturally expire +} + +// ─── FlowNodeBuilder::mark_layout_dirty ─── + +void FlowNodeBuilder::mark_layout_dirty() { + auto gb = owner(); + if (gb) gb->mark_dirty(); + if (!gb || !gb->has_editors()) return; + auto self = std::dynamic_pointer_cast(shared_from_this()); + gb->add_mutation_call(this, [self]() { + for (auto& we : self->editors_) + if (auto e = we.lock()) e->node_layout_changed(self); + }); +} + +// ─── Deserializer ─── + +BuilderResult Deserializer::parse_node( + const std::shared_ptr& gb, + const NodeId& id, const std::string& type, const std::vector& args) { + + NodeTypeID type_id = node_type_id_from_string(type.c_str()); + + if (type_id == NodeTypeID::Unknown) { + return BuilderError("Unknown node type: " + type); + } + + FlowNodeBuilder nb; + nb.type_id = type_id; + + if (is_any_of(type_id, NodeTypeID::Label, NodeTypeID::Error)) { + if (args.size() != 1) + throw std::invalid_argument("Label/Error node requires exactly 1 argument, got " + std::to_string(args.size())); + nb.parsed_args = std::make_shared(); + nb.parsed_args->push_back(gb->build_arg_string(args[0])); + return std::pair{id, std::move(nb)}; + } + + bool is_expr = is_any_of(type_id, NodeTypeID::Expr, NodeTypeID::ExprBang); + + auto parse_result = parse_args_v2(gb, args, is_expr); + if (auto* err = std::get_if(&parse_result)) { + return BuilderError(*err); + } + + nb.parsed_args = std::get>(std::move(parse_result)); + return std::pair{id, std::move(nb)}; +} + +FlowNodeBuilder& Deserializer::parse_or_error( + const std::shared_ptr& gb, + const NodeId& id, const std::string& type, const std::vector& args) { + + auto result = parse_node(gb, id, type, args); + + if (auto* p = std::get_if>(&result)) { + auto entry = std::make_shared(std::move(p->second)); + entry->id(p->first); + entry->owner(gb); + gb->entries[p->first] = entry; + return *entry; + } + + auto& error_msg = std::get(result); + std::string args_joined; + for (auto& a : args) { + if (!args_joined.empty()) args_joined += " "; + args_joined += a; + } + auto entry = std::make_shared(gb); + entry->type_id = NodeTypeID::Error; + entry->parsed_args = std::make_shared(); + entry->parsed_args->push_back(gb->build_arg_string(type + " " + args_joined)); + entry->error = error_msg; + entry->id(id); + gb->entries[id] = entry; + return *entry; +} + +// ─── parse_atto ─── + +Deserializer::ParseAttoResult Deserializer::parse_atto(std::istream& f) { + std::string first_line; + while (std::getline(f, first_line)) { + first_line = trim(first_line); + if (!first_line.empty()) break; + } + if (first_line != "# version instrument@atto:0") { + return BuilderError("Expected '# version instrument@atto:0', got: " + first_line); + } + + auto gb = std::make_shared(); + gb->ensure_sentinels(); + + bool in_node = false; + std::string cur_id, cur_type; + std::vector cur_args; + std::vector cur_inputs, cur_outputs; + float cur_x = 0, cur_y = 0; + bool cur_shadow = false; + + // Track shadow input nets for remap construction during folding + std::map> shadow_input_nets; // shadow_id → input net names + + auto flush_node = [&]() { + if (cur_type.empty()) { + cur_id.clear(); cur_args.clear(); cur_inputs.clear(); cur_outputs.clear(); + return; + } + + if (cur_id.empty()) { + cur_id = "$auto-" + generate_guid(); + } + + auto& nb = parse_or_error(gb, cur_id, cur_type, cur_args); + nb.position = {cur_x, cur_y}; + nb.shadow = cur_shadow; + + // Save shadow input nets for later folding + if (cur_shadow) { + shadow_input_nets[cur_id] = cur_inputs; + } + + auto node_entry = gb->find(cur_id); + + // Wire nets from outputs — smart map old positions to new descriptor order + // Old outputs array: [nexts..., data_outs..., post_bang, lambda_grab] + // New: nb.outputs[i] = ArgNet2 for new output_ports[i] + { + auto* old_nt = find_node_type(cur_type.c_str()); + auto* new_nt = find_node_type2(nb.type_id); + bool is_expr = is_any_of(nb.type_id, NodeTypeID::Expr, NodeTypeID::ExprBang); + int old_num_nexts = old_nt ? old_nt->num_nexts : 0; + + // Helper: net a net and return ArgNet2 + auto wire_output = [&](const std::string& net_name) -> FlowArg2Ptr { + auto [resolved, net_ptr] = gb->find_or_create_net(net_name, true); + if (auto net = net_ptr ? net_ptr->as_net() : nullptr) + net->source(node_entry); + return gb->build_arg_net(resolved, net_ptr); + }; + + // Filter out empty and -as_lambda entries, net all nets + // For expr: outputs are all data (no nexts), dynamic count + // For others: [nexts..., data_outs..., post_bang] + if (is_expr) { + // Expr/Expr!: split outputs into fixed (outputs) and va (outputs_va_args) + // Old format: + // Expr (Flow): [out0, out1, ..., post_bang] — post_bang is side-bang → outputs[0] + // Expr! (Banged): [next, out0, out1, ...] — next → outputs[0] + bool is_flow_expr = (nb.type_id == NodeTypeID::Expr); + auto* va_port = new_nt ? new_nt->output_ports_va_args : nullptr; + + // Collect all non-empty, non-lambda entries + std::vector all_outs; + FlowArg2Ptr post_bang_arg = nullptr; + for (int i = 0; i < (int)cur_outputs.size(); i++) { + auto& net_name = cur_outputs[i]; + if (net_name.empty()) continue; + if (net_name.size() > 10 && net_name.compare(net_name.size() - 10, 10, "-as_lambda") == 0) + continue; + bool is_post_bang = is_flow_expr && (net_name.size() > 10 && + net_name.compare(net_name.size() - 10, 10, "-post_bang") == 0); + auto arg = wire_output(net_name); + if (is_post_bang) { + // Side-bang for flow expr → goes to outputs[0] + if (new_nt && new_nt->num_outputs > 0) + arg->port(&new_nt->output_ports[0]); + post_bang_arg = std::move(arg); + } else { + if (va_port) arg->port(va_port); + all_outs.push_back(std::move(arg)); + } + } + + // Populate fixed outputs from descriptor + int fixed_out = new_nt ? new_nt->num_outputs : 0; + nb.outputs.resize(fixed_out); + auto unconnected = gb->unconnected_net(); + + if (is_flow_expr) { + // Flow expr: outputs[0] = side-bang (post_bang or $unconnected) + if (fixed_out > 0) { + nb.outputs[0] = post_bang_arg ? std::move(post_bang_arg) + : gb->build_arg_net("$unconnected", unconnected, + new_nt ? &new_nt->output_ports[0] : nullptr); + } + // All data outputs go to outputs_va_args + for (auto& a : all_outs) + nb.outputs_va_args.push_back(std::move(a)); + } else { + // Banged expr!: outputs[0] = next (first entry from all_outs if it's bang) + if (fixed_out > 0 && !all_outs.empty()) { + all_outs[0]->port(&new_nt->output_ports[0]); + nb.outputs[0] = std::move(all_outs[0]); + // Rest go to outputs_va_args + for (int i = 1; i < (int)all_outs.size(); i++) + nb.outputs_va_args.push_back(std::move(all_outs[i])); + } else { + for (int i = 0; i < fixed_out; i++) + nb.outputs[i] = gb->build_arg_net("$unconnected", unconnected, + &new_nt->output_ports[i]); + } + } + + // Fill va_args to match expression count + int expr_count = nb.parsed_args ? nb.parsed_args->size() : 0; + while ((int)nb.outputs_va_args.size() < expr_count) + nb.outputs_va_args.push_back(gb->build_arg_net("$unconnected", unconnected, va_port)); + } else { + // Name-based mapping for non-expr nodes + int old_num_outs = old_nt ? old_nt->outputs : 0; + std::map out_net_map; + + for (int i = 0; i < (int)cur_outputs.size(); i++) { + auto& net_name = cur_outputs[i]; + if (net_name.empty()) continue; + if (net_name.size() > 10 && net_name.compare(net_name.size() - 10, 10, "-as_lambda") == 0) + continue; + + auto arg = wire_output(net_name); + + // Determine old pin name from position + std::string old_pin_name; + if (i < old_num_nexts) { + old_pin_name = (old_nt && old_nt->next_ports) ? old_nt->next_ports[i].name : "bang"; + } else if (i < old_num_nexts + old_num_outs) { + int out_idx = i - old_num_nexts; + old_pin_name = (old_nt && old_nt->output_ports) ? old_nt->output_ports[out_idx].name : "result"; + } else { + old_pin_name = "post_bang"; + } + + out_net_map[old_pin_name] = std::move(arg); + } + + // Map to new descriptor order + if (new_nt) { + nb.outputs.resize(new_nt->num_outputs); + auto unconnected = gb->unconnected_net(); + std::set consumed; + for (int i = 0; i < new_nt->num_outputs; i++) { + auto* pd = &new_nt->output_ports[i]; + auto it = out_net_map.find(pd->name); + if (it != out_net_map.end()) { + it->second->port(pd); + nb.outputs[i] = std::move(it->second); + consumed.insert(pd->name); + } else if (strcmp(pd->name, "next") == 0) { + auto it2 = out_net_map.find("bang"); + if (it2 != out_net_map.end()) { + it2->second->port(pd); + nb.outputs[i] = std::move(it2->second); + consumed.insert("bang"); + } else { + nb.outputs[i] = gb->build_arg_net("$unconnected", unconnected, pd); + } + } else { + nb.outputs[i] = gb->build_arg_net("$unconnected", unconnected, pd); + } + } + // Spillover: unconsumed outputs go to outputs_va_args (for event! etc.) + if (new_nt->output_ports_va_args) { + for (auto& [name, arg] : out_net_map) { + if (!consumed.count(name) && name != "post_bang") { + arg->port(new_nt->output_ports_va_args); + nb.outputs_va_args.push_back(std::move(arg)); + } + } + } + } + } + } + + // ─── v0 → v1 port mapping: merge inputs + args by port name ─── + if (!cur_inputs.empty() && !cur_shadow) { + auto* old_nt = find_node_type(cur_type.c_str()); + auto* new_nt = find_node_type2(nb.type_id); + bool is_expr = is_any_of(nb.type_id, NodeTypeID::Expr, NodeTypeID::ExprBang); + bool args_are_type = is_any_of(nb.type_id, NodeTypeID::Cast, NodeTypeID::New); + + // Helper: resolve net/node name to ArgNet2 and register destination + auto resolve_net = [&](const std::string& net_name) -> FlowArg2Ptr { + if (net_name.empty()) { + return gb->build_arg_net("$unconnected", gb->unconnected_net()); + } + // Strip -as_lambda suffix → resolve to node entry directly + std::string resolved_name = net_name; + if (resolved_name.size() > 10 && + resolved_name.compare(resolved_name.size() - 10, 10, "-as_lambda") == 0) { + resolved_name.resize(resolved_name.size() - 10); + } + // Try finding as any entry (node or net) + auto ptr = gb->find(resolved_name); + if (ptr) { + if (auto net = ptr->as_net()) + net->destinations().push_back(node_entry); + return gb->build_arg_net(resolved_name, ptr); + } + // Not found yet — create as net + auto [id, net_ptr] = gb->find_or_create_net(resolved_name); + net_ptr->as_net()->destinations().push_back(node_entry); + return gb->build_arg_net(id, net_ptr); + }; + + if (is_expr) { + // Expr nodes: inputs map to $N remaps, not descriptor ports + // For expr!, inputs[0] is the bang trigger, rest are $N + int bang_offset = is_any_of(nb.type_id, NodeTypeID::ExprBang) ? 1 : 0; + for (int i = 0; i < (int)cur_inputs.size(); i++) { + auto arg = resolve_net(cur_inputs[i]); + if (i < bang_offset) { + // Bang trigger → prepend to parsed_args + if (!nb.parsed_args) nb.parsed_args = std::make_shared(); + nb.parsed_args->insert(nb.parsed_args->begin(), std::move(arg)); + } else { + // $N remap + int remap_idx = i - bang_offset; + while ((int)nb.remaps.size() <= remap_idx) + nb.remaps.push_back(gb->build_arg_net("$unconnected", gb->unconnected_net())); + nb.remaps[remap_idx] = std::move(arg); + } + } + } else if (!old_nt || !new_nt) { + // Unknown types: simple positional prepend + auto merged = std::make_shared(); + for (auto& net_name : cur_inputs) + merged->push_back(resolve_net(net_name)); + if (nb.parsed_args) { + for (auto& a : *nb.parsed_args) + merged->push_back(std::move(a)); + merged->rewrite_input_count = nb.parsed_args->rewrite_input_count; + } + nb.parsed_args = std::move(merged); + } else { + // Name-based mapping using old and new descriptors + + // Step 1: Build old pin name list (matching inputs array order) + std::vector old_pin_names; + // Triggers first + for (int i = 0; i < old_nt->num_triggers; i++) { + if (old_nt->trigger_ports) + old_pin_names.push_back(old_nt->trigger_ports[i].name); + else + old_pin_names.push_back("bang_in"); + } + // Data pins depend on args + std::string args_joined; + for (auto& a : cur_args) { + if (!args_joined.empty()) args_joined += " "; + args_joined += a; + } + + if (args_are_type) { + // Type nodes: all descriptor inputs become pins + for (int i = 0; i < old_nt->inputs; i++) { + if (old_nt->input_ports && i < old_nt->inputs) + old_pin_names.push_back(old_nt->input_ports[i].name); + else + old_pin_names.push_back(std::to_string(i)); + } + } else { + auto info = compute_inline_args(args_joined, old_nt->inputs); + // $N ref pins first + int ref_pins = (info.pin_slots.max_slot >= 0) ? (info.pin_slots.max_slot + 1) : 0; + for (int i = 0; i < ref_pins; i++) { + bool is_lambda = info.pin_slots.is_lambda_slot(i); + old_pin_names.push_back(is_lambda ? ("@" + std::to_string(i)) : std::to_string(i)); + } + // Remaining descriptor pins + for (int i = info.num_inline_args; i < old_nt->inputs; i++) { + if (old_nt->input_ports && i < old_nt->inputs) + old_pin_names.push_back(old_nt->input_ports[i].name); + else + old_pin_names.push_back(std::to_string(i)); + } + } + + // Step 2: Build port_name → ArgNet2 map from inputs array + std::map net_map; + for (int i = 0; i < (int)cur_inputs.size() && i < (int)old_pin_names.size(); i++) { + net_map[old_pin_names[i]] = resolve_net(cur_inputs[i]); + } + + // Step 3: Build port_name → parsed_value map from inlined args + // Inlined args cover input_ports[0..num_inline_args-1] + std::map inline_map; + if (!args_are_type && nb.parsed_args) { + auto info = compute_inline_args(args_joined, old_nt->inputs); + int num_inline = std::min(info.num_inline_args, old_nt->inputs); + for (int i = 0; i < num_inline && i < (int)nb.parsed_args->size(); i++) { + if (old_nt->input_ports && i < old_nt->inputs) + inline_map[old_nt->input_ports[i].name] = std::move((*nb.parsed_args)[i]); + } + } + + // Step 4: Build unified parsed_args in new descriptor order + auto merged = std::make_shared(); + if (nb.parsed_args) + merged->rewrite_input_count = nb.parsed_args->rewrite_input_count; + + // Helper: find value by port name with fallback for bang→bang_in rename + auto find_by_name = [&](const char* name) -> FlowArg2Ptr { + auto net_it = net_map.find(name); + if (net_it != net_map.end()) + return std::move(net_it->second); + auto inline_it = inline_map.find(name); + if (inline_it != inline_map.end()) + return std::move(inline_it->second); + if (strcmp(name, "bang_in") == 0) { + auto it2 = net_map.find("bang"); + if (it2 != net_map.end()) + return std::move(it2->second); + } + return nullptr; + }; + + // Pass 1: fill by name matching + std::vector filled(new_nt->total_inputs(), false); + for (int i = 0; i < new_nt->total_inputs(); i++) { + auto* pd = new_nt->input_port(i); + auto value = find_by_name(pd->name); + if (value) { + value->port(pd); + merged->push_back(std::move(value)); + filled[i] = true; + } else { + auto placeholder = resolve_net(""); + placeholder->port(pd); + merged->push_back(std::move(placeholder)); + } + } + + // Pass 2: fill unfilled non-bang slots from unconsumed parsed_args + // inline_map consumed parsed_args[0..num_inline-1]; rest are available + if (nb.parsed_args) { + int consumed = 0; + if (!args_are_type) { + auto info2 = compute_inline_args(args_joined, old_nt->inputs); + consumed = std::min(info2.num_inline_args, (int)nb.parsed_args->size()); + consumed = std::min(consumed, old_nt->inputs); + } + int arg_cursor = consumed; + for (int i = 0; i < new_nt->total_inputs(); i++) { + if (!filled[i] && new_nt->input_port(i)->kind != PortKind2::BangTrigger) { + if (arg_cursor < (int)nb.parsed_args->size()) { + auto arg = std::move((*nb.parsed_args)[arg_cursor++]); + arg->port(new_nt->input_port(i)); + merged->set(i, std::move(arg)); + filled[i] = true; + } + } + } + // Remaining args beyond descriptor slots → appended (for va_args split later) + for (; arg_cursor < (int)nb.parsed_args->size(); arg_cursor++) + merged->push_back(std::move((*nb.parsed_args)[arg_cursor])); + } + + nb.parsed_args = std::move(merged); + } + } else if (!cur_inputs.empty() && cur_shadow) { + // Shadows: inputs wired as net destinations (handled during folding) + for (auto& net_name : cur_inputs) { + if (net_name.empty()) continue; + auto [_, net_ptr] = gb->find_or_create_net(net_name); + if (auto net = net_ptr ? net_ptr->as_net() : nullptr) + net->destinations().push_back(node_entry); + } + } + + // Ensure remaps are sized to rewrite_input_count (from $N refs in expressions) + if (nb.parsed_args && nb.parsed_args->rewrite_input_count > (int)nb.remaps.size()) { + auto unconnected = gb->unconnected_net(); + while ((int)nb.remaps.size() < nb.parsed_args->rewrite_input_count) + nb.remaps.push_back(gb->build_arg_net("$unconnected", unconnected)); + } + + // Trim trailing $unconnected optional ports from parsed_args + // Optional ports are always trailing: anything beyond num_inputs is optional + { + auto* trim_nt = find_node_type2(nb.type_id); + if (trim_nt && nb.parsed_args) { + while ((int)nb.parsed_args->size() > trim_nt->num_inputs) { + auto an = nb.parsed_args->back()->as_net(); + if (!an || an->first() != "$unconnected") break; + nb.parsed_args->pop_back(); + } + } + } + + cur_id.clear(); cur_type.clear(); cur_args.clear(); + cur_inputs.clear(); cur_outputs.clear(); + cur_x = 0; cur_y = 0; cur_shadow = false; + }; + + std::string line; + while (std::getline(f, line)) { + line = trim(line); + if (line.empty() || (line[0] == '#' && line.find("# version") != 0)) continue; + + if (line == "[[node]]") { + flush_node(); + in_node = true; + continue; + } + + if (line.find("# version") == 0) continue; + if (!in_node) continue; + + auto eq = line.find('='); + if (eq == std::string::npos) continue; + std::string key = trim(line.substr(0, eq)); + std::string val = trim(line.substr(eq + 1)); + + if (key == "id") { cur_id = unquote(val); } + else if (key == "type") { cur_type = unquote(val); } + else if (key == "args") { cur_args = parse_toml_array(val); } + else if (key == "shadow") { cur_shadow = (unquote(val) == "true"); } + else if (key == "inputs") { cur_inputs = parse_toml_array(val); } + else if (key == "outputs") { cur_outputs = parse_toml_array(val); } + else if (key == "position") { + auto coords = parse_toml_array(val); + if (coords.size() >= 2) { + cur_x = std::stof(coords[0]); + cur_y = std::stof(coords[1]); + } + } + } + flush_node(); + + // ─── Re-resolve ArgNet2 entries pointing to stale placeholders ─── + // When a lambda capture references a node parsed later in the file, + // resolve_net creates a NetBuilder placeholder. Now re-resolve to the actual node. + { + auto fixup_args = [&](ParsedArgs2* pa) { + if (!pa) return; + for (auto& a : *pa) { + auto an = a->as_net(); + if (!an) continue; + if (!an->second() || !an->second()->as_net()) continue; + auto actual = gb->find_or_null_node(an->first()); + if (actual && actual->as_node()) + an->entry(actual); + } + }; + for (auto& [id, entry] : gb->entries) { + auto node_p = entry->as_node(); + if (!node_p) continue; + fixup_args(node_p->parsed_args.get()); + fixup_args(node_p->parsed_va_args.get()); + } + } + + // ─── Fold shadow nodes into parents ─── + auto unconnected_entry = gb->unconnected_net(); + + // Collect shadow ids + std::vector shadow_ids; + for (auto& [id, entry] : gb->entries) { + auto node_p = entry->as_node(); + if (!node_p) continue; + if (node_p->shadow) + shadow_ids.push_back(id); + } + + for (auto& shadow_id : shadow_ids) { + // Extract parent id and arg index: "$auto-xyz_s0" → "$auto-xyz", 0 + auto underscore_s = shadow_id.rfind("_s"); + if (underscore_s == std::string::npos) continue; + std::string parent_id = shadow_id.substr(0, underscore_s); + int arg_index = std::stoi(shadow_id.substr(underscore_s + 2)); + + auto parent_ptr = gb->find_node(parent_id); + if (!parent_ptr) continue; + + auto shadow_entry = gb->find(shadow_id); + auto shadow_ptr = shadow_entry ? shadow_entry->as_node() : nullptr; + if (!shadow_ptr) continue; + + // Insert shadow expression into parent's parsed_args + // Find the shadow's output net (e.g. "$auto-xxx_s0-out0") in parent's parsed_args and replace + if (parent_ptr->parsed_args && shadow_ptr->parsed_args && !shadow_ptr->parsed_args->empty()) { + std::string shadow_out_prefix = shadow_id + "-out"; + bool replaced = false; + for (int ai = 0; ai < parent_ptr->parsed_args->size(); ai++) { + auto an = (*parent_ptr->parsed_args)[ai]->as_net(); + if (an && an->first().compare(0, shadow_out_prefix.size(), shadow_out_prefix) == 0) { + parent_ptr->parsed_args->set(ai, (*shadow_ptr->parsed_args)[0]); + replaced = true; + break; + } + } + // Fallback: try positional insertion (for nodes without merged inputs) + if (!replaced) { + while ((int)parent_ptr->parsed_args->size() <= arg_index) + parent_ptr->parsed_args->push_back(gb->build_arg_string("")); + parent_ptr->parsed_args->set(arg_index, (*shadow_ptr->parsed_args)[0]); + } + } + + // Build remaps from saved shadow input nets + auto sin_it = shadow_input_nets.find(shadow_id); + if (sin_it != shadow_input_nets.end()) { + auto& sin = sin_it->second; + for (int i = 0; i < (int)sin.size(); i++) { + while ((int)parent_ptr->remaps.size() <= i) + parent_ptr->remaps.push_back(gb->build_arg_net("$unconnected", unconnected_entry)); + + if (!sin[i].empty()) { + auto net_ptr = gb->find(sin[i]); + if (net_ptr) { + parent_ptr->remaps[i] = gb->build_arg_net(sin[i], net_ptr); + + if (auto net = net_ptr->as_net()) { + auto& dests = net->destinations(); + dests.erase( + std::remove_if(dests.begin(), dests.end(), + [&](auto& w) { return w.lock() == shadow_entry; }), + dests.end()); + net->destinations().push_back(parent_ptr); + } + } + } + } + if (parent_ptr->parsed_args) { + parent_ptr->parsed_args->rewrite_input_count = std::max( + parent_ptr->parsed_args->rewrite_input_count, (int)parent_ptr->remaps.size()); + } + } + + // Remove nets where shadow is source (internal shadow→parent plumbing) + std::vector nets_to_remove; + for (auto& [net_id, net_entry] : gb->entries) { + auto net_as = net_entry->as_net(); + if (!net_as) continue; + auto src = net_as->source().lock(); + if (src == shadow_entry) + nets_to_remove.push_back(net_id); + } + for (auto& nid : nets_to_remove) + gb->entries.erase(nid); + + // Remove shadow from graph + gb->entries.erase(shadow_id); + } + + // ─── Split parsed_args into base + va_args for nodes with va_args ─── + for (auto& [id, entry] : gb->entries) { + if (!entry->is(IdCategory::Node)) continue; + auto& node = *entry->as_node(); + auto* nt = find_node_type2(node.type_id); + if (!nt || !nt->input_ports_va_args || !node.parsed_args) continue; + + // Split at total descriptor input count (required + optional) + int fixed_args = nt->total_inputs(); + + if ((int)node.parsed_args->size() > fixed_args) { + node.parsed_va_args = std::make_shared(); + for (int i = fixed_args; i < (int)node.parsed_args->size(); i++) { + auto arg = std::move((*node.parsed_args)[i]); + arg->port(nt->input_ports_va_args); + node.parsed_va_args->push_back(std::move(arg)); + } + node.parsed_args->resize(fixed_args); + } + } + + // ─── Remove nets with no destinations → replace with $unconnected ─── + { + auto unconnected = gb->unconnected_net(); + auto unconnected_entry = std::static_pointer_cast(unconnected); + + // Collect nets to remove (have source but no destinations) + std::set dead_nets; + for (auto& [id, entry] : gb->entries) { + auto net = entry->as_net(); + if (!net || net->is_the_unconnected()) continue; + net->compact(); + if (net->destinations().empty()) { + dead_nets.insert(entry); + } + } + + if (!dead_nets.empty()) { + // Replace all ArgNet2 references to dead nets with $unconnected + auto fixup_arg = [&](const FlowArg2Ptr& a) { + auto n = a->as_net(); + if (!n) return; + if (dead_nets.count(n->second())) { + n->entry(unconnected_entry); + n->net_id(unconnected->id()); + } + }; + auto fixup_args = [&](ParsedArgs2* pa) { + if (!pa) return; + for (auto& a : *pa) fixup_arg(a); + }; + + for (auto& [id, entry] : gb->entries) { + auto node = entry->as_node(); + if (!node) continue; + fixup_args(node->parsed_args.get()); + fixup_args(node->parsed_va_args.get()); + for (auto& r : node->remaps) fixup_arg(r); + for (auto& o : node->outputs) fixup_arg(o); + for (auto& o : node->outputs_va_args) fixup_arg(o); + } + + // Remove dead nets from entries + for (auto it = gb->entries.begin(); it != gb->entries.end(); ) { + if (dead_nets.count(it->second)) + it = gb->entries.erase(it); + else + ++it; + } + } + } + + // ─── Re-ID: $auto-xxx → $a-N (compact hex IDs) ─── + { + // Build rename map for $auto- entries + std::map rename; + int next_id = 0; + for (auto& [id, _] : gb->entries) { + if (id.compare(0, 6, "$auto-") == 0) { + char buf[32]; + snprintf(buf, sizeof(buf), "$a-%x", next_id++); + rename[id] = buf; + } + } + + // Also rename net names that start with $auto- but aren't in entries + // (they appear as ArgNet2 first-values referencing $auto- prefixed names) + + // Helper: rename an id if it has a mapping + auto remap_id = [&](const std::string& id) -> std::string { + // Check exact match + auto it = rename.find(id); + if (it != rename.end()) return it->second; + // Check if it starts with a known $auto- prefix (e.g. "$auto-xxx-out0") + // Find the longest matching prefix + for (auto& [old_prefix, new_prefix] : rename) { + if (id.size() > old_prefix.size() && id.compare(0, old_prefix.size(), old_prefix) == 0) { + // Check the char after the prefix is a separator + char sep = id[old_prefix.size()]; + if (sep == '-' || sep == '_') { + return new_prefix + id.substr(old_prefix.size()); + } + } + } + return id; + }; + + // Helper: rename ArgNet2 in-place + auto remap_arg = [&](const FlowArg2Ptr& a) { + if (auto n = a->as_net()) + n->net_id(remap_id(n->first())); + }; + auto remap_args = [&](ParsedArgs2* pa) { + if (!pa) return; + for (auto& a : *pa) remap_arg(a); + }; + + // Rename all references inside nodes + for (auto& [id, entry] : gb->entries) { + auto node_p = entry->as_node(); + if (!node_p) continue; + remap_args(node_p->parsed_args.get()); + remap_args(node_p->parsed_va_args.get()); + for (auto& r : node_p->remaps) if (auto n = r->as_net()) n->net_id(remap_id(n->first())); + for (auto& o : node_p->outputs) if (auto n = o->as_net()) n->net_id(remap_id(n->first())); + for (auto& o : node_p->outputs_va_args) if (auto n = o->as_net()) n->net_id(remap_id(n->first())); + } + + // Rebuild entries map with new keys and update entry IDs + std::map new_entries; + for (auto& [id, entry] : gb->entries) { + auto new_id = remap_id(id); + entry->id(new_id); + new_entries[new_id] = std::move(entry); + } + gb->entries = std::move(new_entries); + } + + // ─── Assign node() on all pins ─── + for (auto& [id, entry] : gb->entries) { + auto node_p = entry->as_node(); + if (!node_p) continue; + auto assign_node = [&](ParsedArgs2* pa) { + if (!pa) return; + for (int i = 0; i < pa->size(); i++) + (*pa)[i]->node(node_p); + }; + assign_node(node_p->parsed_args.get()); + assign_node(node_p->parsed_va_args.get()); + for (auto& r : node_p->remaps) r->node(node_p); + for (auto& o : node_p->outputs) o->node(node_p); + for (auto& o : node_p->outputs_va_args) o->node(node_p); + } + + gb->compact(); + + return gb; +} diff --git a/src/atto/graph_builder.h b/src/atto/graph_builder.h new file mode 100644 index 0000000..2c10b7e --- /dev/null +++ b/src/atto/graph_builder.h @@ -0,0 +1,385 @@ +#pragma once +#include "model.h" +#include "types.h" +#include "node_types.h" +#include "graph_editor_interfaces.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using NodeId = std::string; +using BuilderError = std::string; + +// ─── Forward declarations ─── + +enum class IdCategory { + Node, + Net +}; + +struct GraphBuilder; +struct FlowNodeBuilder; +struct NetBuilder; + +using FlowNodeBuilderPtr = std::shared_ptr; +using NetBuilderPtr = std::shared_ptr; + +// ─── FlowArg2: base class for all pin/arg types ─── + +struct PortDesc2; // forward + +enum class ArgKind : uint8_t { Net, Number, String, Expr }; + +struct ArgNet2; +struct ArgNumber2; +struct ArgString2; +struct ArgExpr2; + +struct FlowArg2 : std::enable_shared_from_this { + friend struct GraphBuilder; + virtual ~FlowArg2() = default; + + ArgKind kind() const { return kind_; } + bool is(ArgKind k) const { return kind_ == k; } + + std::shared_ptr as_net(); + std::shared_ptr as_number(); + std::shared_ptr as_string(); + std::shared_ptr as_expr(); + + // Context: which node/net/port this arg belongs to (always valid, never null) + const FlowNodeBuilderPtr& node() const; + void node(const FlowNodeBuilderPtr& n); + + const NetBuilderPtr& net() const; + void net(const NetBuilderPtr& w); + + const PortDesc2* port() const { return port_; } + void port(const PortDesc2* p) { port_ = p; } + bool is_remap() const { return port_ == nullptr; } + unsigned remap_idx() const; // throws if !is_remap() + unsigned input_pin_idx() const; // throws if is_remap(); looks in parsed_args + unsigned input_pin_va_idx() const; // throws if is_remap(); looks in parsed_va_args + + unsigned output_pin_idx() const; // throws if is_remap(); looks in outputs + unsigned output_pin_va_idx() const; // throws if is_remap(); looks in outputs_va_args + + const std::shared_ptr& owner() const; + + // Computed name: "node.port_name" or "node.va_name[idx]" etc. + std::string fq_name() const; + // only port_name or va_name[idx] + std::string name() const; + +protected: + FlowArg2(ArgKind kind, const std::shared_ptr& owner); + + void mark_dirty(); + +private: + ArgKind kind_; + std::shared_ptr owner_; + FlowNodeBuilderPtr node_; // always valid ($empty if unassigned) + NetBuilderPtr net_; // always valid ($unconnected if unassigned) + const PortDesc2* port_ = nullptr; +}; + +using FlowArg2Ptr = std::shared_ptr; + +// ─── Concrete arg types ─── + +struct ArgNet2 : FlowArg2 { + friend struct GraphBuilder; + + const NodeId& net_id() const { return net_id_; } + void net_id(const NodeId& v); + + const std::shared_ptr& entry() const { return entry_; } + void entry(std::shared_ptr v); + + // Convenience aliases + const NodeId& first() const { return net_id_; } + const std::shared_ptr& second() const { return entry_; } + +private: + ArgNet2(NodeId id, std::shared_ptr entry, + const std::shared_ptr& owner) + : FlowArg2(ArgKind::Net, owner), net_id_(std::move(id)), entry_(std::move(entry)) { + if (!entry_) throw std::logic_error("ArgNet2: entry must not be null"); + } + + NodeId net_id_; + std::shared_ptr entry_; +public: + std::vector> editors_; +}; + +struct ArgNumber2 : FlowArg2 { + friend struct GraphBuilder; + + double value() const { return value_; } + void value(double v); + + bool is_float() const { return is_float_; } + void is_float(bool v); + +private: + ArgNumber2(double v, bool f, const std::shared_ptr& owner) + : FlowArg2(ArgKind::Number, owner), value_(v), is_float_(f) {} + + double value_ = 0; + bool is_float_ = false; +public: + std::vector> editors_; +}; + +struct ArgString2 : FlowArg2 { + friend struct GraphBuilder; + + const std::string& value() const { return value_; } + void value(const std::string& v); + +private: + ArgString2(std::string v, const std::shared_ptr& owner) + : FlowArg2(ArgKind::String, owner), value_(std::move(v)) {} + + std::string value_; +public: + std::vector> editors_; +}; + +struct ArgExpr2 : FlowArg2 { + friend struct GraphBuilder; + + const std::string& expr() const { return expr_; } + void expr(const std::string& v); + +private: + ArgExpr2(std::string e, const std::shared_ptr& owner) + : FlowArg2(ArgKind::Expr, owner), expr_(std::move(e)) {} + + std::string expr_; +public: + std::vector> editors_; +}; + +// ─── ParsedArgs2: vector of FlowArg2Ptr with dirty tracking ─── + +struct ParsedArgs2 { + int rewrite_input_count = 0; + + // Read access + bool empty() const { return items_.empty(); } + int size() const { return (int)items_.size(); } + FlowArg2Ptr operator[](int i) { return items_[i]; } + FlowArg2Ptr operator[](int i) const { return items_[i]; } + + using iterator = std::vector::iterator; + using const_iterator = std::vector::const_iterator; + iterator begin() { return items_.begin(); } + iterator end() { return items_.end(); } + const_iterator begin() const { return items_.begin(); } + const_iterator end() const { return items_.end(); } + FlowArg2Ptr back() { return items_.back(); } + FlowArg2Ptr back() const { return items_.back(); } + + // Write access (marks dirty) + void push_back(FlowArg2Ptr arg); + void pop_back(); + void resize(int n); + void insert(iterator pos, FlowArg2Ptr arg); + void clear(); + + // Set item at index (marks dirty) + void set(int i, FlowArg2Ptr arg); + + // Owner + std::shared_ptr owner; + +private: + std::vector items_; +}; + +// ─── BuilderEntry base ─── + +struct BuilderEntry: std::enable_shared_from_this { + BuilderEntry(IdCategory category, const std::shared_ptr& owner = nullptr) + : category_(category), owner_(owner) { } + virtual ~BuilderEntry() = default; + + const NodeId& id() const { return id_; } + void id(const NodeId& v); + + bool is(IdCategory cat) const { return category_ == cat; } + + std::shared_ptr as_node(); + std::shared_ptr as_net(); + + std::shared_ptr owner() const { return owner_; } + void owner(const std::shared_ptr& gb) { owner_ = gb; } + + void mark_dirty(); + +private: + const IdCategory category_; + NodeId id_; + std::shared_ptr owner_; +}; + +using BuilderEntryPtr = std::shared_ptr; +using BuilderEntryWeak = std::weak_ptr; + +// ─── NetBuilder ─── + +struct NetBuilder: BuilderEntry { + NetBuilder(const std::shared_ptr& owner = nullptr): BuilderEntry(IdCategory::Net, owner) { } + + bool auto_wire() const { return auto_wire_; } + void auto_wire(bool v) { auto_wire_ = v; } + + bool is_the_unconnected() const { return is_the_unconnected_; } + void is_the_unconnected(bool v) { is_the_unconnected_ = v; } + + const BuilderEntryWeak& source() const { return source_; } + void source(BuilderEntryWeak v) { source_ = std::move(v); mark_dirty(); } + + std::vector& destinations() { return destinations_; } + const std::vector& destinations() const { return destinations_; } + + void compact(); + bool unused(); + void validate() const; + +private: + bool auto_wire_ = false; + bool is_the_unconnected_ = false; + BuilderEntryWeak source_; + std::vector destinations_; +public: + std::vector> editors_; +}; + +// ─── FlowNodeBuilder ─── + +using Remaps = std::vector; +using Outputs = std::vector; + +struct FlowNodeBuilder: BuilderEntry { + FlowNodeBuilder(const std::shared_ptr& owner = nullptr): BuilderEntry(IdCategory::Node, owner) { } + + NodeTypeID type_id = NodeTypeID::Unknown; + std::shared_ptr parsed_args; + std::shared_ptr parsed_va_args; + Remaps remaps; + Outputs outputs; + Outputs outputs_va_args; + Vec2 position = {0, 0}; + bool shadow = false; + bool is_the_empty = false; // true for the special $empty sentinel node + std::string error; + + std::string args_str() const; + + // Layout-only dirty (position changed). Does NOT bubble to args or graph-level. + void mark_layout_dirty(); + + std::vector> editors_; +}; + +using BuilderResult = std::variant, BuilderError>; + +// ─── GraphBuilder ─── + +struct GraphBuilder : std::enable_shared_from_this { + TypePool pool; + std::map entries; + + std::shared_ptr add_node(NodeId id, NodeTypeID type, std::shared_ptr args); + + // Sentinel accessors (created once, cached) + FlowNodeBuilderPtr empty_node(); // the $empty sentinel node + NetBuilderPtr unconnected_net(); // the $unconnected sentinel net + void ensure_sentinels(); // create both if not yet created + + std::pair find_or_create_net(const NodeId& name, bool for_source = false); + + + BuilderEntryPtr find_or_null_node(const NodeId& id); + BuilderEntryPtr find(const NodeId& id); + + FlowNodeBuilderPtr find_node(const NodeId& id); + NetBuilderPtr find_net(const NodeId& name); + + void compact(); + + NodeId next_id(); + + // Rename an entry (node or net). Returns false if new_id already exists. + bool rename(const BuilderEntryPtr& entry, const NodeId& new_id); + + // Arg factories — all pins are tracked in pins_ + FlowArg2Ptr build_arg_net(NodeId id, BuilderEntryPtr entry, const PortDesc2* port = nullptr); + FlowArg2Ptr build_arg_number(double value, bool is_float, const PortDesc2* port = nullptr); + FlowArg2Ptr build_arg_string(std::string value, const PortDesc2* port = nullptr); + FlowArg2Ptr build_arg_expr(std::string expr, const PortDesc2* port = nullptr); + + const std::vector& pins() const { return pins_; } + + // Dirty tracking + void mark_dirty() { dirty_ = true; } + bool is_dirty() { return dirty_; } + + // Editor registration + void add_editor(const std::shared_ptr& editor); + void remove_editor(const std::shared_ptr& editor); + + // Mutation batching + void edit_start(); // throws if mutations_ not empty (missed commit) + void edit_commit(); // fires all queued callbacks in insertion order, then clears + void add_mutation_call(void* ptr, std::function&& fn); + bool has_editors() const { return !editors_.empty(); } + +private: + bool dirty_ = false; + std::vector pins_; + FlowNodeBuilderPtr empty_; + NetBuilderPtr unconnected_; + + // Editor observers + std::vector> editors_; + + // Mutation batch (between edit_start/edit_commit) + std::vector> mutations_; + std::set mutation_items_; +}; + +// ─── Parse/reconstruct helpers ─── + +using ParseResult = std::variant, std::string>; +ParseResult parse_args_v2(const std::shared_ptr& gb, + const std::vector& exprs, bool is_expr = false); + +std::string reconstruct_args_str(const ParsedArgs2& args); + +// ─── Deserializer ─── + +struct Deserializer { + static BuilderResult parse_node( + const std::shared_ptr& gb, + const NodeId& id, const std::string& type, const std::vector& args); + + static FlowNodeBuilder& parse_or_error( + const std::shared_ptr& gb, + const NodeId& id, const std::string& type, const std::vector& args); + + using ParseAttoResult = std::variant, BuilderError>; + static ParseAttoResult parse_atto(std::istream& f); +}; diff --git a/src/atto/graph_editor_interfaces.h b/src/atto/graph_editor_interfaces.h new file mode 100644 index 0000000..d48bfe3 --- /dev/null +++ b/src/atto/graph_editor_interfaces.h @@ -0,0 +1,73 @@ +#pragma once +#include +#include + +// Forward declarations — no graph_builder.h dependency +struct FlowNodeBuilder; +struct NetBuilder; +struct ArgNet2; +struct ArgNumber2; +struct ArgString2; +struct ArgExpr2; +using NodeId = std::string; + +// ─── Arg editor interfaces (per-type) ─── + +struct IArgNetEditor { + virtual ~IArgNetEditor() = default; + virtual void arg_net_mutated(const std::shared_ptr& arg) = 0; +}; + +struct IArgNumberEditor { + virtual ~IArgNumberEditor() = default; + virtual void arg_number_mutated(const std::shared_ptr& arg) = 0; +}; + +struct IArgStringEditor { + virtual ~IArgStringEditor() = default; + virtual void arg_string_mutated(const std::shared_ptr& arg) = 0; +}; + +struct IArgExprEditor { + virtual ~IArgExprEditor() = default; + virtual void arg_expr_mutated(const std::shared_ptr& arg) = 0; +}; + +// ─── Node editor ─── + +struct INodeEditor { + virtual ~INodeEditor() = default; + + // Structural change: args, ports, connections changed + virtual void node_mutated(const std::shared_ptr& node) = 0; + + // Visual-only change: position moved (does NOT bubble up) + virtual void node_layout_changed(const std::shared_ptr& node) = 0; + + // Arg editor factories — called per-arg when node is registered + virtual std::shared_ptr create_arg_net_editor(const std::shared_ptr& arg) = 0; + virtual std::shared_ptr create_arg_number_editor(const std::shared_ptr& arg) = 0; + virtual std::shared_ptr create_arg_string_editor(const std::shared_ptr& arg) = 0; + virtual std::shared_ptr create_arg_expr_editor(const std::shared_ptr& arg) = 0; +}; + +// ─── Net editor ─── + +struct INetEditor { + virtual ~INetEditor() = default; + virtual void net_mutated(const std::shared_ptr& net) = 0; +}; + +// ─── Graph editor (top-level observer) ─── + +struct IGraphEditor { + virtual ~IGraphEditor() = default; + + // Node lifecycle — returns per-node editor to attach + virtual std::shared_ptr node_added(const NodeId& id, const std::shared_ptr& node) = 0; + virtual void node_removed(const NodeId& id) = 0; + + // Net lifecycle — returns per-net editor to attach + virtual std::shared_ptr net_added(const NodeId& id, const std::shared_ptr& net) = 0; + virtual void net_removed(const NodeId& id) = 0; +}; diff --git a/src/atto/model.h b/src/atto/model.h index b20513a..60725ef 100644 --- a/src/atto/model.h +++ b/src/atto/model.h @@ -126,6 +126,7 @@ struct FlowLink { std::string from_pin; // output pin id string (for serialization) std::string to_pin; // input pin id string (for serialization) std::string net_name; // named net this link belongs to (v2 format, e.g. "$my-signal") + bool auto_wire = false; // true for auto-generated nets (not shown in display) std::string error; // non-empty if this link has a type error (set during inference) // Resolved pointers — populated by GraphIndex::rebuild(), not serialized FlowPin* from = nullptr; diff --git a/src/atto/node_types.h b/src/atto/node_types.h index 1346ebc..7627411 100644 --- a/src/atto/node_types.h +++ b/src/atto/node_types.h @@ -41,6 +41,7 @@ enum class NodeTypeID : uint8_t { Cast, // 34 Label, // 35 Deref, // 36 — internal: dereference iterator to value (shadow node only) + Error, // 37 — error node: displays original args, no pins (like label) COUNT, Unknown = 255 }; @@ -141,6 +142,7 @@ static const NodeType NODE_TYPES[] = { {NodeTypeID::Cast, "cast", "Cast value to type", 0,1, 0,1, false,false,false,false, nullptr, P_VALUE, nullptr, P_RESULT}, {NodeTypeID::Label, "label", "Text label (no connections)", 0,0, 0,0, false,true, false,false, nullptr, nullptr, nullptr, nullptr}, {NodeTypeID::Deref, "deref", "Dereference iterator (internal)", 0,1, 0,1, false,false,false,false, nullptr, P_VALUE, nullptr, P_RESULT}, + {NodeTypeID::Error, "error", "Error: invalid node", 0,0, 0,0, false,false,false,false, nullptr, nullptr, nullptr, nullptr}, }; static constexpr int NUM_NODE_TYPES = sizeof(NODE_TYPES) / sizeof(NODE_TYPES[0]); diff --git a/src/atto/node_types2.h b/src/atto/node_types2.h new file mode 100644 index 0000000..b0e8e5e --- /dev/null +++ b/src/atto/node_types2.h @@ -0,0 +1,612 @@ +#pragma once +#include "node_types.h" // for NodeTypeID + +// New pin model: flattened inputs/outputs, optional, input_ports_va_args, output_ports_va_args + +enum class PortKind2 : uint8_t { + BangTrigger, // bang input (rendered as square, top) + Data, // data input/output + Lambda, // lambda capture (only accepts node refs) + BangNext, // bang output (rendered as square) +}; + +enum class PortPosition2: uint8_t { + Input, + Output, +}; + +struct PortDesc2 { + const char* name; + const char* desc; + PortKind2 kind = PortKind2::Data; + PortPosition2 position = PortPosition2::Input; + const char* type_name = nullptr; + bool optional = false; + bool va_args = false; +}; + +enum class NodeKind2 : uint8_t { + Flow, // dataflow node — side-bang (right-middle) + Banged, // bang trigger input (top) + bang next output (bottom) + Event, // event source — bang next output (bottom), no bang input + Declaration, // compile-time — bang trigger input (top) + bang next output (bottom) + Special, // Label or Error - special handling +}; + +struct NodeType2 { + NodeKind2 kind = NodeKind2::Flow; + NodeTypeID type_id; + + const char* name; + const char* desc; + + const PortDesc2* input_ports = nullptr; + int num_inputs = 0; // required input ports + const PortDesc2* input_optional_ports = nullptr; + int num_inputs_optional = 0; // trailing optional input ports + const PortDesc2* input_ports_va_args = nullptr; // nullptr = no input_ports_va_args, else template for repeating pins + + const PortDesc2* output_ports; + int num_outputs; + const PortDesc2* output_ports_va_args = nullptr; // nullptr = no input_ports_va_args, else template for repeating pins + + int total_inputs() const { return num_inputs + num_inputs_optional; } + const PortDesc2* input_port(int i) const { + if (i < num_inputs) return input_ports ? &input_ports[i] : nullptr; + int oi = i - num_inputs; + if (oi < num_inputs_optional) return input_optional_ports ? &input_optional_ports[oi] : nullptr; + return nullptr; + } + bool is_banged() const { return kind == NodeKind2::Banged || kind == NodeKind2::Event || kind == NodeKind2::Declaration; } + bool is_declaration() const { return kind == NodeKind2::Declaration; } + bool is_flow() const { return kind == NodeKind2::Flow; } + bool is_special() const { return kind == NodeKind2::Special; } + bool is_event() const { return kind == NodeKind2::Event; } +}; + +// ─── Port descriptor arrays ─── + +// Common outputs +static const PortDesc2 P2_NEXT[] = { + {.name = "next", .desc = "fires after completion", .kind = PortKind2::BangNext, .position = PortPosition2::Output}, +}; + +static const PortDesc2 P2_NEXT_RESULT[] = { + {.name = "next", .desc = "fires after completion", .kind = PortKind2::BangNext, .position = PortPosition2::Output}, + {.name = "result", .desc = "result value", .position = PortPosition2::Output}, +}; + +// Common inputs +static const PortDesc2 P2_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger input", .kind = PortKind2::BangTrigger}, +}; +static const PortDesc2 P2_VALUE[] = { + {.name = "value", .desc = "input value"}, +}; + +// expr! +static const PortDesc2 P2_EXPR_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger input", .kind = PortKind2::BangTrigger}, +}; + +// store! +static const PortDesc2 P2_STORE_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "target", .desc = "variable/reference to store into"}, + {.name = "value", .desc = "value to store"}, +}; +static const PortDesc2 P2_STORE_IN[] = { + {.name = "target", .desc = "variable/reference to store into"}, + {.name = "value", .desc = "value to store"}, +}; + +// append! +static const PortDesc2 P2_APPEND_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "target", .desc = "collection to append to"}, + {.name = "value", .desc = "value to append"}, +}; +static const PortDesc2 P2_APPEND_IN[] = { + {.name = "target", .desc = "collection to append to"}, + {.name = "value", .desc = "value to append"}, +}; + +// erase +static const PortDesc2 P2_ERASE_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "target", .desc = "collection to erase from"}, + {.name = "key", .desc = "key/value/iterator to erase"}, +}; +static const PortDesc2 P2_ERASE_IN[] = { + {.name = "target", .desc = "collection to erase from"}, + {.name = "key", .desc = "key/value/iterator to erase"}, +}; + +// select +static const PortDesc2 P2_SELECT_IN[] = { + {.name = "condition", .desc = "boolean selector"}, + {.name = "if_true", .desc = "value when true"}, + {.name = "if_false", .desc = "value when false"}, +}; +static const PortDesc2 P2_SELECT_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "condition", .desc = "boolean condition"}, +}; +static const PortDesc2 P2_SELECT_BANG_OUT[] = { + {.name = "next", .desc = "fires after branch completes", .kind = PortKind2::BangNext, .position = PortPosition2::Output}, + {.name = "true", .desc = "fires when true", .kind = PortKind2::BangNext, .position = PortPosition2::Output}, + {.name = "false", .desc = "fires when false", .kind = PortKind2::BangNext, .position = PortPosition2::Output}, +}; + +// va_args templates +static const PortDesc2 P2_VA_FIELD = {.name = "field", .desc = "constructor field", .va_args = true}; +static const PortDesc2 P2_VA_ARG = {.name = "arg", .desc = "function argument", .va_args = true}; +static const PortDesc2 P2_VA_PARAM = {.name = "param", .desc = "lambda parameter", .va_args = true}; + +// va_args outputs +static const PortDesc2 P2_VA_EVENT_OUT = {.name = "args", .desc = "event arguments", .kind = PortKind2::Data , .position = PortPosition2::Output, .va_args = true}; + +static const PortDesc2 P2_VA_EXPR_OUT = {.name = "expr", .desc = "expression outputs", .kind = PortKind2::Data , .position = PortPosition2::Output, .va_args = true}; + +// new +static const PortDesc2 P2_NEW_IN[] = { + {.name = "type", .desc = "type to instantiate"}, +}; + +// call +static const PortDesc2 P2_CALL_IN[] = { + {.name = "fn", .desc = "function to call"}, +}; +static const PortDesc2 P2_CALL_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "fn", .desc = "function to call"}, +}; + +// iterate +static const PortDesc2 P2_ITERATE_IN[] = { + {.name = "collection", .desc = "collection to iterate over"}, + {.name = "fn", .desc = "it=fn(it); while it!=end", .kind = PortKind2::Lambda}, +}; +static const PortDesc2 P2_ITERATE_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "collection", .desc = "collection to iterate over"}, + {.name = "fn", .desc = "it=fn(it); while it!=end", .kind = PortKind2::Lambda}, +}; + +// lock +static const PortDesc2 P2_LOCK_IN[] = { + {.name = "mutex", .desc = "mutex to lock"}, + {.name = "fn", .desc = "body under lock", .kind = PortKind2::Lambda}, +}; +static const PortDesc2 P2_LOCK_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "mutex", .desc = "mutex to lock"}, + {.name = "fn", .desc = "body under lock", .kind = PortKind2::Lambda}, +}; + +// decl +static const PortDesc2 P2_DECL_TYPE_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "name", .desc = "type name (symbol)"}, + {.name = "type", .desc = "type definition"}, +}; +static const PortDesc2 P2_DECL_TYPE_OUT[] = { + {.name = "next", .desc = "fires after declaration", .kind = PortKind2::BangNext, .position = PortPosition2::Output}, + {.name = "type", .desc = "the declared type", .position = PortPosition2::Output}, +}; +static const PortDesc2 P2_DECL_VAR_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "name", .desc = "variable name (symbol)"}, + {.name = "type", .desc = "variable type"}, +}; +static const PortDesc2 P2_DECL_VAR_OPT_IN[] = { + {.name = "initial", .desc = "variable initial value", .optional = true}, +}; +static const PortDesc2 P2_DECL_VAR_OUT[] = { + {.name = "next", .desc = "fires after declaration", .kind = PortKind2::BangNext, .position = PortPosition2::Output}, + {.name = "ref", .desc = "reference to variable", .position = PortPosition2::Output}, +}; +static const PortDesc2 P2_DECL_OUT[] = { + {.name = "next", .desc = "fires to start declarations", .kind = PortKind2::BangNext, .position = PortPosition2::Output}, +}; +static const PortDesc2 P2_DECL_EVENT_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "name", .desc = "event name (symbol)"}, + {.name = "type", .desc = "event function type"}, +}; +static const PortDesc2 P2_DECL_IMPORT_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "path", .desc = "module path", .type_name = "literal"}, +}; +static const PortDesc2 P2_FFI_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "name", .desc = "function name (symbol)"}, + {.name = "type", .desc = "function type"}, +}; + +// discard +static const PortDesc2 P2_DISCARD_BANG_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "value", .desc = "value to discard"}, +}; + +// output_mix! +static const PortDesc2 P2_OUTPUT_MIX_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "value", .desc = "audio sample to mix"}, +}; + +// resize! +static const PortDesc2 P2_RESIZE_IN[] = { + {.name = "bang_in", .desc = "trigger", .kind = PortKind2::BangTrigger}, + {.name = "target", .desc = "vector to resize"}, + {.name = "size", .desc = "new size", .type_name = "s32"}, +}; + + +// ─── Node type table ─── + +static const NodeType2 NODE_TYPES2[] = { + { + .type_id = NodeTypeID::Expr, + .name = "expr", + .desc = "Evaluate expression", + .output_ports = P2_NEXT, + .num_outputs = 1, + .output_ports_va_args = &P2_VA_EXPR_OUT, + }, + { + .type_id = NodeTypeID::Select, + .name = "select", + .desc = "Select value by condition", + .input_ports = P2_SELECT_IN, + .num_inputs = 3, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .type_id = NodeTypeID::New, + .name = "new", + .desc = "Instantiate a type", + .input_ports = P2_NEW_IN, + .num_inputs = 1, + .input_ports_va_args = &P2_VA_FIELD, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .type_id = NodeTypeID::Dup, + .name = "dup", + .desc = "Duplicate input to output", + .input_ports = P2_VALUE, + .num_inputs = 1, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .type_id = NodeTypeID::Str, + .name = "str", + .desc = "Convert to string", + .input_ports = P2_VALUE, + .num_inputs = 1, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .type_id = NodeTypeID::Void, + .name = "void", + .desc = "Void result", + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::DiscardBang, + .name = "discard!", + .desc = "Discard value, pass bang", + .input_ports = P2_DISCARD_BANG_IN, + .num_inputs = 2, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .type_id = NodeTypeID::Discard, + .name = "discard", + .desc = "Discard input values", + .input_ports = P2_VALUE, + .num_inputs = 1, + .output_ports = P2_NEXT, + .num_outputs = 1 + }, + { + .kind = NodeKind2::Declaration, + .type_id = NodeTypeID::DeclType, + .name = "decl_type", + .desc = "Declare a type", + .input_ports = P2_DECL_TYPE_IN, + .num_inputs = 3, + .output_ports = P2_DECL_TYPE_OUT, + .num_outputs = 2, + }, + { + .kind = NodeKind2::Declaration, + .type_id = NodeTypeID::DeclVar, + .name = "decl_var", + .desc = "Declare a variable", + .input_ports = P2_DECL_VAR_IN, + .num_inputs = 3, + .input_optional_ports = P2_DECL_VAR_OPT_IN, + .num_inputs_optional = 1, + .output_ports = P2_DECL_VAR_OUT, + .num_outputs = 2, + }, + { + .kind = NodeKind2::Declaration, + .type_id = NodeTypeID::Decl, + .name = "decl", + .desc = "Compile-time entry point", + .output_ports = P2_DECL_OUT, + .num_outputs = 1, + }, + { + .kind = NodeKind2::Declaration, + .type_id = NodeTypeID::DeclEvent, + .name = "decl_event", + .desc = "Declare event", + .input_ports = P2_DECL_EVENT_IN, + .num_inputs = 3, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .kind = NodeKind2::Declaration, + .type_id = NodeTypeID::DeclImport, + .name = "decl_import", + .desc = "Import module", + .input_ports = P2_DECL_IMPORT_IN, + .num_inputs = 2, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .kind = NodeKind2::Declaration, + .type_id = NodeTypeID::Ffi, + .name = "ffi", + .desc = "Declare external function", + .input_ports = P2_FFI_IN, + .num_inputs = 3, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .type_id = NodeTypeID::Call, + .name = "call", + .desc = "Call function", + .input_ports = P2_CALL_IN, + .num_inputs = 1, + .input_ports_va_args = &P2_VA_ARG, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::CallBang, + .name = "call!", + .desc = "Call function (bang)", + .input_ports = P2_CALL_BANG_IN, + .num_inputs = 2, + .input_ports_va_args = &P2_VA_ARG, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .type_id = NodeTypeID::Erase, + .name = "erase", + .desc = "Erase from collection", + .input_ports = P2_ERASE_IN, + .num_inputs = 2, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::OutputMixBang, + .name = "output_mix!", + .desc = "Mix into audio output", + .input_ports = P2_OUTPUT_MIX_IN, + .num_inputs = 2, + }, + { + .type_id = NodeTypeID::Append, + .name = "append", + .desc = "Append to collection", + .input_ports = P2_APPEND_IN, + .num_inputs = 2, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::AppendBang, + .name = "append!", + .desc = "Append to collection (bang)", + .input_ports = P2_APPEND_BANG_IN, + .num_inputs = 3, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .type_id = NodeTypeID::Store, + .name = "store", + .desc = "Store value", + .input_ports = P2_STORE_IN, + .num_inputs = 2, + .output_ports = P2_NEXT, + .num_outputs = 1 + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::StoreBang, + .name = "store!", + .desc = "Store value (bang)", + .input_ports = P2_STORE_BANG_IN, + .num_inputs = 3, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .kind = NodeKind2::Event, + .type_id = NodeTypeID::EventBang, + .name = "event!", + .desc = "Event source", + .output_ports = P2_NEXT, + .num_outputs = 1, + .output_ports_va_args = &P2_VA_EVENT_OUT, + }, + { + .kind = NodeKind2::Special, + .type_id = NodeTypeID::OnKeyDownBang, + .name = "on_key_down!", + .desc = "(removed)", + }, + { + .kind = NodeKind2::Special, + .type_id = NodeTypeID::OnKeyUpBang, + .name = "on_key_up!", + .desc = "(removed)", + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::SelectBang, + .name = "select!", + .desc = "Branch on condition", + .input_ports = P2_SELECT_BANG_IN, + .num_inputs = 2, + .output_ports = P2_SELECT_BANG_OUT, + .num_outputs = 3, + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::ExprBang, + .name = "expr!", + .desc = "Evaluate expression on bang", + .input_ports = P2_EXPR_BANG_IN, + .num_inputs = 1, + .output_ports = P2_NEXT, + .num_outputs = 1, + .output_ports_va_args = &P2_VA_EXPR_OUT, + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::EraseBang, + .name = "erase!", + .desc = "Erase from collection (bang)", + .input_ports = P2_ERASE_BANG_IN, + .num_inputs = 3, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .type_id = NodeTypeID::Iterate, + .name = "iterate", + .desc = "Iterate collection", + .input_ports = P2_ITERATE_IN, + .num_inputs = 2, + .output_ports = P2_NEXT, + .num_outputs = 1 + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::IterateBang, + .name = "iterate!", + .desc = "Iterate collection (bang)", + .input_ports = P2_ITERATE_BANG_IN, + .num_inputs = 3, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .type_id = NodeTypeID::Next, + .name = "next", + .desc = "Advance iterator", + .input_ports = P2_VALUE, + .num_inputs = 1, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .type_id = NodeTypeID::Lock, + .name = "lock", + .desc = "Execute under mutex lock", + .input_ports = P2_LOCK_IN, + .num_inputs = 2, + .input_ports_va_args = &P2_VA_PARAM, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::LockBang, + .name = "lock!", + .desc = "Execute under mutex lock (bang)", + .input_ports = P2_LOCK_BANG_IN, + .num_inputs = 3, + .input_ports_va_args = &P2_VA_PARAM, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .kind = NodeKind2::Banged, + .type_id = NodeTypeID::ResizeBang, + .name = "resize!", + .desc = "Resize vector", + .input_ports = P2_RESIZE_IN, + .num_inputs = 3, + .output_ports = P2_NEXT, + .num_outputs = 1, + }, + { + .type_id = NodeTypeID::Cast, + .name = "cast", + .desc = "Cast value to type", + .input_ports = P2_VALUE, + .num_inputs = 1, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2 + }, + { + .kind = NodeKind2::Special, + .type_id = NodeTypeID::Label, + .name = "label", + .desc = "Text label", + }, + { + .type_id = NodeTypeID::Deref, + .name = "deref", + .desc = "Dereference iterator (internal)", + .input_ports = P2_VALUE, + .num_inputs = 1, + .output_ports = P2_NEXT_RESULT, + .num_outputs = 2, + }, + { + .kind = NodeKind2::Special, + .type_id = NodeTypeID::Error, + .name = "error", + .desc = "Error: invalid node", + }, +}; + +static constexpr int NUM_NODE_TYPES2 = sizeof(NODE_TYPES2) / sizeof(NODE_TYPES2[0]); + +static const NodeType2* find_node_type2(NodeTypeID id) { + auto idx = static_cast(id); + if (idx < NUM_NODE_TYPES2) return &NODE_TYPES2[idx]; + return nullptr; +} + +static const NodeType2* find_node_type2(const char* name) { + for (int i = 0; i < NUM_NODE_TYPES2; i++) + if (strcmp(NODE_TYPES2[i].name, name) == 0) return &NODE_TYPES2[i]; + return nullptr; +} diff --git a/src/atto/serial.cpp b/src/atto/serial.cpp index d1fa02f..532d8fd 100644 --- a/src/atto/serial.cpp +++ b/src/atto/serial.cpp @@ -222,7 +222,20 @@ static void resolve_imports(FlowGraph& graph, const std::string& base_path) { } } -// ─── Auto-migrate v1 to v2: assign node_ids and net_names ─── +// ─── Migrate v1 args: strip $ from variable refs, convert @N to $N ─── + +static std::string migrate_args_v1(const std::string& args) { + std::string result; + for (size_t i = 0; i < args.size(); i++) { + if (args[i] == '$' && i + 1 < args.size() && !std::isdigit(args[i + 1])) { + continue; // strip $ from variable names (e.g., $oscs → oscs) + } + result += args[i]; + } + return result; +} + +// ─── Auto-migrate v1 to v2: assign node_ids, net_names, migrate args ─── static void migrate_v1_to_v2(FlowGraph& graph) { // Assign $auto- node IDs to nodes that don't have one @@ -232,6 +245,15 @@ static void migrate_v1_to_v2(FlowGraph& graph) { } } + // Migrate args: strip $ from variable refs, convert @N to $N + for (auto& node : graph.nodes) { + std::string migrated = migrate_args_v1(node.args); + if (migrated != node.args) { + node.args = migrated; + node.parse_args(); + } + } + // Assign net names to links that don't have one // Build unique net names from from_pin int net_counter = 0; @@ -261,8 +283,10 @@ static void migrate_v1_to_v2(FlowGraph& graph) { } if (!existing_net.empty()) { link.net_name = existing_net; + link.auto_wire = true; } else { link.net_name = source_node_id + "-" + pin_name; + link.auto_wire = true; } } } @@ -613,6 +637,7 @@ static bool load_v2_stream(std::istream& f, FlowGraph& graph, const std::string& for (auto& link : graph.links) { if (link.id == link_id) { link.net_name = net_name; + link.auto_wire = (net_name.substr(0, 6) == "$auto-"); break; } } diff --git a/src/atto/shadow.cpp b/src/atto/shadow.cpp index bff11db..1aa9f9c 100644 --- a/src/atto/shadow.cpp +++ b/src/atto/shadow.cpp @@ -349,6 +349,32 @@ void rebuild_all_inline_display(FlowGraph& graph) { std::sort(infos.begin(), infos.end(), [](auto& a, auto& b) { return a.arg_index < b.arg_index; }); } + // Helper: find the user-named net connected to a node's descriptor input pin + auto find_connected_net = [&](const FlowNode& node, const std::string& pin_name) -> std::string { + std::string pin_id = node.guid + "." + pin_name; + for (auto& link : graph.links) { + if (link.to_pin == pin_id && !link.net_name.empty() && !link.auto_wire) { + return link.net_name; + } + } + return ""; + }; + + // Helper: find the node_id of a node connected via as_lambda to a given pin + auto find_connected_lambda_id = [&](const FlowNode& node, const std::string& pin_name) -> std::string { + std::string pin_id = node.guid + "." + pin_name; + for (auto& link : graph.links) { + if (link.to_pin != pin_id) continue; + // Find source node — the from_pin should be "guid.as_lambda" + for (auto& src : graph.nodes) { + if (src.lambda_grab.id == link.from_pin && !src.node_id.empty()) { + return src.node_id; + } + } + } + return ""; + }; + // Rebuild inline_display for all non-shadow nodes for (auto& node : graph.nodes) { if (node.shadow) continue; @@ -374,9 +400,33 @@ void rebuild_all_inline_display(FlowGraph& graph) { for (auto& si : it->second) tokens[si.arg_index] = si.expr; + // Substitute net names for shadow tokens connected via nets + auto* nt = find_node_type(node.type_id); + for (auto& si : it->second) { + if (si.arg_index < (int)tokens.size() && nt && nt->input_ports && si.arg_index < nt->inputs) { + std::string pin_name = nt->input_ports[si.arg_index].name; + std::string net = find_connected_net(node, pin_name); + if (!net.empty()) { + tokens[si.arg_index] = net; + } + } + } + for (auto& t : tokens) { s += " " + t; } + + // Append lambda pin references (show $node-id for connected lambdas) + if (nt && nt->input_ports) { + for (int i = 0; i < nt->inputs; i++) { + if (nt->input_ports[i].kind == PortKind::Lambda) { + std::string lambda_id = find_connected_lambda_id(node, nt->input_ports[i].name); + if (!lambda_id.empty()) { + s += " " + lambda_id; + } + } + } + } } else if (!node.args.empty()) { s += " " + node.args; } diff --git a/src/attoflow/atto_editor_shared_state.h b/src/attoflow/atto_editor_shared_state.h new file mode 100644 index 0000000..3302cdf --- /dev/null +++ b/src/attoflow/atto_editor_shared_state.h @@ -0,0 +1,7 @@ +#pragma once +#include "atto/graph_builder.h" +#include + +struct AttoEditorSharedState { + std::set selected_nodes; +}; diff --git a/src/attoflow/editor.h b/src/attoflow/editor.h deleted file mode 100644 index ea59949..0000000 --- a/src/attoflow/editor.h +++ /dev/null @@ -1,180 +0,0 @@ -#pragma once -#include "sdl_imgui_window.h" -#include "atto/model.h" -#include "atto/types.h" -#include -#include -#include -#include -#include -#include -#ifdef _WIN32 -#define NOMINMAX -#include -#endif - -// Conversion between Vec2 (model) and ImVec2 (UI) -inline ImVec2 to_imvec(Vec2 v) { return {v.x, v.y}; } -inline Vec2 to_vec2(ImVec2 v) { return {v.x, v.y}; } - -// Per-tab state: each open .atto file gets its own TabState -struct TabState { - FlowGraph graph; - std::string file_path; // absolute path to this .atto file - std::string tab_name; // display name (filename without extension) - bool dirty = false; - - // Canvas - ImVec2 canvas_offset = {0, 0}; - float canvas_zoom = 1.0f; - - // Selection - std::set selected_nodes; - - // Undo/Redo - std::vector undo_stack; - std::vector redo_stack; - - // Type inference - TypePool type_pool; - bool inference_dirty = true; - - // Clipboard - struct ClipboardNode { - NodeTypeID type_id; std::string args; - ImVec2 offset; // relative to centroid - }; - struct ClipboardLink { - int from_idx, to_idx; // indices into clipboard_nodes - std::string from_pin_name, to_pin_name; - }; - std::vector clipboard_nodes; - std::vector clipboard_links; - - // Highlight animation - int highlight_node_id = -1; - float highlight_timer = 0.0f; -}; - -class FlowEditorWindow { -public: - bool init(const std::string& project_dir = ""); - void shutdown(); - bool is_open() const { return win_.open; } - - void process_event(SDL_Event& e); - void draw(); - - SdlImGuiWindow& sdl_window() { return win_; } - FlowGraph& graph() { return active().graph; } - - // Tab management - TabState& active() { return tabs_[active_tab_]; } - const TabState& active() const { return tabs_[active_tab_]; } - void open_tab(const std::string& file_path); - void close_tab(int idx); - void scan_project_files(); - -private: - SdlImGuiWindow win_; - - // Project - std::string project_dir_; - std::vector project_files_; // cached .atto filenames - float file_panel_width_ = 200.0f; - - // Tabs - std::vector tabs_; - int active_tab_ = 0; - - // Per-tab helpers (operate on active tab) - void mark_dirty(); - void auto_save(); - void push_undo(); - void undo(); - void redo(); - void copy_selection(); - void paste_at(ImVec2 canvas_pos); - - // Debounced save - void schedule_save(); - double save_deadline_ = 0; // 0 = no pending save - void check_debounced_save(); - - // Interaction state (global — always applies to active tab) - int dragging_node_ = -1; - bool dragging_selection_ = false; - std::string dragging_link_from_pin_; - bool dragging_link_from_output_ = true; // true if drag started from output-like pin - ImVec2 dragging_link_start_; - bool canvas_dragging_ = false; - ImVec2 canvas_drag_start_; - - // Grabbed links - struct GrabbedLink { std::string from_pin; std::string to_pin; }; - std::vector grabbed_links_; - std::string grabbed_pin_; - bool grab_is_output_ = false; - bool grab_pending_ = false; - ImVec2 grab_start_; - - // Box selection - bool box_selecting_ = false; - ImVec2 box_select_start_; - - // Node name editing - int editing_node_ = -1; - std::string edit_buf_; - bool edit_just_opened_ = false; - bool edit_cursor_to_end_ = false; - bool creating_new_node_ = false; - ImVec2 new_node_pos_; - - // Shadow pin filtering (rebuilt each frame before drawing) - std::set shadow_connected_pins_; // pin IDs connected from shadow nodes - - // Drawing helpers - ImVec2 canvas_to_screen(ImVec2 p, ImVec2 canvas_origin) const; - ImVec2 screen_to_canvas(ImVec2 p, ImVec2 canvas_origin) const; - ImVec2 get_pin_pos(const FlowNode& node, const FlowPin& pin, ImVec2 canvas_origin) const; - void draw_node(ImDrawList* dl, FlowNode& node, ImVec2 canvas_origin); - void draw_link(ImDrawList* dl, const FlowLink& link, ImVec2 canvas_origin); - - // Hit testing - struct PinHit { int node_id; std::string pin_id; FlowPin::Direction dir; }; - PinHit hit_test_pin(ImVec2 screen_pos, ImVec2 canvas_origin, float radius = 8.0f) const; - int hit_test_link(ImVec2 screen_pos, ImVec2 canvas_origin, float threshold = 6.0f) const; - - // Validation & type inference - void validate_nodes(); - void run_type_inference(); - - // Navigation - void center_on_node(const FlowNode& node, ImVec2 canvas_size); - - // Viewport sync - void sync_viewport(TabState& tab); - - // Panel sizes - float side_panel_width_ = 200.0f; - float bottom_panel_height_ = 250.0f; - - // Run/Stop - enum class BuildState { Idle, Building, Running, BuildFailed }; - std::atomic build_state_{BuildState::Idle}; - std::string build_log_; - std::mutex build_log_mutex_; - bool show_build_log_ = false; - char search_buf_[128] = {}; - float last_canvas_w_ = 800, last_canvas_h_ = 600; - std::thread build_thread_; -#ifdef _WIN32 - HANDLE child_process_ = nullptr; -#else - pid_t child_pid_ = 0; -#endif - void run_program(bool release = false); - void stop_program(); - void poll_child_process(); - void draw_toolbar(); -}; diff --git a/src/attoflow/editor2.cpp b/src/attoflow/editor2.cpp new file mode 100644 index 0000000..cf47b9b --- /dev/null +++ b/src/attoflow/editor2.cpp @@ -0,0 +1,301 @@ +#include "editor2.h" +#include "tooltip_renderer.h" +#include "atto/graph_builder.h" +#include "atto/node_types2.h" +#include "imgui.h" +#include +#include +#include +#include +#include + +// ─── Factory ─── + +Editor2Pane::Editor2Pane(const std::shared_ptr& gb, + const std::shared_ptr& shared) + : VisualEditor(shared), gb_(gb) { +} + +std::shared_ptr make_editor2( + const std::shared_ptr& gb, + const std::shared_ptr& shared) { + auto pane = std::make_shared(gb, shared); + gb->add_editor(pane); + return pane; +} + +// ─── Per-item editor implementations ─── + +void NodeEditorImpl::rebuild(ImVec2 canvas_origin, float zoom) { + auto* nt = find_node_type2(node->type_id); + vpm = VisualPinMap::build(node, nt); + layout = compute_node_layout(node, vpm, canvas_origin, zoom); + display_text = nt ? nt->name : "?"; + std::string args = node->args_str(); + if (!args.empty()) display_text += " " + args; + has_error = !node->error.empty(); +} + +void NodeEditorImpl::node_mutated(const std::shared_ptr&) { + pane->invalidate_wires(); +} + +void NodeEditorImpl::node_layout_changed(const std::shared_ptr&) { + pane->invalidate_wires(); +} + +std::shared_ptr NodeEditorImpl::create_arg_net_editor(const std::shared_ptr& arg) { + return std::make_shared(pane, arg); +} +std::shared_ptr NodeEditorImpl::create_arg_number_editor(const std::shared_ptr& arg) { + return std::make_shared(pane, arg); +} +std::shared_ptr NodeEditorImpl::create_arg_string_editor(const std::shared_ptr& arg) { + return std::make_shared(pane, arg); +} +std::shared_ptr NodeEditorImpl::create_arg_expr_editor(const std::shared_ptr& arg) { + return std::make_shared(pane, arg); +} + +void NetEditorImpl::net_mutated(const std::shared_ptr&) { + pane->invalidate_wires(); +} + +void ArgNetEditorImpl::arg_net_mutated(const std::shared_ptr&) {} +void ArgNumberEditorImpl::arg_number_mutated(const std::shared_ptr&) {} +void ArgStringEditorImpl::arg_string_mutated(const std::shared_ptr&) {} +void ArgExprEditorImpl::arg_expr_mutated(const std::shared_ptr&) {} + +// ─── IGraphEditor implementation ─── + +std::shared_ptr Editor2Pane::node_added(const NodeId& id, const std::shared_ptr& node) { + auto ned = std::make_shared(this, node); + node_editors_[id] = ned; + wires_dirty_ = true; + return ned; +} + +void Editor2Pane::node_removed(const NodeId& id) { + node_editors_.erase(id); + wires_dirty_ = true; +} + +std::shared_ptr Editor2Pane::net_added(const NodeId& id, const std::shared_ptr& net) { + auto ned = std::make_shared(this, net); + net_editors_[id] = ned; + wires_dirty_ = true; + return ned; +} + +void Editor2Pane::net_removed(const NodeId& id) { + net_editors_.erase(id); + wires_dirty_ = true; +} + +// ─── Wire rebuilding ─── + +void Editor2Pane::rebuild_wires(ImVec2 canvas_origin) { + cached_wires_.clear(); + + for (auto& [dst_id, ned] : node_editors_) { + auto dst_node = ned->node; + auto* dst_nt = find_node_type2(dst_node->type_id); + if (!dst_nt) continue; + + ned->rebuild(canvas_origin, canvas_zoom_); + auto& dst_layout = ned->layout; + auto& dst_vpm = ned->vpm; + + for (int i = 0; i < (int)dst_vpm.inputs.size(); i++) { + auto& pin = dst_vpm.inputs[i]; + if (pin.kind == VisualPinKind::AddDiamond || pin.kind == VisualPinKind::AbsentOptional) continue; + if (!pin.arg) continue; + auto an = pin.arg->as_net(); + if (!an) continue; + + auto entry = an->second(); + if (!entry) continue; + + FlowNodeBuilderPtr src_node = nullptr; + bool is_lambda = false; + int source_pin = 0; + + if (auto net = entry->as_net()) { + if (net->is_the_unconnected()) continue; + auto src_ptr = net->source().lock(); + src_node = src_ptr ? src_ptr->as_node() : nullptr; + if (!src_node) continue; + for (int k = 0; k < (int)src_node->outputs.size(); k++) { + auto out_net = src_node->outputs[k]->as_net(); + if (out_net && out_net->second() == entry) { source_pin = k; break; } + } + if (source_pin == 0) { + int base = (int)src_node->outputs.size(); + for (int k = 0; k < (int)src_node->outputs_va_args.size(); k++) { + auto out_net = src_node->outputs_va_args[k]->as_net(); + if (out_net && out_net->second() == entry) { source_pin = base + k; break; } + } + } + } else if (auto node = entry->as_node()) { + src_node = node; + is_lambda = true; + } else { + continue; + } + + auto* src_nt = find_node_type2(src_node->type_id); + auto src_vpm = VisualPinMap::build(src_node, src_nt); + auto src_layout = compute_node_layout(src_node, src_vpm, canvas_origin, canvas_zoom_); + ImVec2 to = dst_layout.input_pin_pos(i); + + ImVec2 from; + bool is_side_bang = false; + if (is_lambda) { + from = src_layout.lambda_grab_pos(); + } else { + is_side_bang = src_nt && src_nt->is_flow() && + source_pin < (src_nt->num_outputs) && + src_nt->output_ports && src_nt->output_ports[source_pin].kind == PortKind2::BangNext; + if (is_side_bang) { + from = src_layout.side_bang_pos(); + } else { + int visual_pin = source_pin; + if (src_nt && src_nt->is_flow()) visual_pin = std::max(0, visual_pin - 1); + from = src_layout.output_pin_pos(visual_pin); + } + } + + cached_wires_.push_back(compute_wire_geometry( + from, to, is_lambda, is_side_bang, canvas_zoom_, + entry, src_node->id(), dst_id, an->first())); + } + } + + wires_dirty_ = false; +} + +// ─── Draw ─── + +void Editor2Pane::draw() { + if (!gb_) { + ImGui::TextDisabled("No graph loaded"); + return; + } + draw_canvas("##canvas2"); +} + +// ─── VisualEditor hooks ─── + +void Editor2Pane::draw_content(const CanvasFrame& frame) { + // Rebuild node layouts and draw nodes + for (auto& [id, ned] : node_editors_) { + if (ned->node->shadow) + throw std::logic_error("Editor2Pane: shadow nodes must be folded before rendering (id: " + id + ")"); + ned->rebuild(frame.canvas_origin, canvas_zoom_); + + auto& node = ned->node; + auto state = build_render_state(node, hover_item_, shared_.get()); + auto* nt = find_node_type2(node->type_id); + render_node(frame.dl, node, nt, ned->layout, ned->vpm, ned->display_text, + state, canvas_zoom_, draw_tooltips_); + } + + // Rebuild wires + rebuild_wires(frame.canvas_origin); + + // Draw wires + for (auto& w : cached_wires_) { + render_wire(frame.dl, w, canvas_zoom_); + render_wire_label(frame.dl, w, canvas_zoom_); + } +} + +HoverItem Editor2Pane::do_detect_hover(ImVec2 mouse, ImVec2 canvas_origin) { + std::vector targets; + targets.reserve(node_editors_.size()); + for (auto& [id, ned] : node_editors_) { + auto* nt = find_node_type2(ned->node->type_id); + if (!nt) continue; + targets.push_back({ned->node, nt, &ned->layout, &ned->vpm}); + } + + auto wire_hit = hit_test_wires(mouse, cached_wires_, canvas_zoom_); + auto node_hit = hit_test_node_bodies(mouse, targets, canvas_zoom_); + auto pin_hit = hit_test_pins(mouse, targets, canvas_zoom_); + + HitResult best = wire_hit; + if (node_hit.distance < best.distance) best = node_hit; + if (pin_hit.distance < best.distance) best = pin_hit; + + return best.item; +} + +void Editor2Pane::do_draw_hover_effects(ImDrawList* dl, ImVec2 canvas_origin, const HoverItem& hover) { + if (std::holds_alternative(hover)) return; + + FlowNodeBuilderPtr hover_node = nullptr; + BuilderEntryPtr hover_entry = nullptr; + + if (auto* ep = std::get_if(&hover)) { + hover_entry = *ep; + hover_node = hover_entry ? hover_entry->as_node() : nullptr; + } + + if (hover_node) { + for (auto& w : cached_wires_) + if (w.is_lambda() && w.entry() == hover_node) + render_wire_highlight(dl, w, canvas_zoom_); + } + + if (hover_entry && hover_entry->as_net()) { + for (auto& w : cached_wires_) + if (w.entry() == hover_entry) + render_wire_highlight(dl, w, canvas_zoom_); + if (draw_tooltips_) { + for (auto& w : cached_wires_) { + if (w.entry() == hover_entry) { + tooltip_wire(w); + break; + } + } + } + } +} + +FlowNodeBuilderPtr Editor2Pane::hover_to_node(const HoverItem& item) { + if (auto* ep = std::get_if(&item)) { + if (*ep) return (*ep)->as_node(); + } else if (auto* pin = std::get_if(&item)) { + return (*pin)->node(); + } + return nullptr; +} + +bool Editor2Pane::test_drag_overlap(const FlowNodeBuilderPtr& sel, float nx, float ny) { + float pad = S.node_height * 0.5f; + auto sel_vpm = VisualPinMap::build(sel, find_node_type2(sel->type_id)); + auto sel_layout = compute_node_layout(sel, sel_vpm, {0,0}, 1.0f); + for (auto& [oid, oned] : node_editors_) { + auto on = oned->node; + if (shared_ && shared_->selected_nodes.count(on)) continue; + auto on_vpm = VisualPinMap::build(on, find_node_type2(on->type_id)); + auto ol = compute_node_layout(on, on_vpm, {0,0}, 1.0f); + float ox = on->position.x - pad, oy = on->position.y - pad; + float ow = ol.width + pad * 2, oh = ol.height + pad * 2; + if (nx < ox + ow && nx + sel_layout.width > ox && + ny < oy + oh && ny + sel_layout.height > oy) + return true; + } + return false; +} + +std::vector Editor2Pane::get_box_test_nodes() { + std::vector result; + for (auto& [id, ned] : node_editors_) { + auto node = ned->node; + auto vpm = VisualPinMap::build(node, find_node_type2(node->type_id)); + auto layout = compute_node_layout(node, vpm, {0,0}, 1.0f); + result.push_back({node, node->position.x, node->position.y, layout.width, layout.height}); + } + return result; +} diff --git a/src/attoflow/editor2.h b/src/attoflow/editor2.h new file mode 100644 index 0000000..7c9bce7 --- /dev/null +++ b/src/attoflow/editor2.h @@ -0,0 +1,121 @@ +#pragma once +#include "editor_pane.h" +#include "visual_editor.h" +#include "atto/graph_editor_interfaces.h" +#include +#include +#include + +// ─── Forward declaration ─── + +class Editor2Pane; + +// ─── Per-item editor implementations ─── + +struct NodeEditorImpl : INodeEditor, std::enable_shared_from_this { + Editor2Pane* pane; + FlowNodeBuilderPtr node; + NodeLayout layout; + VisualPinMap vpm; + std::string display_text; + bool has_error = false; + + NodeEditorImpl(Editor2Pane* p, const FlowNodeBuilderPtr& n) : pane(p), node(n) {} + + void rebuild(ImVec2 canvas_origin, float zoom); + + void node_mutated(const std::shared_ptr& node) override; + void node_layout_changed(const std::shared_ptr& node) override; + std::shared_ptr create_arg_net_editor(const std::shared_ptr& arg) override; + std::shared_ptr create_arg_number_editor(const std::shared_ptr& arg) override; + std::shared_ptr create_arg_string_editor(const std::shared_ptr& arg) override; + std::shared_ptr create_arg_expr_editor(const std::shared_ptr& arg) override; +}; + +struct NetEditorImpl : INetEditor { + Editor2Pane* pane; + NetBuilderPtr net; + NetEditorImpl(Editor2Pane* p, const NetBuilderPtr& n) : pane(p), net(n) {} + void net_mutated(const std::shared_ptr& net) override; +}; + +struct ArgNetEditorImpl : IArgNetEditor { + Editor2Pane* pane; + std::shared_ptr arg; + ArgNetEditorImpl(Editor2Pane* p, const std::shared_ptr& a) : pane(p), arg(a) {} + void arg_net_mutated(const std::shared_ptr& arg) override; +}; + +struct ArgNumberEditorImpl : IArgNumberEditor { + Editor2Pane* pane; + std::shared_ptr arg; + ArgNumberEditorImpl(Editor2Pane* p, const std::shared_ptr& a) : pane(p), arg(a) {} + void arg_number_mutated(const std::shared_ptr& arg) override; +}; + +struct ArgStringEditorImpl : IArgStringEditor { + Editor2Pane* pane; + std::shared_ptr arg; + ArgStringEditorImpl(Editor2Pane* p, const std::shared_ptr& a) : pane(p), arg(a) {} + void arg_string_mutated(const std::shared_ptr& arg) override; +}; + +struct ArgExprEditorImpl : IArgExprEditor { + Editor2Pane* pane; + std::shared_ptr arg; + ArgExprEditorImpl(Editor2Pane* p, const std::shared_ptr& a) : pane(p), arg(a) {} + void arg_expr_mutated(const std::shared_ptr& arg) override; +}; + +// ─── Editor2Pane ─── + +class Editor2Pane : public IEditorPane, public VisualEditor, + public IGraphEditor, public std::enable_shared_from_this { +public: + Editor2Pane(const std::shared_ptr& gb, + const std::shared_ptr& shared); + + // IEditorPane + void draw() override; + const char* type_name() const override { return "graph"; } + std::shared_ptr get_graph_builder() const override { return gb_; } + + // IGraphEditor (observer) + std::shared_ptr node_added(const NodeId& id, const std::shared_ptr& node) override; + void node_removed(const NodeId& id) override; + std::shared_ptr net_added(const NodeId& id, const std::shared_ptr& net) override; + void net_removed(const NodeId& id) override; + + void invalidate_wires() { wires_dirty_ = true; } + +protected: + // VisualEditor hooks + void draw_content(const CanvasFrame& frame) override; + HoverItem do_detect_hover(ImVec2 mouse, ImVec2 canvas_origin) override; + void do_draw_hover_effects(ImDrawList* dl, ImVec2 canvas_origin, const HoverItem& hover) override; + FlowNodeBuilderPtr hover_to_node(const HoverItem& item) override; + bool test_drag_overlap(const FlowNodeBuilderPtr& sel, float nx, float ny) override; + std::vector get_box_test_nodes() override; + void on_nodes_moved() override { wires_dirty_ = true; } + +private: + friend struct NodeEditorImpl; + friend struct NetEditorImpl; + + std::shared_ptr gb_; + + // Per-item editor caches + std::map> node_editors_; + std::map> net_editors_; + + // Wire cache + std::vector cached_wires_; + bool wires_dirty_ = true; + + void rebuild_wires(ImVec2 canvas_origin); +}; + +// Factory +std::shared_ptr make_editor2( + const std::shared_ptr& gb, + const std::shared_ptr& shared); diff --git a/src/attoflow/editor_pane.h b/src/attoflow/editor_pane.h new file mode 100644 index 0000000..5554bad --- /dev/null +++ b/src/attoflow/editor_pane.h @@ -0,0 +1,13 @@ +#pragma once +#include + +struct GraphBuilder; + +// Interface for editor panes — views into a GraphBuilder +struct IEditorPane { + virtual ~IEditorPane() = default; + + virtual void draw() = 0; + virtual const char* type_name() const = 0; + virtual std::shared_ptr get_graph_builder() const = 0; +}; diff --git a/src/attoflow/editor_style.cpp b/src/attoflow/editor_style.cpp new file mode 100644 index 0000000..2b3d8f4 --- /dev/null +++ b/src/attoflow/editor_style.cpp @@ -0,0 +1,55 @@ +#include "editor_style.h" + +Editor2Style::Editor2Style() + // Layout + : node_min_width(80.0f) + , node_height(40.0f) + , pin_radius(5.0f) + , pin_spacing(16.0f) + , node_rounding(4.0f) + , grid_step(20.0f) + // Thickness + , wire_thickness(2.5f) + , node_border(1.0f) + , highlight_offset(2.0f) + , highlight_thickness(2.0f) + , add_pin_line(1.5f) + // Hit testing + , pin_hit_radius_mul(2.5f) + , wire_hit_threshold(30.0f) + , node_hit_threshold_mul(6.f) + , dismiss_radius(20.0f) + , pin_priority_bias(1e6f) + // Canvas colors + , col_bg(IM_COL32(30, 30, 40, 255)) + , col_grid(IM_COL32(50, 50, 60, 255)) + // Node colors + , col_node(IM_COL32(50, 55, 75, 220)) + , col_node_sel(IM_COL32(80, 90, 130, 255)) + , col_node_err(IM_COL32(130, 40, 40, 220)) + , col_node_border(IM_COL32(80, 80, 100, 255)) + , col_err_border(IM_COL32(255, 80, 80, 255)) + , col_text(IM_COL32(220, 220, 220, 255)) + // Pin colors + , col_pin_data(IM_COL32(100, 200, 100, 255)) + , col_pin_bang(IM_COL32(255, 200, 80, 255)) + , col_pin_lambda(IM_COL32(180, 130, 255, 255)) + , col_pin_hover(IM_COL32(255, 255, 255, 255)) + , col_add_pin(IM_COL32(120, 120, 140, 180)) + , col_add_pin_fg(IM_COL32(200, 200, 220, 220)) + , col_opt_pin_fg(IM_COL32(30, 30, 40, 255)) + // Wire colors + , col_wire(IM_COL32(200, 200, 100, 200)) + , col_wire_named(IM_COL32(200, 200, 100, 120)) + , col_wire_lambda(IM_COL32(180, 130, 255, 200)) + // Net label colors + , col_label_bg(IM_COL32(30, 30, 40, 200)) + , col_label_text(IM_COL32(180, 220, 255, 255)) + // Interaction + , scroll_pan_speed(120.0f) + // Tooltip + , tooltip_scale(1.0f) +{ +} + +Editor2Style S; diff --git a/src/attoflow/editor_style.h b/src/attoflow/editor_style.h new file mode 100644 index 0000000..2eee00e --- /dev/null +++ b/src/attoflow/editor_style.h @@ -0,0 +1,66 @@ +#pragma once +#include "imgui.h" + +struct Editor2Style { + Editor2Style(); + + // Layout + float node_min_width; + float node_height; + float pin_radius; + float pin_spacing; + float node_rounding; + float grid_step; + + // Thickness + float wire_thickness; + float node_border; + float highlight_offset; + float highlight_thickness; + float add_pin_line; + + // Hit testing + float pin_hit_radius_mul; + float wire_hit_threshold; + float node_hit_threshold_mul; + float dismiss_radius; + float pin_priority_bias; + + // Canvas colors + ImU32 col_bg; + ImU32 col_grid; + + // Node colors + ImU32 col_node; + ImU32 col_node_sel; + ImU32 col_node_err; + ImU32 col_node_border; + ImU32 col_err_border; + ImU32 col_text; + + // Pin colors + ImU32 col_pin_data; + ImU32 col_pin_bang; + ImU32 col_pin_lambda; + ImU32 col_pin_hover; + ImU32 col_add_pin; + ImU32 col_add_pin_fg; + ImU32 col_opt_pin_fg; + + // Wire colors + ImU32 col_wire; + ImU32 col_wire_named; + ImU32 col_wire_lambda; + + // Net label colors + ImU32 col_label_bg; + ImU32 col_label_text; + + // Interaction + float scroll_pan_speed; + + // Tooltip + float tooltip_scale; +}; + +extern Editor2Style S; diff --git a/src/attoflow/fonts/LICENSE-LiberationFonts b/src/attoflow/fonts/LICENSE-LiberationFonts new file mode 100644 index 0000000..aba73e8 --- /dev/null +++ b/src/attoflow/fonts/LICENSE-LiberationFonts @@ -0,0 +1,102 @@ +Digitized data copyright (c) 2010 Google Corporation + with Reserved Font Arimo, Tinos and Cousine. +Copyright (c) 2012 Red Hat, Inc. + with Reserved Font Name Liberation. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE The goals of the Open Font License (OFL) are to stimulate +worldwide development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to provide +a free and open framework in which fonts may be shared and improved in +partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. +The fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + + + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. +This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components +as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting ? in part or in whole ? +any of the components of the Original Version, by changing formats or +by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer +or other person who contributed to the Font Software. + + +PERMISSION & CONDITIONS + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components,in + Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5) The Font Software, modified or unmodified, in part or in whole, must + be distributed entirely under this license, and must not be distributed + under any other license. The requirement for fonts to remain under + this license does not apply to any document created using the Font + Software. + + + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + + + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + diff --git a/src/attoflow/fonts/LiberationMono-Bold.ttf b/src/attoflow/fonts/LiberationMono-Bold.ttf new file mode 100644 index 0000000..2e46737 Binary files /dev/null and b/src/attoflow/fonts/LiberationMono-Bold.ttf differ diff --git a/src/attoflow/fonts/LiberationMono-Regular.ttf b/src/attoflow/fonts/LiberationMono-Regular.ttf new file mode 100644 index 0000000..e774859 Binary files /dev/null and b/src/attoflow/fonts/LiberationMono-Regular.ttf differ diff --git a/src/attoflow/main.cpp b/src/attoflow/main.cpp index 01a40c3..3676208 100644 --- a/src/attoflow/main.cpp +++ b/src/attoflow/main.cpp @@ -1,6 +1,6 @@ #include #include -#include "editor.h" +#include "window.h" int main(int argc, char* argv[]) { if (!SDL_Init(SDL_INIT_VIDEO)) { @@ -43,7 +43,7 @@ int main(int argc, char* argv[]) { } } - editor.shutdown(); + editor.shutdown(); SDL_Quit(); return 0; } diff --git a/src/attoflow/nets_editor.cpp b/src/attoflow/nets_editor.cpp new file mode 100644 index 0000000..35e7578 --- /dev/null +++ b/src/attoflow/nets_editor.cpp @@ -0,0 +1,463 @@ +#include "nets_editor.h" +#include "tooltip_renderer.h" +#include "atto/graph_builder.h" +#include "atto/node_types2.h" +#include "imgui.h" +#include +#include + +// ─── Factory ─── + +NetsEditor::NetsEditor(const std::shared_ptr& gb, + const std::shared_ptr& shared) + : VisualEditor(shared), gb_(gb) { +} + +std::shared_ptr make_nets_editor( + const std::shared_ptr& gb, + const std::shared_ptr& shared) { + return std::make_shared(gb, shared); +} + +// ─── Layout constants ─── + +static constexpr float ROW_HEIGHT_MULT = 6.0f; // rows are 6x node height apart +static constexpr float LEFT_MARGIN = 20.0f; +static constexpr float NODE_GAP = 30.0f; +static constexpr float LABEL_GAP = 60.0f; // gap between src node and label area +static constexpr float FADE_STUB_LENGTH_MULT = 2.0f; // 2x node height + +// ─── Helpers ─── + +// Find which visual output pin of src_node produces this net entry +static int find_source_output_pin(const FlowNodeBuilderPtr& src_node, const BuilderEntryPtr& net_entry) { + for (int k = 0; k < (int)src_node->outputs.size(); k++) { + auto out_net = src_node->outputs[k]->as_net(); + if (out_net && out_net->second() == net_entry) return k; + } + int base = (int)src_node->outputs.size(); + for (int k = 0; k < (int)src_node->outputs_va_args.size(); k++) { + auto out_net = src_node->outputs_va_args[k]->as_net(); + if (out_net && out_net->second() == net_entry) return base + k; + } + return 0; +} + +// Find which visual input pin of dst_node receives this net entry, using VisualPinMap +static int find_dest_input_pin(const VisualPinMap& vpm, const BuilderEntryPtr& net_entry) { + for (int i = 0; i < (int)vpm.inputs.size(); i++) { + auto& pin = vpm.inputs[i]; + if (!pin.arg) continue; + auto an = pin.arg->as_net(); + if (an && an->second() == net_entry) return i; + } + return 0; +} + +// ─── rebuild_layout ─── + +void NetsEditor::rebuild_layout(ImVec2 canvas_origin) { + rows_.clear(); + all_wires_.clear(); + rendered_nodes_.clear(); + + float zoom = canvas_zoom_; + float row_h = S.node_height * ROW_HEIGHT_MULT * zoom; + float node_h = S.node_height * zoom; + + // Collect all non-sentinel nets, sorted alphabetically + std::vector> nets; + for (auto& [id, entry] : gb_->entries) { + auto net = entry->as_net(); + if (!net || net->is_the_unconnected()) continue; + nets.push_back({id, net}); + } + std::sort(nets.begin(), nets.end(), [](auto& a, auto& b) { return a.first < b.first; }); + + // Layout: net label on baseline, nodes above/below with wires curving to baseline + // + // ┌───────────┐ ┌──────────┐ ┌──────────┐ + // │ src_node │ │ dst_a │ │ dst_b │ + // └─────┬─────┘ └─────┬────┘ └────┬─────┘ + // │ (curve down) │ │ + // ──────┴─────── net_label ───────────────┴─────────────┴────── + // (baseline) + + // Start with enough offset so the first row's source node (above baseline) is visible + float first_row_offset = node_h * ROW_HEIGHT_MULT; + + int row_idx = 0; + for (auto& [net_id, net] : nets) { + auto src_ptr = net->source().lock(); + auto src_node = src_ptr ? src_ptr->as_node() : nullptr; + if (!src_node) { row_idx++; continue; } + + NetRow row; + row.net = net; + row.net_id = net_id; + row.src_node = src_node; + row.row_y = first_row_offset + row_idx * row_h; + + float baseline_y = canvas_origin.y + row.row_y; + + // Source node + auto* src_nt = find_node_type2(src_node->type_id); + row.src_vpm = VisualPinMap::build(src_node, src_nt); + row.src_display = src_nt ? src_nt->name : "?"; + std::string args = src_node->args_str(); + if (!args.empty()) row.src_display += " " + args; + + row.src_output_pin = find_source_output_pin(src_node, net); + row.src_is_bang = src_nt && row.src_output_pin < src_nt->num_outputs && + src_nt->output_ports && + src_nt->output_ports[row.src_output_pin].kind == PortKind2::BangNext; + + // Source node placement: put node on OPPOSITE side of baseline from its pin. + // Output pins (bottom of node) → node ABOVE baseline, pin near baseline, wire curves down. + // Side-bang (right of node) → node above baseline (side-bang is mid-height). + row.src_layout = compute_node_layout(src_node, row.src_vpm, {0,0}, zoom); + float src_x = canvas_origin.x + LEFT_MARGIN * zoom; + float gap = node_h * 0.3f; + + bool is_side_bang = row.src_is_bang && row.src_vpm.has_side_bang; + // Output pins are at the bottom → node above baseline + row.src_layout.pos = {src_x, baseline_y - row.src_layout.height - gap * 3}; + + rendered_nodes_.push_back({src_node, row.src_layout, row.src_vpm, row.src_display}); + + // Get source pin position + ImVec2 src_pin_pos; + if (is_side_bang) { + src_pin_pos = row.src_layout.side_bang_pos(); + } else { + int visual_pin = row.src_output_pin; + if (row.src_vpm.is_flow) visual_pin = std::max(0, visual_pin - 1); + src_pin_pos = row.src_layout.output_pin_pos(visual_pin); + } + ImVec2 src_baseline = {src_pin_pos.x, baseline_y}; + + // Collect destinations sorted alphabetically + net->compact(); + std::vector> dest_list; + for (auto& dw : net->destinations()) { + auto dp = dw.lock(); + auto dn = dp ? dp->as_node() : nullptr; + if (dn) dest_list.push_back({dn->id(), dn}); + } + std::sort(dest_list.begin(), dest_list.end(), [](auto& a, auto& b) { return a.first < b.first; }); + + // Net label position: between source and first destination + float stub_len = node_h * FADE_STUB_LENGTH_MULT; + float src_extra = row.src_vpm.has_side_bang ? stub_len + S.pin_radius * zoom : 0.0f; + float label_x = src_x + row.src_layout.width + src_extra + LABEL_GAP * 0.5f * zoom; + float dest_start_x = src_x + row.src_layout.width + src_extra + LABEL_GAP * zoom; + + // Wire: source pin → baseline (pin is above baseline, curves down) + { + float curve_dy = std::abs(src_pin_pos.y - baseline_y) * 0.5f; + ImVec2 cp1 = {src_pin_pos.x, src_pin_pos.y + curve_dy}; + ImVec2 cp2 = {src_baseline.x, src_baseline.y - curve_dy}; + all_wires_.push_back({net, src_pin_pos, cp1, cp2, src_baseline, + src_node->id(), "", net_id}); + } + + // Deduplicate destinations: group by node ID, collect all input pins + // A node may appear multiple times if it has multiple pins connected to this net + struct DestGroup { + FlowNodeBuilderPtr node; + NodeId id; + std::vector input_pins; // all visual input pin indices connected to this net + }; + std::map dest_groups; + for (auto& [did, dnode] : dest_list) { + auto& grp = dest_groups[did]; + if (!grp.node) { + grp.node = dnode; + grp.id = did; + } + auto dvpm = VisualPinMap::build(dnode, find_node_type2(dnode->type_id)); + // Find ALL input pins connected to this net (not just the first) + for (int i = 0; i < (int)dvpm.inputs.size(); i++) { + auto& pin = dvpm.inputs[i]; + if (!pin.arg) continue; + auto an = pin.arg->as_net(); + if (an && an->second() == net) grp.input_pins.push_back(i); + } + } + + // Sort groups alphabetically and lay out + std::vector sorted_groups; + for (auto& [id, grp] : dest_groups) sorted_groups.push_back(&grp); + std::sort(sorted_groups.begin(), sorted_groups.end(), + [](auto* a, auto* b) { return a->id < b->id; }); + + float dest_x = dest_start_x; + for (auto* grp : sorted_groups) { + auto* dst_nt = find_node_type2(grp->node->type_id); + NetRow::Dest dest; + dest.node = grp->node; + dest.vpm = VisualPinMap::build(grp->node, dst_nt); + dest.display = dst_nt ? dst_nt->name : "?"; + std::string dargs = grp->node->args_str(); + if (!dargs.empty()) dest.display += " " + dargs; + dest.input_pin = grp->input_pins.empty() ? 0 : grp->input_pins[0]; + dest.is_bang = dest.input_pin < (int)dest.vpm.inputs.size() && + dest.vpm.inputs[dest.input_pin].port_kind == PortKind2::BangTrigger; + + // Node below baseline, more gap + dest.layout = compute_node_layout(grp->node, dest.vpm, {0,0}, zoom); + dest.layout.pos = {dest_x, baseline_y + gap * 3}; + + rendered_nodes_.push_back({grp->node, dest.layout, dest.vpm, dest.display}); + + // Wire for EACH connected pin on this node + for (int pin_idx : grp->input_pins) { + ImVec2 dst_pin_pos = dest.layout.input_pin_pos(pin_idx); + ImVec2 dst_baseline = {dst_pin_pos.x, baseline_y}; + float curve_dy = std::abs(dst_pin_pos.y - baseline_y) * 0.5f; + ImVec2 cp1 = {dst_baseline.x, dst_baseline.y + curve_dy}; + ImVec2 cp2 = {dst_pin_pos.x, dst_pin_pos.y - curve_dy}; + all_wires_.push_back({net, dst_baseline, cp1, cp2, dst_pin_pos, + "", grp->id, net_id}); + } + + // Extra gap if node has side-bang stub (extends past right edge) + float extra = dest.vpm.has_side_bang ? stub_len + S.pin_radius * zoom : 0.0f; + dest_x += dest.layout.width + extra + NODE_GAP * zoom; + row.dests.push_back(std::move(dest)); + } + + row.label_x = label_x; + + // Add short stub wires for non-primary connections on all rendered nodes + auto is_unconnected = [](const BuilderEntryPtr& e) { + if (!e) return true; + auto n = e->as_net(); + return n && n->is_the_unconnected(); + }; + + auto add_stubs = [&](const NodeLayout& layout, const VisualPinMap& vpm, + const BuilderEntryPtr& primary_net) { + float stub_len = node_h * FADE_STUB_LENGTH_MULT; + // Output pin stubs (curve downward from pin) + for (int i = 0; i < (int)vpm.outputs.size(); i++) { + auto& pin = vpm.outputs[i]; + if (!pin.arg) continue; + auto an = pin.arg->as_net(); + if (!an) continue; + auto entry = an->second(); + if (!entry || entry == primary_net || is_unconnected(entry)) continue; + ImVec2 pp = layout.output_pin_pos(i); + ImVec2 end = {pp.x, pp.y + stub_len}; + all_wires_.push_back({entry, pp, {pp.x, pp.y + stub_len * 0.3f}, + {pp.x, pp.y + stub_len * 0.7f}, end, + "", "", an->first()}); + } + // Input pin stubs (curve upward from pin) + for (int i = 0; i < (int)vpm.inputs.size(); i++) { + auto& pin = vpm.inputs[i]; + if (!pin.arg || pin.kind == VisualPinKind::AddDiamond || pin.kind == VisualPinKind::AbsentOptional) continue; + auto an = pin.arg->as_net(); + if (!an) continue; + auto entry = an->second(); + if (!entry || entry == primary_net || is_unconnected(entry)) continue; + ImVec2 pp = layout.input_pin_pos(i); + ImVec2 end = {pp.x, pp.y - stub_len}; + all_wires_.push_back({entry, pp, {pp.x, pp.y - stub_len * 0.3f}, + {pp.x, pp.y - stub_len * 0.7f}, end, + "", "", an->first()}); + } + // Side-bang stub + if (vpm.has_side_bang && vpm.side_bang_arg) { + auto an = vpm.side_bang_arg->as_net(); + if (an) { + auto entry = an->second(); + if (entry && entry != primary_net && !is_unconnected(entry)) { + ImVec2 pp = layout.side_bang_pos(); + ImVec2 end = {pp.x + stub_len, pp.y}; + all_wires_.push_back({entry, pp, {pp.x + stub_len * 0.3f, pp.y}, + {pp.x + stub_len * 0.7f, pp.y}, end, + "", "", an->first()}); + } + } + } + }; + + add_stubs(row.src_layout, row.src_vpm, net); + for (auto& dest : row.dests) + add_stubs(dest.layout, dest.vpm, net); + + // Baseline as two flat wire segments (left of label, right of label) + // so they get automatic hit-testing and highlighting + float bl_left = src_baseline.x; + float bl_right = dest_x > dest_start_x ? dest_x : dest_start_x; + // Estimate label width for the gap + ImVec2 label_sz = ImGui::CalcTextSize(net_id.c_str()); + float label_w = label_sz.x * canvas_zoom_ * 0.8f / ImGui::GetFontSize() * ImGui::GetFontSize(); + // Simpler: just use a fixed estimate + float label_half = (label_sz.x * zoom * 0.8f + 6.0f) * 0.5f; + float label_cx = label_x + label_half; + + // Left segment: source baseline → just before label + if (label_x - 3.0f * zoom > bl_left) { + ImVec2 l0 = {bl_left, baseline_y}; + ImVec2 l1 = {label_x - 3.0f * zoom, baseline_y}; + all_wires_.push_back({net, l0, l0, l1, l1, + src_node->id(), "", net_id}); + } + // Right segment: just after label → rightmost dest + if (bl_right > label_x + label_half * 2 + 3.0f * zoom) { + ImVec2 r0 = {label_x + label_half * 2 + 3.0f * zoom, baseline_y}; + ImVec2 r1 = {bl_right, baseline_y}; + all_wires_.push_back({net, r0, r0, r1, r1, + "", "", net_id}); + } + + rows_.push_back(std::move(row)); + row_idx++; + } +} + +// ─── Draw ─── + +void NetsEditor::draw() { + if (!gb_) { + ImGui::TextDisabled("No graph loaded"); + return; + } + draw_canvas("##canvas_nets"); +} + +// ─── draw_content ─── + +void NetsEditor::draw_content(const CanvasFrame& frame) { + rebuild_layout(frame.canvas_origin); + + float zoom = canvas_zoom_; + + float row_h = S.node_height * ROW_HEIGHT_MULT * zoom; + + // Track which nodes already had tooltips to avoid duplicates + std::set tooltipped_nodes; + + for (int ri = 0; ri < (int)rows_.size(); ri++) { + auto& row = rows_[ri]; + float baseline_y = frame.canvas_origin.y + row.row_y; + + // Draw separator between rows + if (ri > 0) { + float sep_y = baseline_y - row_h * 0.5f; + float sep_left = frame.canvas_origin.x; + float sep_right = sep_left + frame.canvas_sz.x / zoom * 2.0f; // wide enough + frame.dl->AddLine({sep_left, sep_y}, {sep_right, sep_y}, + IM_COL32(60, 60, 80, 120), 1.0f); + } + + // Render source node (only show tooltip on first occurrence) + auto* src_nt = find_node_type2(row.src_node->type_id); + auto src_state = build_render_state(row.src_node, hover_item_, shared_.get()); + bool src_tt = draw_tooltips_ && tooltipped_nodes.insert(row.src_node).second; + render_node(frame.dl, row.src_node, src_nt, row.src_layout, row.src_vpm, + row.src_display, src_state, zoom, src_tt); + + // Render destination nodes + for (auto& dest : row.dests) { + auto* dst_nt = find_node_type2(dest.node->type_id); + auto dst_state = build_render_state(dest.node, hover_item_, shared_.get()); + bool dst_tt = draw_tooltips_ && tooltipped_nodes.insert(dest.node).second; + render_node(frame.dl, dest.node, dst_nt, dest.layout, dest.vpm, + dest.display, dst_state, zoom, dst_tt); + } + } + + // Draw all wires (baseline segments are included as WireInfo) + for (auto& w : all_wires_) { + render_wire(frame.dl, w, zoom); + } + + // Draw net labels ON TOP of wires so highlights don't cover them + float font_size = ImGui::GetFontSize() * zoom * 0.8f; + if (font_size > 5.0f) { + for (auto& row : rows_) { + float baseline_y = frame.canvas_origin.y + row.row_y; + ImVec2 text_sz = ImGui::CalcTextSize(row.net_id.c_str()); + float tw = text_sz.x * (font_size / ImGui::GetFontSize()); + float th = text_sz.y * (font_size / ImGui::GetFontSize()); + frame.dl->AddRectFilled({row.label_x - 3, baseline_y - th * 0.5f - 1}, + {row.label_x + tw + 3, baseline_y + th * 0.5f + 1}, + S.col_label_bg, S.node_rounding); + frame.dl->AddText(nullptr, font_size, {row.label_x, baseline_y - th * 0.5f}, + S.col_label_text, row.net_id.c_str()); + } + } +} + +// ─── Hover detection ─── + +HoverItem NetsEditor::do_detect_hover(ImVec2 mouse, ImVec2 canvas_origin) { + // Build hit targets from all rendered node instances + std::vector targets; + targets.reserve(rendered_nodes_.size()); + for (auto& rn : rendered_nodes_) { + auto* nt = find_node_type2(rn.node->type_id); + if (!nt) continue; + targets.push_back({rn.node, nt, &rn.layout, &rn.vpm}); + } + + auto wire_hit = hit_test_wires(mouse, all_wires_, canvas_zoom_); + auto node_hit = hit_test_node_bodies(mouse, targets, canvas_zoom_); + auto pin_hit = hit_test_pins(mouse, targets, canvas_zoom_); + + HitResult best = wire_hit; + if (node_hit.distance < best.distance) best = node_hit; + if (pin_hit.distance < best.distance) best = pin_hit; + + return best.item; +} + +void NetsEditor::do_draw_hover_effects(ImDrawList* dl, ImVec2 canvas_origin, const HoverItem& hover) { + if (std::holds_alternative(hover)) return; + + BuilderEntryPtr hover_entry = nullptr; + if (auto* ep = std::get_if(&hover)) { + hover_entry = *ep; + } + + // Highlight all wire segments sharing the hovered entry (nets and lambda nodes) + if (hover_entry) { + for (auto& w : all_wires_) { + if (w.entry() == hover_entry) + render_wire_highlight(dl, w, canvas_zoom_); + } + if (draw_tooltips_) { + for (auto& w : all_wires_) { + if (w.entry() == hover_entry) { + tooltip_wire(w); + break; + } + } + } + } +} + +FlowNodeBuilderPtr NetsEditor::hover_to_node(const HoverItem& item) { + if (auto* ep = std::get_if(&item)) { + if (*ep) return (*ep)->as_node(); + } else if (auto* pin = std::get_if(&item)) { + return (*pin)->node(); + } + return nullptr; +} + +bool NetsEditor::test_drag_overlap(const FlowNodeBuilderPtr&, float, float) { + return true; // Dragging disabled — positions are computed +} + +std::vector NetsEditor::get_box_test_nodes() { + std::vector result; + for (auto& rn : rendered_nodes_) { + result.push_back({rn.node, rn.layout.pos.x, rn.layout.pos.y, + rn.layout.width, rn.layout.height}); + } + return result; +} diff --git a/src/attoflow/nets_editor.h b/src/attoflow/nets_editor.h new file mode 100644 index 0000000..72a6d0c --- /dev/null +++ b/src/attoflow/nets_editor.h @@ -0,0 +1,78 @@ +#pragma once +#include "editor_pane.h" +#include "visual_editor.h" +#include +#include +#include + +class NetsEditor : public IEditorPane, public VisualEditor { +public: + NetsEditor(const std::shared_ptr& gb, + const std::shared_ptr& shared); + + // IEditorPane + void draw() override; + const char* type_name() const override { return "nets"; } + std::shared_ptr get_graph_builder() const override { return gb_; } + +protected: + // VisualEditor hooks + void draw_content(const CanvasFrame& frame) override; + HoverItem do_detect_hover(ImVec2 mouse, ImVec2 canvas_origin) override; + void do_draw_hover_effects(ImDrawList* dl, ImVec2 canvas_origin, const HoverItem& hover) override; + FlowNodeBuilderPtr hover_to_node(const HoverItem& item) override; + bool test_drag_overlap(const FlowNodeBuilderPtr& sel, float nx, float ny) override; + std::vector get_box_test_nodes() override; + +private: + std::shared_ptr gb_; + + // Per-row layout for each net + struct NetRow { + NetBuilderPtr net; + NodeId net_id; + + // Source node + FlowNodeBuilderPtr src_node; + NodeLayout src_layout; + VisualPinMap src_vpm; + std::string src_display; + int src_output_pin; // output pin index connected to this net + bool src_is_bang; // source is a bang (node below line) + + // Destination nodes (sorted alphabetically) + struct Dest { + FlowNodeBuilderPtr node; + NodeLayout layout; + VisualPinMap vpm; + std::string display; + int input_pin; // visual input pin index receiving this net + bool is_bang; // destination is a bang trigger + }; + std::vector dests; + + float row_y; // canvas-space Y baseline + float label_x; // screen-space X for net label + }; + + std::vector rows_; + + // All wires for hit-testing + std::vector all_wires_; + + // All rendered node instances for hit-testing + struct RenderedNode { + FlowNodeBuilderPtr node; + NodeLayout layout; + VisualPinMap vpm; + std::string display; + }; + std::vector rendered_nodes_; + + void rebuild_layout(ImVec2 canvas_origin); +}; + +// Factory +std::shared_ptr make_nets_editor( + const std::shared_ptr& gb, + const std::shared_ptr& shared); diff --git a/src/attoflow/node_renderer.cpp b/src/attoflow/node_renderer.cpp new file mode 100644 index 0000000..31148d7 --- /dev/null +++ b/src/attoflow/node_renderer.cpp @@ -0,0 +1,557 @@ +#include "node_renderer.h" +#include "atto_editor_shared_state.h" +#include "tooltip_renderer.h" +#include +#include + +// ─── build_render_state ─── + +NodeRenderState build_render_state(const FlowNodeBuilderPtr& node, + const HoverItem& hover_item, + const AttoEditorSharedState* shared) { + NodeRenderState state; + state.selected = shared && shared->selected_nodes.count(node) > 0; + state.node_hovered = false; + if (auto* ep = std::get_if(&hover_item)) + state.node_hovered = (*ep == node); + state.pin_hovered_on_this = false; + if (auto* pin = std::get_if(&hover_item)) + state.pin_hovered_on_this = ((*pin)->node() == node); + else if (auto* add = std::get_if(&hover_item)) + state.pin_hovered_on_this = (add->node == node); + state.hovered_pin = nullptr; + if (auto* pp = std::get_if(&hover_item)) + state.hovered_pin = *pp; + state.add_pin_hover = std::get_if(&hover_item); + return state; +} + +// ─── Geometry helpers ─── + +float point_to_bezier_dist(ImVec2 p, ImVec2 p0, ImVec2 p1, ImVec2 p2, ImVec2 p3) { + float min_d2 = 1e18f; + for (int i = 0; i <= 20; i++) { + float t = i / 20.0f; + float u = 1.0f - t; + float x = u*u*u*p0.x + 3*u*u*t*p1.x + 3*u*t*t*p2.x + t*t*t*p3.x; + float y = u*u*u*p0.y + 3*u*u*t*p1.y + 3*u*t*t*p2.y + t*t*t*p3.y; + float dx = p.x - x, dy = p.y - y; + float d2 = dx*dx + dy*dy; + if (d2 < min_d2) min_d2 = d2; + } + return std::sqrt(min_d2); +} + +static float dist2d(ImVec2 a, ImVec2 b) { + return std::sqrt((a.x-b.x)*(a.x-b.x) + (a.y-b.y)*(a.y-b.y)); +} + +ImU32 pin_color(PortKind2 kind) { + switch (kind) { + case PortKind2::BangTrigger: + case PortKind2::BangNext: return S.col_pin_bang; + case PortKind2::Lambda: return S.col_pin_lambda; + default: return S.col_pin_data; + } +} + +// ─── VisualPinMap::build ─── + +VisualPinMap VisualPinMap::build(const FlowNodeBuilderPtr& node, const NodeType2* nt) { + VisualPinMap vpm; + if (!nt) return vpm; + + vpm.is_flow = nt->is_flow(); + bool has_input_va = nt->input_ports_va_args != nullptr; + + // Input pins: base args (only Net kind get pins) + int parsed_size = node->parsed_args ? (int)node->parsed_args->size() : 0; + if (node->parsed_args) { + for (int i = 0; i < parsed_size; i++) { + auto arg = (*node->parsed_args)[i]; + if (arg->is(ArgKind::Net)) { + PortKind2 pk = PortKind2::Data; + bool opt = false; + const PortDesc2* pd = nt->input_port(i); + if (pd) { pk = pd->kind; opt = pd->optional; } + vpm.inputs.push_back({VisualPinKind::Base, arg, pd, pk, opt}); + } + } + } + // Absent trailing optional ports + for (int i = parsed_size; i < nt->total_inputs(); i++) { + if (i >= nt->num_inputs) { + const PortDesc2* pd = nt->input_port(i); + PortKind2 pk = pd ? pd->kind : PortKind2::Data; + vpm.inputs.push_back({VisualPinKind::AbsentOptional, nullptr, pd, pk, true}); + } + } + // Va_args + if (node->parsed_va_args) { + PortKind2 va_kind = nt->input_ports_va_args ? nt->input_ports_va_args->kind : PortKind2::Data; + for (int i = 0; i < (int)node->parsed_va_args->size(); i++) { + auto arg = (*node->parsed_va_args)[i]; + if (arg->is(ArgKind::Net)) { + vpm.inputs.push_back({VisualPinKind::VaArg, arg, nt->input_ports_va_args, va_kind, false}); + } + } + } + // +diamond + if (has_input_va) { + vpm.add_diamond_va_port = nt->input_ports_va_args; + vpm.inputs.push_back({VisualPinKind::AddDiamond, nullptr, nt->input_ports_va_args, PortKind2::Data, false}); + } + // Remaps + for (int i = 0; i < (int)node->remaps.size(); i++) { + vpm.inputs.push_back({VisualPinKind::Remap, node->remaps[i], nullptr, PortKind2::Data, false}); + } + + // Output pins + int skip_sb = nt->is_flow() ? 1 : 0; + if (skip_sb && !node->outputs.empty()) { + vpm.has_side_bang = true; + vpm.side_bang_arg = node->outputs[0]; + } + // Fixed outputs (skipping side-bang) + for (int i = skip_sb; i < (int)node->outputs.size(); i++) { + PortKind2 pk = PortKind2::Data; + const PortDesc2* pd = (nt->output_ports && i < nt->num_outputs) ? &nt->output_ports[i] : nullptr; + if (pd) pk = pd->kind; + vpm.outputs.push_back({VisualPinKind::Base, node->outputs[i], pd, pk, false}); + } + // Va_args outputs + PortKind2 out_va_kind = nt->output_ports_va_args ? nt->output_ports_va_args->kind : PortKind2::Data; + for (int i = 0; i < (int)node->outputs_va_args.size(); i++) { + vpm.outputs.push_back({VisualPinKind::VaArg, node->outputs_va_args[i], nt->output_ports_va_args, out_va_kind, false}); + } + + return vpm; +} + +// ─── compute_node_layout ─── + +NodeLayout compute_node_layout(const FlowNodeBuilderPtr& node, const VisualPinMap& vpm, + ImVec2 canvas_origin, float zoom) { + auto* nt = find_node_type2(node->type_id); + std::string display = nt ? nt->name : "?"; + std::string args = node->args_str(); + if (!args.empty()) display += " " + args; + + ImVec2 text_sz = ImGui::CalcTextSize(display.c_str()); + float text_w = text_sz.x * zoom + 16.0f * zoom; + + int num_in = (int)vpm.inputs.size(); + int num_out = (int)vpm.outputs.size(); + + float pin_w_top = std::max(0, num_in) * S.pin_spacing * zoom; + float pin_w_bot = std::max(0, num_out) * S.pin_spacing * zoom; + float node_w = std::max({S.node_min_width * zoom, text_w, pin_w_top, pin_w_bot}); + float node_h = S.node_height * zoom; + + ImVec2 pos = {canvas_origin.x + node->position.x * zoom, + canvas_origin.y + node->position.y * zoom}; + + return {pos, node_w, node_h, num_in, num_out, zoom}; +} + +// ─── render_background ─── + +void render_background(ImDrawList* dl, ImVec2 canvas_p0, ImVec2 canvas_sz, + ImVec2 canvas_offset, float zoom) { + dl->AddRectFilled(canvas_p0, v2add(canvas_p0, canvas_sz), S.col_bg); + + float grid_step = S.grid_step * zoom; + if (grid_step > 5.0f) { + for (float x = fmodf(canvas_offset.x, grid_step); x < canvas_sz.x; x += grid_step) + dl->AddLine({canvas_p0.x + x, canvas_p0.y}, {canvas_p0.x + x, canvas_p0.y + canvas_sz.y}, S.col_grid); + for (float y = fmodf(canvas_offset.y, grid_step); y < canvas_sz.y; y += grid_step) + dl->AddLine({canvas_p0.x, canvas_p0.y + y}, {canvas_p0.x + canvas_sz.x, canvas_p0.y + y}, S.col_grid); + } +} + +// ─── render_node ─── + +void render_node(ImDrawList* dl, const FlowNodeBuilderPtr& node, const NodeType2* nt, + const NodeLayout& layout, const VisualPinMap& vpm, + const std::string& display_text, const NodeRenderState& state, + float zoom, bool draw_tooltips) { + if (!nt) return; + + float pr = S.pin_radius * zoom; + + // Special nodes: label and error + if (nt->is_special()) { + std::string display; + if (node->parsed_args && !node->parsed_args->empty()) { + auto a = (*node->parsed_args)[0]; + if (auto s = a->as_string()) display = s->value(); + else if (auto e = a->as_expr()) display = e->expr(); + else display = node->args_str(); + } + + float font_size = ImGui::GetFontSize() * zoom; + bool is_error = (node->type_id == NodeTypeID::Error); + + if (is_error) { + dl->AddRectFilled(layout.pos, {layout.pos.x + layout.width, layout.pos.y + layout.height}, + S.col_node_err, S.node_rounding * zoom); + dl->AddRect(layout.pos, {layout.pos.x + layout.width, layout.pos.y + layout.height}, + S.col_err_border, S.node_rounding * zoom); + } + + if (font_size > 5.0f) { + ImVec2 text_sz = ImGui::CalcTextSize(display.c_str()); + float tw = text_sz.x * zoom; + float cx = layout.pos.x + (layout.width - tw) * 0.5f; + float cy = layout.pos.y + (layout.height - font_size) * 0.5f; + dl->AddText(nullptr, font_size, {cx, cy}, S.col_text, display.c_str()); + } + return; + } + + // Node body + ImU32 col = state.selected ? S.col_node_sel : S.col_node; + if (!node->error.empty()) col = S.col_node_err; + dl->AddRectFilled(layout.pos, {layout.pos.x + layout.width, layout.pos.y + layout.height}, + col, S.node_rounding * zoom); + dl->AddRect(layout.pos, {layout.pos.x + layout.width, layout.pos.y + layout.height}, + state.node_hovered ? S.col_pin_hover : S.col_node_border, S.node_rounding * zoom, + 0, state.node_hovered ? S.highlight_thickness : 1.0f); + + // Text + float font_size = ImGui::GetFontSize() * zoom; + if (font_size > 5.0f) { + ImVec2 text_sz = ImGui::CalcTextSize(display_text.c_str()); + float tw = text_sz.x * zoom; + float cx = layout.pos.x + (layout.width - tw) * 0.5f; + float cy = layout.pos.y + (layout.height - font_size) * 0.5f; + dl->AddText(nullptr, font_size, {cx, cy}, S.col_text, display_text.c_str()); + } + + // ─── Input pins ─── + for (int i = 0; i < (int)vpm.inputs.size(); i++) { + auto& pin = vpm.inputs[i]; + ImVec2 pp = layout.input_pin_pos(i); + + if (pin.kind == VisualPinKind::AddDiamond) { + ImU32 pc = S.col_add_pin; + dl->AddQuadFilled({pp.x, pp.y - pr}, {pp.x + pr, pp.y}, {pp.x, pp.y + pr}, {pp.x - pr, pp.y}, pc); + float cr = pr * 0.5f; + float lth = S.add_pin_line * zoom; + dl->AddLine({pp.x - cr, pp.y}, {pp.x + cr, pp.y}, S.col_add_pin_fg, lth); + dl->AddLine({pp.x, pp.y - cr}, {pp.x, pp.y + cr}, S.col_add_pin_fg, lth); + continue; + } + + ImU32 pc = pin_color(pin.port_kind); + if (pin.kind == VisualPinKind::AbsentOptional || (pin.is_optional && pin.kind == VisualPinKind::Base)) { + dl->AddQuadFilled({pp.x, pp.y - pr}, {pp.x + pr, pp.y}, {pp.x, pp.y + pr}, {pp.x - pr, pp.y}, pc); + if (pin.kind == VisualPinKind::AbsentOptional) { + float font_sz = pr * 1.6f; + if (font_sz > 3.0f) { + ImVec2 ts = ImGui::CalcTextSize("?"); + float scale = font_sz / ImGui::GetFontSize(); + dl->AddText(nullptr, font_sz, + {pp.x - ts.x * scale * 0.5f, pp.y - ts.y * scale * 0.5f}, + S.col_opt_pin_fg, "?"); + } + } + } else if (pin.port_kind == PortKind2::BangTrigger) { + dl->AddRectFilled({pp.x - pr, pp.y - pr}, {pp.x + pr, pp.y + pr}, pc); + } else if (pin.port_kind == PortKind2::Lambda) { + dl->AddTriangleFilled({pp.x - pr, pp.y - pr}, {pp.x + pr, pp.y - pr}, {pp.x, pp.y + pr}, pc); + } else if (pin.kind == VisualPinKind::VaArg) { + dl->AddQuadFilled({pp.x, pp.y - pr}, {pp.x + pr, pp.y}, {pp.x, pp.y + pr}, {pp.x - pr, pp.y}, pc); + } else { + dl->AddCircleFilled(pp, pr, pc); + } + } + + // ─── Output pins ─── + for (int i = 0; i < (int)vpm.outputs.size(); i++) { + auto& pin = vpm.outputs[i]; + ImVec2 pp = layout.output_pin_pos(i); + + ImU32 pc = pin_color(pin.port_kind); + if (pin.port_kind == PortKind2::BangNext) { + dl->AddRectFilled({pp.x - pr, pp.y - pr}, {pp.x + pr, pp.y + pr}, pc); + } else if (pin.kind == VisualPinKind::VaArg) { + dl->AddQuadFilled({pp.x, pp.y + pr}, {pp.x + pr, pp.y}, {pp.x, pp.y - pr}, {pp.x - pr, pp.y}, pc); + } else { + dl->AddCircleFilled(pp, pr, pc); + } + } + + // ─── Flow-only: lambda grab (left) and side-bang (right) ─── + if (vpm.is_flow) { + ImVec2 gp = layout.lambda_grab_pos(); + dl->AddTriangleFilled( + {gp.x + pr, gp.y - pr}, {gp.x - pr, gp.y}, {gp.x + pr, gp.y + pr}, + S.col_pin_lambda); + if (state.node_hovered) { + float ho = S.highlight_offset * zoom; + dl->AddTriangle( + {gp.x + pr + ho, gp.y - pr - ho}, {gp.x - pr - ho, gp.y}, {gp.x + pr + ho, gp.y + pr + ho}, + S.col_pin_hover, S.highlight_thickness); + } + + ImVec2 bp = layout.side_bang_pos(); + dl->AddRectFilled({bp.x - pr, bp.y - pr}, {bp.x + pr, bp.y + pr}, S.col_pin_bang); + } + + // ─── Hover highlights ─── + if (!state.node_hovered && !state.pin_hovered_on_this) return; + + float ho = S.highlight_offset * zoom; + ImU32 COL_HOVER = S.col_pin_hover; + float ht = S.highlight_thickness; + + enum class PinShape { Circle, Square, Diamond, TriangleDown, TriangleLeft }; + auto draw_highlight = [&](ImVec2 pos, PinShape shape) { + switch (shape) { + case PinShape::Circle: dl->AddCircle(pos, pr + ho, COL_HOVER, 0, ht); break; + case PinShape::Square: dl->AddRect({pos.x-pr-ho,pos.y-pr-ho},{pos.x+pr+ho,pos.y+pr+ho}, COL_HOVER, 0, 0, ht); break; + case PinShape::Diamond: dl->AddQuad({pos.x,pos.y-pr-ho},{pos.x+pr+ho,pos.y},{pos.x,pos.y+pr+ho},{pos.x-pr-ho,pos.y}, COL_HOVER, ht); break; + case PinShape::TriangleDown: dl->AddTriangle({pos.x-pr-ho,pos.y-pr-ho},{pos.x+pr+ho,pos.y-pr-ho},{pos.x,pos.y+pr+ho}, COL_HOVER, ht); break; + case PinShape::TriangleLeft: dl->AddTriangle({pos.x+pr+ho,pos.y-pr-ho},{pos.x-pr-ho,pos.y},{pos.x+pr+ho,pos.y+pr+ho}, COL_HOVER, ht); break; + } + }; + + auto pin_shape_for = [](const VisualPin& pin) -> PinShape { + if (pin.kind == VisualPinKind::VaArg || pin.kind == VisualPinKind::AddDiamond) return PinShape::Diamond; + if (pin.is_optional || pin.kind == VisualPinKind::AbsentOptional) return PinShape::Diamond; + if (pin.port_kind == PortKind2::BangTrigger || pin.port_kind == PortKind2::BangNext) return PinShape::Square; + if (pin.port_kind == PortKind2::Lambda) return PinShape::TriangleDown; + return PinShape::Circle; + }; + + // +diamond hover + if (state.add_pin_hover && state.add_pin_hover->node == node) { + for (int i = 0; i < (int)vpm.inputs.size(); i++) { + if (vpm.inputs[i].kind == VisualPinKind::AddDiamond) { + draw_highlight(layout.input_pin_pos(i), PinShape::Diamond); + if (draw_tooltips) + tooltip_add_diamond(*state.add_pin_hover); + return; + } + } + } + + if (state.hovered_pin) { + // Input pins + for (int i = 0; i < (int)vpm.inputs.size(); i++) { + auto& pin = vpm.inputs[i]; + if (pin.kind == VisualPinKind::AddDiamond || pin.kind == VisualPinKind::AbsentOptional) continue; + if (pin.arg == state.hovered_pin) { + draw_highlight(layout.input_pin_pos(i), pin_shape_for(pin)); + if (draw_tooltips) + tooltip_input_pin(pin); + return; + } + } + // Output pins + for (int i = 0; i < (int)vpm.outputs.size(); i++) { + auto& pin = vpm.outputs[i]; + if (pin.arg == state.hovered_pin) { + draw_highlight(layout.output_pin_pos(i), pin_shape_for(pin)); + if (draw_tooltips) + tooltip_output_pin(pin, i); + return; + } + } + // Side-bang + if (vpm.has_side_bang && vpm.side_bang_arg == state.hovered_pin) { + draw_highlight(layout.side_bang_pos(), PinShape::Square); + if (draw_tooltips) + tooltip_side_bang(); + return; + } + } + + // Node body tooltip + if (state.node_hovered && draw_tooltips) + tooltip_node_body(node); +} + +// ─── Wire rendering ─── + +void render_wire(ImDrawList* dl, const WireInfo& w, float zoom) { + bool is_lambda = w.is_lambda(); + bool named = false; + if (!is_lambda) { + if (auto net = w.entry_->as_net()) + named = !net->auto_wire(); + } + ImU32 wire_col = is_lambda ? S.col_wire_lambda : (named ? S.col_wire_named : S.col_wire); + float th = S.wire_thickness * zoom; + dl->AddBezierCubic(w.p0, w.p1, w.p2, w.p3, wire_col, th); +} + +void render_wire_label(ImDrawList* dl, const WireInfo& w, float zoom) { + if (w.is_lambda()) return; + auto net = w.entry_ ? w.entry_->as_net() : nullptr; + if (!net || net->auto_wire()) return; + + float font_size = ImGui::GetFontSize() * zoom * 0.8f; + if (font_size <= 5.0f) return; + + ImVec2 mid = {(w.p0.x + w.p3.x) * 0.5f, (w.p0.y + w.p3.y) * 0.5f}; + ImVec2 text_sz = ImGui::CalcTextSize(w.net_id.c_str()); + float tw = text_sz.x * (font_size / ImGui::GetFontSize()); + float tth = text_sz.y * (font_size / ImGui::GetFontSize()); + float cx = mid.x - tw * 0.5f; + float cy = mid.y - tth * 0.5f; + dl->AddRectFilled({cx - 3, cy - 1}, {cx + tw + 3, cy + tth + 1}, + S.col_label_bg, S.node_rounding); + dl->AddText(nullptr, font_size, {cx, cy}, S.col_label_text, w.net_id.c_str()); +} + +void render_wire_highlight(ImDrawList* dl, const WireInfo& w, float zoom) { + float th = (S.wire_thickness + 2.0f) * zoom; + dl->AddBezierCubic(w.p0, w.p1, w.p2, w.p3, S.col_pin_hover, th); +} + +void render_selection_rect(ImDrawList* dl, ImVec2 p0, ImVec2 p1) { + dl->AddRectFilled(p0, p1, IM_COL32(100, 130, 200, 40)); + dl->AddRect(p0, p1, IM_COL32(100, 130, 200, 180), 0, 0, 1.5f); +} + +// ─── compute_wire_geometry ─── + +WireInfo compute_wire_geometry(ImVec2 from, ImVec2 to, bool is_lambda, bool is_side_bang, + float zoom, const BuilderEntryPtr& entry, + const NodeId& src_id, const NodeId& dst_id, const NodeId& net_id) { + ImVec2 cp1, cp2; + if (is_lambda) { + float dx = std::max(std::abs(to.x - from.x) * 0.5f, 30.0f * zoom); + float dy = std::max(std::abs(to.y - from.y) * 0.5f, 30.0f * zoom); + cp1 = {from.x - dx, from.y}; + cp2 = {to.x, to.y - dy}; + } else if (is_side_bang) { + float dx = std::max(std::abs(to.x - from.x) * 0.5f, 30.0f * zoom); + float dy = std::max(std::abs(to.y - from.y) * 0.5f, 30.0f * zoom); + cp1 = {from.x + dx, from.y}; + cp2 = {to.x, to.y - dy}; + } else { + float dy = std::max(std::abs(to.y - from.y) * 0.5f, 30.0f * zoom); + cp1 = {from.x, from.y + dy}; + cp2 = {to.x, to.y - dy}; + } + return {entry, from, cp1, cp2, to, src_id, dst_id, net_id}; +} + +// ─── Hit-testing ─── + +HitResult hit_test_wires(ImVec2 mouse, const std::vector& wires, float zoom) { + HitResult best; + float wire_thresh = S.wire_hit_threshold * zoom; + for (auto& w : wires) { + float d = point_to_bezier_dist(mouse, w.p0, w.p1, w.p2, w.p3); + if (d < wire_thresh && d < best.distance) { + best.distance = d; + best.item = w.entry(); + } + } + return best; +} + +HitResult hit_test_node_bodies(ImVec2 mouse, const std::vector& nodes, float zoom) { + HitResult best; + for (auto it = nodes.rbegin(); it != nodes.rend(); ++it) { + auto& layout = *it->layout; + float nd; + bool inside = mouse.x >= layout.pos.x && mouse.x <= layout.pos.x + layout.width && + mouse.y >= layout.pos.y && mouse.y <= layout.pos.y + layout.height; + if (inside) { + float dl_ = mouse.x - layout.pos.x; + float dr = layout.pos.x + layout.width - mouse.x; + float dt = mouse.y - layout.pos.y; + float db = layout.pos.y + layout.height - mouse.y; + nd = std::min({dl_, dr, dt, db}); + } else { + float cx = std::clamp(mouse.x, layout.pos.x, layout.pos.x + layout.width); + float cy = std::clamp(mouse.y, layout.pos.y, layout.pos.y + layout.height); + nd = dist2d(mouse, {cx, cy}); + } + if (nd < S.pin_radius * zoom * S.node_hit_threshold_mul && nd < best.distance) { + best.distance = nd; + best.item = BuilderEntryPtr(it->node); + } + } + return best; +} + +HitResult hit_test_pins(ImVec2 mouse, const std::vector& nodes, float zoom) { + HitResult best; + float pin_thresh = S.pin_radius * zoom * S.pin_hit_radius_mul; + float pin_bias = S.pin_priority_bias; + + for (auto it = nodes.rbegin(); it != nodes.rend(); ++it) { + auto& node = it->node; + auto& layout = *it->layout; + auto& vpm = *it->vpm; + + // Input pins + for (int i = 0; i < (int)vpm.inputs.size(); i++) { + auto& pin = vpm.inputs[i]; + if (pin.kind == VisualPinKind::AbsentOptional) continue; + float pd = dist2d(mouse, layout.input_pin_pos(i)); + if (pin.kind == VisualPinKind::AddDiamond) { + if (pd < pin_thresh && vpm.add_diamond_va_port) { + float biased = pd - pin_bias; + if (biased < best.distance) { + best.distance = biased; + best.item = AddPinHover{node, vpm.add_diamond_va_port, true}; + } + } + continue; + } + if (pd < pin_thresh && pin.arg) { + float biased = pd - pin_bias; + if (biased < best.distance) { + best.distance = biased; + best.item = pin.arg; + } + } + } + + // Output pins + for (int i = 0; i < (int)vpm.outputs.size(); i++) { + auto& pin = vpm.outputs[i]; + float pd = dist2d(mouse, layout.output_pin_pos(i)); + if (pd < pin_thresh && pin.arg) { + float biased = pd - pin_bias; + if (biased < best.distance) { + best.distance = biased; + best.item = pin.arg; + } + } + } + + // Lambda grab → node itself + if (vpm.is_flow) { + float pd = dist2d(mouse, layout.lambda_grab_pos()); + if (pd < pin_thresh) { + float biased = pd - pin_bias; + if (biased < best.distance) { + best.distance = biased; + best.item = BuilderEntryPtr(node); + } + } + } + + // Side-bang + if (vpm.has_side_bang && vpm.side_bang_arg) { + float pd = dist2d(mouse, layout.side_bang_pos()); + if (pd < pin_thresh) { + float biased = pd - pin_bias; + if (biased < best.distance) { + best.distance = biased; + best.item = vpm.side_bang_arg; + } + } + } + } + + return best; +} diff --git a/src/attoflow/node_renderer.h b/src/attoflow/node_renderer.h new file mode 100644 index 0000000..08346ed --- /dev/null +++ b/src/attoflow/node_renderer.h @@ -0,0 +1,143 @@ +#pragma once +#include "editor_style.h" +#include "atto/graph_builder.h" +#include "atto/node_types2.h" +#include "imgui.h" +#include +#include +#include +#include + +// ─── Vector helpers ─── + +inline ImVec2 v2add(ImVec2 a, ImVec2 b) { return {a.x + b.x, a.y + b.y}; } +inline ImVec2 v2sub(ImVec2 a, ImVec2 b) { return {a.x - b.x, a.y - b.y}; } +inline ImVec2 v2mul(ImVec2 a, float s) { return {a.x * s, a.y * s}; } + +// ─── VisualPin: typed pin entry replacing magic sentinel indices ─── + +enum class VisualPinKind { Base, VaArg, AbsentOptional, AddDiamond, Remap }; + +struct VisualPin { + VisualPinKind kind; + FlowArg2Ptr arg; // the actual arg (null for AbsentOptional, AddDiamond) + const PortDesc2* port_desc; // port descriptor (null for remaps) + PortKind2 port_kind; // resolved shape (Data, BangTrigger, Lambda, BangNext) + bool is_optional; // for visual rendering of optional markers +}; + +struct VisualPinMap { + std::vector inputs; // input pins in visual order + std::vector outputs; // output pins in visual order (side-bang excluded for flow) + bool has_side_bang = false; + FlowArg2Ptr side_bang_arg; + const PortDesc2* add_diamond_va_port = nullptr; // non-null if +diamond exists + bool is_flow = false; + + static VisualPinMap build(const FlowNodeBuilderPtr& node, const NodeType2* nt); +}; + +// ─── NodeLayout: computed screen-space layout ─── + +struct NodeLayout { + ImVec2 pos; + float width; + float height; + int num_in; + int num_out; + float zoom; + + ImVec2 input_pin_pos(int i) const { + return {pos.x + (i + 0.5f) * S.pin_spacing * zoom, pos.y}; + } + ImVec2 output_pin_pos(int i) const { + return {pos.x + (i + 0.5f) * S.pin_spacing * zoom, pos.y + height}; + } + ImVec2 lambda_grab_pos() const { + return {pos.x, pos.y + height * 0.5f}; + } + ImVec2 side_bang_pos() const { + return {pos.x + width, pos.y + height * 0.5f}; + } +}; + +NodeLayout compute_node_layout(const FlowNodeBuilderPtr& node, const VisualPinMap& vpm, + ImVec2 canvas_origin, float zoom); + +// ─── WireInfo ─── + +struct WireInfo { + BuilderEntryPtr entry_; + ImVec2 p0, p1, p2, p3; + NodeId src_id, dst_id, net_id; + BuilderEntryPtr entry() const { return entry_; } + bool is_lambda() const { return entry_ && entry_->is(IdCategory::Node); } +}; + +// ─── AddPinHover ─── + +struct AddPinHover { + FlowNodeBuilderPtr node; + const PortDesc2* va_port; + bool is_input; +}; + +// ─── HoverItem ─── + +using HoverItem = std::variant; + +// ─── NodeRenderState: editor-derived state passed to renderer ─── + +struct NodeRenderState { + bool selected; + bool node_hovered; + bool pin_hovered_on_this; + FlowArg2Ptr hovered_pin; // null if no pin hovered + const AddPinHover* add_pin_hover; // null if no +diamond hovered +}; + +struct AttoEditorSharedState; + +NodeRenderState build_render_state(const FlowNodeBuilderPtr& node, + const HoverItem& hover_item, + const AttoEditorSharedState* shared); + +// ─── Hit-testing ─── + +struct HitResult { + HoverItem item; + float distance = 1e18f; +}; + +struct NodeHitTarget { + FlowNodeBuilderPtr node; + const NodeType2* nt; + const NodeLayout* layout; + const VisualPinMap* vpm; +}; + +float point_to_bezier_dist(ImVec2 p, ImVec2 p0, ImVec2 p1, ImVec2 p2, ImVec2 p3); +ImU32 pin_color(PortKind2 kind); + +HitResult hit_test_wires(ImVec2 mouse, const std::vector& wires, float zoom); +HitResult hit_test_node_bodies(ImVec2 mouse, const std::vector& nodes, float zoom); +HitResult hit_test_pins(ImVec2 mouse, const std::vector& nodes, float zoom); + +// ─── Rendering functions ─── + +void render_background(ImDrawList* dl, ImVec2 canvas_p0, ImVec2 canvas_sz, + ImVec2 canvas_offset, float zoom); + +void render_node(ImDrawList* dl, const FlowNodeBuilderPtr& node, const NodeType2* nt, + const NodeLayout& layout, const VisualPinMap& vpm, + const std::string& display_text, const NodeRenderState& state, + float zoom, bool draw_tooltips); + +void render_wire(ImDrawList* dl, const WireInfo& w, float zoom); +void render_wire_label(ImDrawList* dl, const WireInfo& w, float zoom); +void render_wire_highlight(ImDrawList* dl, const WireInfo& w, float zoom); +void render_selection_rect(ImDrawList* dl, ImVec2 p0, ImVec2 p1); + +WireInfo compute_wire_geometry(ImVec2 from, ImVec2 to, bool is_lambda, bool is_side_bang, + float zoom, const BuilderEntryPtr& entry, + const NodeId& src_id, const NodeId& dst_id, const NodeId& net_id); diff --git a/src/attoflow/sdl_imgui_window.h b/src/attoflow/sdl_imgui_window.h index a90dc25..a5da172 100644 --- a/src/attoflow/sdl_imgui_window.h +++ b/src/attoflow/sdl_imgui_window.h @@ -4,6 +4,7 @@ #include #include #include +#include "LiberationMono_Regular.h" // Wraps an SDL3 window + renderer + ImGui context. // Each instance is an independent ImGui context, enabling multi-window apps. @@ -42,9 +43,15 @@ struct SdlImGuiWindow { ImGui::SetCurrentContext(imgui_ctx); ImGuiIO& io = ImGui::GetIO(); io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; - ImFontConfig font_cfg; - font_cfg.SizePixels = 17.0f * dpi_scale; - io.Fonts->AddFontDefault(&font_cfg); + { + float font_size = 16.0f * dpi_scale; + ImFontConfig font_cfg; + font_cfg.FontDataOwnedByAtlas = false; + io.Fonts->AddFontFromMemoryTTF( + (void*)LiberationMono_Regular_data, + LiberationMono_Regular_size, + font_size, &font_cfg); + } io.FontGlobalScale = 1.0f / dpi_scale; ImGui::StyleColorsDark(); ImGui_ImplSDL3_InitForSDLRenderer(window, renderer); @@ -67,9 +74,23 @@ struct SdlImGuiWindow { ImGui::SetCurrentContext(imgui_ctx); ImGuiIO& io = ImGui::GetIO(); io.Fonts->Clear(); - ImFontConfig font_cfg; - font_cfg.SizePixels = 17.0f * dpi_scale; - io.Fonts->AddFontDefault(&font_cfg); + { + float font_size = 16.0f * dpi_scale; + ImFont* font = nullptr; + const char* font_paths[] = { + "fonts/LiberationMono-Regular.ttf", + "../src/attoflow/fonts/LiberationMono-Regular.ttf", + "src/attoflow/fonts/LiberationMono-Regular.ttf", + nullptr + }; + for (auto* p = font_paths; *p && !font; p++) + font = io.Fonts->AddFontFromFileTTF(*p, font_size); + if (!font) { + ImFontConfig font_cfg; + font_cfg.SizePixels = font_size; + io.Fonts->AddFontDefault(&font_cfg); + } + } io.FontGlobalScale = 1.0f / dpi_scale; ImGui_ImplSDLRenderer3_DestroyFontsTexture(); io.Fonts->Build(); diff --git a/src/attoflow/tab.h b/src/attoflow/tab.h new file mode 100644 index 0000000..f18bad8 --- /dev/null +++ b/src/attoflow/tab.h @@ -0,0 +1,21 @@ +#pragma once +#include "editor_pane.h" +#include "atto_editor_shared_state.h" +#include "atto/graph_builder.h" +#include +#include + +struct TabState { + std::shared_ptr gb; + std::shared_ptr shared; + std::shared_ptr pane; + std::string file_path; + std::string tab_name; + + std::string label() const { + std::string l = tab_name; + if (pane) l += std::string("[") + pane->type_name() + "]"; + if (gb && gb->is_dirty()) l += "*"; + return l; + } +}; diff --git a/src/attoflow/tooltip_renderer.cpp b/src/attoflow/tooltip_renderer.cpp new file mode 100644 index 0000000..27730a1 --- /dev/null +++ b/src/attoflow/tooltip_renderer.cpp @@ -0,0 +1,80 @@ +#include "tooltip_renderer.h" +#include "node_renderer.h" + +void tooltip_add_diamond(const AddPinHover& hover) { + ImGui::BeginTooltip(); + ImGui::SetWindowFontScale(S.tooltip_scale); + ImGui::Text("add %s", hover.va_port ? hover.va_port->name : "arg"); + ImGui::EndTooltip(); +} + +void tooltip_input_pin(const VisualPin& pin) { + ImGui::BeginTooltip(); + ImGui::SetWindowFontScale(S.tooltip_scale); + if (pin.arg->port()) + ImGui::Text("%s", pin.arg->name().c_str()); + else if (pin.kind == VisualPinKind::Remap) + ImGui::Text("$%d", pin.arg->remap_idx()); + ImGui::EndTooltip(); +} + +void tooltip_output_pin(const VisualPin& pin, int visual_index) { + ImGui::BeginTooltip(); + ImGui::SetWindowFontScale(S.tooltip_scale); + if (pin.arg->port()) + ImGui::Text("%s", pin.arg->name().c_str()); + else + ImGui::Text("out%d", visual_index); + ImGui::EndTooltip(); +} + +void tooltip_side_bang() { + ImGui::BeginTooltip(); + ImGui::SetWindowFontScale(S.tooltip_scale); + ImGui::Text("post_bang"); + ImGui::EndTooltip(); +} + +void tooltip_node_body(const FlowNodeBuilderPtr& node) { + ImGui::BeginTooltip(); + ImGui::SetWindowFontScale(S.tooltip_scale); + ImGui::Text("id: %s", node->id().c_str()); + auto show_args = [](const char* label, const ParsedArgs2* pa) { + if (!pa) return; + ImGui::Text("%s (%d):", label, pa->size()); + for (int i = 0; i < pa->size(); i++) { + auto a = (*pa)[i]; + if (auto n = a->as_net()) + ImGui::Text(" [%d] net: %s", i, n->first().c_str()); + else if (auto e = a->as_expr()) + ImGui::Text(" [%d] expr: %s", i, e->expr().c_str()); + else if (auto s = a->as_string()) + ImGui::Text(" [%d] str: %s", i, s->value().c_str()); + else if (auto v = a->as_number()) + ImGui::Text(" [%d] num: %g", i, v->value()); + } + }; + show_args("parsed_args", node->parsed_args.get()); + if (node->parsed_va_args && !node->parsed_va_args->empty()) + show_args("parsed_va_args", node->parsed_va_args.get()); + if (!node->remaps.empty()) { + ImGui::Text("remaps (%d):", (int)node->remaps.size()); + for (int i = 0; i < (int)node->remaps.size(); i++) { + if (auto n = node->remaps[i]->as_net()) + ImGui::Text(" $%d -> %s", i, n->first().c_str()); + } + } + ImGui::EndTooltip(); +} + +void tooltip_wire(const WireInfo& w) { + ImGui::BeginTooltip(); + ImGui::SetWindowFontScale(S.tooltip_scale); + if (w.is_lambda()) + ImGui::Text("lambda: %s", w.src_id.c_str()); + else + ImGui::Text("net: %s", w.net_id.c_str()); + ImGui::Text("src: %s", w.src_id.c_str()); + ImGui::Text("dst: %s", w.dst_id.c_str()); + ImGui::EndTooltip(); +} diff --git a/src/attoflow/tooltip_renderer.h b/src/attoflow/tooltip_renderer.h new file mode 100644 index 0000000..146fff3 --- /dev/null +++ b/src/attoflow/tooltip_renderer.h @@ -0,0 +1,30 @@ +#pragma once +#include "editor_style.h" +#include "atto/graph_builder.h" +#include "atto/node_types2.h" +#include "imgui.h" +#include +#include + +struct VisualPin; +enum class VisualPinKind; +struct AddPinHover; +struct WireInfo; + +// Tooltip for a hovered +diamond (add va_arg) pin +void tooltip_add_diamond(const AddPinHover& hover); + +// Tooltip for a hovered input pin +void tooltip_input_pin(const VisualPin& pin); + +// Tooltip for a hovered output pin (visual_index for fallback name) +void tooltip_output_pin(const VisualPin& pin, int visual_index); + +// Tooltip for the side-bang output +void tooltip_side_bang(); + +// Tooltip for the node body (detailed debug info) +void tooltip_node_body(const FlowNodeBuilderPtr& node); + +// Tooltip for a hovered wire/net +void tooltip_wire(const WireInfo& w); diff --git a/src/attoflow/visual_editor.cpp b/src/attoflow/visual_editor.cpp new file mode 100644 index 0000000..5582ef2 --- /dev/null +++ b/src/attoflow/visual_editor.cpp @@ -0,0 +1,154 @@ +#include "visual_editor.h" +#include +#include + +void VisualEditor::draw_canvas(const char* id) { + ImVec2 canvas_p0 = ImGui::GetCursorScreenPos(); + ImVec2 canvas_sz = ImGui::GetContentRegionAvail(); + if (canvas_sz.x < 50.0f) canvas_sz.x = 50.0f; + if (canvas_sz.y < 50.0f) canvas_sz.y = 50.0f; + + ImGui::InvisibleButton(id, canvas_sz, + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight); + bool canvas_hovered = ImGui::IsItemHovered(); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 canvas_origin = v2add(canvas_p0, canvas_offset_); + + render_background(dl, canvas_p0, canvas_sz, canvas_offset_, canvas_zoom_); + + dl->PushClipRect(canvas_p0, v2add(canvas_p0, canvas_sz), true); + + CanvasFrame frame{dl, canvas_p0, canvas_sz, canvas_origin, canvas_hovered}; + draw_content(frame); + + dl->PopClipRect(); + + // ─── Hover detection + effects ─── + if (canvas_hovered) { + ImVec2 mouse = ImGui::GetIO().MousePos; + hover_item_ = do_detect_hover(mouse, canvas_origin); + } else { + hover_item_ = std::monostate{}; + } + do_draw_hover_effects(dl, canvas_origin, hover_item_); + + // Extract hover node + FlowNodeBuilderPtr hover_node = hover_to_node(hover_item_); + + // ─── Selection + dragging with left mouse ─── + if (canvas_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + bool ctrl = ImGui::GetIO().KeyCtrl; + + if (ctrl && hover_node) { + if (shared_->selected_nodes.count(hover_node)) + shared_->selected_nodes.erase(hover_node); + else + shared_->selected_nodes.insert(hover_node); + } else if (hover_node) { + if (!shared_->selected_nodes.count(hover_node)) { + shared_->selected_nodes.clear(); + shared_->selected_nodes.insert(hover_node); + } + dragging_started_ = true; + + // Check initial overlap + drag_was_overlapping_ = false; + for (auto& sel : shared_->selected_nodes) { + if (test_drag_overlap(sel, sel->position.x, sel->position.y)) { + drag_was_overlapping_ = true; + break; + } + } + } else { + shared_->selected_nodes.clear(); + selection_rect_active_ = true; + ImVec2 mouse = ImGui::GetIO().MousePos; + selection_rect_start_ = {(mouse.x - canvas_origin.x) / canvas_zoom_, + (mouse.y - canvas_origin.y) / canvas_zoom_}; + } + } + + // Drag selected nodes + if (dragging_started_ && ImGui::IsMouseDragging(ImGuiMouseButton_Left) && !shared_->selected_nodes.empty()) { + ImVec2 delta = ImGui::GetIO().MouseDelta; + float dx = delta.x / canvas_zoom_; + float dy = delta.y / canvas_zoom_; + + bool blocked = false; + if (!drag_was_overlapping_) { + for (auto& sel : shared_->selected_nodes) { + if (test_drag_overlap(sel, sel->position.x + dx, sel->position.y + dy)) { + blocked = true; + break; + } + } + } + if (!blocked) { + for (auto& sel : shared_->selected_nodes) { + sel->position.x += dx; + sel->position.y += dy; + } + on_nodes_moved(); + } + } + + // Selection rectangle + if (selection_rect_active_) { + ImVec2 mouse = ImGui::GetIO().MousePos; + ImVec2 cur_canvas = {(mouse.x - canvas_origin.x) / canvas_zoom_, + (mouse.y - canvas_origin.y) / canvas_zoom_}; + + float x0 = std::min(selection_rect_start_.x, cur_canvas.x); + float y0 = std::min(selection_rect_start_.y, cur_canvas.y); + float x1 = std::max(selection_rect_start_.x, cur_canvas.x); + float y1 = std::max(selection_rect_start_.y, cur_canvas.y); + + ImVec2 sp0 = {canvas_origin.x + x0 * canvas_zoom_, canvas_origin.y + y0 * canvas_zoom_}; + ImVec2 sp1 = {canvas_origin.x + x1 * canvas_zoom_, canvas_origin.y + y1 * canvas_zoom_}; + render_selection_rect(dl, sp0, sp1); + + shared_->selected_nodes.clear(); + for (auto& btn : get_box_test_nodes()) { + if (btn.x < x1 && btn.x + btn.w > x0 && btn.y < y1 && btn.y + btn.h > y0) + shared_->selected_nodes.insert(btn.node); + } + } + + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + dragging_started_ = false; + selection_rect_active_ = false; + } + + // Pan + if (canvas_hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Middle)) { + canvas_offset_.x += ImGui::GetIO().MouseDelta.x; + canvas_offset_.y += ImGui::GetIO().MouseDelta.y; + } + if (canvas_hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Right)) { + canvas_offset_.x += ImGui::GetIO().MouseDelta.x; + canvas_offset_.y += ImGui::GetIO().MouseDelta.y; + } + + // Scroll: zoom, or pan with modifiers + if (canvas_hovered) { + float wheel = ImGui::GetIO().MouseWheel; + if (wheel != 0) { + bool shift = ImGui::GetIO().KeyShift; + bool alt = ImGui::GetIO().KeyAlt; + if (shift || alt) { + float pan_speed = S.scroll_pan_speed; + if (shift) canvas_offset_.x += wheel * pan_speed; + if (alt) canvas_offset_.y += wheel * pan_speed; + } else { + float old_zoom = canvas_zoom_; + canvas_zoom_ *= (wheel > 0) ? 1.1f : 0.9f; + canvas_zoom_ = std::clamp(canvas_zoom_, 0.1f, 10.0f); + ImVec2 mouse = ImGui::GetIO().MousePos; + ImVec2 mouse_rel = v2sub(v2sub(mouse, canvas_p0), canvas_offset_); + ImVec2 mouse_canvas = v2mul(mouse_rel, 1.0f / old_zoom); + canvas_offset_ = v2sub(v2sub(mouse, canvas_p0), v2mul(mouse_canvas, canvas_zoom_)); + } + } + } +} diff --git a/src/attoflow/visual_editor.h b/src/attoflow/visual_editor.h new file mode 100644 index 0000000..d462f15 --- /dev/null +++ b/src/attoflow/visual_editor.h @@ -0,0 +1,71 @@ +#pragma once +#include "atto_editor_shared_state.h" +#include "node_renderer.h" +#include "imgui.h" +#include +#include + +// Reusable 2D canvas interaction layer. +// Provides pan/zoom/select/drag; subclass provides content drawing and hit-testing. +class VisualEditor { +public: + VisualEditor(const std::shared_ptr& shared) : shared_(shared) {} + virtual ~VisualEditor() = default; + + struct CanvasFrame { + ImDrawList* dl; + ImVec2 canvas_p0; + ImVec2 canvas_sz; + ImVec2 canvas_origin; + bool hovered; + }; + + // Call from draw(). Sets up canvas, calls draw_content, handles all interaction. + void draw_canvas(const char* id); + + // Canvas state + ImVec2 canvas_offset() const { return canvas_offset_; } + float canvas_zoom() const { return canvas_zoom_; } + + const HoverItem& hover_item() const { return hover_item_; } + +protected: + ImVec2 canvas_offset_ = {0, 0}; + float canvas_zoom_ = 1.0f; + bool draw_tooltips_ = true; + HoverItem hover_item_; + std::shared_ptr shared_; + + // Interaction state + bool dragging_started_ = false; + bool drag_was_overlapping_ = false; + bool selection_rect_active_ = false; + ImVec2 selection_rect_start_ = {0, 0}; + + // ─── Subclass hooks ─── + + // Draw all content (nodes, wires) inside the clipped canvas + virtual void draw_content(const CanvasFrame& frame) = 0; + + // Find what's under the mouse + virtual HoverItem do_detect_hover(ImVec2 mouse, ImVec2 canvas_origin) = 0; + + // Draw hover highlights and tooltips + virtual void do_draw_hover_effects(ImDrawList* dl, ImVec2 canvas_origin, const HoverItem& hover) = 0; + + // Extract draggable node from hover item (nullptr if not a node) + virtual FlowNodeBuilderPtr hover_to_node(const HoverItem& item) = 0; + + // Test if moving sel to (nx, ny) would overlap non-selected nodes + virtual bool test_drag_overlap(const FlowNodeBuilderPtr& sel, float nx, float ny) = 0; + + // Get all nodes for box-select testing (canvas-space, unzoomed) + struct BoxTestNode { + FlowNodeBuilderPtr node; + float x, y, w, h; + }; + virtual std::vector get_box_test_nodes() = 0; + + // Called when dragging moves selected nodes (subclass can mark wires dirty etc.) + virtual void on_nodes_moved() {} +}; diff --git a/src/attoflow/window.cpp b/src/attoflow/window.cpp new file mode 100644 index 0000000..db78566 --- /dev/null +++ b/src/attoflow/window.cpp @@ -0,0 +1,635 @@ +#include "window.h" +#include "atto/graph_builder.h" +#include "atto/args.h" +#include "atto/serial.h" +#include +#include +#include +#include +#include +#ifndef _WIN32 +#include +#include +#include +#endif +#ifdef __APPLE__ +#include +#endif + +bool FlowEditorWindow::init(const std::string& project_dir) { + if (!win_.init("Flow Editor", 900, 600)) return false; + project_dir_ = project_dir; + + if (!project_dir_.empty()) { + scan_project_files(); + namespace fs = std::filesystem; + std::string main_path = (fs::path(project_dir_) / "main.atto").string(); + if (fs::exists(main_path)) { + open_tab(main_path); + } else if (!project_files_.empty()) { + open_tab((fs::path(project_dir_) / project_files_[0]).string()); + } + } + + return true; +} + +void FlowEditorWindow::scan_project_files() { + namespace fs = std::filesystem; + project_files_.clear(); + if (project_dir_.empty()) return; + for (auto& entry : fs::directory_iterator(project_dir_)) { + if (entry.path().extension() == ".atto") { + project_files_.push_back(entry.path().filename().string()); + } + } + std::sort(project_files_.begin(), project_files_.end()); +} + +void FlowEditorWindow::open_tab(const std::string& file_path) { + namespace fs = std::filesystem; + std::string abs_path = fs::absolute(file_path).string(); + + // Check if already open (match on file_path + graph editor type) + for (int i = 0; i < (int)tabs_.size(); i++) { + if (tabs_[i].file_path == abs_path && tabs_[i].pane && + std::string(tabs_[i].pane->type_name()) == "graph") { + pending_tab_select_ = i; + return; + } + } + + TabState tab; + tab.file_path = abs_path; + tab.tab_name = fs::path(file_path).stem().string(); + + // Parse file into GraphBuilder + if (fs::exists(abs_path)) { + std::ifstream f(abs_path); + if (f.is_open()) { + auto result = Deserializer::parse_atto(f); + if (auto* gb = std::get_if>(&result)) { + tab.gb = *gb; + } else { + auto* err = std::get_if(&result); + fprintf(stderr, "Window: %s\n", err ? err->c_str() : "unknown error"); + } + } + } + if (!tab.gb) tab.gb = std::make_shared(); + tab.shared = std::make_shared(); + tab.pane = make_editor2(tab.gb, tab.shared); + + // Create nets editor tab sharing the same graph + state + TabState nets_tab; + nets_tab.file_path = abs_path; + nets_tab.tab_name = tab.tab_name; + nets_tab.gb = tab.gb; + nets_tab.shared = tab.shared; + nets_tab.pane = make_nets_editor(nets_tab.gb, nets_tab.shared); + + tabs_.push_back(std::move(tab)); + tabs_.push_back(std::move(nets_tab)); + pending_tab_select_ = (int)tabs_.size() - 2; // focus on the graph editor tab +} + +void FlowEditorWindow::close_tab(int idx) { + if (idx < 0 || idx >= (int)tabs_.size()) return; + #if LEGACY_EDITOR + // Auto-save before closing (Editor1Pane handles its own save) + if (auto e1 = std::dynamic_pointer_cast(tabs_[idx].pane)) { + if (e1->is_dirty() && !e1->file_path().empty()) { + e1->sync_viewport(); + e1->auto_save(); + } + } + #endif + tabs_.erase(tabs_.begin() + idx); + if (active_tab_ >= (int)tabs_.size()) + active_tab_ = std::max(0, (int)tabs_.size() - 1); +} + +void FlowEditorWindow::shutdown() { + stop_program(); + if (build_thread_.joinable()) + build_thread_.join(); + win_.shutdown(); +} + +void FlowEditorWindow::process_event(SDL_Event& e) { win_.process_event(e); } + +void FlowEditorWindow::draw() { + if (!win_.open) return; + + win_.begin_frame(); + ImGui::SetCurrentContext(win_.imgui_ctx); + + ImGui::SetNextWindowPos({0, 0}); + int w, h; + SDL_GetWindowSize(win_.window, &w, &h); + ImGui::SetNextWindowSize({(float)w, (float)h}); + ImGui::Begin("##main", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoBringToFrontOnFocus); + + draw_toolbar(); + ImGui::Separator(); + + poll_child_process(); + + float total_w = (float)w; + float total_h = ImGui::GetContentRegionAvail().y; + + file_panel_width_ = std::clamp(file_panel_width_, 80.0f, total_w * 0.3f); + side_panel_width_ = std::clamp(side_panel_width_, 100.0f, total_w * 0.5f); + bottom_panel_height_ = std::clamp(bottom_panel_height_, 40.0f, total_h * 0.5f); + + // --- File browser panel (left) --- + ImGui::BeginChild("##file_browser", {file_panel_width_, total_h}, false, + ImGuiWindowFlags_NoScrollbar); + ImGui::TextUnformatted("Files"); + ImGui::Separator(); + ImGui::BeginChild("##file_list", {0, 0}, false); + for (auto& fname : project_files_) { + namespace fs = std::filesystem; + std::string stem = fs::path(fname).stem().string(); + bool is_active = (active_tab_ < (int)tabs_.size() && + tabs_[active_tab_].tab_name == stem); + if (is_active) ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(100, 200, 255, 255)); + if (ImGui::Selectable(stem.c_str(), is_active)) { + std::string full_path = (fs::path(project_dir_) / fname).string(); + open_tab(full_path); + } + if (is_active) ImGui::PopStyleColor(); + } + ImGui::EndChild(); + ImGui::EndChild(); + + ImGui::SameLine(); + ImGui::Button("##file_vsplitter", {4.0f, total_h}); + if (ImGui::IsItemActive()) + file_panel_width_ += ImGui::GetIO().MouseDelta.x; + ImGui::SameLine(); + + float center_w = total_w - file_panel_width_ - side_panel_width_ - 12.0f; + float canvas_h = total_h - bottom_panel_height_ - 4.0f; + + // --- Center column: tabs + canvas + bottom panel --- + ImGui::BeginGroup(); + + // --- Tab bar --- + if (ImGui::BeginTabBar("##atto_tabs")) { + int pending_select = pending_tab_select_; + pending_tab_select_ = -1; + for (int i = 0; i < (int)tabs_.size(); i++) { + std::string label = tabs_[i].label(); + label += "###tab" + std::to_string(i); + bool open = true; + ImGuiTabItemFlags flags = (i == pending_select) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(label.c_str(), &open, flags)) { + active_tab_ = i; + ImGui::EndTabItem(); + } + if (!open) { + close_tab(i); + if (i <= active_tab_ && active_tab_ > 0) active_tab_--; + i--; + } + } + ImGui::EndTabBar(); + } + + float canvas_w = center_w; + last_canvas_w_ = canvas_w; + last_canvas_h_ = canvas_h; + + // --- Canvas --- + ImGui::BeginChild("##flow_canvas", {canvas_w, canvas_h}, false, + ImGuiWindowFlags_NoScrollbar); + if (!tabs_.empty() && active().pane) { + active().pane->draw(); + } else { + ImVec2 sz = ImGui::GetContentRegionAvail(); + const char* msg = "Select a file from the file list to open it."; + ImVec2 text_sz = ImGui::CalcTextSize(msg); + ImGui::SetCursorPos({(sz.x - text_sz.x) * 0.5f, (sz.y - text_sz.y) * 0.5f}); + ImGui::TextDisabled("%s", msg); + } + ImGui::EndChild(); + + // --- Horizontal splitter --- + ImGui::InvisibleButton("##hsplitter", {canvas_w, 4.0f}); + if (ImGui::IsItemActive()) + bottom_panel_height_ -= ImGui::GetIO().MouseDelta.y; + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + + // --- Bottom panel --- + // auto* e1 = dynamic_cast(active().pane.get()); + ImGui::BeginChild("##bottom_panel", {canvas_w, bottom_panel_height_}, true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + if (ImGui::BeginTabBar("##bottom_tabs")) { +#if LEGACY_EDITOR + if (e1) { + int error_count = 0; + for (auto& node : e1->graph().nodes) if (!node.error.empty()) error_count++; + for (auto& link : e1->graph().links) if (!link.error.empty()) error_count++; + + char errors_label[64]; + snprintf(errors_label, sizeof(errors_label), "Errors%s", error_count > 0 ? " (!)" : ""); + + if (ImGui::BeginTabItem(errors_label)) { + ImGui::BeginChild("##errors_scroll", {0, 0}, false); + for (auto& node : e1->graph().nodes) { + if (node.error.empty()) continue; + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 100, 100, 255)); + std::string label = std::string(node_type_str(node.type_id)) + " [" + node.guid.substr(0, 8) + "]: " + node.error; + if (ImGui::Selectable(label.c_str())) { + e1->center_on_node(node, {canvas_w, canvas_h}); + } + ImGui::PopStyleColor(); + } + for (auto& link : e1->graph().links) { + if (link.error.empty()) continue; + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 160, 80, 255)); + std::string label = "link [" + link.from_pin.substr(0, 8) + "->...]: " + link.error; + if (ImGui::Selectable(label.c_str())) { + auto dot = link.from_pin.find('.'); + if (dot != std::string::npos) { + std::string guid = link.from_pin.substr(0, dot); + for (auto& n : e1->graph().nodes) { + if (n.guid == guid) { e1->center_on_node(n, {canvas_w, canvas_h}); break; } + } + } + } + ImGui::PopStyleColor(); + } + ImGui::Dummy({0, bottom_panel_height_ * 0.5f}); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } +#endif + + if (ImGui::BeginTabItem("Build Log", nullptr, show_build_log_ ? ImGuiTabItemFlags_SetSelected : 0)) { + show_build_log_ = false; + ImGui::BeginChild("##buildlog_scroll", {0, 0}, false); + { + std::lock_guard lock(build_log_mutex_); + ImGui::TextWrapped("%s", build_log_.c_str()); + } + ImGui::Dummy({0, bottom_panel_height_ * 0.5f}); + if (build_state_ == BuildState::Building) { + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 40.0f) + ImGui::SetScrollHereY(1.0f); + } + ImGui::EndChild(); + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + } + ImGui::EndChild(); + + ImGui::EndGroup(); + + ImGui::SameLine(); + + // --- Vertical splitter --- + ImGui::InvisibleButton("##vsplitter", {4.0f, total_h}); + if (ImGui::IsItemActive()) + side_panel_width_ -= ImGui::GetIO().MouseDelta.x; + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + + ImGui::SameLine(); + + // --- Side panel: declarations --- + ImGui::BeginChild("##side_panel", {side_panel_width_, total_h}, true); +#if LEGACY_EDITOR + if (e1) { + ImGui::TextUnformatted("Declarations"); + ImGui::Separator(); + for (auto& node : e1->graph().nodes) { + auto* nt_decl = find_node_type(node.type_id); + if (!nt_decl || !nt_decl->is_declaration) continue; + if (node.imported || node.shadow) continue; + bool has_err = !node.error.empty(); + if (has_err) ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 100, 100, 255)); + if (ImGui::Selectable(node.display_text().c_str())) { + e1->center_on_node(node, {canvas_w, canvas_h}); + } + if (has_err) ImGui::PopStyleColor(); + if (has_err && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(node.error.c_str()); + ImGui::EndTooltip(); + } + } + + for (auto& imp_node : e1->graph().nodes) { + if (imp_node.type_id != NodeTypeID::DeclImport) continue; + auto tokens = tokenize_args(imp_node.args, false); + if (tokens.empty()) continue; + std::string label = tokens[0]; + if (label.size() >= 2 && label.front() == '"' && label.back() == '"') + label = label.substr(1, label.size() - 2); + if (ImGui::TreeNode(label.c_str())) { + for (auto& node : e1->graph().nodes) { + if (!node.imported) continue; + auto* nt_decl = find_node_type(node.type_id); + if (!nt_decl || !nt_decl->is_declaration) continue; + ImGui::TextDisabled("%s", node.display_text().c_str()); + } + ImGui::TreePop(); + } + } + } +#endif + ImGui::EndChild(); + + ImGui::End(); // main + +#if LEGACY_EDITOR + // Check debounced save for Editor1Pane + if (e1) e1->check_debounced_save(); +#endif + + win_.end_frame(30, 30, 40); +} + +// --- Toolbar --- + +void FlowEditorWindow::draw_toolbar() { + auto state = build_state_.load(); + + bool can_run = (state == BuildState::Idle || state == BuildState::BuildFailed); + bool can_stop = (state == BuildState::Running); + + if (!can_run) ImGui::BeginDisabled(); + if (ImGui::Button("Run")) run_program(false); + ImGui::SameLine(); + if (ImGui::Button("Run Release")) run_program(true); + if (!can_run) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!can_stop) ImGui::BeginDisabled(); + if (ImGui::Button("Stop")) stop_program(); + if (!can_stop) ImGui::EndDisabled(); + + ImGui::SameLine(); + + // Search (Editor1 only) + ImGui::SameLine(); + ImGui::SetNextItemWidth(120); + if (ImGui::InputTextWithHint("##search", "Find node...", search_buf_, sizeof(search_buf_), + ImGuiInputTextFlags_EnterReturnsTrue)) { + #if LEGACY_EDITOR + if (auto* e1 = dynamic_cast(active().pane.get())) { + std::string query(search_buf_); + if (!query.empty()) { + for (auto& node : e1->graph().nodes) { + if (node.imported || node.shadow) continue; + if (node.guid.find(query) != std::string::npos || + node.display_text().find(query) != std::string::npos) { + e1->center_on_node(node, {last_canvas_w_, last_canvas_h_}); + break; + } + } + } + } + #endif + } + + ImGui::SameLine(); + switch (state) { + case BuildState::Idle: ImGui::TextDisabled("Idle"); break; + case BuildState::Building: ImGui::TextColored({1.0f, 0.8f, 0.0f, 1.0f}, "Building..."); break; + case BuildState::Running: ImGui::TextColored({0.0f, 1.0f, 0.0f, 1.0f}, "Running"); break; + case BuildState::BuildFailed: ImGui::TextColored({1.0f, 0.2f, 0.2f, 1.0f}, "Build Failed"); break; + } +} + +// --- Run/Stop --- + +void FlowEditorWindow::run_program(bool release) { + stop_program(); + if (build_thread_.joinable()) + build_thread_.join(); + + show_build_log_ = true; + { + std::lock_guard lock(build_log_mutex_); + build_log_.clear(); + } + +#if LEGACY_EDITOR + // Auto-save via Editor1Pane if applicable + if (auto* e1 = dynamic_cast(active().pane.get())) { + e1->auto_save(); + } +#endif + + if (active().file_path.empty()) return; + std::string active_path = active().file_path; + + namespace fs = std::filesystem; + + fs::path atto_path = fs::absolute(active_path); + fs::path project_dir = atto_path.parent_path(); + std::string source_name = project_dir.filename().string(); + fs::path output_dir = project_dir / ".generated" / source_name; + + fs::path exe_dir; +#ifdef _WIN32 + char exe_buf[MAX_PATH]; + GetModuleFileNameA(nullptr, exe_buf, MAX_PATH); + exe_dir = fs::path(exe_buf).parent_path(); +#elif defined(__APPLE__) + { + uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + std::string buf(size, '\0'); + _NSGetExecutablePath(buf.data(), &size); + exe_dir = fs::canonical(buf).parent_path(); + } +#else + exe_dir = fs::canonical("/proc/self/exe").parent_path(); +#endif + fs::path attoc_path = exe_dir / "attoc.exe"; + if (!fs::exists(attoc_path)) + attoc_path = exe_dir / "attoc"; + + std::string tc_str; +#ifdef _WIN32 + { + const char* vr = std::getenv("VCPKG_ROOT"); + if (!vr) { + std::lock_guard lock(build_log_mutex_); + build_log_ += "Error: VCPKG_ROOT environment variable is not set\n"; + build_state_ = BuildState::BuildFailed; + return; + } + tc_str = (fs::path(vr) / "scripts" / "buildsystems" / "vcpkg.cmake").string(); + } +#endif + + std::string attoc_str = attoc_path.string(); + std::string atto_str = project_dir.string(); + std::string out_str = output_dir.string(); + std::string sn = source_name; + + build_state_ = BuildState::Building; + { + std::lock_guard lock(build_log_mutex_); + build_log_.clear(); + } + + build_thread_ = std::thread([this, attoc_str, atto_str, out_str, tc_str, sn, release]() { + namespace fs = std::filesystem; + fs::create_directories(out_str); + + auto run_cmd = [this](const std::string& cmd) -> int { +#ifdef _WIN32 + std::string full_cmd = "\"" + cmd + " 2>&1\""; + FILE* pipe = _popen(full_cmd.c_str(), "r"); +#else + std::string full_cmd = cmd + " 2>&1"; + FILE* pipe = popen(full_cmd.c_str(), "r"); +#endif + if (!pipe) return -1; + char buf[256]; + while (fgets(buf, sizeof(buf), pipe)) { + std::lock_guard lock(build_log_mutex_); + build_log_ += buf; + } +#ifdef _WIN32 + return _pclose(pipe); +#else + return pclose(pipe); +#endif + }; + + { + std::lock_guard lock(build_log_mutex_); + build_log_ += "=== Running attoc ===\n"; + } + std::string cmd1 = "\"" + attoc_str + "\" \"" + atto_str + "\" -o \"" + out_str + "\""; + if (run_cmd(cmd1) != 0) { build_state_ = BuildState::BuildFailed; return; } + + std::string build_dir = out_str + "/build"; + std::string cache_file = build_dir + "/CMakeCache.txt"; + { + std::ifstream cache_check(cache_file); + if (!cache_check.good()) { + { + std::lock_guard lock(build_log_mutex_); + build_log_ += "\n=== CMake Configure ===\n"; + } + std::string cmd2 = "cmake -B \"" + build_dir + "\" -S \"" + out_str + "\""; + if (!tc_str.empty()) + cmd2 += " \"-DCMAKE_TOOLCHAIN_FILE=" + tc_str + "\""; + if (run_cmd(cmd2) != 0) { build_state_ = BuildState::BuildFailed; return; } + } else { + std::lock_guard lock(build_log_mutex_); + build_log_ += "\n=== CMake Configure (cached) ===\n"; + } + } + + { + std::lock_guard lock(build_log_mutex_); + build_log_ += "\n=== CMake Build ===\n"; + } + std::string config = release ? "Release" : "Debug"; + std::string cmd3 = "cmake --build \"" + build_dir + "\" --config " + config + " --parallel"; + if (run_cmd(cmd3) != 0) { build_state_ = BuildState::BuildFailed; return; } + +#ifdef _WIN32 + fs::path exe_path = fs::path(build_dir) / config / (sn + ".exe"); + if (!fs::exists(exe_path)) + exe_path = fs::path(build_dir) / (sn + ".exe"); +#else + fs::path exe_path = fs::path(build_dir) / sn; +#endif + if (!fs::exists(exe_path)) { + std::lock_guard lock(build_log_mutex_); + build_log_ += "\nError: executable not found at " + exe_path.string() + "\n"; + build_state_ = BuildState::BuildFailed; + return; + } + +#ifdef _WIN32 + STARTUPINFOA si = {}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi = {}; + std::string exe_str = exe_path.string(); + if (CreateProcessA(exe_str.c_str(), nullptr, nullptr, nullptr, FALSE, + 0, nullptr, nullptr, &si, &pi)) { + CloseHandle(pi.hThread); + child_process_ = pi.hProcess; + build_state_ = BuildState::Running; + } else { + std::lock_guard lock(build_log_mutex_); + build_log_ += "\nError: failed to launch " + exe_str + "\n"; + build_state_ = BuildState::BuildFailed; + } +#else + pid_t pid = fork(); + if (pid == 0) { + execl(exe_path.c_str(), exe_path.c_str(), nullptr); + _exit(1); + } else if (pid > 0) { + child_pid_ = pid; + build_state_ = BuildState::Running; + } else { + std::lock_guard lock(build_log_mutex_); + build_log_ += "\nError: fork failed\n"; + build_state_ = BuildState::BuildFailed; + } +#endif + }); +} + +void FlowEditorWindow::stop_program() { +#ifdef _WIN32 + if (child_process_) { + TerminateProcess(child_process_, 0); + WaitForSingleObject(child_process_, 1000); + CloseHandle(child_process_); + child_process_ = nullptr; + } +#else + if (child_pid_ > 0) { + kill(child_pid_, SIGTERM); + waitpid(child_pid_, nullptr, 0); + child_pid_ = 0; + } +#endif + build_state_ = BuildState::Idle; +} + +void FlowEditorWindow::poll_child_process() { + if (build_state_.load() != BuildState::Running) return; + +#ifdef _WIN32 + if (child_process_) { + DWORD exit_code; + if (GetExitCodeProcess(child_process_, &exit_code) && exit_code != STILL_ACTIVE) { + CloseHandle(child_process_); + child_process_ = nullptr; + build_state_ = BuildState::Idle; + } + } +#else + if (child_pid_ > 0) { + int status; + pid_t result = waitpid(child_pid_, &status, WNOHANG); + if (result == child_pid_) { + child_pid_ = 0; + build_state_ = BuildState::Idle; + } + } +#endif +} diff --git a/src/attoflow/window.h b/src/attoflow/window.h new file mode 100644 index 0000000..e86a69d --- /dev/null +++ b/src/attoflow/window.h @@ -0,0 +1,69 @@ +#pragma once +#include "sdl_imgui_window.h" +#include "tab.h" +#include "editor2.h" +#include "nets_editor.h" +#include +#include +#include +#include +#include +#ifdef _WIN32 +#define NOMINMAX +#include +#endif + +class FlowEditorWindow { +public: + bool init(const std::string& project_dir = ""); + void shutdown(); + bool is_open() const { return win_.open; } + + void process_event(SDL_Event& e); + void draw(); + + SdlImGuiWindow& sdl_window() { return win_; } + + // Tab management + TabState& active() { return tabs_[active_tab_]; } + const TabState& active() const { return tabs_[active_tab_]; } + void open_tab(const std::string& file_path); + void close_tab(int idx); + void scan_project_files(); + +private: + SdlImGuiWindow win_; + + // Project + std::string project_dir_; + std::vector project_files_; + float file_panel_width_ = 200.0f; + + // Tabs + std::vector tabs_; + int active_tab_ = 0; + int pending_tab_select_ = -1; // one-shot: set to force tab selection next frame + + // Panel sizes + float side_panel_width_ = 200.0f; + float bottom_panel_height_ = 250.0f; + + // Run/Stop + enum class BuildState { Idle, Building, Running, BuildFailed }; + std::atomic build_state_{BuildState::Idle}; + std::string build_log_; + std::mutex build_log_mutex_; + bool show_build_log_ = false; + char search_buf_[128] = {}; + float last_canvas_w_ = 800, last_canvas_h_ = 600; + std::thread build_thread_; +#ifdef _WIN32 + HANDLE child_process_ = nullptr; +#else + pid_t child_pid_ = 0; +#endif + void run_program(bool release = false); + void stop_program(); + void poll_child_process(); + void draw_toolbar(); +}; diff --git a/src/attoflow/editor.cpp b/src/legacy/editor1.cpp similarity index 59% rename from src/attoflow/editor.cpp rename to src/legacy/editor1.cpp index b8599b5..b3a7fdf 100644 --- a/src/attoflow/editor.cpp +++ b/src/legacy/editor1.cpp @@ -1,26 +1,19 @@ -#include "editor.h" +#include "editor1.h" #include "atto/args.h" #include "atto/expr.h" #include "atto/inference.h" #include "atto/serial.h" #include "atto/shadow.h" #include "atto/types.h" +#include "atto/node_types.h" #include #include #include #include #include -#include #include -#ifndef _WIN32 -#include -#include -#include -#endif -#ifdef __APPLE__ -#include -#endif +// --- Constants --- static constexpr float NODE_ROUNDING = 4.0f; static constexpr float PIN_RADIUS = 5.0f; static constexpr float PIN_SPACING = 20.0f; @@ -37,20 +30,18 @@ static constexpr ImU32 COL_PIN_HOVER = IM_COL32(255, 255, 255, 255); static constexpr ImU32 COL_LINK = IM_COL32(200, 200, 100, 200); static constexpr ImU32 COL_LINK_DRAG = IM_COL32(255, 255, 150, 200); -#include "atto/node_types.h" +// --- Static helpers --- + +#include "atto/type_utils.h" // Look up port description for a pin on a node. // Returns {port_name, port_desc} or {"", ""} if not found. static std::pair get_port_desc(const FlowNode& node, const FlowPin& pin) { - // Use the pin's own name — it reflects $N:name annotations from parse_args() - // For descriptor pins (non-$N), the name comes from the node type descriptor - // For $N ref pins, the name is either the numeric index or the :name annotation if (node.lambda_grab.id == pin.id) return {"as_lambda", "pass as lambda"}; if (node.bang_pin.id == pin.id) return {"bang", "bang connector"}; auto* nt = find_node_type(node.type_id); - // For bang pins, use descriptor names auto find_bang = [&](const auto& pins, const PortDesc* descs, int count) -> std::pair { int idx = 0; for (auto& p : pins) { @@ -72,13 +63,10 @@ static std::pair get_port_desc(const FlowNode& node, c if (!r.first.empty()) return r; } - // For data input pins: check if a $N:name annotation exists in parsed expressions for (int i = 0; i < (int)node.inputs.size(); i++) { if (node.inputs[i]->id != pin.id) continue; - // Look for a PinRef with this index that has a :name annotation for (auto& expr : node.parsed_exprs) { if (!expr) continue; - // Walk AST to find PinRef for this pin index struct Finder { int target_idx; std::string result; void walk(const ExprPtr& e) { @@ -88,7 +76,6 @@ static std::pair get_port_desc(const FlowNode& node, c for (auto& c : e->children) walk(c); } }; - // Parse pin name as index int pin_idx = -1; try { pin_idx = std::stoi(pin.name); } catch (...) {} if (pin_idx >= 0) { @@ -114,8 +101,6 @@ static std::string pin_label(const FlowNode& node, const FlowPin& pin) { return node_display_name(node) + "." + port_name; } -#include "atto/type_utils.h" - static float dist2(ImVec2 a, ImVec2 b) { float dx = a.x - b.x, dy = a.y - b.y; return dx * dx + dy * dy; @@ -135,7 +120,6 @@ static float point_to_bezier_dist(ImVec2 p, ImVec2 p0, ImVec2 p1, ImVec2 p2, ImV return std::sqrt(min_d2); } - enum class PinShape { Square, Signal, LambdaDown, LambdaLeft }; static void draw_pin(ImDrawList* dl, ImVec2 pos, float r, ImU32 col, PinShape shape, float zoom) { @@ -154,7 +138,6 @@ static void draw_pin(ImDrawList* dl, ImVec2 pos, float r, ImU32 col, PinShape sh } break; case PinShape::LambdaDown: - // Down-pointing triangle (for lambda inputs on top) dl->AddTriangleFilled( {pos.x - r, pos.y - r}, {pos.x + r, pos.y - r}, @@ -162,7 +145,6 @@ static void draw_pin(ImDrawList* dl, ImVec2 pos, float r, ImU32 col, PinShape sh col); break; case PinShape::LambdaLeft: - // Left-pointing triangle (for lambda grab on left) dl->AddTriangleFilled( {pos.x + r, pos.y - r}, {pos.x - r, pos.y}, @@ -208,182 +190,163 @@ static void draw_vbezier(ImDrawList* dl, ImVec2 from, ImVec2 to, ImU32 col, floa dl->AddBezierCubic(from, {from.x, from.y + dy}, {to.x, to.y - dy}, to, col, thickness * zoom); } -bool FlowEditorWindow::init(const std::string& project_dir) { - if (!win_.init("Flow Editor", 900, 600)) return false; - project_dir_ = project_dir; - - if (!project_dir_.empty()) { - scan_project_files(); - // Open main.atto as the first tab - namespace fs = std::filesystem; - std::string main_path = (fs::path(project_dir_) / "main.atto").string(); - if (fs::exists(main_path)) { - open_tab(main_path); - } else if (!project_files_.empty()) { - open_tab((fs::path(project_dir_) / project_files_[0]).string()); - } - } - - // Ensure at least one tab exists - if (tabs_.empty()) { - tabs_.push_back({}); - tabs_.back().tab_name = "untitled"; - } - - return true; +static ImVec2 bezier_sample(ImVec2 p0, ImVec2 p1, ImVec2 p2, ImVec2 p3, float t) { + float u = 1.0f - t; + float uu = u * u, uuu = uu * u; + float tt = t * t, ttt = tt * t; + return {uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x, + uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y}; } -void FlowEditorWindow::scan_project_files() { - namespace fs = std::filesystem; - project_files_.clear(); - if (project_dir_.empty()) return; - for (auto& entry : fs::directory_iterator(project_dir_)) { - if (entry.path().extension() == ".atto") { - project_files_.push_back(entry.path().filename().string()); +static void draw_dashed_bezier(ImDrawList* dl, ImVec2 p0, ImVec2 p1, ImVec2 p2, ImVec2 p3, + ImU32 col, float thickness, float dash_len, float gap_len) { + const int N = 128; + ImVec2 pts[N + 1]; + float arc[N + 1]; + pts[0] = p0; arc[0] = 0; + for (int i = 1; i <= N; i++) { + pts[i] = bezier_sample(p0, p1, p2, p3, (float)i / N); + float dx = pts[i].x - pts[i-1].x, dy = pts[i].y - pts[i-1].y; + arc[i] = arc[i-1] + sqrtf(dx*dx + dy*dy); + } + float total = arc[N]; + if (total < 1.0f) return; + float cycle = dash_len + gap_len; + + auto lerp_at = [&](float d) -> ImVec2 { + if (d <= 0) return pts[0]; + if (d >= total) return pts[N]; + int lo = 0, hi = N; + while (lo < hi - 1) { int mid = (lo+hi)/2; if (arc[mid] < d) lo = mid; else hi = mid; } + float seg_len = arc[hi] - arc[lo]; + float t = (seg_len > 0) ? (d - arc[lo]) / seg_len : 0; + return {pts[lo].x + t * (pts[hi].x - pts[lo].x), + pts[lo].y + t * (pts[hi].y - pts[lo].y)}; + }; + + float d = 0; + while (d < total) { + float d_end = std::min(d + dash_len, total); + ImVec2 prev = lerp_at(d); + float step = 3.0f; + for (float dd = d + step; dd <= d_end; dd += step) { + ImVec2 cur = lerp_at(dd); + dl->AddLine(prev, cur, col, thickness); + prev = cur; } + ImVec2 end = lerp_at(d_end); + dl->AddLine(prev, end, col, thickness); + d += cycle; } - std::sort(project_files_.begin(), project_files_.end()); } -void FlowEditorWindow::open_tab(const std::string& file_path) { - namespace fs = std::filesystem; - std::string abs_path = fs::absolute(file_path).string(); +static void draw_dashed_vbezier(ImDrawList* dl, ImVec2 from, ImVec2 to, ImU32 col, float thickness, float zoom) { + float dy = std::max(std::abs(to.y - from.y) * 0.5f, 30.0f * zoom); + draw_dashed_bezier(dl, from, {from.x, from.y + dy}, {to.x, to.y - dy}, to, + col, thickness * zoom, 8.0f * zoom, 4.0f * zoom); +} - // Check if already open - for (int i = 0; i < (int)tabs_.size(); i++) { - if (tabs_[i].file_path == abs_path) { - active_tab_ = i; - return; - } - } +// ============================================================ +// Editor1Pane methods +// ============================================================ + +bool Editor1Pane::load(const std::string& path) { + namespace fs = std::filesystem; + std::string abs_path = fs::absolute(path).string(); + file_path_ = abs_path; + tab_name_ = fs::path(path).stem().string(); - // Create new tab - TabState tab; - tab.file_path = abs_path; - tab.tab_name = fs::path(file_path).stem().string(); if (fs::exists(abs_path)) { - load_atto(abs_path, tab.graph); - } - if (tab.graph.has_viewport) { - tab.canvas_offset = {tab.graph.viewport_x, tab.graph.viewport_y}; - tab.canvas_zoom = tab.graph.viewport_zoom; + if (!load_atto(abs_path, graph_)) + return false; } - tab.inference_dirty = true; - tabs_.push_back(std::move(tab)); - active_tab_ = (int)tabs_.size() - 1; -} -void FlowEditorWindow::close_tab(int idx) { - if (idx < 0 || idx >= (int)tabs_.size()) return; - // Auto-save before closing - if (tabs_[idx].dirty && !tabs_[idx].file_path.empty()) { - sync_viewport(tabs_[idx]); - save_atto(tabs_[idx].file_path, tabs_[idx].graph); - } - tabs_.erase(tabs_.begin() + idx); - if (active_tab_ >= (int)tabs_.size()) - active_tab_ = std::max(0, (int)tabs_.size() - 1); - // Ensure at least one tab - if (tabs_.empty()) { - tabs_.push_back({}); - tabs_.back().tab_name = "untitled"; + if (graph_.has_viewport) { + canvas_offset_ = {graph_.viewport_x, graph_.viewport_y}; + canvas_zoom_ = graph_.viewport_zoom; } + inference_dirty_ = true; + return true; } -void FlowEditorWindow::mark_dirty() { +void Editor1Pane::mark_dirty() { push_undo(); - active().dirty = true; - active().inference_dirty = true; + dirty_ = true; + inference_dirty_ = true; schedule_save(); } -void FlowEditorWindow::push_undo() { - active().undo_stack.push_back(save_atto_string(active().graph)); - active().redo_stack.clear(); - // Limit undo history - if (active().undo_stack.size() > 200) active().undo_stack.erase(active().undo_stack.begin()); +void Editor1Pane::push_undo() { + undo_stack_.push_back(save_atto_string(graph_)); + redo_stack_.clear(); + if (undo_stack_.size() > 200) undo_stack_.erase(undo_stack_.begin()); } -void FlowEditorWindow::undo() { - if (active().undo_stack.empty()) return; - // Save current state to redo - active().redo_stack.push_back(save_atto_string(active().graph)); - // Restore from undo - load_atto_string(active().undo_stack.back(), active().graph); - active().undo_stack.pop_back(); - active().dirty = true; +void Editor1Pane::undo() { + if (undo_stack_.empty()) return; + redo_stack_.push_back(save_atto_string(graph_)); + load_atto_string(undo_stack_.back(), graph_); + undo_stack_.pop_back(); + dirty_ = true; } -void FlowEditorWindow::redo() { - if (active().redo_stack.empty()) return; - // Save current state to undo (without clearing redo) - active().undo_stack.push_back(save_atto_string(active().graph)); - // Restore from redo - load_atto_string(active().redo_stack.back(), active().graph); - active().redo_stack.pop_back(); - active().dirty = true; +void Editor1Pane::redo() { + if (redo_stack_.empty()) return; + undo_stack_.push_back(save_atto_string(graph_)); + load_atto_string(redo_stack_.back(), graph_); + redo_stack_.pop_back(); + dirty_ = true; } -void FlowEditorWindow::schedule_save() { - active().dirty = true; - save_deadline_ = ImGui::GetTime() + 0.5; // 500ms debounce +void Editor1Pane::schedule_save() { + dirty_ = true; + save_deadline_ = ImGui::GetTime() + 0.5; } -void FlowEditorWindow::check_debounced_save() { +void Editor1Pane::check_debounced_save() { if (save_deadline_ > 0 && ImGui::GetTime() >= save_deadline_) { save_deadline_ = 0; auto_save(); } } -void FlowEditorWindow::sync_viewport(TabState& tab) { - tab.graph.viewport_x = tab.canvas_offset.x; - tab.graph.viewport_y = tab.canvas_offset.y; - tab.graph.viewport_zoom = tab.canvas_zoom; +void Editor1Pane::sync_viewport() { + graph_.viewport_x = canvas_offset_.x; + graph_.viewport_y = canvas_offset_.y; + graph_.viewport_zoom = canvas_zoom_; } -void FlowEditorWindow::auto_save() { - if (active().dirty && !active().file_path.empty()) { - sync_viewport(active()); - save_atto(active().file_path, active().graph); - active().dirty = false; +void Editor1Pane::auto_save() { + if (dirty_ && !file_path_.empty()) { + sync_viewport(); + save_atto(file_path_, graph_); + dirty_ = false; } } -void FlowEditorWindow::shutdown() { - stop_program(); - if (build_thread_.joinable()) - build_thread_.join(); - win_.shutdown(); -} -void FlowEditorWindow::process_event(SDL_Event& e) { win_.process_event(e); } - -ImVec2 FlowEditorWindow::canvas_to_screen(ImVec2 p, ImVec2 origin) const { - return {origin.x + (p.x + active().canvas_offset.x) * active().canvas_zoom, - origin.y + (p.y + active().canvas_offset.y) * active().canvas_zoom}; +ImVec2 Editor1Pane::canvas_to_screen(ImVec2 p, ImVec2 origin) const { + return {origin.x + (p.x + canvas_offset_.x) * canvas_zoom_, + origin.y + (p.y + canvas_offset_.y) * canvas_zoom_}; } -ImVec2 FlowEditorWindow::screen_to_canvas(ImVec2 p, ImVec2 origin) const { - return {(p.x - origin.x) / active().canvas_zoom - active().canvas_offset.x, - (p.y - origin.y) / active().canvas_zoom - active().canvas_offset.y}; +ImVec2 Editor1Pane::screen_to_canvas(ImVec2 p, ImVec2 origin) const { + return {(p.x - origin.x) / canvas_zoom_ - canvas_offset_.x, + (p.y - origin.y) / canvas_zoom_ - canvas_offset_.y}; } -ImVec2 FlowEditorWindow::get_pin_pos(const FlowNode& node, const FlowPin& pin, ImVec2 origin) const { +ImVec2 Editor1Pane::get_pin_pos(const FlowNode& node, const FlowPin& pin, ImVec2 origin) const { if (pin.direction == FlowPin::LambdaGrab) { - // Grab handle: middle-left float x = node.position.x; float y = node.position.y + node.size.y * 0.5f; return canvas_to_screen({x, y}, origin); } - // Bang pin: middle-right if (pin.id == node.bang_pin.id && pin.name == "bang") { float x = node.position.x + node.size.x; float y = node.position.y + node.size.y * 0.5f; return canvas_to_screen({x, y}, origin); } - // Bang inputs first on top if (pin.direction == FlowPin::BangTrigger) { int idx = 0; for (auto& p : node.triggers) { if (p->id == pin.id) break; idx++; } @@ -393,8 +356,6 @@ ImVec2 FlowEditorWindow::get_pin_pos(const FlowNode& node, const FlowPin& pin, I } if (pin.direction == FlowPin::Input || pin.direction == FlowPin::Lambda) { - // Data inputs and lambdas after bang inputs on the top row. - // Skip shadow-connected pins in slot calculation. int bang_offset = (int)node.triggers.size(); int slot = 0; for (auto& p : node.inputs) { @@ -406,7 +367,6 @@ ImVec2 FlowEditorWindow::get_pin_pos(const FlowNode& node, const FlowPin& pin, I return canvas_to_screen({x, y}, origin); } - // Bang outputs first on bottom, then data outputs if (pin.direction == FlowPin::BangNext) { int idx = 0; for (auto& p : node.nexts) { if (p->id == pin.id) break; idx++; } @@ -415,7 +375,6 @@ ImVec2 FlowEditorWindow::get_pin_pos(const FlowNode& node, const FlowPin& pin, I return canvas_to_screen({x, y}, origin); } - // Data outputs after bang outputs int offset = (int)node.nexts.size(); int idx = 0; for (auto& p : node.outputs) { if (p->id == pin.id) break; idx++; } @@ -424,9 +383,9 @@ ImVec2 FlowEditorWindow::get_pin_pos(const FlowNode& node, const FlowPin& pin, I return canvas_to_screen({x, y}, origin); } -FlowEditorWindow::PinHit FlowEditorWindow::hit_test_pin(ImVec2 sp, ImVec2 co, float radius) const { - float r2 = radius * radius * active().canvas_zoom * active().canvas_zoom; - for (auto& node : active().graph.nodes) { +Editor1Pane::PinHit Editor1Pane::hit_test_pin(ImVec2 sp, ImVec2 co, float radius) const { + float r2 = radius * radius * canvas_zoom_ * canvas_zoom_; + for (auto& node : graph_.nodes) { if (node.imported || node.shadow) continue; for (auto& pin : node.triggers) if (dist2(sp, get_pin_pos(node, *pin, co)) < r2) @@ -454,12 +413,12 @@ FlowEditorWindow::PinHit FlowEditorWindow::hit_test_pin(ImVec2 sp, ImVec2 co, fl return {-1, "", FlowPin::Input}; } -int FlowEditorWindow::hit_test_link(ImVec2 sp, ImVec2 co, float threshold) const { - for (auto& link : active().graph.links) { +int Editor1Pane::hit_test_link(ImVec2 sp, ImVec2 co, float threshold) const { + for (auto& link : graph_.links) { ImVec2 fp = {}, tp = {}; bool ff = false, ft = false; bool from_grab = false, from_bang_pin = false, to_lambda = false; - for (auto& n : active().graph.nodes) { + for (auto& n : graph_.nodes) { for (auto& p : n.outputs) if (p->id == link.from_pin) { fp = get_pin_pos(n, *p, co); ff = true; } for (auto& p : n.nexts) if (p->id == link.from_pin) { fp = get_pin_pos(n, *p, co); ff = true; } for (auto& p : n.triggers) if (p->id == link.from_pin) { fp = get_pin_pos(n, *p, co); ff = true; } @@ -469,29 +428,27 @@ int FlowEditorWindow::hit_test_link(ImVec2 sp, ImVec2 co, float threshold) const if (n.bang_pin.id == link.from_pin) { fp = get_pin_pos(n, n.bang_pin, co); ff = true; from_bang_pin = true; } } if (!ff || !ft) continue; - // Use the same curve shape as draw_link for accurate hit testing float d; if (from_grab) { - float dx = std::max(std::abs(tp.x - fp.x) * 0.5f, 30.0f * active().canvas_zoom); - float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * active().canvas_zoom); + float dx = std::max(std::abs(tp.x - fp.x) * 0.5f, 30.0f * canvas_zoom_); + float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * canvas_zoom_); d = point_to_bezier_dist(sp, fp, {fp.x - dx, fp.y}, {tp.x, tp.y - dy}, tp); } else if (from_bang_pin) { - float dx = std::max(std::abs(tp.x - fp.x) * 0.5f, 30.0f * active().canvas_zoom); - float dy_hit = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * active().canvas_zoom); + float dx = std::max(std::abs(tp.x - fp.x) * 0.5f, 30.0f * canvas_zoom_); + float dy_hit = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * canvas_zoom_); d = point_to_bezier_dist(sp, fp, {fp.x + dx, fp.y}, {tp.x, tp.y - dy_hit}, tp); } else { - float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * active().canvas_zoom); + float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * canvas_zoom_); d = point_to_bezier_dist(sp, fp, {fp.x, fp.y + dy}, {tp.x, tp.y - dy}, tp); } - if (d < threshold * active().canvas_zoom) return link.id; + if (d < threshold * canvas_zoom_) return link.id; } return -1; } -void FlowEditorWindow::draw_node(ImDrawList* dl, FlowNode& node, ImVec2 origin) { +void Editor1Pane::draw_node(ImDrawList* dl, FlowNode& node, ImVec2 origin) { bool is_label = (node.type_id == NodeTypeID::Label); - // Width from pins (top row = inputs + lambdas, bottom row = outputs) int visible_inputs = 0; for (auto& pin : node.inputs) if (!shadow_connected_pins_.count(pin->id)) visible_inputs++; @@ -500,7 +457,6 @@ void FlowEditorWindow::draw_node(ImDrawList* dl, FlowNode& node, ImVec2 origin) int max_pins = std::max(top_pins, bottom_pins); float pin_w = (float)(max_pins + 1) * PIN_SPACING; - // Width from display text std::string display_text; if (is_label) { display_text = node.args.empty() ? "(label)" : node.args; @@ -509,7 +465,7 @@ void FlowEditorWindow::draw_node(ImDrawList* dl, FlowNode& node, ImVec2 origin) } float font_scale = 17.0f / ImGui::GetFontSize(); ImVec2 ts = ImGui::CalcTextSize(display_text.c_str()); - float text_w = ts.x * font_scale + 16.0f; // padding + float text_w = ts.x * font_scale + 16.0f; float needed_w = std::max({pin_w, text_w, NODE_MIN_WIDTH}); node.size = {needed_w, NODE_HEIGHT}; @@ -519,39 +475,34 @@ void FlowEditorWindow::draw_node(ImDrawList* dl, FlowNode& node, ImVec2 origin) node.position.y + node.size.y}, origin); if (is_label) { - // Labels: no background box, just text - float font_size = 17.0f * active().canvas_zoom; + float font_size = 17.0f * canvas_zoom_; if (font_size > 6.0f && editing_node_ != node.id) { const char* display = node.args.empty() ? "(label)" : node.args.c_str(); ImU32 col = node.args.empty() ? IM_COL32(100, 100, 100, 180) : IM_COL32(255, 255, 255, 255); dl->AddText(nullptr, font_size, - {tl.x + 2 * active().canvas_zoom, tl.y + (br.y - tl.y - font_size) * 0.5f}, + {tl.x + 2 * canvas_zoom_, tl.y + (br.y - tl.y - font_size) * 0.5f}, col, display); } } else { - // Normal node: filled bar (red if error) ImU32 bg = node.error.empty() ? COL_NODE_BG : IM_COL32(120, 30, 30, 230); ImU32 border = node.error.empty() ? IM_COL32(100, 100, 150, 255) : IM_COL32(200, 60, 60, 255); - dl->AddRectFilled(tl, br, bg, NODE_ROUNDING * active().canvas_zoom); + dl->AddRectFilled(tl, br, bg, NODE_ROUNDING * canvas_zoom_); - // Highlight animation: blink dark yellow overlay - if (active().highlight_node_id == node.id && active().highlight_timer > 0.0f) { - float blink = std::sin(active().highlight_timer * 6.0f) * 0.5f + 0.5f; + if (highlight_node_id_ == node.id && highlight_timer_ > 0.0f) { + float blink = std::sin(highlight_timer_ * 6.0f) * 0.5f + 0.5f; int a = (int)(blink * 140.0f); - dl->AddRectFilled(tl, br, IM_COL32(180, 160, 40, a), NODE_ROUNDING * active().canvas_zoom); + dl->AddRectFilled(tl, br, IM_COL32(180, 160, 40, a), NODE_ROUNDING * canvas_zoom_); } - dl->AddRect(tl, br, border, NODE_ROUNDING * active().canvas_zoom); + dl->AddRect(tl, br, border, NODE_ROUNDING * canvas_zoom_); - // Selection highlight - if (active().selected_nodes.count(node.id)) { - dl->AddRect({tl.x - 2*active().canvas_zoom, tl.y - 2*active().canvas_zoom}, - {br.x + 2*active().canvas_zoom, br.y + 2*active().canvas_zoom}, - IM_COL32(100, 180, 255, 200), NODE_ROUNDING * active().canvas_zoom, 0, 2.0f * active().canvas_zoom); + if (selected_nodes_.count(node.id)) { + dl->AddRect({tl.x - 2*canvas_zoom_, tl.y - 2*canvas_zoom_}, + {br.x + 2*canvas_zoom_, br.y + 2*canvas_zoom_}, + IM_COL32(100, 180, 255, 200), NODE_ROUNDING * canvas_zoom_, 0, 2.0f * canvas_zoom_); } - // Display text - float font_size = 17.0f * active().canvas_zoom; + float font_size = 17.0f * canvas_zoom_; if (font_size > 6.0f && editing_node_ != node.id) { std::string text = node.display_text(); float scale = font_size / ImGui::GetFontSize(); @@ -566,48 +517,43 @@ void FlowEditorWindow::draw_node(ImDrawList* dl, FlowNode& node, ImVec2 origin) auto* nt = find_node_type(node.type_id); bool is_event = nt && nt->is_event; - // Pins PinShape io_shape = PinShape::Signal; - float pr = PIN_RADIUS * active().canvas_zoom; + float pr = PIN_RADIUS * canvas_zoom_; { - // Bang inputs (top, before data inputs) for (auto& pin : node.triggers) { ImVec2 pp = get_pin_pos(node, *pin, origin); - draw_pin(dl, pp, pr, IM_COL32(255, 200, 80, 255), PinShape::Square, active().canvas_zoom); + draw_pin(dl, pp, pr, IM_COL32(255, 200, 80, 255), PinShape::Square, canvas_zoom_); } for (auto& pin : node.inputs) { if (shadow_connected_pins_.count(pin->id)) continue; ImVec2 pp = get_pin_pos(node, *pin, origin); if (pin->direction == FlowPin::Lambda) - draw_pin(dl, pp, pr, IM_COL32(180, 130, 255, 255), PinShape::LambdaDown, active().canvas_zoom); + draw_pin(dl, pp, pr, IM_COL32(180, 130, 255, 255), PinShape::LambdaDown, canvas_zoom_); else - draw_pin(dl, pp, pr, COL_PIN_IN, io_shape, active().canvas_zoom); + draw_pin(dl, pp, pr, COL_PIN_IN, io_shape, canvas_zoom_); } - // Bang outputs (bottom, before data outputs) for (auto& pin : node.nexts) { ImVec2 pp = get_pin_pos(node, *pin, origin); - draw_pin(dl, pp, pr, IM_COL32(255, 200, 80, 255), PinShape::Square, active().canvas_zoom); + draw_pin(dl, pp, pr, IM_COL32(255, 200, 80, 255), PinShape::Square, canvas_zoom_); } for (auto& pin : node.outputs) { ImVec2 pp = get_pin_pos(node, *pin, origin); - draw_pin(dl, pp, pr, COL_PIN_OUT, io_shape, active().canvas_zoom); + draw_pin(dl, pp, pr, COL_PIN_OUT, io_shape, canvas_zoom_); } - // Lambda grab handle (left) — not on event nodes bool show_lambda = nt && nt->has_lambda; if (!node.lambda_grab.id.empty() && show_lambda) { ImVec2 pp = get_pin_pos(node, node.lambda_grab, origin); - draw_pin(dl, pp, pr, IM_COL32(180, 130, 255, 150), PinShape::LambdaLeft, active().canvas_zoom); + draw_pin(dl, pp, pr, IM_COL32(180, 130, 255, 150), PinShape::LambdaLeft, canvas_zoom_); } - // Bang pin (right) — not on event nodes or no_post_bang nodes bool no_post_bang = nt && nt->no_post_bang; if (!node.bang_pin.id.empty() && !is_event && !no_post_bang) { ImVec2 pp = get_pin_pos(node, node.bang_pin, origin); - draw_pin(dl, pp, pr * 0.7f, IM_COL32(255, 200, 80, 255), PinShape::Square, active().canvas_zoom); + draw_pin(dl, pp, pr * 0.7f, IM_COL32(255, 200, 80, 255), PinShape::Square, canvas_zoom_); } } } -void FlowEditorWindow::draw_link(ImDrawList* dl, const FlowLink& link, ImVec2 origin) { +void Editor1Pane::draw_link(ImDrawList* dl, const FlowLink& link, ImVec2 origin) { ImVec2 fp = {}, tp = {}; bool ff = false, ft = false; bool to_lambda = false; @@ -615,7 +561,7 @@ void FlowEditorWindow::draw_link(ImDrawList* dl, const FlowLink& link, ImVec2 or bool from_bang_pin = false; FlowPin* from_pin_ptr = nullptr; FlowPin* to_pin_ptr = nullptr; - for (auto& n : active().graph.nodes) { + for (auto& n : graph_.nodes) { for (auto& p : n.outputs) if (p->id == link.from_pin) { fp = get_pin_pos(n, *p, origin); ff = true; from_pin_ptr = p.get(); } for (auto& p : n.nexts) if (p->id == link.from_pin) { fp = get_pin_pos(n, *p, origin); ff = true; from_pin_ptr = p.get(); } for (auto& p : n.triggers) if (p->id == link.from_pin) { fp = get_pin_pos(n, *p, origin); ff = true; from_pin_ptr = p.get(); } @@ -626,306 +572,545 @@ void FlowEditorWindow::draw_link(ImDrawList* dl, const FlowLink& link, ImVec2 or } if (!ff || !ft) return; - // Check type compatibility for link coloring - bool type_error = !link.error.empty(); // lambda/inference errors + bool type_error = !link.error.empty(); if (!type_error && from_pin_ptr && to_pin_ptr && from_pin_ptr->resolved_type && to_pin_ptr->resolved_type && !from_pin_ptr->resolved_type->is_generic && !to_pin_ptr->resolved_type->is_generic) { type_error = !types_compatible(from_pin_ptr->resolved_type, to_pin_ptr->resolved_type); } - // Check if from-pin is a trigger (top of node, bidirectional) bool from_trigger = from_pin_ptr && from_pin_ptr->direction == FlowPin::BangTrigger; ImU32 col_error = IM_COL32(255, 60, 60, 220); + bool named = !link.net_name.empty() && !link.auto_wire; + + auto dim = [](ImU32 c) -> ImU32 { + return (c & 0x00FFFFFF) | (((c >> 24) * 100 / 255) << 24); + }; + if (named) col_error = dim(col_error); + + auto wire_col = [&](ImU32 c) { return named ? dim(c) : c; }; if (from_trigger) { - // Trigger-as-source: exits upward from top, curves to target - float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 40.0f * active().canvas_zoom); - float dx = std::max(std::abs(tp.x - fp.x) * 0.3f, 20.0f * active().canvas_zoom); - dl->AddBezierCubic(fp, {fp.x, fp.y - dy}, {tp.x, tp.y - dy}, tp, - type_error ? col_error : IM_COL32(255, 200, 80, 200), 2.5f * active().canvas_zoom); + float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 40.0f * canvas_zoom_); + ImU32 col = type_error ? col_error : wire_col(IM_COL32(255, 200, 80, 200)); + float th = 2.5f * canvas_zoom_; + if (named) + draw_dashed_bezier(dl, fp, {fp.x, fp.y - dy}, {tp.x, tp.y - dy}, tp, col, th, 8.0f * canvas_zoom_, 4.0f * canvas_zoom_); + else + dl->AddBezierCubic(fp, {fp.x, fp.y - dy}, {tp.x, tp.y - dy}, tp, col, th); } else if (from_grab) { - float dx = std::max(std::abs(tp.x - fp.x) * 0.5f, 30.0f * active().canvas_zoom); - float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * active().canvas_zoom); - dl->AddBezierCubic(fp, {fp.x - dx, fp.y}, {tp.x, tp.y - dy}, tp, - type_error ? col_error : IM_COL32(180, 130, 255, 200), 2.5f * active().canvas_zoom); + float dx = std::max(std::abs(tp.x - fp.x) * 0.5f, 30.0f * canvas_zoom_); + float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * canvas_zoom_); + ImU32 col = type_error ? col_error : wire_col(IM_COL32(180, 130, 255, 200)); + float th = 2.5f * canvas_zoom_; + if (named) + draw_dashed_bezier(dl, fp, {fp.x - dx, fp.y}, {tp.x, tp.y - dy}, tp, col, th, 8.0f * canvas_zoom_, 4.0f * canvas_zoom_); + else + dl->AddBezierCubic(fp, {fp.x - dx, fp.y}, {tp.x, tp.y - dy}, tp, col, th); } else if (from_bang_pin) { - float dx = std::max(std::abs(tp.x - fp.x) * 0.5f, 30.0f * active().canvas_zoom); - float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * active().canvas_zoom); - // Side bang exits horizontally right, enters bang input vertically from above - dl->AddBezierCubic(fp, {fp.x + dx, fp.y}, {tp.x, tp.y - dy}, tp, - type_error ? col_error : IM_COL32(255, 200, 80, 200), 2.5f * active().canvas_zoom); + float dx = std::max(std::abs(tp.x - fp.x) * 0.5f, 30.0f * canvas_zoom_); + float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 30.0f * canvas_zoom_); + ImU32 col = type_error ? col_error : wire_col(IM_COL32(255, 200, 80, 200)); + float th = 2.5f * canvas_zoom_; + if (named) + draw_dashed_bezier(dl, fp, {fp.x + dx, fp.y}, {tp.x, tp.y - dy}, tp, col, th, 8.0f * canvas_zoom_, 4.0f * canvas_zoom_); + else + dl->AddBezierCubic(fp, {fp.x + dx, fp.y}, {tp.x, tp.y - dy}, tp, col, th); } else if (to_lambda) { - draw_vbezier(dl, fp, tp, type_error ? col_error : IM_COL32(180, 130, 255, 200), 2.5f, active().canvas_zoom); + ImU32 col = type_error ? col_error : wire_col(IM_COL32(180, 130, 255, 200)); + if (named) + draw_dashed_vbezier(dl, fp, tp, col, 2.5f, canvas_zoom_); + else + draw_vbezier(dl, fp, tp, col, 2.5f, canvas_zoom_); } else { - draw_vbezier(dl, fp, tp, type_error ? col_error : COL_LINK, 2.5f, active().canvas_zoom); - } -} - -void FlowEditorWindow::draw() { - if (!win_.open) return; - - win_.begin_frame(); - ImGui::SetCurrentContext(win_.imgui_ctx); - - // Tick highlight timer - if (active().highlight_timer > 0.0f) { - active().highlight_timer -= ImGui::GetIO().DeltaTime; - if (active().highlight_timer <= 0.0f) { - active().highlight_timer = 0.0f; - active().highlight_node_id = -1; + ImU32 col = type_error ? col_error : wire_col(COL_LINK); + if (named) + draw_dashed_vbezier(dl, fp, tp, col, 2.5f, canvas_zoom_); + else + draw_vbezier(dl, fp, tp, col, 2.5f, canvas_zoom_); + } + + if (!link.net_name.empty() && !link.auto_wire) { + float font_size = ImGui::GetFontSize() * canvas_zoom_ * 0.8f; + if (font_size > 5.0f) { + ImVec2 mid = {(fp.x + tp.x) * 0.5f, (fp.y + tp.y) * 0.5f}; + ImVec2 text_sz = ImGui::CalcTextSize(link.net_name.c_str()); + float tw = text_sz.x * (font_size / ImGui::GetFontSize()); + float th = text_sz.y * (font_size / ImGui::GetFontSize()); + float cx = mid.x - tw * 0.5f; + float cy = mid.y - th * 0.5f; + dl->AddRectFilled({cx - 3, cy - 1}, {cx + tw + 3, cy + th + 1}, + IM_COL32(30, 30, 40, 200), 3.0f); + dl->AddText(nullptr, font_size, {cx, cy}, IM_COL32(180, 220, 255, 255), link.net_name.c_str()); } } +} - // Validate only when graph structure changes - if (active().graph.dirty) { - validate_nodes(); - active().graph.dirty = false; - } +void Editor1Pane::validate_nodes() { + resolve_type_based_pins(graph_); - ImGui::SetNextWindowPos({0, 0}); - int w, h; - SDL_GetWindowSize(win_.window, &w, &h); - ImGui::SetNextWindowSize({(float)w, (float)h}); - ImGui::Begin("##main", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoScrollWithMouse | - ImGuiWindowFlags_NoBringToFrontOnFocus); - - // Toolbar - draw_toolbar(); - ImGui::Separator(); - - // Poll child process - poll_child_process(); - - float total_w = (float)w; - float total_h = ImGui::GetContentRegionAvail().y; - - // Clamp panel sizes - file_panel_width_ = std::clamp(file_panel_width_, 80.0f, total_w * 0.3f); - side_panel_width_ = std::clamp(side_panel_width_, 100.0f, total_w * 0.5f); - bottom_panel_height_ = std::clamp(bottom_panel_height_, 40.0f, total_h * 0.5f); - - // --- File browser panel (left) --- - ImGui::BeginChild("##file_browser", {file_panel_width_, total_h}, false, - ImGuiWindowFlags_NoScrollbar); - ImGui::TextUnformatted("Files"); - ImGui::Separator(); - ImGui::BeginChild("##file_list", {0, 0}, false); - for (auto& fname : project_files_) { - namespace fs = std::filesystem; - std::string stem = fs::path(fname).stem().string(); - bool is_active = (active_tab_ < (int)tabs_.size() && - tabs_[active_tab_].tab_name == stem); - if (is_active) ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(100, 200, 255, 255)); - if (ImGui::Selectable(stem.c_str(), is_active)) { - std::string full_path = (fs::path(project_dir_) / fname).string(); - open_tab(full_path); - } - if (is_active) ImGui::PopStyleColor(); - } - ImGui::EndChild(); - ImGui::EndChild(); - - // File browser splitter - ImGui::SameLine(); - ImGui::Button("##file_vsplitter", {4.0f, total_h}); - if (ImGui::IsItemActive()) - file_panel_width_ += ImGui::GetIO().MouseDelta.x; - ImGui::SameLine(); - - float center_w = total_w - file_panel_width_ - side_panel_width_ - 12.0f; - float canvas_h = total_h - bottom_panel_height_ - 4.0f; - - // --- Center column: tabs + canvas + bottom panel --- - ImGui::BeginGroup(); - - // --- Tab bar --- - if (ImGui::BeginTabBar("##atto_tabs")) { - for (int i = 0; i < (int)tabs_.size(); i++) { - std::string label = tabs_[i].tab_name; - if (tabs_[i].dirty) label += "*"; - label += "###tab" + std::to_string(i); - bool open = true; - ImGuiTabItemFlags flags = (i == active_tab_) ? ImGuiTabItemFlags_SetSelected : 0; - if (ImGui::BeginTabItem(label.c_str(), &open, flags)) { - if (active_tab_ != i) { - active_tab_ = i; - // Reset interaction state when switching tabs - editing_node_ = -1; - dragging_node_ = -1; - dragging_link_from_pin_.clear(); - grabbed_links_.clear(); + TypeRegistry registry; + for (auto& node : graph_.nodes) { + if (node.type_id == NodeTypeID::DeclType) { + auto tokens = tokenize_args(node.args, false); + if (tokens.size() >= 2) { + std::string type_name = tokens[0]; + std::string def; + for (size_t i = 1; i < tokens.size(); i++) { + if (!def.empty()) def += " "; + def += tokens[i]; + } + int decl_class = classify_decl_type(tokens); + if (decl_class == 0 || decl_class == 1) { + registry.register_type(type_name, def); + } else { + registry.register_type(type_name, "void"); } - ImGui::EndTabItem(); - } - if (!open) { - close_tab(i); - if (i <= active_tab_ && active_tab_ > 0) active_tab_--; - i--; // re-check this index } } - ImGui::EndTabBar(); } - float canvas_w = center_w; - last_canvas_w_ = canvas_w; - last_canvas_h_ = canvas_h; - - // --- Canvas --- - ImGui::BeginChild("##flow_canvas", {canvas_w, canvas_h}, false, - ImGuiWindowFlags_NoScrollbar); + registry.resolve_all(); - ImVec2 canvas_origin = ImGui::GetCursorScreenPos(); - ImVec2 canvas_size = ImGui::GetContentRegionAvail(); - ImDrawList* dl = ImGui::GetWindowDrawList(); + for (auto& node : graph_.nodes) { + node.error.clear(); - // Background - dl->AddRectFilled(canvas_origin, - {canvas_origin.x + canvas_size.x, canvas_origin.y + canvas_size.y}, COL_BG); + auto* nt = find_node_type(node.type_id); + if (!nt) { + node.error = "Unknown node type: " + std::string(node_type_str(node.type_id)); + continue; + } - // Safety: remove any empty-named nodes that aren't currently being edited - // (type validation relaxed — any name is allowed) - std::erase_if(active().graph.nodes, [&](auto& n) { - if (n.id == editing_node_) return false; - if (n.guid.empty()) return true; - return false; - }); + for (auto& other : graph_.nodes) { + if (&other != &node && other.guid == node.guid) { + node.error = "Duplicate guid: " + node.guid; + break; + } + } + if (!node.error.empty()) continue; - // Grid - float grid = GRID_SIZE * active().canvas_zoom; - if (grid > 4.0f) { - float ox = std::fmod(active().canvas_offset.x * active().canvas_zoom, grid); - float oy = std::fmod(active().canvas_offset.y * active().canvas_zoom, grid); - for (float x = ox; x < canvas_size.x; x += grid) - dl->AddLine({canvas_origin.x + x, canvas_origin.y}, - {canvas_origin.x + x, canvas_origin.y + canvas_size.y}, COL_GRID); - for (float y = oy; y < canvas_size.y; y += grid) - dl->AddLine({canvas_origin.x, canvas_origin.y + y}, - {canvas_origin.x + canvas_size.x, canvas_origin.y + y}, COL_GRID); - } + if (node.type_id == NodeTypeID::DeclType) { + auto tokens = tokenize_args(node.args, false); + if (tokens.empty()) { + node.error = "decl_type requires a type name"; + continue; + } + std::string type_name = tokens[0]; + if (!type_name.empty() && type_name[0] == '$') { + node.error = "Type name should not start with $"; + continue; + } - ImGui::InvisibleButton("##canvas", canvas_size, - ImGuiButtonFlags_MouseButtonLeft | - ImGuiButtonFlags_MouseButtonMiddle | - ImGuiButtonFlags_MouseButtonRight); - bool canvas_hovered = ImGui::IsItemHovered(); - ImVec2 mouse_pos = ImGui::GetMousePos(); + auto err_it = registry.errors.find(type_name); + if (err_it != registry.errors.end()) { + node.error = err_it->second; + continue; + } - // --- Canvas pan --- - if (canvas_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Middle)) { - canvas_dragging_ = true; - canvas_drag_start_ = mouse_pos; - } - if (canvas_dragging_) { - if (ImGui::IsMouseDown(ImGuiMouseButton_Middle)) { - ImVec2 delta = {mouse_pos.x - canvas_drag_start_.x, mouse_pos.y - canvas_drag_start_.y}; - active().canvas_offset.x += delta.x / active().canvas_zoom; - active().canvas_offset.y += delta.y / active().canvas_zoom; - canvas_drag_start_ = mouse_pos; - schedule_save(); - } else { canvas_dragging_ = false; } - } + int decl_class_v = classify_decl_type(tokens); + if (decl_class_v == 2) { + bool has_any_field = false; + for (size_t i = 1; i < tokens.size(); i++) { + if (tokens[i].find(':') != std::string::npos) { has_any_field = true; break; } + } + if (!has_any_field) { + node.error = "Struct type '" + type_name + "' must have at least one field (name:type)"; + continue; + } + } - // --- Canvas zoom --- - if (canvas_hovered) { - float wheel = ImGui::GetIO().MouseWheel; - if (std::abs(wheel) > 0.01f) { - float zf = std::pow(1.1f, wheel); - ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); - active().canvas_zoom *= zf; - active().canvas_zoom = std::clamp(active().canvas_zoom, 0.2f, 5.0f); - ImVec2 mc2 = screen_to_canvas(mouse_pos, canvas_origin); - active().canvas_offset.x += mc2.x - mc.x; - active().canvas_offset.y += mc2.y - mc.y; - schedule_save(); + for (size_t i = 1; i < tokens.size(); i++) { + auto& tok = tokens[i]; + if (tok == "->" || tok[0] == '(') continue; + auto colon = tok.find(':'); + if (colon != std::string::npos) { + std::string field_type = tok.substr(colon + 1); + std::string err; + if (!registry.validate_type(field_type, err)) { + node.error = "Field '" + tok.substr(0, colon) + "': " + err; + break; + } + } + } } - } - // Helper: hit test node at canvas pos - auto hit_test_node = [&](ImVec2 mc) -> int { - for (int i = (int)active().graph.nodes.size() - 1; i >= 0; i--) { - auto& node = active().graph.nodes[i]; - if (node.imported || node.shadow) continue; - if (mc.x >= node.position.x && mc.x <= node.position.x + node.size.x && - mc.y >= node.position.y && mc.y <= node.position.y + node.size.y) - return node.id; + if (node.type_id == NodeTypeID::DeclVar) { + auto tokens = tokenize_args(node.args, false); + if (tokens.size() < 2) { + node.error = "decl_var requires: name type"; + continue; + } + if (!tokens[0].empty() && tokens[0][0] == '$') { + node.error = "Variable name should not start with $ in declarations"; + continue; + } + std::string err; + if (!registry.validate_type(tokens[1], err)) { + node.error = "Invalid type: " + err; + } } - return -1; - }; - // --- Double-click on node: edit --- - if (canvas_hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { - ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); - int hit_id = hit_test_node(mc); - if (hit_id >= 0) { - for (auto& node : active().graph.nodes) { - if (node.id == hit_id) { - editing_node_ = node.id; - creating_new_node_ = false; - dragging_node_ = -1; - edit_buf_ = node.edit_text(); - edit_just_opened_ = true; - break; - } + if (node.type_id == NodeTypeID::New) { + auto tokens = tokenize_args(node.args, false); + if (tokens.empty()) { + node.error = "new requires a type name"; + continue; + } + if (registry.type_defs.count(tokens[0]) == 0) { + node.error = "Unknown type: " + tokens[0]; } } - } - // --- Single click --- - else if (canvas_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - if (editing_node_ >= 0) { - if (creating_new_node_ && editing_node_ > 0) active().graph.remove_node(editing_node_); - editing_node_ = -1; - creating_new_node_ = false; - active().selected_nodes.clear(); - } else { - auto pin_hit = hit_test_pin(mouse_pos, canvas_origin); - if (!pin_hit.pin_id.empty()) { - // Start new link from any pin - dragging_link_from_pin_ = pin_hit.pin_id; - // All pins can be drag sources — direction determined at drop time - dragging_link_from_output_ = true; // will be refined at drop - dragging_node_ = -1; - dragging_selection_ = false; - } else { - dragging_link_from_pin_.clear(); - ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); - int hit_id = hit_test_node(mc); - if (hit_id >= 0) { - if (active().selected_nodes.count(hit_id)) { - // Clicking an already-selected node: start dragging selection - dragging_selection_ = true; - dragging_node_ = -1; - } else { - // Click unselected node: select only this one - active().selected_nodes.clear(); - active().selected_nodes.insert(hit_id); - dragging_selection_ = true; - dragging_node_ = -1; - } - } else { - // Click empty space: start potential box select - // If released without dragging: deselect (if selected) or create node - box_selecting_ = true; - box_select_start_ = mouse_pos; - dragging_node_ = -1; - dragging_selection_ = false; + if (node.type_id == NodeTypeID::EventBang) { + auto tokens = tokenize_args(node.args, false); + if (tokens.empty()) { + node.error = "event! requires an event name (e.g. ~my_event)"; + continue; + } + if (tokens[0].empty() || tokens[0][0] != '~') { + node.error = "Event name must start with ~ (e.g. ~" + tokens[0] + ")"; + continue; + } + auto* event_decl = find_event_node(graph_, tokens[0]); + if (!event_decl) { + node.error = "Unknown event: " + tokens[0]; + continue; + } + auto ev_tokens = tokenize_args(event_decl->args, false); + bool found_arrow = false; + std::string ret_type; + for (size_t i = 1; i < ev_tokens.size(); i++) { + if (ev_tokens[i] == "->") { + found_arrow = true; + if (i + 1 < ev_tokens.size()) ret_type = ev_tokens[i + 1]; + break; } } + if (found_arrow && ret_type != "void") { + node.error = "Event return type must be void (got: " + ret_type + ")"; + } } } - // --- Box selection --- - if (box_selecting_) { - if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - float dx = mouse_pos.x - box_select_start_.x; - float dy = mouse_pos.y - box_select_start_.y; + run_type_inference(); +} + +void Editor1Pane::run_type_inference() { + GraphInference inference(type_pool_); + inference.run(graph_); +} + +void Editor1Pane::center_on_node(const FlowNode& node, ImVec2 canvas_size) { + canvas_offset_.x = -node.position.x - node.size.x * 0.5f + canvas_size.x * 0.5f / canvas_zoom_; + canvas_offset_.y = -node.position.y - node.size.y * 0.5f + canvas_size.y * 0.5f / canvas_zoom_; + highlight_node_id_ = node.id; + highlight_timer_ = 3.0f; +} + +void Editor1Pane::copy_selection() { + clipboard_nodes_.clear(); + clipboard_links_.clear(); + if (selected_nodes_.empty()) return; + + ImVec2 centroid = {0, 0}; + int count = 0; + for (auto& node : graph_.nodes) { + if (!selected_nodes_.count(node.id)) continue; + centroid.x += node.position.x; + centroid.y += node.position.y; + count++; + } + if (count > 0) { centroid.x /= count; centroid.y /= count; } + + std::map id_to_idx; + for (auto& node : graph_.nodes) { + if (!selected_nodes_.count(node.id)) continue; + int idx = (int)clipboard_nodes_.size(); + id_to_idx[node.id] = idx; + clipboard_nodes_.push_back({node.type_id, node.args, + {node.position.x - centroid.x, node.position.y - centroid.y}}); + } + + std::map> pin_owner; + for (auto& node : graph_.nodes) { + if (!selected_nodes_.count(node.id)) continue; + auto register_pin = [&](const FlowPin& p) { pin_owner[p.id] = {node.id, p.name}; }; + for (auto& p : node.triggers) register_pin(*p); + for (auto& p : node.inputs) register_pin(*p); + for (auto& p : node.outputs) register_pin(*p); + for (auto& p : node.nexts) register_pin(*p); + register_pin(node.lambda_grab); + register_pin(node.bang_pin); + } + for (auto& link : graph_.links) { + auto fi = pin_owner.find(link.from_pin); + auto ti = pin_owner.find(link.to_pin); + if (fi != pin_owner.end() && ti != pin_owner.end()) { + auto from_idx = id_to_idx[fi->second.first]; + auto to_idx = id_to_idx[ti->second.first]; + clipboard_links_.push_back({from_idx, to_idx, fi->second.second, ti->second.second}); + } + } +} + +void Editor1Pane::paste_at(ImVec2 canvas_pos) { + if (clipboard_nodes_.empty()) return; + + selected_nodes_.clear(); + std::vector new_guids; + + for (auto& cn : clipboard_nodes_) { + std::string guid = generate_guid(); + new_guids.push_back(guid); + ImVec2 pos = {canvas_pos.x + cn.offset.x, canvas_pos.y + cn.offset.y}; + int id = graph_.add_node(guid, to_vec2(pos), 0, 0); + + for (auto& node : graph_.nodes) { + if (node.id != id) continue; + node.type_id = cn.type_id; + node.args = cn.args; + node.parse_args(); + + auto* nt = find_node_type(cn.type_id); + if (nt) { + node.triggers.clear(); + node.inputs.clear(); + node.outputs.clear(); + node.nexts.clear(); + + for (int i = 0; i < nt->num_triggers; i++) { + std::string biname = (nt->trigger_ports && i < nt->num_triggers) ? nt->trigger_ports[i].name : ("bang_in" + std::to_string(i)); + node.triggers.push_back(make_pin("", biname, "", nullptr, FlowPin::BangTrigger)); + } + + bool is_expr_paste = is_any_of(cn.type_id, NodeTypeID::Expr, NodeTypeID::ExprBang); + int num_outputs = nt->outputs; + if (is_expr_paste) { + auto parsed = scan_slots(cn.args); + int total_top = parsed.total_pin_count(nt->inputs); + for (int i = 0; i < total_top; i++) { + bool il = parsed.is_lambda_slot(i); + std::string pn = il ? ("@"+std::to_string(i)) : std::to_string(i); + node.inputs.push_back(make_pin("", pn, "", nullptr, il ? FlowPin::Lambda : FlowPin::Input)); + } + if (!cn.args.empty()) { + auto tokens = tokenize_args(cn.args, false); + num_outputs = std::max(1, (int)tokens.size()); + } + } else { + auto info = compute_inline_args(cn.args, nt->inputs); + if (!info.error.empty()) node.error = info.error; + int ref_pins = (info.pin_slots.max_slot >= 0) ? (info.pin_slots.max_slot + 1) : 0; + for (int i = 0; i < ref_pins; i++) { + bool il = info.pin_slots.is_lambda_slot(i); + std::string pn = il ? ("@"+std::to_string(i)) : std::to_string(i); + node.inputs.push_back(make_pin("", pn, "", nullptr, il ? FlowPin::Lambda : FlowPin::Input)); + } + for (int i = info.num_inline_args; i < nt->inputs; i++) { + std::string pn; bool il = false; + if (nt->input_ports && i < nt->inputs) { + pn = nt->input_ports[i].name; + il = (nt->input_ports[i].kind == PortKind::Lambda); + } else pn = std::to_string(i); + node.inputs.push_back(make_pin("", pn, "", nullptr, il ? FlowPin::Lambda : FlowPin::Input)); + } + } + for (int i = 0; i < num_outputs; i++) { + std::string oname = (nt->output_ports && i < nt->outputs) ? nt->output_ports[i].name : ("out" + std::to_string(i)); + node.outputs.push_back(make_pin("", oname, "", nullptr, FlowPin::Output)); + } + for (int i = 0; i < nt->num_nexts; i++) { + std::string bname = (nt->next_ports && i < nt->num_nexts) ? nt->next_ports[i].name : ("bang" + std::to_string(i)); + node.nexts.push_back(make_pin("", bname, "", nullptr, FlowPin::BangNext)); + } + } + node.rebuild_pin_ids(); + selected_nodes_.insert(id); + break; + } + } + + for (auto& cl : clipboard_links_) { + if (cl.from_idx < 0 || cl.from_idx >= (int)new_guids.size()) continue; + if (cl.to_idx < 0 || cl.to_idx >= (int)new_guids.size()) continue; + std::string from_id = new_guids[cl.from_idx] + "." + cl.from_pin_name; + std::string to_id = new_guids[cl.to_idx] + "." + cl.to_pin_name; + graph_.add_link(from_id, to_id); + } + + resolve_type_based_pins(graph_); + mark_dirty(); +} + +// ============================================================ +// Editor1Pane::draw() — the big legacy canvas drawing function +// ============================================================ + +void Editor1Pane::draw() { + // Tick highlight timer + if (highlight_timer_ > 0.0f) { + highlight_timer_ -= ImGui::GetIO().DeltaTime; + if (highlight_timer_ <= 0.0f) { + highlight_timer_ = 0.0f; + highlight_node_id_ = -1; + } + } + + // Validate only when graph structure changes + if (graph_.dirty) { + validate_nodes(); + graph_.dirty = false; + } + + // Check debounced save + check_debounced_save(); + + ImVec2 canvas_origin = ImGui::GetCursorScreenPos(); + ImVec2 canvas_size = ImGui::GetContentRegionAvail(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + // Background + dl->AddRectFilled(canvas_origin, + {canvas_origin.x + canvas_size.x, canvas_origin.y + canvas_size.y}, COL_BG); + + // Safety: remove any empty-named nodes that aren't currently being edited + std::erase_if(graph_.nodes, [&](auto& n) { + if (n.id == editing_node_) return false; + if (n.guid.empty()) return true; + return false; + }); + + // Grid + float grid = GRID_SIZE * canvas_zoom_; + if (grid > 4.0f) { + float ox = std::fmod(canvas_offset_.x * canvas_zoom_, grid); + float oy = std::fmod(canvas_offset_.y * canvas_zoom_, grid); + for (float x = ox; x < canvas_size.x; x += grid) + dl->AddLine({canvas_origin.x + x, canvas_origin.y}, + {canvas_origin.x + x, canvas_origin.y + canvas_size.y}, COL_GRID); + for (float y = oy; y < canvas_size.y; y += grid) + dl->AddLine({canvas_origin.x, canvas_origin.y + y}, + {canvas_origin.x + canvas_size.x, canvas_origin.y + y}, COL_GRID); + } + + ImGui::InvisibleButton("##canvas", canvas_size, + ImGuiButtonFlags_MouseButtonLeft | + ImGuiButtonFlags_MouseButtonMiddle | + ImGuiButtonFlags_MouseButtonRight); + bool canvas_hovered = ImGui::IsItemHovered(); + ImVec2 mouse_pos = ImGui::GetMousePos(); + + // --- Canvas pan --- + if (canvas_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Middle)) { + canvas_dragging_ = true; + canvas_drag_start_ = mouse_pos; + } + if (canvas_dragging_) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Middle)) { + ImVec2 delta = {mouse_pos.x - canvas_drag_start_.x, mouse_pos.y - canvas_drag_start_.y}; + canvas_offset_.x += delta.x / canvas_zoom_; + canvas_offset_.y += delta.y / canvas_zoom_; + canvas_drag_start_ = mouse_pos; + schedule_save(); + } else { canvas_dragging_ = false; } + } + + // --- Canvas zoom --- + if (canvas_hovered) { + float wheel = ImGui::GetIO().MouseWheel; + if (std::abs(wheel) > 0.01f) { + float zf = std::pow(1.1f, wheel); + ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); + canvas_zoom_ *= zf; + canvas_zoom_ = std::clamp(canvas_zoom_, 0.2f, 5.0f); + ImVec2 mc2 = screen_to_canvas(mouse_pos, canvas_origin); + canvas_offset_.x += mc2.x - mc.x; + canvas_offset_.y += mc2.y - mc.y; + schedule_save(); + } + } + + // Helper: hit test node at canvas pos + auto hit_test_node = [&](ImVec2 mc) -> int { + for (int i = (int)graph_.nodes.size() - 1; i >= 0; i--) { + auto& node = graph_.nodes[i]; + if (node.imported || node.shadow) continue; + if (mc.x >= node.position.x && mc.x <= node.position.x + node.size.x && + mc.y >= node.position.y && mc.y <= node.position.y + node.size.y) + return node.id; + } + return -1; + }; + + // --- Double-click on node: edit --- + if (canvas_hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); + int hit_id = hit_test_node(mc); + if (hit_id >= 0) { + for (auto& node : graph_.nodes) { + if (node.id == hit_id) { + editing_node_ = node.id; + creating_new_node_ = false; + dragging_node_ = -1; + edit_buf_ = node.edit_text(); + edit_just_opened_ = true; + break; + } + } + } + } + // --- Single click --- + else if (canvas_hovered && editing_link_ < 0 && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + if (editing_node_ >= 0) { + if (creating_new_node_ && editing_node_ > 0) graph_.remove_node(editing_node_); + editing_node_ = -1; + creating_new_node_ = false; + selected_nodes_.clear(); + } else { + auto pin_hit = hit_test_pin(mouse_pos, canvas_origin); + if (!pin_hit.pin_id.empty()) { + dragging_link_from_pin_ = pin_hit.pin_id; + dragging_link_from_output_ = true; + dragging_node_ = -1; + dragging_selection_ = false; + } else { + dragging_link_from_pin_.clear(); + ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); + int hit_id = hit_test_node(mc); + + if (hit_id >= 0) { + if (selected_nodes_.count(hit_id)) { + dragging_selection_ = true; + dragging_node_ = -1; + } else { + selected_nodes_.clear(); + selected_nodes_.insert(hit_id); + dragging_selection_ = true; + dragging_node_ = -1; + } + } else { + int wire_hit = hit_test_link(mouse_pos, canvas_origin); + if (wire_hit >= 0) { + dragging_node_ = -1; + dragging_selection_ = false; + } else { + box_selecting_ = true; + box_select_start_ = mouse_pos; + dragging_node_ = -1; + dragging_selection_ = false; + } + } + } + } + } + + // --- Box selection --- + if (box_selecting_) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + float dx = mouse_pos.x - box_select_start_.x; + float dy = mouse_pos.y - box_select_start_.y; float dist = dx*dx + dy*dy; - // Only draw box if dragged more than a few pixels if (dist > 25.0f) { ImVec2 a = box_select_start_; ImVec2 b = mouse_pos; @@ -936,26 +1121,23 @@ void FlowEditorWindow::draw() { ImVec2 ca = screen_to_canvas(tl_box, canvas_origin); ImVec2 cb = screen_to_canvas(br_box, canvas_origin); - active().selected_nodes.clear(); - for (auto& node : active().graph.nodes) { + selected_nodes_.clear(); + for (auto& node : graph_.nodes) { if (node.imported || node.shadow) continue; if (node.position.x + node.size.x >= ca.x && node.position.x <= cb.x && node.position.y + node.size.y >= ca.y && node.position.y <= cb.y) - active().selected_nodes.insert(node.id); + selected_nodes_.insert(node.id); } } } else { - // Released: if didn't drag much, deselect or create node float dx = mouse_pos.x - box_select_start_.x; float dy = mouse_pos.y - box_select_start_.y; if (dx*dx + dy*dy <= 25.0f) { - if (!active().selected_nodes.empty()) { - // Had selection: just deselect - active().selected_nodes.clear(); + if (!selected_nodes_.empty()) { + selected_nodes_.clear(); } else { - // No selection: open editor for a new node (node created on commit) creating_new_node_ = true; - editing_node_ = 0; // sentinel: no real node yet + editing_node_ = 0; new_node_pos_ = screen_to_canvas(mouse_pos, canvas_origin); edit_buf_.clear(); edit_just_opened_ = true; @@ -970,12 +1152,8 @@ void FlowEditorWindow::draw() { if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { auto pin_hit = hit_test_pin(mouse_pos, canvas_origin); if (!pin_hit.pin_id.empty() && pin_hit.pin_id != dragging_link_from_pin_) { - // Determine link direction from pin pair. - // Pure sources: Output, BangNext, LambdaGrab - // Pure destinations: Input, Lambda - // Bidirectional: BangTrigger (destination for bang chains, source for () -> void values) - auto from_dir = FlowPin::Input; // direction of the drag-start pin - for (auto& node : active().graph.nodes) { + auto from_dir = FlowPin::Input; + for (auto& node : graph_.nodes) { for (auto& p : node.triggers) if (p->id == dragging_link_from_pin_) from_dir = p->direction; for (auto& p : node.inputs) if (p->id == dragging_link_from_pin_) from_dir = p->direction; for (auto& p : node.outputs) if (p->id == dragging_link_from_pin_) from_dir = p->direction; @@ -993,7 +1171,6 @@ void FlowEditorWindow::draw() { d == FlowPin::Lambda; }; - // Try both orientations — prefer the one that makes sense std::string from_pin, to_pin; bool valid = false; if (is_source(from_dir) && is_dest(pin_hit.dir)) { @@ -1007,17 +1184,15 @@ void FlowEditorWindow::draw() { } if (valid) { - // BangTrigger and Lambda allow multiple incoming connections - // (validation happens in inference, not here) FlowPin::Direction to_dir = FlowPin::Input; - for (auto& node : active().graph.nodes) { + for (auto& node : graph_.nodes) { for (auto& p : node.triggers) if (p->id == to_pin) to_dir = FlowPin::BangTrigger; for (auto& p : node.inputs) if (p->id == to_pin) to_dir = p->direction; } bool allow_multi = (to_dir == FlowPin::BangTrigger || to_dir == FlowPin::Lambda); if (!allow_multi) - std::erase_if(active().graph.links, [&](auto& l) { return l.to_pin == to_pin; }); - active().graph.add_link(from_pin, to_pin); + std::erase_if(graph_.links, [&](auto& l) { return l.to_pin == to_pin; }); + graph_.add_link(from_pin, to_pin); mark_dirty(); } } @@ -1032,7 +1207,7 @@ void FlowEditorWindow::draw() { float dy = mouse_pos.y - grab_start_.y; if (dx*dx + dy*dy > 25.0f) { grab_pending_ = false; - for (auto& l : active().graph.links) { + for (auto& l : graph_.links) { if (grab_is_output_) { if (l.from_pin == grabbed_pin_) grabbed_links_.push_back({l.from_pin, l.to_pin}); @@ -1043,10 +1218,10 @@ void FlowEditorWindow::draw() { } if (!grabbed_links_.empty()) { if (grab_is_output_) - std::erase_if(active().graph.links, [&](auto& l) { return l.from_pin == grabbed_pin_; }); + std::erase_if(graph_.links, [&](auto& l) { return l.from_pin == grabbed_pin_; }); else - std::erase_if(active().graph.links, [&](auto& l) { return l.to_pin == grabbed_pin_; }); - active().graph.dirty = true; + std::erase_if(graph_.links, [&](auto& l) { return l.to_pin == grabbed_pin_; }); + graph_.dirty = true; } else { grabbed_pin_.clear(); } @@ -1061,11 +1236,10 @@ void FlowEditorWindow::draw() { if (!grabbed_links_.empty() && !grab_pending_) { if (ImGui::IsMouseDown(ImGuiMouseButton_Right)) { for (auto& gl : grabbed_links_) { - // Find the anchored end position (the end NOT being dragged) ImVec2 anchor = {}; bool found = false; std::string anchor_id = grab_is_output_ ? gl.to_pin : gl.from_pin; - for (auto& n : active().graph.nodes) { + for (auto& n : graph_.nodes) { for (auto& p : n.outputs) if (p->id == anchor_id) { anchor = get_pin_pos(n, *p, canvas_origin); found = true; } for (auto& p : n.nexts) if (p->id == anchor_id) { anchor = get_pin_pos(n, *p, canvas_origin); found = true; } if (n.lambda_grab.id == anchor_id) { anchor = get_pin_pos(n, n.lambda_grab, canvas_origin); found = true; } @@ -1076,41 +1250,36 @@ void FlowEditorWindow::draw() { if (found) { ImU32 col = COL_LINK_DRAG; if (grab_is_output_) - draw_vbezier(dl, mouse_pos, anchor, col, 2.5f, active().canvas_zoom); + draw_vbezier(dl, mouse_pos, anchor, col, 2.5f, canvas_zoom_); else - draw_vbezier(dl, anchor, mouse_pos, col, 2.5f, active().canvas_zoom); + draw_vbezier(dl, anchor, mouse_pos, col, 2.5f, canvas_zoom_); } } } else { - // Released: try to reconnect auto pin_hit = hit_test_pin(mouse_pos, canvas_origin); bool reconnected = false; if (!pin_hit.pin_id.empty()) { if (grab_is_output_) { - // Was dragging source side: drop on another source pin if (pin_hit.dir == FlowPin::Output || pin_hit.dir == FlowPin::BangNext || pin_hit.dir == FlowPin::LambdaGrab) { for (auto& gl : grabbed_links_) - active().graph.add_link(pin_hit.pin_id, gl.to_pin); + graph_.add_link(pin_hit.pin_id, gl.to_pin); reconnected = true; mark_dirty(); } } else { - // Was dragging dest side: drop on another dest pin if (pin_hit.dir == FlowPin::Input || pin_hit.dir == FlowPin::BangTrigger || pin_hit.dir == FlowPin::Lambda) { - // BangTrigger and Lambda allow multiple — don't erase if (pin_hit.dir != FlowPin::BangTrigger && pin_hit.dir != FlowPin::Lambda) - std::erase_if(active().graph.links, [&](auto& l) { return l.to_pin == pin_hit.pin_id; }); + std::erase_if(graph_.links, [&](auto& l) { return l.to_pin == pin_hit.pin_id; }); for (auto& gl : grabbed_links_) - active().graph.add_link(gl.from_pin, pin_hit.pin_id); + graph_.add_link(gl.from_pin, pin_hit.pin_id); reconnected = true; mark_dirty(); } } } if (!reconnected) { - // Put links back where they were for (auto& gl : grabbed_links_) - active().graph.add_link(gl.from_pin, gl.to_pin); + graph_.add_link(gl.from_pin, gl.to_pin); } grabbed_links_.clear(); grabbed_pin_.clear(); @@ -1120,10 +1289,10 @@ void FlowEditorWindow::draw() { // Selection dragging (move all selected nodes) if (dragging_selection_ && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { ImVec2 delta = ImGui::GetIO().MouseDelta; - for (auto& node : active().graph.nodes) { - if (active().selected_nodes.count(node.id)) { - node.position.x += delta.x / active().canvas_zoom; - node.position.y += delta.y / active().canvas_zoom; + for (auto& node : graph_.nodes) { + if (selected_nodes_.count(node.id)) { + node.position.x += delta.x / canvas_zoom_; + node.position.y += delta.y / canvas_zoom_; } } } @@ -1144,19 +1313,18 @@ void FlowEditorWindow::draw() { paste_at(mc); } if (ctrl && ImGui::IsKeyPressed(ImGuiKey_D)) { - // Duplicate: copy + paste at mouse, without affecting clipboard - auto saved_nodes = active().clipboard_nodes; - auto saved_links = active().clipboard_links; + auto saved_nodes = clipboard_nodes_; + auto saved_links = clipboard_links_; copy_selection(); ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); paste_at(mc); - active().clipboard_nodes = saved_nodes; - active().clipboard_links = saved_links; + clipboard_nodes_ = saved_nodes; + clipboard_links_ = saved_links; } - if (ImGui::IsKeyPressed(ImGuiKey_Delete) && !active().selected_nodes.empty()) { - for (int id : active().selected_nodes) - active().graph.remove_node(id); - active().selected_nodes.clear(); + if (ImGui::IsKeyPressed(ImGuiKey_Delete) && !selected_nodes_.empty()) { + for (int id : selected_nodes_) + graph_.remove_node(id); + selected_nodes_.clear(); mark_dirty(); } if (ctrl && ImGui::IsKeyPressed(ImGuiKey_Z)) { @@ -1164,11 +1332,11 @@ void FlowEditorWindow::draw() { redo(); else undo(); - active().selected_nodes.clear(); + selected_nodes_.clear(); } if (ctrl && ImGui::IsKeyPressed(ImGuiKey_Y)) { redo(); - active().selected_nodes.clear(); + selected_nodes_.clear(); } } @@ -1176,7 +1344,6 @@ void FlowEditorWindow::draw() { static ImVec2 right_click_start = {}; if (canvas_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { right_click_start = mouse_pos; - // Check if right-clicking a pin with connections -> potential grab auto pin_hit = hit_test_pin(mouse_pos, canvas_origin); if (!pin_hit.pin_id.empty()) { grabbed_links_.clear(); @@ -1188,35 +1355,31 @@ void FlowEditorWindow::draw() { } } - // --- Right click release: disconnect pin, delete link, or delete node (only if not dragged) --- + // --- Right click release: disconnect pin, delete link, or delete node --- if (canvas_hovered && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) { float rdx = mouse_pos.x - right_click_start.x; float rdy = mouse_pos.y - right_click_start.y; bool was_drag = (rdx*rdx + rdy*rdy > 25.0f); if (!was_drag) { - // First check if right-clicking a connected pin to disconnect auto pin_hit = hit_test_pin(mouse_pos, canvas_origin); if (!pin_hit.pin_id.empty()) { - // Remove all links to/from this pin - std::erase_if(active().graph.links, [&](auto& l) { + std::erase_if(graph_.links, [&](auto& l) { return l.from_pin == pin_hit.pin_id || l.to_pin == pin_hit.pin_id; }); - active().graph.dirty = true; + graph_.dirty = true; } - // Then check links else { int lid = hit_test_link(mouse_pos, canvas_origin); if (lid >= 0) { - active().graph.remove_link(lid); + graph_.remove_link(lid); } else { - // Check if right-clicking a node to delete it ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); - for (int i = (int)active().graph.nodes.size() - 1; i >= 0; i--) { - auto& node = active().graph.nodes[i]; + for (int i = (int)graph_.nodes.size() - 1; i >= 0; i--) { + auto& node = graph_.nodes[i]; if (mc.x >= node.position.x && mc.x <= node.position.x + node.size.x && mc.y >= node.position.y && mc.y <= node.position.y + node.size.y) { - active().graph.remove_node(node.id); + graph_.remove_node(node.id); if (editing_node_ == node.id) { editing_node_ = -1; creating_new_node_ = false; @@ -1233,9 +1396,9 @@ void FlowEditorWindow::draw() { // --- Build shadow filter sets for drawing --- std::set shadow_guids; shadow_connected_pins_.clear(); - for (auto& node : active().graph.nodes) + for (auto& node : graph_.nodes) if (node.shadow) shadow_guids.insert(node.guid); - for (auto& link : active().graph.links) { + for (auto& link : graph_.links) { auto d1 = link.from_pin.find('.'); if (d1 != std::string::npos && shadow_guids.count(link.from_pin.substr(0, d1))) shadow_connected_pins_.insert(link.to_pin); @@ -1245,7 +1408,7 @@ void FlowEditorWindow::draw() { } // --- Draw links (skip links involving shadow nodes) --- - for (auto& link : active().graph.links) { + for (auto& link : graph_.links) { auto d1 = link.from_pin.find('.'); auto d2 = link.to_pin.find('.'); if (d1 != std::string::npos && shadow_guids.count(link.from_pin.substr(0, d1))) continue; @@ -1255,8 +1418,7 @@ void FlowEditorWindow::draw() { // --- Draw link being dragged --- if (!dragging_link_from_pin_.empty() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - for (auto& node : active().graph.nodes) { - // Find the dragged pin position (any pin type) + for (auto& node : graph_.nodes) { ImVec2 from = {}; bool from_grab = false; bool from_bang_pin = false; @@ -1285,20 +1447,19 @@ void FlowEditorWindow::draw() { } if (found) { auto target = hit_test_pin(mouse_pos, canvas_origin); - // Any different pin is a potential target — validation happens at drop bool valid_target = !target.pin_id.empty() && target.pin_id != dragging_link_from_pin_; ImU32 col = valid_target ? COL_PIN_HOVER : COL_LINK_DRAG; if (from_grab) { - float dx = std::max(std::abs(mouse_pos.x - from.x) * 0.5f, 30.0f * active().canvas_zoom); - float dy = std::max(std::abs(mouse_pos.y - from.y) * 0.5f, 30.0f * active().canvas_zoom); + float dx = std::max(std::abs(mouse_pos.x - from.x) * 0.5f, 30.0f * canvas_zoom_); + float dy = std::max(std::abs(mouse_pos.y - from.y) * 0.5f, 30.0f * canvas_zoom_); dl->AddBezierCubic(from, {from.x - dx, from.y}, {mouse_pos.x, mouse_pos.y - dy}, - mouse_pos, col, 2.5f * active().canvas_zoom); + mouse_pos, col, 2.5f * canvas_zoom_); } else if (from_bang_pin) { - float dx = std::max(std::abs(mouse_pos.x - from.x) * 0.5f, 30.0f * active().canvas_zoom); + float dx = std::max(std::abs(mouse_pos.x - from.x) * 0.5f, 30.0f * canvas_zoom_); dl->AddBezierCubic(from, {from.x + dx, from.y}, {mouse_pos.x - dx, mouse_pos.y}, - mouse_pos, col, 2.5f * active().canvas_zoom); + mouse_pos, col, 2.5f * canvas_zoom_); } else { - draw_vbezier(dl, from, mouse_pos, col, 2.5f, active().canvas_zoom); + draw_vbezier(dl, from, mouse_pos, col, 2.5f, canvas_zoom_); } goto done_drag; } @@ -1308,47 +1469,44 @@ void FlowEditorWindow::draw() { // --- Draw nodes --- auto hovered_pin = hit_test_pin(mouse_pos, canvas_origin); - for (auto& node : active().graph.nodes) { + for (auto& node : graph_.nodes) { if (node.imported || node.shadow) continue; draw_node(dl, node, canvas_origin); } // Pin hover highlight if (!hovered_pin.pin_id.empty()) { - for (auto& node : active().graph.nodes) { + for (auto& node : graph_.nodes) { PinShape io_shape = PinShape::Signal; - float pr = PIN_RADIUS * active().canvas_zoom; + float pr = PIN_RADIUS * canvas_zoom_; auto check = [&](auto& pins, PinShape shape) { for (auto& pin : pins) if (pin->id == hovered_pin.pin_id) { ImVec2 pp = get_pin_pos(node, *pin, canvas_origin); - draw_pin_highlight(dl, pp, pr, COL_PIN_HOVER, shape, active().canvas_zoom); + draw_pin_highlight(dl, pp, pr, COL_PIN_HOVER, shape, canvas_zoom_); } }; check(node.triggers, PinShape::Square); - // Inputs: check each pin's direction for shape for (auto& pin : node.inputs) if (pin->id == hovered_pin.pin_id) { ImVec2 pp = get_pin_pos(node, *pin, canvas_origin); PinShape shape = (pin->direction == FlowPin::Lambda) ? PinShape::LambdaDown : io_shape; - draw_pin_highlight(dl, pp, pr, COL_PIN_HOVER, shape, active().canvas_zoom); + draw_pin_highlight(dl, pp, pr, COL_PIN_HOVER, shape, canvas_zoom_); } check(node.nexts, PinShape::Square); check(node.outputs, io_shape); if (node.lambda_grab.id == hovered_pin.pin_id) { ImVec2 pp = get_pin_pos(node, node.lambda_grab, canvas_origin); - draw_pin_highlight(dl, pp, pr, COL_PIN_HOVER, PinShape::LambdaLeft, active().canvas_zoom); + draw_pin_highlight(dl, pp, pr, COL_PIN_HOVER, PinShape::LambdaLeft, canvas_zoom_); } } } // --- Tooltips --- - if (canvas_hovered && editing_node_ < 0) { + if (canvas_hovered && editing_node_ < 0 && editing_link_ < 0) { if (!hovered_pin.pin_id.empty()) { - // Pin tooltip - for (auto& node : active().graph.nodes) { + for (auto& node : graph_.nodes) { if (node.id != hovered_pin.node_id) continue; - // Find the pin object auto find_pin = [&](auto& pins) -> const FlowPin* { for (auto& p : pins) if (p->id == hovered_pin.pin_id) return p.get(); return nullptr; @@ -1369,7 +1527,7 @@ void FlowEditorWindow::draw() { else type_str = "?"; ImGui::BeginTooltip(); - ImGui::SetWindowFontScale(active().canvas_zoom); + ImGui::SetWindowFontScale(canvas_zoom_); ImGui::TextUnformatted((port_name + " : " + type_str).c_str()); if (!port_desc.empty()) ImGui::TextDisabled("%s", port_desc.c_str()); @@ -1378,14 +1536,12 @@ void FlowEditorWindow::draw() { break; } } else { - // Check link hover int lid = hit_test_link(mouse_pos, canvas_origin); if (lid >= 0) { - // Find the link - for (auto& link : active().graph.links) { + for (auto& link : graph_.links) { if (link.id != lid) continue; std::string from_label, to_label; - for (auto& n : active().graph.nodes) { + for (auto& n : graph_.nodes) { for (auto& p : n.outputs) if (p->id == link.from_pin) from_label = pin_label(n, *p); for (auto& p : n.nexts) if (p->id == link.from_pin) from_label = pin_label(n, *p); for (auto& p : n.triggers) if (p->id == link.from_pin) from_label = pin_label(n, *p); @@ -1395,9 +1551,8 @@ void FlowEditorWindow::draw() { for (auto& p : n.triggers) if (p->id == link.to_pin) to_label = pin_label(n, *p); } if (!from_label.empty() && !to_label.empty()) { - // Get types for the link endpoints - auto* fp = active().graph.find_pin(link.from_pin); - auto* tp = active().graph.find_pin(link.to_pin); + auto* fp = graph_.find_pin(link.from_pin); + auto* tp = graph_.find_pin(link.to_pin); std::string from_type_str = (fp && fp->resolved_type) ? type_to_string(fp->resolved_type) : "?"; std::string to_type_str = (tp && tp->resolved_type) ? type_to_string(tp->resolved_type) : "?"; bool type_err = !link.error.empty(); @@ -1406,27 +1561,36 @@ void FlowEditorWindow::draw() { type_err = !types_compatible(fp->resolved_type, tp->resolved_type); ImGui::BeginTooltip(); - ImGui::SetWindowFontScale(active().canvas_zoom); + ImGui::SetWindowFontScale(canvas_zoom_); + if (!link.net_name.empty()) { + ImGui::TextColored({0.7f, 0.9f, 1.0f, 1.0f}, "%s", link.net_name.c_str()); + } ImGui::TextUnformatted((from_label + " -> " + to_label).c_str()); ImGui::TextDisabled("%s -> %s", from_type_str.c_str(), to_type_str.c_str()); if (!link.error.empty()) ImGui::TextColored({1.0f, 0.2f, 0.2f, 1.0f}, "%s", link.error.c_str()); else if (type_err) ImGui::TextColored({1.0f, 0.2f, 0.2f, 1.0f}, "Type mismatch!"); + ImGui::TextDisabled("Click to rename wire"); ImGui::EndTooltip(); + + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + editing_link_ = link.id; + link_edit_buf_ = link.net_name.empty() ? "$" : link.net_name; + link_edit_just_opened_ = true; + } } break; } } else { - // Check node hover ImVec2 mc = screen_to_canvas(mouse_pos, canvas_origin); - for (int i = (int)active().graph.nodes.size() - 1; i >= 0; i--) { - auto& node = active().graph.nodes[i]; + for (int i = (int)graph_.nodes.size() - 1; i >= 0; i--) { + auto& node = graph_.nodes[i]; if (mc.x >= node.position.x && mc.x <= node.position.x + node.size.x && mc.y >= node.position.y && mc.y <= node.position.y + node.size.y) { auto* nt = find_node_type(node.type_id); ImGui::BeginTooltip(); - ImGui::SetWindowFontScale(active().canvas_zoom); + ImGui::SetWindowFontScale(canvas_zoom_); ImGui::TextUnformatted(node_display_name(node).c_str()); if (nt && nt->desc) ImGui::TextDisabled("%s", nt->desc); @@ -1448,9 +1612,8 @@ void FlowEditorWindow::draw() { // --- Name editing: inline inside the node --- if (editing_node_ >= 0) { - // Find the node, or use new_node_pos_ for pending new nodes FlowNode* edit_node = nullptr; - for (auto& node : active().graph.nodes) { + for (auto& node : graph_.nodes) { if (node.id == editing_node_) { edit_node = &node; break; } } ImVec2 edit_pos = edit_node ? to_imvec(edit_node->position) : new_node_pos_; @@ -1462,18 +1625,18 @@ void FlowEditorWindow::draw() { edit_pos.y + edit_size.y}, canvas_origin); float nw = br.x - tl.x; - float text_w = ImGui::CalcTextSize(edit_buf_.c_str()).x * active().canvas_zoom + 40.0f * active().canvas_zoom; - float scaled_min_w = std::max({nw, 160.0f * active().canvas_zoom, text_w}); + float text_w = ImGui::CalcTextSize(edit_buf_.c_str()).x * canvas_zoom_ + 40.0f * canvas_zoom_; + float scaled_min_w = std::max({nw, 160.0f * canvas_zoom_, text_w}); ImGui::SetNextWindowPos(tl); ImGui::SetNextWindowSize({scaled_min_w, 0}); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {2 * active().canvas_zoom, 2 * active().canvas_zoom}); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {4 * active().canvas_zoom, 2 * active().canvas_zoom}); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {4 * active().canvas_zoom, 2 * active().canvas_zoom}); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {2 * canvas_zoom_, 2 * canvas_zoom_}); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {4 * canvas_zoom_, 2 * canvas_zoom_}); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {4 * canvas_zoom_, 2 * canvas_zoom_}); ImGui::Begin("##name_edit", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar); - ImGui::SetWindowFontScale(active().canvas_zoom); + ImGui::SetWindowFontScale(canvas_zoom_); if (edit_just_opened_) { ImGui::SetKeyboardFocusHere(); @@ -1484,7 +1647,6 @@ void FlowEditorWindow::draw() { strncpy(buf, edit_buf_.c_str(), sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; - // Callback to move cursor to end after autocomplete bool* cursor_to_end_ptr = &edit_cursor_to_end_; auto edit_callback = [](ImGuiInputTextCallbackData* data) -> int { bool* flag = (bool*)data->UserData; @@ -1503,7 +1665,6 @@ void FlowEditorWindow::draw() { edit_callback, cursor_to_end_ptr); edit_buf_ = buf; - // Split into first word (type name) and rest (args) for matching std::string first_word = edit_buf_; std::string rest_args; auto space_pos = edit_buf_.find(' '); @@ -1512,17 +1673,14 @@ void FlowEditorWindow::draw() { rest_args = edit_buf_.substr(space_pos + 1); } - // Autocompletion: match against first word, show all when empty - // Only show when no space yet (still typing the type name) if (space_pos == std::string::npos) { for (int i = 0; i < NUM_NODE_TYPES; i++) { std::string nt_name(NODE_TYPES[i].name); if (first_word.empty() || (nt_name.find(first_word) != std::string::npos && nt_name != first_word)) { if (ImGui::Selectable(NODE_TYPES[i].name)) { - // Insert type + space, keep editor open edit_buf_ = nt_name + " "; - edit_just_opened_ = true; // re-focus the text input next frame - edit_cursor_to_end_ = true; // place cursor at end, not select-all + edit_just_opened_ = true; + edit_cursor_to_end_ = true; } } } @@ -1541,10 +1699,9 @@ void FlowEditorWindow::draw() { std::string node_type = first_word; if (node_type.empty()) break; - // If this is a pending new node (no backing node yet), create it now if (creating_new_node_ && !edit_node) { - int id = active().graph.add_node("", to_vec2(new_node_pos_), 0, 0); - for (auto& n : active().graph.nodes) { + int id = graph_.add_node("", to_vec2(new_node_pos_), 0, 0); + for (auto& n : graph_.nodes) { if (n.id == id) { edit_node = &n; break; } } editing_node_ = id; @@ -1553,7 +1710,6 @@ void FlowEditorWindow::draw() { auto* nt = find_node_type(node_type.c_str()); if (!nt) { - // Unknown type: treat entire input as an expr node nt = find_node_type("expr"); node_type = "expr"; rest_args = edit_buf_; @@ -1563,34 +1719,28 @@ void FlowEditorWindow::draw() { int default_outputs = nt ? nt->outputs : 0; int default_nexts = nt ? nt->num_nexts : 0; - // Auto-assign guid if not set auto& node = *edit_node; if (node.guid.empty()) node.guid = generate_guid(); node.type_id = node_type_id_from_string(node_type.c_str()); node.args = rest_args; node.parse_args(); - active().graph.dirty = true; + graph_.dirty = true; creating_new_node_ = false; - // Resize a pin vector: reuse existing pins (preserving IDs/links), - // add new ones at end, remove excess from end (clearing their links). auto resize_pins = [&](PinVec& pins, int needed, const std::vector& names, FlowPin::Direction dir, bool is_output) { - // Reuse existing: just rename for (int i = 0; i < std::min((int)pins.size(), needed); i++) pins[i]->name = names[i]; - // Add new for (int i = (int)pins.size(); i < needed; i++) pins.push_back(make_pin("", names[i], "", nullptr, dir)); - // Remove excess (from back) while ((int)pins.size() > needed) { auto pid = pins.back()->id; if (is_output) - std::erase_if(active().graph.links, [&pid](auto& l) { return l.from_pin == pid; }); + std::erase_if(graph_.links, [&pid](auto& l) { return l.from_pin == pid; }); else - std::erase_if(active().graph.links, [&pid](auto& l) { return l.to_pin == pid; }); + std::erase_if(graph_.links, [&pid](auto& l) { return l.to_pin == pid; }); pins.pop_back(); } }; @@ -1604,14 +1754,13 @@ void FlowEditorWindow::draw() { int needed_outputs = default_outputs; bool is_expr_type = is_any_of(node.type_id, NodeTypeID::Expr, NodeTypeID::ExprBang); - // Build desired input pin list (data + lambda unified, in slot order) struct DesiredPin { std::string name; FlowPin::Direction dir; }; std::vector desired_inputs; if (node.type_id == NodeTypeID::New) { auto tokens = tokenize_args(rest_args, false); std::string inst_type_name = tokens.empty() ? "" : tokens[0]; - auto* type_node = find_type_node(active().graph, inst_type_name); + auto* type_node = find_type_node(graph_, inst_type_name); if (type_node) { auto fields = parse_type_fields(*type_node); for (auto& field : fields) @@ -1619,31 +1768,27 @@ void FlowEditorWindow::draw() { } needed_outputs = 1; } else if (node.type_id == NodeTypeID::EventBang) { - // Outputs come from event declaration args auto tokens = tokenize_args(rest_args, false); std::string event_name = tokens.empty() ? "" : tokens[0]; - auto* event_decl = find_event_node(active().graph, event_name); + auto* event_decl = find_event_node(graph_, event_name); if (event_decl) { - auto args = parse_event_args(*event_decl, active().graph); - // Override outputs + auto args = parse_event_args(*event_decl, graph_); std::vector out_names; for (auto& a : args) out_names.push_back(a.name); needed_outputs = (int)out_names.size(); - // Resize outputs directly here for (int i = 0; i < std::min((int)node.outputs.size(), needed_outputs); i++) node.outputs[i]->name = out_names[i]; for (int i = (int)node.outputs.size(); i < needed_outputs; i++) node.outputs.push_back(make_pin("", out_names[i], "", nullptr, FlowPin::Output)); while ((int)node.outputs.size() > needed_outputs) { auto pid = node.outputs.back()->id; - std::erase_if(active().graph.links, [&pid](auto& l) { return l.from_pin == pid; }); + std::erase_if(graph_.links, [&pid](auto& l) { return l.from_pin == pid; }); node.outputs.pop_back(); } - needed_outputs = -1; // skip generic output resize below + needed_outputs = -1; } } else { if (is_expr_type) { - // Expr nodes: pin count from $N refs, output count from tokens auto parsed = scan_slots(rest_args); int total_top = parsed.total_pin_count(default_inputs); for (int i = 0; i < total_top; i++) { @@ -1656,7 +1801,6 @@ void FlowEditorWindow::draw() { needed_outputs = std::max(1, (int)tokens.size()); } } else if (node_type == "cast" || node_type == "new") { - // Args are type names — use descriptor defaults directly for (int i = 0; i < default_inputs; i++) { std::string pin_name; bool is_lambda = false; @@ -1669,17 +1813,14 @@ void FlowEditorWindow::draw() { desired_inputs.push_back({pin_name, is_lambda ? FlowPin::Lambda : FlowPin::Input}); } } else { - // Non-expr nodes: use inline arg computation auto info = compute_inline_args(rest_args, default_inputs); if (!info.error.empty()) node.error = info.error; - // First: $N/@N ref pins int ref_pins = (info.pin_slots.max_slot >= 0) ? (info.pin_slots.max_slot + 1) : 0; for (int i = 0; i < ref_pins; i++) { bool is_lambda = info.pin_slots.is_lambda_slot(i); std::string pin_name = is_lambda ? ("@" + std::to_string(i)) : std::to_string(i); desired_inputs.push_back({pin_name, is_lambda ? FlowPin::Lambda : FlowPin::Input}); } - // Then: remaining descriptor inputs for (int i = info.num_inline_args; i < default_inputs; i++) { std::string pin_name; bool is_lambda = false; @@ -1697,23 +1838,19 @@ void FlowEditorWindow::draw() { // Resize inputs (unified data + lambda), preserving connections { int needed = (int)desired_inputs.size(); - // Reuse existing: update name and direction for (int i = 0; i < std::min((int)node.inputs.size(), needed); i++) { node.inputs[i]->name = desired_inputs[i].name; node.inputs[i]->direction = desired_inputs[i].dir; } - // Add new for (int i = (int)node.inputs.size(); i < needed; i++) node.inputs.push_back(make_pin("", desired_inputs[i].name, "", nullptr, desired_inputs[i].dir)); - // Remove excess while ((int)node.inputs.size() > needed) { auto pid = node.inputs.back()->id; - std::erase_if(active().graph.links, [&pid](auto& l) { return l.to_pin == pid; }); + std::erase_if(graph_.links, [&pid](auto& l) { return l.to_pin == pid; }); node.inputs.pop_back(); } } - // Resize bang inputs resize_pins(node.triggers, default_triggers, make_names("bang_in", default_triggers), FlowPin::BangTrigger, false); if (needed_outputs >= 0) @@ -1722,14 +1859,11 @@ void FlowEditorWindow::draw() { resize_pins(node.nexts, default_nexts, make_names("bang", default_nexts), FlowPin::BangNext, true); - // Rebuild pin IDs from guid and update links - // Collect old->new ID mapping for pins whose name changed auto update_pin_ids = [&](PinVec& pins) { for (auto& p : pins) { std::string new_id = node.pin_id(p->name); if (p->id != new_id) { - // Update any links referencing old ID - for (auto& l : active().graph.links) { + for (auto& l : graph_.links) { if (l.from_pin == p->id) l.from_pin = new_id; if (l.to_pin == p->id) l.to_pin = new_id; } @@ -1743,7 +1877,7 @@ void FlowEditorWindow::draw() { update_pin_ids(node.nexts); { std::string new_id = node.pin_id("as_lambda"); - for (auto& l : active().graph.links) { + for (auto& l : graph_.links) { if (l.from_pin == node.lambda_grab.id) l.from_pin = new_id; if (l.to_pin == node.lambda_grab.id) l.to_pin = new_id; } @@ -1751,15 +1885,14 @@ void FlowEditorWindow::draw() { } { std::string new_id = node.pin_id("post_bang"); - for (auto& l : active().graph.links) { + for (auto& l : graph_.links) { if (l.from_pin == node.bang_pin.id) l.from_pin = new_id; if (l.to_pin == node.bang_pin.id) l.to_pin = new_id; } node.bang_pin.id = new_id; } - // Generate shadow nodes for inline args and rebuild display text - update_shadows_for_node(active().graph, node, rest_args); + update_shadows_for_node(graph_, node, rest_args); editing_node_ = -1; mark_dirty(); @@ -1767,7 +1900,7 @@ void FlowEditorWindow::draw() { if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { if (creating_new_node_ && edit_node) { - active().graph.remove_node(editing_node_); + graph_.remove_node(editing_node_); } creating_new_node_ = false; editing_node_ = -1; @@ -1778,769 +1911,100 @@ void FlowEditorWindow::draw() { } // end of edit window block } - ImGui::EndChild(); // flow_canvas - - // --- Horizontal splitter (between canvas and bottom panel) --- - ImGui::InvisibleButton("##hsplitter", {canvas_w, 4.0f}); - if (ImGui::IsItemActive()) { - bottom_panel_height_ -= ImGui::GetIO().MouseDelta.y; - } - if (ImGui::IsItemHovered() || ImGui::IsItemActive()) - ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); - - // --- Bottom panel: tabbed (Errors / Build Log) --- - ImGui::BeginChild("##bottom_panel", {canvas_w, bottom_panel_height_}, true, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - if (ImGui::BeginTabBar("##bottom_tabs")) { - // Count errors for tab label - int error_count = 0; - for (auto& node : active().graph.nodes) if (!node.error.empty()) error_count++; - for (auto& link : active().graph.links) if (!link.error.empty()) error_count++; - - char errors_label[64]; - snprintf(errors_label, sizeof(errors_label), "Errors%s", error_count > 0 ? " (!)" : ""); - - if (ImGui::BeginTabItem(errors_label)) { - ImGui::BeginChild("##errors_scroll", {0, 0}, false); - for (auto& node : active().graph.nodes) { - if (node.error.empty()) continue; - ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 100, 100, 255)); - std::string label = std::string(node_type_str(node.type_id)) + " [" + node.guid.substr(0, 8) + "]: " + node.error; - if (ImGui::Selectable(label.c_str())) { - center_on_node(node, {canvas_w, canvas_h}); - } - ImGui::PopStyleColor(); - } - for (auto& link : active().graph.links) { - if (link.error.empty()) continue; - ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 160, 80, 255)); - std::string label = "link [" + link.from_pin.substr(0, 8) + "->...]: " + link.error; - if (ImGui::Selectable(label.c_str())) { - auto dot = link.from_pin.find('.'); - if (dot != std::string::npos) { - std::string guid = link.from_pin.substr(0, dot); - for (auto& n : active().graph.nodes) { - if (n.guid == guid) { center_on_node(n, {canvas_w, canvas_h}); break; } - } - } - } - ImGui::PopStyleColor(); - } - ImGui::Dummy({0, bottom_panel_height_ * 0.5f}); - ImGui::EndChild(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Build Log", nullptr, show_build_log_ ? ImGuiTabItemFlags_SetSelected : 0)) { - show_build_log_ = false; - ImGui::BeginChild("##buildlog_scroll", {0, 0}, false); - { - std::lock_guard lock(build_log_mutex_); - ImGui::TextWrapped("%s", build_log_.c_str()); - } - // Bottom padding so the last line isn't stuck at the edge - ImGui::Dummy({0, bottom_panel_height_ * 0.5f}); - if (build_state_ == BuildState::Building) { - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 40.0f) - ImGui::SetScrollHereY(1.0f); - } - ImGui::EndChild(); - ImGui::EndTabItem(); - } - - ImGui::EndTabBar(); - } - ImGui::EndChild(); - - ImGui::EndGroup(); - - ImGui::SameLine(); - - // --- Vertical splitter (between canvas column and side panel) --- - ImGui::InvisibleButton("##vsplitter", {4.0f, total_h}); - if (ImGui::IsItemActive()) { - side_panel_width_ -= ImGui::GetIO().MouseDelta.x; - } - if (ImGui::IsItemHovered() || ImGui::IsItemActive()) - ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); - - ImGui::SameLine(); - - // --- Side panel: declarations (right) --- - ImGui::BeginChild("##side_panel", {side_panel_width_, total_h}, true); - ImGui::TextUnformatted("Declarations"); - ImGui::Separator(); - - // Local declarations (non-imported) - for (auto& node : active().graph.nodes) { - auto* nt_decl = find_node_type(node.type_id); - if (!nt_decl || !nt_decl->is_declaration) continue; - if (node.imported || node.shadow) continue; - bool has_err = !node.error.empty(); - if (has_err) ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 100, 100, 255)); - if (ImGui::Selectable(node.display_text().c_str())) { - center_on_node(node, {canvas_w, canvas_h}); - } - if (has_err) ImGui::PopStyleColor(); - if (has_err && ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextUnformatted(node.error.c_str()); - ImGui::EndTooltip(); - } - } - - // Imported declarations grouped by import source - // Collect unique import paths - for (auto& imp_node : active().graph.nodes) { - if (imp_node.type_id != NodeTypeID::DeclImport) continue; - auto tokens = tokenize_args(imp_node.args, false); - if (tokens.empty()) continue; - std::string label = tokens[0]; - // Strip quotes from string literal - if (label.size() >= 2 && label.front() == '"' && label.back() == '"') - label = label.substr(1, label.size() - 2); - if (ImGui::TreeNode(label.c_str())) { - for (auto& node : active().graph.nodes) { - if (!node.imported) continue; - auto* nt_decl = find_node_type(node.type_id); - if (!nt_decl || !nt_decl->is_declaration) continue; - ImGui::TextDisabled("%s", node.display_text().c_str()); - } - ImGui::TreePop(); - } - } - ImGui::EndChild(); - - ImGui::End(); // main - check_debounced_save(); - win_.end_frame(30, 30, 40); -} - -void FlowEditorWindow::validate_nodes() { - // Resolve type-based pins (new, event!) from current declarations - resolve_type_based_pins(active().graph); - - // Build type registry from decl_type nodes - TypeRegistry registry; - for (auto& node : active().graph.nodes) { - if (node.type_id == NodeTypeID::DeclType) { - auto tokens = tokenize_args(node.args, false); - if (tokens.size() >= 2) { - // First token is the type name, rest is the definition - std::string type_name = tokens[0]; - // Reconstruct the definition: for struct types, build field list - // For now, register the raw args minus the name - std::string def; - for (size_t i = 1; i < tokens.size(); i++) { - if (!def.empty()) def += " "; - def += tokens[i]; - } - int decl_class = classify_decl_type(tokens); - if (decl_class == 0 || decl_class == 1) { // alias or function type - registry.register_type(type_name, def); - } else { - registry.register_type(type_name, "void"); // placeholder, fields validated below - } - } - } - } - - // Resolve all types and check for cycles - registry.resolve_all(); - - for (auto& node : active().graph.nodes) { - node.error.clear(); - - auto* nt = find_node_type(node.type_id); - if (!nt) { - node.error = "Unknown node type: " + std::string(node_type_str(node.type_id)); - continue; - } - - // Check for duplicate guids - for (auto& other : active().graph.nodes) { - if (&other != &node && other.guid == node.guid) { - node.error = "Duplicate guid: " + node.guid; - break; - } - } - if (!node.error.empty()) continue; - - // Validate decl_type nodes - if (node.type_id == NodeTypeID::DeclType) { - auto tokens = tokenize_args(node.args, false); - if (tokens.empty()) { - node.error = "decl_type requires a type name"; - continue; - } - std::string type_name = tokens[0]; - if (!type_name.empty() && type_name[0] == '$') { - node.error = "Type name should not start with $"; - continue; + // --- Wire name editing popup --- + if (editing_link_ >= 0) { + FlowLink* edit_link = nullptr; + for (auto& link : graph_.links) { + if (link.id == editing_link_) { edit_link = &link; break; } + } + if (!edit_link) { + editing_link_ = -1; + } else { + ImVec2 fp = {}, tp = {}; + for (auto& n : graph_.nodes) { + for (auto& p : n.outputs) if (p->id == edit_link->from_pin) fp = get_pin_pos(n, *p, canvas_origin); + for (auto& p : n.nexts) if (p->id == edit_link->from_pin) fp = get_pin_pos(n, *p, canvas_origin); + if (n.lambda_grab.id == edit_link->from_pin) fp = get_pin_pos(n, n.lambda_grab, canvas_origin); + if (n.bang_pin.id == edit_link->from_pin) fp = get_pin_pos(n, n.bang_pin, canvas_origin); + for (auto& p : n.inputs) if (p->id == edit_link->to_pin) tp = get_pin_pos(n, *p, canvas_origin); + for (auto& p : n.triggers) if (p->id == edit_link->to_pin) tp = get_pin_pos(n, *p, canvas_origin); } + ImVec2 mid = {(fp.x + tp.x) * 0.5f, (fp.y + tp.y) * 0.5f}; + + float text_w = ImGui::CalcTextSize(link_edit_buf_.c_str()).x * canvas_zoom_ + 40.0f * canvas_zoom_; + float popup_w = std::max(200.0f * canvas_zoom_, text_w); + ImGui::SetNextWindowPos({mid.x - popup_w * 0.5f, mid.y - 15.0f * canvas_zoom_}); + ImGui::SetNextWindowSize({popup_w, 0}); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {4 * canvas_zoom_, 4 * canvas_zoom_}); + ImGui::Begin("##wire_rename", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar); + ImGui::SetWindowFontScale(canvas_zoom_); - // Check registry errors for this type - auto err_it = registry.errors.find(type_name); - if (err_it != registry.errors.end()) { - node.error = err_it->second; - continue; + bool was_just_opened = link_edit_just_opened_; + if (link_edit_just_opened_) { + ImGui::SetKeyboardFocusHere(); + link_edit_just_opened_ = false; } - // Check struct types have fields - int decl_class_v = classify_decl_type(tokens); - if (decl_class_v == 2) { // struct - // Must be a struct — check it has at least one field - bool has_any_field = false; - for (size_t i = 1; i < tokens.size(); i++) { - if (tokens[i].find(':') != std::string::npos) { has_any_field = true; break; } - } - if (!has_any_field) { - node.error = "Struct type '" + type_name + "' must have at least one field (name:type)"; - continue; - } - } + char buf[128]; + strncpy(buf, link_edit_buf_.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; - // Validate each field type - for (size_t i = 1; i < tokens.size(); i++) { - auto& tok = tokens[i]; - // Skip function syntax tokens - if (tok == "->" || tok[0] == '(') continue; - auto colon = tok.find(':'); - if (colon != std::string::npos) { - std::string field_type = tok.substr(colon + 1); - std::string err; - if (!registry.validate_type(field_type, err)) { - node.error = "Field '" + tok.substr(0, colon) + "': " + err; + bool committed = ImGui::InputText("##wire_name", buf, sizeof(buf), + ImGuiInputTextFlags_EnterReturnsTrue); + link_edit_buf_ = buf; + + bool valid = true; + std::string error_msg; + std::string new_name = link_edit_buf_; + if (new_name.empty() || new_name[0] != '$') { + valid = false; + error_msg = "Must start with $"; + } else if (new_name.size() < 2) { + valid = false; + error_msg = "Name too short"; + } else { + for (auto& other : graph_.links) { + if (other.id == edit_link->id) continue; + if (other.net_name == new_name && other.from_pin != edit_link->from_pin) { + valid = false; + error_msg = "Name already in use"; break; } } } - } - - // Validate decl_var nodes: decl_var - if (node.type_id == NodeTypeID::DeclVar) { - auto tokens = tokenize_args(node.args, false); - if (tokens.size() < 2) { - node.error = "decl_var requires: name type"; - continue; - } - // Check name doesn't start with $ - if (!tokens[0].empty() && tokens[0][0] == '$') { - node.error = "Variable name should not start with $ in declarations"; - continue; - } - // Validate type (second arg) - std::string err; - if (!registry.validate_type(tokens[1], err)) { - node.error = "Invalid type: " + err; - } - } - - - // Validate 'new' nodes — type must exist - if (node.type_id == NodeTypeID::New) { - auto tokens = tokenize_args(node.args, false); - if (tokens.empty()) { - node.error = "new requires a type name"; - continue; - } - if (registry.type_defs.count(tokens[0]) == 0) { - node.error = "Unknown type: " + tokens[0]; - } - } - // Validate event! nodes — must reference a valid decl_event with ~ prefix, return must be void - if (node.type_id == NodeTypeID::EventBang) { - auto tokens = tokenize_args(node.args, false); - if (tokens.empty()) { - node.error = "event! requires an event name (e.g. ~my_event)"; - continue; - } - if (tokens[0].empty() || tokens[0][0] != '~') { - node.error = "Event name must start with ~ (e.g. ~" + tokens[0] + ")"; - continue; - } - auto* event_decl = find_event_node(active().graph, tokens[0]); - if (!event_decl) { - node.error = "Unknown event: " + tokens[0]; - continue; - } - // Check return type is void - auto ev_tokens = tokenize_args(event_decl->args, false); - bool found_arrow = false; - std::string ret_type; - for (size_t i = 1; i < ev_tokens.size(); i++) { - if (ev_tokens[i] == "->") { - found_arrow = true; - if (i + 1 < ev_tokens.size()) ret_type = ev_tokens[i + 1]; - break; - } + if (!valid && !error_msg.empty()) { + ImGui::TextColored({1.0f, 0.3f, 0.3f, 1.0f}, "%s", error_msg.c_str()); } - if (found_arrow && ret_type != "void") { - node.error = "Event return type must be void (got: " + ret_type + ")"; - } - } - } - - // Run type inference (always, since validate_nodes clears errors each frame) - run_type_inference(); -} - -void FlowEditorWindow::run_type_inference() { - GraphInference inference(active().type_pool); - inference.run(active().graph); -} - -void FlowEditorWindow::center_on_node(const FlowNode& node, ImVec2 canvas_size) { - active().canvas_offset.x = -node.position.x - node.size.x * 0.5f + canvas_size.x * 0.5f / active().canvas_zoom; - active().canvas_offset.y = -node.position.y - node.size.y * 0.5f + canvas_size.y * 0.5f / active().canvas_zoom; - active().highlight_node_id = node.id; - active().highlight_timer = 3.0f; -} -void FlowEditorWindow::copy_selection() { - active().clipboard_nodes.clear(); - active().clipboard_links.clear(); - if (active().selected_nodes.empty()) return; - - // Compute centroid - ImVec2 centroid = {0, 0}; - int count = 0; - for (auto& node : active().graph.nodes) { - if (!active().selected_nodes.count(node.id)) continue; - centroid.x += node.position.x; - centroid.y += node.position.y; - count++; - } - if (count > 0) { centroid.x /= count; centroid.y /= count; } - - // Build index map: node id -> clipboard index - std::map id_to_idx; - for (auto& node : active().graph.nodes) { - if (!active().selected_nodes.count(node.id)) continue; - int idx = (int)active().clipboard_nodes.size(); - id_to_idx[node.id] = idx; - active().clipboard_nodes.push_back({node.type_id, node.args, - {node.position.x - centroid.x, node.position.y - centroid.y}}); - } - - // Copy internal links (both endpoints in selection) - // Build pin_id -> (node_id, pin_name) map - std::map> pin_owner; - for (auto& node : active().graph.nodes) { - if (!active().selected_nodes.count(node.id)) continue; - auto register_pin = [&](const FlowPin& p) { pin_owner[p.id] = {node.id, p.name}; }; - for (auto& p : node.triggers) register_pin(*p); - for (auto& p : node.inputs) register_pin(*p); - for (auto& p : node.outputs) register_pin(*p); - for (auto& p : node.nexts) register_pin(*p); - register_pin(node.lambda_grab); - register_pin(node.bang_pin); - } - for (auto& link : active().graph.links) { - auto fi = pin_owner.find(link.from_pin); - auto ti = pin_owner.find(link.to_pin); - if (fi != pin_owner.end() && ti != pin_owner.end()) { - auto from_idx = id_to_idx[fi->second.first]; - auto to_idx = id_to_idx[ti->second.first]; - active().clipboard_links.push_back({from_idx, to_idx, fi->second.second, ti->second.second}); - } - } -} - -void FlowEditorWindow::paste_at(ImVec2 canvas_pos) { - if (active().clipboard_nodes.empty()) return; - - active().selected_nodes.clear(); - std::vector new_guids; - - // Create nodes - for (auto& cn : active().clipboard_nodes) { - std::string guid = generate_guid(); - new_guids.push_back(guid); - ImVec2 pos = {canvas_pos.x + cn.offset.x, canvas_pos.y + cn.offset.y}; - int id = active().graph.add_node(guid, to_vec2(pos), 0, 0); - - // Set type and args, rebuild pins - for (auto& node : active().graph.nodes) { - if (node.id != id) continue; - node.type_id = cn.type_id; - node.args = cn.args; - node.parse_args(); - - // Rebuild pins from type descriptor - auto* nt = find_node_type(cn.type_id); - if (nt) { - node.triggers.clear(); - node.inputs.clear(); - node.outputs.clear(); - node.nexts.clear(); - - for (int i = 0; i < nt->num_triggers; i++) { - std::string biname = (nt->trigger_ports && i < nt->num_triggers) ? nt->trigger_ports[i].name : ("bang_in" + std::to_string(i)); - node.triggers.push_back(make_pin("", biname, "", nullptr, FlowPin::BangTrigger)); - } - - bool is_expr_paste = is_any_of(cn.type_id, NodeTypeID::Expr, NodeTypeID::ExprBang); - int num_outputs = nt->outputs; - if (is_expr_paste) { - auto parsed = scan_slots(cn.args); - int total_top = parsed.total_pin_count(nt->inputs); - for (int i = 0; i < total_top; i++) { - bool il = parsed.is_lambda_slot(i); - std::string pn = il ? ("@"+std::to_string(i)) : std::to_string(i); - node.inputs.push_back(make_pin("", pn, "", nullptr, il ? FlowPin::Lambda : FlowPin::Input)); - } - if (!cn.args.empty()) { - auto tokens = tokenize_args(cn.args, false); - num_outputs = std::max(1, (int)tokens.size()); - } - } else { - auto info = compute_inline_args(cn.args, nt->inputs); - if (!info.error.empty()) node.error = info.error; - int ref_pins = (info.pin_slots.max_slot >= 0) ? (info.pin_slots.max_slot + 1) : 0; - for (int i = 0; i < ref_pins; i++) { - bool il = info.pin_slots.is_lambda_slot(i); - std::string pn = il ? ("@"+std::to_string(i)) : std::to_string(i); - node.inputs.push_back(make_pin("", pn, "", nullptr, il ? FlowPin::Lambda : FlowPin::Input)); - } - for (int i = info.num_inline_args; i < nt->inputs; i++) { - std::string pn; bool il = false; - if (nt->input_ports && i < nt->inputs) { - pn = nt->input_ports[i].name; - il = (nt->input_ports[i].kind == PortKind::Lambda); - } else pn = std::to_string(i); - node.inputs.push_back(make_pin("", pn, "", nullptr, il ? FlowPin::Lambda : FlowPin::Input)); + if (committed && valid) { + std::string old_from = edit_link->from_pin; + for (auto& link : graph_.links) { + if (link.from_pin == old_from) { + link.net_name = new_name; + link.auto_wire = false; } } - for (int i = 0; i < num_outputs; i++) { - std::string oname = (nt->output_ports && i < nt->outputs) ? nt->output_ports[i].name : ("out" + std::to_string(i)); - node.outputs.push_back(make_pin("", oname, "", nullptr, FlowPin::Output)); - } - for (int i = 0; i < nt->num_nexts; i++) { - std::string bname = (nt->next_ports && i < nt->num_nexts) ? nt->next_ports[i].name : ("bang" + std::to_string(i)); - node.nexts.push_back(make_pin("", bname, "", nullptr, FlowPin::BangNext)); - } - } - node.rebuild_pin_ids(); - active().selected_nodes.insert(id); - break; - } - } - - // Recreate internal links - for (auto& cl : active().clipboard_links) { - if (cl.from_idx < 0 || cl.from_idx >= (int)new_guids.size()) continue; - if (cl.to_idx < 0 || cl.to_idx >= (int)new_guids.size()) continue; - std::string from_id = new_guids[cl.from_idx] + "." + cl.from_pin_name; - std::string to_id = new_guids[cl.to_idx] + "." + cl.to_pin_name; - active().graph.add_link(from_id, to_id); - } - - // Resolve type-based pins for pasted nodes - resolve_type_based_pins(active().graph); - mark_dirty(); -} - -// --- Run/Stop --- - -void FlowEditorWindow::draw_toolbar() { - auto state = build_state_.load(); - - bool can_run = (state == BuildState::Idle || state == BuildState::BuildFailed); - bool can_stop = (state == BuildState::Running); - - if (!can_run) ImGui::BeginDisabled(); - if (ImGui::Button("Run")) { - run_program(false); - } - ImGui::SameLine(); - if (ImGui::Button("Run Release")) { - run_program(true); - } - if (!can_run) ImGui::EndDisabled(); - - ImGui::SameLine(); - - if (!can_stop) ImGui::BeginDisabled(); - if (ImGui::Button("Stop")) { - stop_program(); - } - if (!can_stop) ImGui::EndDisabled(); - - ImGui::SameLine(); - - // Search by node guid - ImGui::SameLine(); - ImGui::SetNextItemWidth(120); - if (ImGui::InputTextWithHint("##search", "Find node...", search_buf_, sizeof(search_buf_), - ImGuiInputTextFlags_EnterReturnsTrue)) { - std::string query(search_buf_); - if (!query.empty()) { - for (auto& node : active().graph.nodes) { - if (node.imported || node.shadow) continue; - if (node.guid.find(query) != std::string::npos || - node.display_text().find(query) != std::string::npos) { - center_on_node(node, {last_canvas_w_, last_canvas_h_}); - active().selected_nodes.clear(); - active().selected_nodes.insert(node.id); - break; - } + editing_link_ = -1; + rebuild_all_inline_display(graph_); + mark_dirty(); } - } - } - - ImGui::SameLine(); - - // Status indicator - switch (state) { - case BuildState::Idle: - ImGui::TextDisabled("Idle"); - break; - case BuildState::Building: - ImGui::TextColored({1.0f, 0.8f, 0.0f, 1.0f}, "Building..."); - break; - case BuildState::Running: - ImGui::TextColored({0.0f, 1.0f, 0.0f, 1.0f}, "Running"); - break; - case BuildState::BuildFailed: - ImGui::TextColored({1.0f, 0.2f, 0.2f, 1.0f}, "Build Failed"); - break; - } -} - -void FlowEditorWindow::run_program(bool release) { - // Stop existing - stop_program(); - - // Wait for any previous build thread - if (build_thread_.joinable()) - build_thread_.join(); - - // Auto-open build log and clear it - show_build_log_ = true; - { - std::lock_guard lock(build_log_mutex_); - build_log_.clear(); - } - - // Auto-save - auto_save(); - - if (active().file_path.empty()) return; - namespace fs = std::filesystem; - - // Determine paths — nanoc expects a project folder containing main.atto - fs::path atto_path = fs::absolute(active().file_path); - fs::path project_dir = atto_path.parent_path(); - std::string source_name = project_dir.filename().string(); - fs::path output_dir = project_dir / ".generated" / source_name; - - // Find nanoc relative to this exe - fs::path exe_path; -#ifdef _WIN32 - char exe_buf[MAX_PATH]; - GetModuleFileNameA(nullptr, exe_buf, MAX_PATH); - exe_path = fs::path(exe_buf).parent_path(); -#elif defined(__APPLE__) - { - uint32_t size = 0; - _NSGetExecutablePath(nullptr, &size); - std::string buf(size, '\0'); - _NSGetExecutablePath(buf.data(), &size); - exe_path = fs::canonical(buf).parent_path(); - } -#else - exe_path = fs::canonical("/proc/self/exe").parent_path(); -#endif - fs::path attoc_path = exe_path / "attoc.exe"; - if (!fs::exists(attoc_path)) - attoc_path = exe_path / "attoc"; - - // vcpkg toolchain (Windows only — Linux/macOS use FetchContent via NanoDeps.cmake) - std::string tc_str; -#ifdef _WIN32 - { - const char* vr = std::getenv("VCPKG_ROOT"); - if (!vr) { - std::lock_guard lock(build_log_mutex_); - build_log_ += "Error: VCPKG_ROOT environment variable is not set\n"; - build_state_ = BuildState::BuildFailed; - return; - } - tc_str = (fs::path(vr) / "scripts" / "buildsystems" / "vcpkg.cmake").string(); - } -#endif - - // Capture paths as strings for the thread - std::string attoc_str = attoc_path.string(); - std::string atto_str = project_dir.string(); - std::string out_str = output_dir.string(); - std::string sn = source_name; - - build_state_ = BuildState::Building; - { - std::lock_guard lock(build_log_mutex_); - build_log_.clear(); - } - - build_thread_ = std::thread([this, attoc_str, atto_str, out_str, tc_str, sn, release]() { - namespace fs = std::filesystem; - fs::create_directories(out_str); - - auto run_cmd = [this](const std::string& cmd) -> int { -#ifdef _WIN32 - // cmd.exe needs the entire command wrapped in quotes when args contain quotes - std::string full_cmd = "\"" + cmd + " 2>&1\""; - FILE* pipe = _popen(full_cmd.c_str(), "r"); -#else - std::string full_cmd = cmd + " 2>&1"; - FILE* pipe = popen(full_cmd.c_str(), "r"); -#endif - if (!pipe) return -1; - char buf[256]; - while (fgets(buf, sizeof(buf), pipe)) { - std::lock_guard lock(build_log_mutex_); - build_log_ += buf; + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { + editing_link_ = -1; } -#ifdef _WIN32 - return _pclose(pipe); -#else - return pclose(pipe); -#endif - }; - - // Step 1: nanoc - { - std::lock_guard lock(build_log_mutex_); - build_log_ += "=== Running attoc ===\n"; - } - std::string cmd1 = "\"" + attoc_str + "\" \"" + atto_str + "\" -o \"" + out_str + "\""; - if (run_cmd(cmd1) != 0) { - build_state_ = BuildState::BuildFailed; - return; - } - // Step 2: cmake configure (skip if already configured) - std::string build_dir = out_str + "/build"; - std::string cache_file = build_dir + "/CMakeCache.txt"; - { - std::ifstream cache_check(cache_file); - if (!cache_check.good()) { - { - std::lock_guard lock(build_log_mutex_); - build_log_ += "\n=== CMake Configure ===\n"; - } - std::string cmd2 = "cmake -B \"" + build_dir + "\" -S \"" + out_str + "\""; - if (!tc_str.empty()) - cmd2 += " \"-DCMAKE_TOOLCHAIN_FILE=" + tc_str + "\""; - if (run_cmd(cmd2) != 0) { - build_state_ = BuildState::BuildFailed; - return; - } - } else { - std::lock_guard lock(build_log_mutex_); - build_log_ += "\n=== CMake Configure (cached) ===\n"; + if (!was_just_opened && + !ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + editing_link_ = -1; } - } - // Step 3: cmake build - { - std::lock_guard lock(build_log_mutex_); - build_log_ += "\n=== CMake Build ===\n"; - } - std::string config = release ? "Release" : "Debug"; - std::string cmd3 = "cmake --build \"" + build_dir + "\" --config " + config + " --parallel"; - if (run_cmd(cmd3) != 0) { - build_state_ = BuildState::BuildFailed; - return; - } - - // Step 4: launch exe -#ifdef _WIN32 - fs::path exe_path = fs::path(build_dir) / config / (sn + ".exe"); - if (!fs::exists(exe_path)) - exe_path = fs::path(build_dir) / (sn + ".exe"); -#else - fs::path exe_path = fs::path(build_dir) / sn; -#endif - if (!fs::exists(exe_path)) { - std::lock_guard lock(build_log_mutex_); - build_log_ += "\nError: executable not found at " + exe_path.string() + "\n"; - build_state_ = BuildState::BuildFailed; - return; - } - -#ifdef _WIN32 - STARTUPINFOA si = {}; - si.cb = sizeof(si); - PROCESS_INFORMATION pi = {}; - std::string exe_str = exe_path.string(); - if (CreateProcessA(exe_str.c_str(), nullptr, nullptr, nullptr, FALSE, - 0, nullptr, nullptr, &si, &pi)) { - CloseHandle(pi.hThread); - child_process_ = pi.hProcess; - build_state_ = BuildState::Running; - } else { - std::lock_guard lock(build_log_mutex_); - build_log_ += "\nError: failed to launch " + exe_str + "\n"; - build_state_ = BuildState::BuildFailed; - } -#else - pid_t pid = fork(); - if (pid == 0) { - execl(exe_path.c_str(), exe_path.c_str(), nullptr); - _exit(1); - } else if (pid > 0) { - child_pid_ = pid; - build_state_ = BuildState::Running; - } else { - std::lock_guard lock(build_log_mutex_); - build_log_ += "\nError: fork failed\n"; - build_state_ = BuildState::BuildFailed; - } -#endif - }); -} - -void FlowEditorWindow::stop_program() { -#ifdef _WIN32 - if (child_process_) { - TerminateProcess(child_process_, 0); - WaitForSingleObject(child_process_, 1000); - CloseHandle(child_process_); - child_process_ = nullptr; - } -#else - if (child_pid_ > 0) { - kill(child_pid_, SIGTERM); - waitpid(child_pid_, nullptr, 0); - child_pid_ = 0; - } -#endif - build_state_ = BuildState::Idle; -} - -void FlowEditorWindow::poll_child_process() { - if (build_state_.load() != BuildState::Running) return; - -#ifdef _WIN32 - if (child_process_) { - DWORD exit_code; - if (GetExitCodeProcess(child_process_, &exit_code) && exit_code != STILL_ACTIVE) { - CloseHandle(child_process_); - child_process_ = nullptr; - build_state_ = BuildState::Idle; - } - } -#else - if (child_pid_ > 0) { - int status; - pid_t result = waitpid(child_pid_, &status, WNOHANG); - if (result == child_pid_) { - child_pid_ = 0; - build_state_ = BuildState::Idle; + ImGui::End(); + ImGui::PopStyleVar(1); } } -#endif } diff --git a/src/legacy/editor1.h b/src/legacy/editor1.h new file mode 100644 index 0000000..11656e9 --- /dev/null +++ b/src/legacy/editor1.h @@ -0,0 +1,135 @@ +#pragma once +#include "editor_pane.h" +#include "atto/model.h" +#include "atto/types.h" +#include "imgui.h" +#include +#include +#include +#include + +// Conversion between Vec2 (model) and ImVec2 (UI) +inline ImVec2 to_imvec(Vec2 v) { return {v.x, v.y}; } +inline Vec2 to_vec2(ImVec2 v) { return {v.x, v.y}; } + +class Editor1Pane : public IEditorPane { +public: + // IEditorPane + bool load(const std::string& path) override; + void draw() override; + bool is_loaded() const override { return !graph_.nodes.empty() || !file_path_.empty(); } + bool is_dirty() const override { return dirty_; } + const std::string& file_path() const override { return file_path_; } + const std::string& tab_name() const override { return tab_name_; } + + // Legacy graph access (for FlowEditorWindow toolbar/build) + FlowGraph& graph() { return graph_; } + + // Edit operations + void mark_dirty(); + void push_undo(); + void undo(); + void redo(); + void copy_selection(); + void paste_at(ImVec2 canvas_pos); + + // Debounced save + void schedule_save(); + void check_debounced_save(); + void auto_save(); + + // Validation & type inference + void validate_nodes(); + void run_type_inference(); + + // Navigation + void center_on_node(const FlowNode& node, ImVec2 canvas_size); + + // Viewport sync (call before save) + void sync_viewport(); + +private: + // Model + FlowGraph graph_; + std::string file_path_; + std::string tab_name_; + bool dirty_ = false; + + // Canvas + ImVec2 canvas_offset_ = {0, 0}; + float canvas_zoom_ = 1.0f; + + // Selection + std::set selected_nodes_; + + // Undo/Redo + std::vector undo_stack_; + std::vector redo_stack_; + + // Type inference + TypePool type_pool_; + bool inference_dirty_ = true; + + // Clipboard + struct ClipboardNode { + NodeTypeID type_id; std::string args; + ImVec2 offset; + }; + struct ClipboardLink { + int from_idx, to_idx; + std::string from_pin_name, to_pin_name; + }; + std::vector clipboard_nodes_; + std::vector clipboard_links_; + + // Highlight animation + int highlight_node_id_ = -1; + float highlight_timer_ = 0.0f; + + // Interaction state + int dragging_node_ = -1; + bool dragging_selection_ = false; + std::string dragging_link_from_pin_; + bool dragging_link_from_output_ = true; + ImVec2 dragging_link_start_; + bool canvas_dragging_ = false; + ImVec2 canvas_drag_start_; + + struct GrabbedLink { std::string from_pin; std::string to_pin; }; + std::vector grabbed_links_; + std::string grabbed_pin_; + bool grab_is_output_ = false; + bool grab_pending_ = false; + ImVec2 grab_start_; + + bool box_selecting_ = false; + ImVec2 box_select_start_; + + int editing_node_ = -1; + std::string edit_buf_; + bool edit_just_opened_ = false; + bool edit_cursor_to_end_ = false; + bool creating_new_node_ = false; + ImVec2 new_node_pos_; + + int editing_link_ = -1; + std::string link_edit_buf_; + bool link_edit_just_opened_ = false; + + std::set shadow_connected_pins_; + + // Debounced save + double save_deadline_ = 0; + + // Drawing helpers + ImVec2 canvas_to_screen(ImVec2 p, ImVec2 canvas_origin) const; + ImVec2 screen_to_canvas(ImVec2 p, ImVec2 canvas_origin) const; + ImVec2 get_pin_pos(const FlowNode& node, const FlowPin& pin, ImVec2 canvas_origin) const; + void draw_node(ImDrawList* dl, FlowNode& node, ImVec2 canvas_origin); + void draw_link(ImDrawList* dl, const FlowLink& link, ImVec2 canvas_origin); + + // Hit testing + struct PinHit { int node_id; std::string pin_id; FlowPin::Direction dir; }; + PinHit hit_test_pin(ImVec2 screen_pos, ImVec2 canvas_origin, float radius = 8.0f) const; + int hit_test_link(ImVec2 screen_pos, ImVec2 canvas_origin, float threshold = 6.0f) const; +};