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/README.md b/README.md index f985432..a6885b7 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Each instrument is a self-contained `.atto` program that defines its Functionali nanolang +Listen to the [introductory podcast](docs/podcasts/introducing-orgasm.md) — *Growing Instruments with the Organic Assembler* ([SoundCloud](https://soundcloud.com/poiitidis/growing-instruments-with-the), [YouTube](https://youtu.be/ymzuD-oekFM)). See an [example instrument](scenes/klavier/main.atto) and the full [language specification](docs/attolang.md). @@ -53,6 +54,12 @@ cmake -B build -DCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake cmake --build build --parallel --config Release ``` +## Instructions + +New to the project? Start with the [Instructions](docs/instructions.md) — a guide to interpreting and following instructions, operating on the codebase, and building instruments with the Organic Assembler. It covers everything from the build system and architectural layers to the anatomy of an instrument and the audio callback pattern. + +For naming philosophy, see [Names](docs/names.md). For the full documentation suite: [Architecture](docs/architecture.md), [Language Spec](docs/attolang.md), [Patterns](docs/patterns.md), [Thinking](docs/thinking.md), [Style](docs/style.md), [Coding](docs/coding.md), [Changelog](docs/changelog.md). + ## License MIT — see [LICENSE](LICENSE). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e02c0d9 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,400 @@ +# Organic Assembler — Architecture + +## Overview + +Organic Assembler (orgasm) is an **Operating System for Instruments**. An "instrument" is a +multimodal dataflow program authored as a node graph in a `.atto` file using the attolang +language. The system comprises four major subsystems: the core language library, the compiler, +the visual editor, and the runtime. Each is independently buildable and has well-defined +boundaries. + +``` +┌─────────────────────────────────────────────────────────┐ +│ User / Developer │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ +│ │ attoflow │ │ attoc │ │ attohost │ │ +│ │ (editor) │───>│ (compiler) │───>│ (runtime) │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬─────┘ │ +│ │ │ │ │ +│ └──────────────────┼──────────────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ attolang │ │ +│ │ (core library) │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## Build Targets + +The project uses CMake 3.25+ with C++20. There are four build targets: + +| Target | Type | Description | Dependencies | +|-------------------|------------|------------------------------------------|----------------------| +| `attolang` | Static lib | Core language: types, parsing, inference | C++20 stdlib only | +| `attoc` | Executable | Standalone compiler (.atto → C++) | attolang | +| `test_inference` | Executable | Unit tests for type inference | attolang | +| `attoflow` | Executable | Visual node editor (optional) | attolang, SDL3, ImGui| + +The editor (`attoflow`) is optional and gated behind `ATTOLANG_BUILD_EDITOR`. The core +library and compiler have zero external dependencies beyond the C++20 standard library. + +## Directory Layout + +``` +orgasm/ +├── CMakeLists.txt # Top-level build configuration +├── cmake/AttoDeps.cmake # SDL3 + ImGui fetch (vcpkg on Windows, FetchContent otherwise) +├── vcpkg.json # Windows dependency manifest +│ +├── src/ +│ ├── atto/ # Core language library (attolang) +│ │ ├── model.h # FlowGraph, FlowNode, FlowLink, FlowPin +│ │ ├── types.h / types.cpp # Type system: TypeExpr, TypeParser, TypePool +│ │ ├── expr.h / expr.cpp # Expression AST: tokenizer, parser, ExprNode +│ │ ├── inference.h / .cpp # Bidirectional type inference engine +│ │ ├── graph_builder.h / .cpp # Structured graph editing with dirty tracking +│ │ ├── graph_index.h / .cpp # Fast pin/node lookup by ID +│ │ ├── serial.h / serial.cpp # .atto file serialization (TOML-like) +│ │ ├── shadow.h / shadow.cpp # Shadow expression nodes (deref, internal) +│ │ ├── symbol_table.h / .cpp # Compile-time symbol resolution +│ │ ├── type_utils.h / .cpp # Type compatibility, upcasting rules +│ │ ├── args.h / args.cpp # Argument tokenization utilities +│ │ ├── node_types.h / .cpp # Node type descriptors (v1) +│ │ ├── node_types2.h # Extended node type descriptors (v2) +│ │ └── graph_editor_interfaces.h # Observer interfaces for editor integration +│ │ +│ ├── attoc/ # Standalone compiler +│ │ ├── main.cpp # Entry point: load → infer → codegen +│ │ ├── codegen.h / .cpp # C++ code generation (types, header, impl, cmake) +│ │ +│ ├── attoflow/ # Visual editor +│ │ ├── main.cpp # SDL3 window init, 60 FPS event loop +│ │ ├── window.h / .cpp # Tab management, project browser, build toolbar +│ │ ├── editor2.h / .cpp # Editor2Pane: wraps GraphBuilder, wire connection +│ │ ├── visual_editor.h / .cpp # Canvas: pan/zoom, grid, hover detection +│ │ ├── node_renderer.h / .cpp # Node visualization: pins, args, errors +│ │ ├── nets_editor.h / .cpp # Named nets panel +│ │ ├── editor_style.h / .cpp # Colors, fonts, spacing constants +│ │ ├── tooltip_renderer.h/.cpp# Hover tooltip rendering +│ │ ├── sdl_imgui_window.h # SDL3 + ImGui integration layer +│ │ └── fonts/ # Liberation Mono (embedded via CMake hex read) +│ │ +│ ├── attoruntime/ # Instrument runtime +│ │ ├── attoruntime.h # Runtime API declarations +│ │ ├── atto_gui.cpp # GUI bindings (imgui calls) +│ │ ├── nano_imgui.cpp # High-level ImGui wrappers +│ │ └── main.cpp # Stub entry point +│ │ +│ └── legacy/ # Deprecated editor1 code +│ +├── attostd/ # Standard library (.atto modules) +│ ├── gui.atto # AV/audio FFI: av_create_window, audio_tick +│ └── imgui.atto # ImGui FFI: buttons, sliders, text, windows +│ +├── scenes/ # Example instruments +│ ├── klavier/main.atto # Piano/synthesizer (29KB, 100+ nodes) +│ └── multifader/main.atto # Fader mixing instrument (25KB) +│ +├── tests/ +│ └── test_inference.cpp # Type inference unit tests +│ +└── docs/ + └── attolang.md # Full language specification (44KB) +``` + +## Subsystem Deep Dives + +### 1. attolang — Core Language Library + +The core library is a static C++ library with **zero external dependencies**. It defines +the data model, type system, expression parser, inference engine, serialization, and +graph builder. Everything in `src/atto/` belongs here. + +#### Data Model (`model.h`) + +The fundamental representation is a **FlowGraph**: a directed graph of nodes connected +by typed links. + +``` +FlowGraph +├── nodes: vector +│ ├── guid: string (stable identifier) +│ ├── type: NodeTypeID (one of 38 built-in types) +│ ├── args: string (inline arguments, legacy) +│ ├── position: vec2 (editor layout) +│ ├── inputs: vector (pin IDs for incoming connections) +│ └── outputs: vector (pin IDs for outgoing connections) +│ +└── links: vector + ├── from: string (output pin ID) + ├── to: string (input pin ID) + └── net_name: string (optional named net) +``` + +Nodes are passive data containers. All semantic meaning comes from the node's `type` +field, which indexes into the node type registry. + +#### Type System (`types.h`, `type_utils.h`) + +The type system is the backbone of attolang. Every value has a **TypeExpr** with two +orthogonal dimensions: + +1. **TypeKind** — what the value is (Void, Bool, Scalar, Container, Function, Symbol, etc.) +2. **TypeCategory** — how the value is accessed (Data, Reference, Iterator, Lambda, etc.) + +Categories are denoted by sigils in the wire format: + +| Sigil | Category | Meaning | +|-------|------------|-----------------------------------| +| `%` | Data | Plain value (default) | +| `&` | Reference | Reference to a mutable location | +| `^` | Iterator | Iterator into a container | +| `@` | Lambda | Callable function reference | +| `#` | Enum | Enumeration value | +| `!` | Bang | Trigger signal (carries no data) | +| `~` | Event | Event source | + +Type expressions are parsed by `TypeParser` (recursive descent) and interned in +`TypePool` for deduplication. The pool caches every parsed type string → `TypePtr` +mapping. + +Key type features: +- **Literal types**: `literal` — compile-time constants with type domain T and value V +- **Symbol types**: `symbol` — first-class named references with decay +- **Meta types**: `type` — types as compile-time values +- **Generic types**: `unsigned`, `float` — resolved via backpropagation +- **Automatic upcasting**: u8 → u16 → u32 → u64, s8 → s32 → s64, integer → float + +#### Expression Parser (`expr.h`, `expr.cpp`) + +Expressions are inline code within nodes (e.g., `$0 + $1 * sin($2)`). The parser is a +recursive-descent parser producing an `ExprNode` AST. + +Expression kinds include: +- `Literal` — all constants (integers, floats, booleans, strings) +- `PinRef` — `$N` references to input pins +- `BinaryOp` — arithmetic, comparison, logical operators +- `UnaryOp` — negation, logical not +- `FunctionCall` — `name(args...)` including type constructors +- `FieldAccess` — `expr.field` +- `IndexOp` — `expr[index]` +- `TypeApply` — `$0<$1,$2>` speculative generic application + +The tokenizer handles TOML string escaping, nested parentheses, and the distinction +between struct types (`{x:f32 y:f32}`) and struct literals (`{x:1.0f, y:2.0f}`) via +comma detection. + +#### Inference Engine (`inference.h`, `inference.cpp`) + +Type inference is **bidirectional** and **multi-pass**: + +``` +Phase 1: clear_all() — Reset all pin types +Phase 2: resolve_pin_type_names() — Parse type annotations via TypePool +Phase 3: propagate_connections() — Forward: flow types through wires +Phase 4: infer_expr_nodes() — Recursive inference on expression ASTs +Phase 5: propagate_pin_ref_types()— Backward: backprop from consumers +Phase 6: resolve_lambdas() — Validate lambda capture and parameters +Phase 7: fixup_expr_derefs() — Insert Deref nodes for iterators +Phase 8: insert_deref_nodes() — Materialize shadow deref nodes +``` + +The engine runs to a fixed point for phases 3-5, iterating until no types change. +This handles mutual dependencies between nodes (A depends on B depends on A). + +#### Graph Builder (`graph_builder.h`, `graph_builder.cpp`) + +The GraphBuilder is the **high-level editing API** layered over the raw FlowGraph model. +It provides: + +- **Structured entries**: `FlowNodeBuilder` and `NetBuilder` with `BuilderEntry` base +- **Pin model**: `FlowArg2` hierarchy (ArgNet2, ArgNumber2, ArgString2, ArgExpr2) +- **Dirty tracking**: mutations bubble up from arg → node → graph +- **Observer pattern**: `IGraphEditor` / `INodeEditor` / `INetEditor` interfaces +- **Mutation batching**: `edit_start()` / `edit_commit()` for transactional edits +- **Sentinels**: `$empty` (node) and `$unconnected` (net) — always valid, never null + +The builder also handles v0→v1 format migration, auto-generated IDs (`$auto-xxx` → `$a-N`), +and shadow node folding during import. + +#### Serialization (`serial.h`, `serial.cpp`) + +The `.atto` file format is TOML-like with section headers: + +```toml +version = "instrument@atto:0" + +[viewport] +x = -6131.11 +y = -1999.86 +zoom = 1.4641 + +[[node]] +guid = "934e3b98bb914e95" +type = "decl_type" +args = ["osc_res", "s:f32", "e:bool"] +position = [757.077, 266.729] + +[[link]] +from = "$auto-df6e4aa3d0d8d2bc-out0" +to = "$auto-831e483b4e4602dc_s1-out0" +net_name = "$osc-signal" +``` + +Version history: `nanoprog@0` → `nanoprog@1` → `attoprog@0` → `attoprog@1` → `instrument@atto:0`. +Auto-migration on load handles all legacy formats. + +### 2. attoc — Compiler + +The compiler is a standalone executable that transforms `.atto` files into self-contained +C++ projects. The pipeline: + +``` +.atto file + │ + ▼ +load_atto() ──────────────── Parse TOML → FlowGraph + │ + ▼ +resolve_type_based_pins() ── Populate dynamic pins for new/event! nodes + │ + ▼ +GraphInference::run() ────── Multi-pass bidirectional type inference + │ + ▼ +validate() ──────────────── Collect type errors, pin mismatches + │ + ▼ +CodeGenerator::generate_*() + ├── _types.h ───── Struct definitions from decl_type nodes + ├── _program.h ─── Class declaration with event handlers + ├── _program.cpp ─ Implementation: node logic, expression eval + ├── CMakeLists.txt ───── Build script for the instrument + └── vcpkg.json ───────── Dependency manifest +``` + +The generated C++ is a complete, standalone project. Each instrument compiles +independently with its own CMakeLists.txt. + +### 3. attoflow — Visual Editor + +The editor has a three-layer architecture: + +``` +FlowEditorWindow (top-level) +├── Tab management (multiple .atto files) +├── Project file browser +├── Build/run toolbar +├── Child process management +│ +└── Editor2Pane (per-tab) + ├── Wraps GraphBuilder (semantic model) + ├── Implements IGraphEditor observer + ├── Wire connection logic (drag, connect, reconnect) + ├── NodeEditorImpl per node (layout cache) + ├── NetEditorImpl per net + │ + └── VisualEditor (rendering base) + ├── Canvas state (pan, zoom, grid) + ├── Hover detection (wires → nodes → pins) + ├── Node rendering (pins, inline args, errors) + └── Wire drawing (bezier curves, WireInfo) +``` + +The hover system uses a distance-based priority: wires → nodes → pins, with pins +getting a priority bias for easier selection. + +Pin shapes encode type categories: +- **Circle** — Data +- **Square** — Bang +- **Triangle** — Lambda +- **Diamond** — Variadic args / optional + +### 4. attohost — Runtime (Planned) + +The runtime architecture separates the editor from user program execution: + +``` +attoflow.exe attohost.exe +(editor process) (host process) + │ │ + │ spawn + pipe name arg │ + ├──────────────────────────────>│ + │ │ LoadLibrary(instrument.dll) + │ │ resolve on_start() + │ IPC: wire table │ run on_start() + │<──────────────────────────────┤ + │ │ + │ IPC: dirty wire updates │ per-frame/tick updates + │<──────────────────────────────┤ + │ │ + │ IPC: value override │ + ├──────────────────────────────>│ inject values for debugging + │ │ + │ IPC: reload signal │ + ├──────────────────────────────>│ unload dll, load new, restart +``` + +Key design decisions: +- **Process isolation**: user program crash doesn't take down the editor +- **Hot-reload**: unload DLL, load new one, call `on_start` again +- **wire**: zero-cost in release (typedef to T), inspectable in debug mode +- **Named pipes**: editor is always the server, host is always the client + +## Cross-Cutting Concerns + +### Node Type System + +There are 38 built-in node types organized into categories: + +| Category | Nodes | +|---------------|----------------------------------------------------------| +| Data | expr, new, dup, str, select, cast, void | +| Collections | append/append!, erase/erase!, iterate/iterate!, next | +| Control | select!, lock!, iterate!, store!, output_mix! | +| Declarations | decl_type, decl_var, decl_event, decl_import, ffi, decl | +| Events | on_key_down!, on_key_up!, event! | +| Special | call/call!, lock/lock!, label, error | + +Bang (`!`) nodes have execution ordering — they fire in the order of their bang chain, +providing deterministic side-effect sequencing in a dataflow graph. + +### Standard Library + +The `attostd/` directory contains `.atto` module files that declare FFI bindings: + +- **gui.atto**: `av_create_window(title, audio_tick, sample_rate, channels, video_tick, width, height, on_close)` — creates a window with audio and video callbacks +- **imgui.atto**: 25+ ImGui bindings (buttons, sliders, text, windows, layout) + +These are imported via `decl_import` nodes in user instruments. + +### Web Target (Planned) + +Future web deployment via a compile server: + +``` +Browser (Emscripten build of editor) + │ + │ POST /compile (sends .atto) + ▼ +Compile Server (hardened Pi) + │ attoc → C++ → emcc → .wasm + ▼ +Browser loads result as side module +``` + +The server is containerized with no network access inside, 60s timeout, 512MB RAM limit, +read-only rootfs. Only `.atto` goes in (small attack surface), controlled C++ comes out. + +## Design Principles + +1. **Zero-dependency core**: attolang has no external dependencies. Only the editor needs SDL3/ImGui. +2. **Graph IS the program**: no separate AST. The FlowGraph is the canonical representation, serialized directly. +3. **Type-driven everything**: inference, validation, codegen, and editor rendering all flow from the type system. +4. **Bidirectional inference**: types propagate both forward (producers → consumers) and backward (consumers → producers). +5. **Observer not polling**: GraphBuilder notifies editors of changes via interfaces, not polling for dirty state. +6. **Sentinels over nulls**: `$empty` and `$unconnected` are always-valid stand-ins, eliminating null checks. +7. **Process isolation**: the editor and runtime are separate processes communicating via IPC. +8. **Instruments are self-contained**: each compiled instrument is a standalone C++ project with its own build system. 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/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..ffcc74f --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,339 @@ +# Organic Assembler — Changelog + +This document traces the evolution of the Organic Assembler project through its major +development phases, architectural milestones, and design turning points. + +## Phase 1: Nanolang Foundation (March 23, 2026) + +The project began as "nanolang" — a visual programming language for building instruments. +This phase established the core architecture that all subsequent work built upon. + +### Language Core +- Created the FlowGraph data model: nodes, links, pins +- Built a recursive-descent expression parser for inline node expressions +- Implemented the type system with scalar types (u8–u64, s8–s64, f32, f64) +- Added container types: vector, map, set, list, queue, ordered_map, ordered_set +- Established the value category system: Data, Reference, Iterator, Lambda, Enum, Bang, Event +- Implemented category sigils: `%`, `&`, `^`, `@`, `#`, `!`, `~` + +### Type Inference +- Built the bidirectional type inference engine (GraphInference) +- Multi-pass propagation: forward through connections, backward from consumers +- Automatic integer upcasting (u8 → u16 → u32 → u64 → f32 → f64) +- Expression node inference with operator type resolution + +### Compiler (nanoc) +- Created the code generator: .atto → C++ with struct definitions, class declarations, implementations +- Generated self-contained CMake projects for each instrument +- FFI support for external C functions (gui, imgui bindings) + +### Visual Editor (nanoflow) +- SDL3 + ImGui-based node graph editor +- Canvas with pan/zoom, grid rendering +- Node rendering with pins, inline arguments +- Wire drawing with bezier curves +- Project file browser with build/run toolbar +- DPI scaling and retina display support + +### First Instruments +- **klavier**: Piano/synthesizer instrument with oscillator synthesis, key events, audio mixing +- **multifader**: Fader mixing instrument with vector operations + +### Standard Library +- `gui.atto`: AV runtime FFI — `av_create_window` with audio_tick (48kHz) and video_tick callbacks +- `imgui.atto`: 25+ ImGui bindings for UI construction + +### Serialization +- TOML-like `.atto` format with `[[node]]` sections and `[[link]]` connections +- Viewport persistence in metafiles (`.atto/.yaml`) +- Version markers: `nanoprog@0`, `nanoprog@1` + +--- + +## Phase 2: Type System and Lambda Development (March 24, 2026) + +A major deepening of the type system and introduction of lambda/closure support. + +### Internal Refactoring (5 phases) +- **Phase 1–2**: Structural reorganization and code consolidation +- **Phase 3–3b**: Pin pointer stability improvements +- **Phase 4a–4b**: Large-scale restructuring of node type handling +- **Phase 5**: Final cleanup and stabilization + +### Literal Types +- Introduced `literal` — unified compile-time value representation +- All constants (integers, floats, booleans, strings) represented as literals +- `is_generic` flag for unresolved type parameters (e.g., `0` could be u8/u32/f32) +- `is_unvalued_literal` flag for input pins expecting values (e.g., `literal`) +- Literal decay: operations consume literals and produce runtime types +- Backpropagation of literal types resolves generics from context +- `strip_literal()` utility for removing literal annotations from operation results + +### Symbol Types +- `symbol` — first-class named references carrying their decay type +- `undefined_symbol` — bare identifiers not yet in the symbol table +- Automatic symbol decay when consumed by operations +- Symbol table populated by declaration nodes and built-in entries +- Reserved keywords: `symbol`, `undefined_symbol`, `literal` + +### Shadow Nodes +- Canonical shadow node system for expression handling +- Auto-generated expression nodes for inline arguments +- Shadow nodes transparent to users (not rendered in editor) +- Removed before serialization + +### Lambda System +- Lambda capture and parameter collection +- `as_lambda` pin for lambda grab rendering +- Stored lambda support (`store! $name` captures lambda) +- Lambda boundary detection (scope tracking) +- Lambda parameter identification via unconnected pins + +### Bang/Trigger System +- Renamed BangInput → BangTrigger, BangOutput → BangNext (clearer semantics) +- Bang connections to input pins +- `select!` supporting three bang outputs (true/false/done) +- Deterministic execution ordering via bang chains + +### Expression System Enhancements +- Function types: `(x:f32)->f32` parsed as type expressions +- Struct types: `{x:f32 y:f32}` parsed with space-separated fields +- Struct literals: `{x:1.0f, y:2.0f}` with comma-separated values +- `TypeApply`: `$0<$1,$2>` speculative generic application +- Validation functions per builtin type (array, vector, map) with error messages + +### Declaration Refactor +- All nodes except `label` get shadow nodes +- `decl_var` descriptor: 2 inputs (name + type), optional 3rd (init) +- `build_context` and `build_registry` removed — replaced by `decl` bang chain +- `decl_import` pin type: `literal` +- Helpers: `get_decl_name(node, graph)`, `get_decl_type_str(node, graph)` + +### UI/UX Improvements +- Bottom panel with tabbed error and build log display +- DPI scaling refinements +- Search bar for node discovery +- Improved error reporting with per-node and per-link messages + +--- + +## Phase 2b: The Great Rename (March 24, 2026) + +**Commit f65b7c0**: Complete rename from nanolang to attolang. + +- All source directories: `nano` → `atto`, `nanoc` → `attoc`, `nanoflow` → `attoflow` +- File extensions: `.nano` → `.atto` +- Documentation: `nanolang.md` → `attolang.md` +- CMake targets and internal references updated +- Format versions: `attoprog@0`, `attoprog@1` + +The rename reflected the project's evolution from a "nano" (small) language to +"atto" (even smaller, but also a pun on the attosecond — the smallest measurable +unit of time, fitting for a real-time instrument platform). + +--- + +## Phase 3: Instrument Specification (March 25, 2026) + +### instrument@atto:0 +- **Commit 3498b4c**: Formal version specification `instrument@atto:0` +- Established the canonical file format for instruments +- Large-scale klavier scene updates (703 lines changed) +- Serial.cpp rewritten (1128 line changes) for the new format + +### Test Suite +- `test_inference.cpp` with 94 unit tests for type inference +- Custom test framework with TEST/ASSERT/ASSERT_EQ/ASSERT_TYPE macros +- Static test registry pattern for auto-discovery +- Programmatic graph construction via test helper utilities + +### Named Styles +- Visual consistency system for editor rendering +- Centralized color and spacing definitions + +### Wire Management +- Link name editing in the editor +- Auto-wire functionality for common connection patterns +- Named nets for organizing complex wiring + +--- + +## Phase 4: GraphBuilder Architecture (March 25–26, 2026) + +A fundamental restructuring of how graphs are constructed and edited. + +### BuilderEntry Hierarchy +- `BuilderEntry` base class with `IdCategory` (Node/Net) +- `FlowNodeBuilder` and `NetBuilder` as concrete types +- `as_node()` / `as_net()` accessors with `shared_from_this()` +- Every entry has a stable ID, category, and dirty flag + +### FlowArg2 Pin Model +- Inheritance hierarchy replacing variants: `ArgNet2`, `ArgNumber2`, `ArgString2`, `ArgExpr2` +- Each arg always has valid `node()`, `net()`, `port()` — sentinels instead of null +- Computed names: `"port_name"`, `"va_name[idx]"`, `"remaps[idx]"` +- Remap tracking: `is_remap()`, `remap_idx()`, `input_pin_idx()`, etc. +- Private constructors — only `GraphBuilder::build_arg_*()` can create args + +### Dirty Tracking +- Three levels: arg → node → graph +- `mark_dirty()` on any arg bubbles to its owning node, then to the graph +- Layout-only dirty (position changes) tracked separately, doesn't trigger re-inference +- `is_dirty()` queries at every level + +### Sentinel Pattern +- `$empty` (FlowNodeBuilder) — default for unassigned node references +- `$unconnected` (NetBuilder) — default for unassigned net references +- Pre-registered via `ensure_sentinels()`, never destroyed +- Eliminates null pointer checks throughout the codebase + +### Mutation Batching +- `edit_start()` / `edit_commit()` for transactional graph edits +- Mutations queued as callbacks, fired in order on commit +- Prevents partial updates from reaching observers +- Throws if mutations pending from a previous uncommitted edit + +### Format Migration +- v0→v1 migration: name-based port mapping using old/new descriptors +- Shadow folding during import +- Lambda `-as_lambda` stripping +- `$auto-xxx` → `$a-N` compact hex re-ID on import + +### NodeKind2 Classification +- `Flow` — standard data processing nodes +- `Banged` — nodes with execution ordering (bang chains) +- `Event` — event source nodes +- `Declaration` — compile-time declaration nodes +- `Special` — label, error, and other non-data nodes + +--- + +## Phase 5: Editor2 Architecture (March 26, 2026) + +The next-generation editor built on top of the GraphBuilder. + +### Editor2Pane +- Wraps GraphBuilder as the semantic model +- Implements `IGraphEditor` observer for reactive updates +- `NodeEditorImpl` per node — caches layout and visual state +- `NetEditorImpl` per named net +- Wire connection logic: drag from pin, snap to target, reconnect + +### Hover System +- `hover_item_` = `variant` +- `detect_hover()` returns best match by distance priority +- Priority order: wires → nodes → pins (pins get priority bias) +- `draw_hover_effects()` renders highlights and tooltips +- All elements hoverable: pins, nodes, wires, lambda grabs, side-bangs, +diamonds + +### Selection System +- `set` for multi-selection +- Ctrl+click toggles individual selection +- Selection rectangle drag for area selection +- Node dragging with overlap prevention and padding + +### Pin Shapes +- Circle → Data pins +- Square → Bang pins +- Triangle → Lambda pins +- Diamond → Variadic args / optional pins + +### Visual Refinements +- Liberation Mono font embedded via CMake `file(READ HEX)` +- Editor style struct `S` centralizing all colors, sizes, thresholds +- Tooltip scaling and positioning +- Non-overlapping node layout algorithm +- Auto string renumbering for wire IDs + +### Code Organization +- Extracted `node_renderer.cpp` from monolithic editor +- Extracted `tooltip_renderer.cpp` +- Separated `window.cpp/h` from editor logic +- Legacy editor1 moved to `src/legacy/` +- Removed `#if LEGACY_EDITOR` conditionals + +--- + +## Phase 6: Wiring and Networking (March 26–27, 2026) + +Current active development on branch `skmp/wirring-and-networking`. + +### Wire Connection System +- Pin grabbing and dragging interaction +- Visual wire preview during drag +- Snap-to-pin connection on release +- Wire reconnection (drag existing wire to new target) +- Wire grab undo (remembers previous connection state) + +### Named Nets +- Nets editor panel for viewing all named connections +- `$unconnected` sentinel replacing removed nets +- Net name display and management + +### Multi-Output Nodes +- `num_outputs` increased from 1 to 2 for applicable nodes +- Variadic output args support +- Output pin mapping and indexing + +### Visual Polish +- Diamond pin hover detection for +pins +- Unified highlight system across all hoverable elements +- Wire hover: highlights all wires sharing the same entry +- Lambda wire highlighting for connected scope +- Scroll pan speed controls in editor style +- Shortened node IDs for cleaner display + +### Delete Functionality +- Delete hovered items (nodes, wires, nets) +- Cleanup of orphaned connections on deletion + +--- + +## Planned Future Work + +### args Elimination +Replace `node.args` string with structured pre-parsed fields on `FlowNode`: +- Pre-extract ALL metadata at load time (type fields, variable names, function refs) +- `tokenize_args()` (~40+ call sites) and `scan_slots()` (~10 sites) to be removed +- Migration path: codegen first (highest call count), then inference, then editor + +### Nested Lambda Scope Fix +Proper lambda boundary detection for nested lambdas: +- Graph analysis pass identifying all lambda boundaries +- Node-to-lambda-scope assignment ("lambda ownership") +- `collect_lambda_params` respects scope boundaries +- Prevents outer stored lambda params from leaking into inner lock/iterate lambdas + +### DLL Host Architecture +- `attohost.exe` — separate host process for running instruments +- `attoc` generates `.dll` (SHARED) instead of `.exe` +- Hot-reload via DLL unload/load cycle +- `wire` — zero-cost in release, inspectable in debug +- IPC via named pipes for live value inspection + +### Web Deployment +- Editor compiled to Emscripten (SDL3 + ImGui in browser) +- Compile server on hardened Pi +- `POST /compile` endpoint: .atto → C++ → emcc → .wasm +- Containerized with no network, 60s timeout, 512MB RAM + +### Compile-Time Phase +- `decl` bang chain evaluation (compile-time interpreter) +- `decl_type` outputs `type` for metaprogramming +- Event names without `~` prefix +- Namespace `::` operator in expressions +- Type construction via calling (e.g., `f32(42)` as cast) + +--- + +## Version History + +| Version | Period | Key Change | +|--------------------|-------------|------------------------------------------| +| `nanoprog@0` | Mar 23 | Initial format | +| `nanoprog@1` | Mar 23 | Pin structure changes | +| `attoprog@0` | Mar 24 | Post-rename format | +| `attoprog@1` | Mar 24–25 | Structured args, extended pins | +| `instrument@atto:0`| Mar 25+ | Formal instrument specification | + +Each version is auto-migrated on load. The serializer always writes the latest format. diff --git a/docs/coding.md b/docs/coding.md new file mode 100644 index 0000000..14aa7a3 --- /dev/null +++ b/docs/coding.md @@ -0,0 +1,451 @@ +# Organic Assembler — Coding Conventions + +This document describes the C++ coding conventions, idioms, and practices used throughout +the Organic Assembler codebase. + +## Language Standard + +The project uses **C++20** (`CMAKE_CXX_STANDARD 20`). Features actively used: + +- `std::variant<>` for algebraic data types +- `std::shared_ptr<>` / `std::weak_ptr<>` / `std::unique_ptr<>` for ownership +- `std::enable_shared_from_this` for safe self-references +- Range-based for loops everywhere +- Lambda expressions with captures for callbacks and inline logic +- `constexpr` for compile-time computation +- Default member initializers +- `auto` for type deduction (used judiciously) +- Fold expressions: `((id == ids) || ...)` for variadic checks +- Scoped enums (`enum class`) exclusively + +Features intentionally **not** used: +- RTTI / `dynamic_cast` on raw pointers (only on `shared_ptr` casts) +- Exceptions as control flow (used only for invariant violations) +- Concepts / constraints +- Modules (C++20) +- Coroutines +- `std::optional<>` (error strings or variants preferred) +- Structured bindings (rarely used) + +## Naming Conventions + +### Types and Classes +```cpp +// PascalCase for all type names +struct FlowGraph; +struct GraphBuilder; +struct TypeExpr; +class CodeGenerator; +enum class NodeTypeID; +enum class TypeKind; +``` + +### Functions and Methods +```cpp +// snake_case for all functions and methods +void add_node(); +void mark_dirty(); +void rebuild_pin_ids(); +TypePtr resolve_type(); +bool is_numeric(const TypePtr& t); +``` + +### Variables +```cpp +// snake_case for locals and parameters +int result; +FlowNode* source_node; +int temp_counter; + +// snake_case with trailing underscore for private members +ArgKind kind_; +std::shared_ptr owner_; +FlowNodeBuilderPtr node_; +bool dirty_ = false; + +// snake_case without underscore for public members +std::vector nodes; +std::vector links; +std::map entries; +``` + +### Enum Values +```cpp +// PascalCase for enum values +enum class TypeKind { Void, Bool, String, Scalar, Named, Container, ... }; +enum class NodeTypeID : uint8_t { Expr, New, Dup, Str, Select, ... }; +enum class ArgKind : uint8_t { Net, Number, String, Expr }; +enum class IdCategory { Node, Net }; +``` + +### Type Aliases +```cpp +// PascalCase with descriptive suffixes +using TypePtr = std::shared_ptr; +using ExprPtr = std::shared_ptr; +using FlowArg2Ptr = std::shared_ptr; +using FlowNodeBuilderPtr = std::shared_ptr; +using NetBuilderPtr = std::shared_ptr; +using BuilderEntryWeak = std::weak_ptr; +using NodeId = std::string; +using BuilderError = std::string; +using Remaps = std::vector>; +``` + +### Constants and Statics +```cpp +// Static arrays for descriptors +static const NodeType NODE_TYPES[] = { ... }; +static constexpr int NUM_NODE_TYPES = sizeof(...) / sizeof(...); + +// Inline helpers in headers +inline bool is_numeric(const TypePtr& t); +inline TypeCategory parse_category(char c); +``` + +## Header Guards + +Always `#pragma once`. No `#ifndef` guards are used anywhere in the codebase. + +```cpp +#pragma once +#include +#include +// ... +``` + +## Include Order + +1. Standard library headers +2. Project headers + +```cpp +#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 +``` + +Note: project headers often come first, then standard headers. This is the established +convention — not the Google style of standard-first. + +## Memory Management + +### Ownership Model + +The codebase uses smart pointers consistently. Raw pointers are only for non-owning +references within the same scope. + +```cpp +// Shared ownership for graph entries +std::shared_ptr node; +std::shared_ptr net; +std::shared_ptr type; + +// Unique ownership for pins +std::unique_ptr pin; + +// Weak references for cycle breaking +std::weak_ptr editor; +std::vector> net_editors; + +// Raw pointers only for non-owning, same-scope references +const PortDesc2* port = nullptr; // borrowed from node type descriptor +FlowNode* node_ptr; // temporary within a function +``` + +### Move Semantics + +FlowGraph and similar containers are movable but non-copyable: + +```cpp +FlowGraph(FlowGraph&&) = default; +FlowGraph& operator=(FlowGraph&&) = default; +FlowGraph(const FlowGraph&) = delete; +FlowGraph& operator=(const FlowGraph&) = delete; +``` + +### enable_shared_from_this + +Used on objects that need to hand out shared pointers to themselves: + +```cpp +struct FlowArg2 : std::enable_shared_from_this { + // Can safely call shared_from_this() in methods +}; + +struct BuilderEntry : std::enable_shared_from_this { + // Entries can refer to themselves in observer callbacks +}; +``` + +## Error Handling + +### Variant-Based Results (Primary Pattern) + +Functions that can fail return a variant of success/error: + +```cpp +using BuilderResult = std::variant, BuilderError>; +using ParseAttoResult = std::variant, BuilderError>; +using ParseResult = std::variant, std::string>; +using SplitResult = std::variant, std::string>; +``` + +Usage: +```cpp +auto result = parse_atto(stream); +if (auto* err = std::get_if(&result)) { + // handle error +} else { + auto& gb = std::get>(result); + // use graph builder +} +``` + +### Error Strings on Objects + +Objects that can be in an error state carry an error string: + +```cpp +struct FlowNode { + std::string error; // non-empty if node has a type error +}; + +struct FlowLink { + std::string error; // non-empty if connection is invalid +}; +``` + +### Exceptions for Invariant Violations + +Exceptions are reserved for programmer errors, never for expected failures: + +```cpp +if (!entry_) throw std::logic_error("ArgNet2: entry must not be null"); +if (!owner_) throw std::logic_error("FlowArg2: owner must not be null"); +``` + +These should never fire in correct code — they guard against impossible states. + +### Early Returns + +Functions validate preconditions and return early: + +```cpp +bool validate_type(const std::string& type_str, std::string& error) { + if (type_str.empty()) { + error = "empty type string"; + return false; + } + // ... + return true; +} +``` + +## Enums + +Always `enum class` (scoped), never plain `enum`: + +```cpp +enum class TypeKind { + Void, Bool, String, Mutex, Scalar, Named, Container, + ContainerIterator, Array, Tensor, Function, Struct, + Symbol, UndefinedSymbol, MetaType +}; + +enum class TypeCategory { Data, Reference, Iterator, Lambda, Enum, Bang, Event }; +enum class ScalarType { U8, S8, U16, S16, U32, S32, U64, S64, F32, F64 }; +enum class ContainerKind { Map, OrderedMap, Set, OrderedSet, List, Queue, Vector }; +enum class ArgKind : uint8_t { Net, Number, String, Expr }; +enum class NodeTypeID : uint8_t { /* 38 values */ }; +``` + +Underlying types (`: uint8_t`) specified when the enum is stored in compact structures. + +## Discriminated Unions + +Two approaches are used, depending on context: + +### std::variant (for simple unions) +```cpp +using FlowArg = std::variant; +using HoverItem = std::variant; +``` + +### Kind enum + as_* methods (for complex hierarchies) +```cpp +struct FlowArg2 { + 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(); +}; +``` + +The kind-based approach is preferred when the types share substantial base behavior +and need `shared_from_this()`. + +## String Handling + +- `std::string` for all text — no `char*` arrays +- Pass by `const std::string&` for inputs +- `std::move()` for ownership transfers +- String IDs for nodes and pins: `"guid.pin_name"` format + +```cpp +// ID generation +inline std::string generate_guid() { + static std::mt19937_64 rng(std::random_device{}()); + // hex from RNG + return s; +} + +// Tokenization with nesting awareness +std::vector tokenize_args(const std::string& args); +std::vector split_args(const std::string& args); +``` + +## Inline Functions + +Type utility functions are `inline` in headers for zero-overhead abstraction: + +```cpp +inline bool is_numeric(const TypePtr& t) { ... } +inline bool is_integer(const TypePtr& t) { ... } +inline bool is_float(const TypePtr& t) { ... } +inline TypePtr decay_symbol(const TypePtr& t) { ... } +inline TypePtr strip_literal(const TypePtr& t) { ... } +inline bool is_category_sigil(char c) { ... } +``` + +## Static Helpers in .cpp Files + +Implementation-only helpers are `static` in the .cpp file, never in headers: + +```cpp +// In serial.cpp +static std::string trim(std::string s); +static std::string unescape_toml(const std::string& s); +static std::string unquote(const std::string& s); +static std::vector parse_toml_array(const std::string& val); +``` + +## File Organization + +Each `.h` / `.cpp` pair covers a single concept: + +| Pair | Concept | +|-----------------------|----------------------------| +| `types.h/cpp` | Type system and parsing | +| `expr.h/cpp` | Expression AST and parsing | +| `inference.h/cpp` | Type inference algorithm | +| `graph_builder.h/cpp` | Structured graph editing | +| `serial.h/cpp` | .atto serialization | +| `codegen.h/cpp` | C++ code generation | +| `shadow.h/cpp` | Shadow expression nodes | +| `symbol_table.h/cpp` | Symbol resolution | +| `type_utils.h/cpp` | Type compatibility utils | +| `model.h` | Core data structures | + +Headers contain: +1. `#pragma once` +2. Includes (project, then standard) +3. Forward declarations +4. Type aliases +5. Struct/class definitions +6. Inline function implementations + +Implementation files contain: +1. Header include +2. Additional includes +3. Static helper functions +4. Method implementations + +## Templated Helpers + +Variadic templates with fold expressions for type checks: + +```cpp +template +constexpr bool is_any_of(NodeTypeID id, Ts... ids) { + return ((id == ids) || ...); +} +``` + +## Macros + +Macro usage is minimal and confined: + +1. **Test framework** (only in test_inference.cpp): + ```cpp + #define TEST(name) static void test_##name() + #define ASSERT(cond) if (!(cond)) { printf(...); tests_failed++; return; } + #define ASSERT_EQ(a, b) + #define ASSERT_TYPE(pin_ptr, expected_str) + ``` + +2. **No preprocessor configuration** — all configuration is runtime state + +## Constructor Patterns + +### Private Constructors with Factory Methods +```cpp +struct ArgNet2 : FlowArg2 { + friend struct GraphBuilder; // only GraphBuilder can create +private: + ArgNet2(const std::shared_ptr& owner); +}; + +// Creation via factory +auto arg = gb->build_arg_net(...); +``` + +### Default Member Initialization +```cpp +struct TypeExpr { + TypeKind kind = TypeKind::Void; + TypeCategory category = TypeCategory::Data; + bool is_generic = false; + bool is_unvalued_literal = false; + ScalarType scalar = ScalarType::U8; + ContainerKind container = ContainerKind::Vector; + const PortDesc2* port_ = nullptr; + bool dirty_ = false; +}; +``` + +## Container Usage + +- `std::vector<>` — primary sequential container +- `std::map<>` — ordered associative (for deterministic iteration) +- `std::set<>` — ordered unique collection (for selections) +- `std::unordered_map<>` — rare, only for performance-critical lookups +- `std::erase_if()` — for filtered removal from containers + +## const Correctness + +- Input parameters: `const std::string&`, `const TypePtr&` +- Accessor methods: `const` qualified +- Mutable state: clearly non-const + +```cpp +ArgKind kind() const { return kind_; } +const PortDesc2* port() const { return port_; } +const FlowNodeBuilderPtr& node() const; +void node(const FlowNodeBuilderPtr& n); // setter is non-const +``` diff --git a/docs/differential.md b/docs/differential.md new file mode 100644 index 0000000..c187520 --- /dev/null +++ b/docs/differential.md @@ -0,0 +1,472 @@ +# Organic Assembler — Differential Programming, Debugging, and Development + +This document explores three interlocking meanings of "differential" in the Organic +Assembler project: (1) differential/incremental computation patterns in the codebase, +(2) differential debugging approaches used during development, and (3) how the concept +of differentiation — rates of change, deltas, accumulation — applies to instruments +as a domain. + +## Part I: Differential Computation in the Codebase + +### 1. Three-Level Dirty Cascade + +The graph editing system tracks changes at the finest granularity and propagates them +upward. This is a differential approach: instead of re-evaluating the entire graph, +only the changed parts trigger work. + +``` +FlowArg2 mutation + │ mark_dirty() + ▼ +FlowNodeBuilder dirty flag + │ notify node editor + ▼ +GraphBuilder dirty flag + │ notify graph editor + ▼ +Inference / rendering invalidation +``` + +Key implementation details: + +- **Arg-level**: Any setter (`ArgNet2::net_id()`, `ArgNumber2::value()`) calls `mark_dirty()` +- **Node-level**: Two independent dirty flags — semantic dirty (triggers re-inference) + and layout dirty (triggers re-render only, not re-inference) +- **Graph-level**: Dirty propagation is deferred inside `edit_start()`/`edit_commit()` batches + +The layout/semantic split is critical: dragging a node changes its position (layout dirty) +but not its type signature (no semantic dirty). Without this split, every mouse drag +during node movement would trigger a full type inference pass. + +### 2. Mutation Deduplication + +Within a single edit batch, each item's observer callback fires at most once: + +```cpp +void GraphBuilder::add_mutation_call(void* ptr, std::function&& fn) { + if (mutation_items_.count(ptr)) return; // Already queued + mutation_items_.insert(ptr); + mutations_.push_back(std::move(fn)); +} +``` + +This is differential in the set-theoretic sense: the delta between "state before edit" +and "state after edit" is computed as a deduplicated set of changed items, not a log +of every micro-mutation. If an arg is changed three times within one batch, observers +see one notification, not three. + +### 3. Fixed-Point Type Inference + +The inference engine is the purest example of differential computation in the project. +It iterates until the delta between iterations is empty: + +```cpp +for (int iter = 0; iter < 10; iter++) { + bool changed = false; + changed |= propagate_connections(graph); + changed |= infer_expr_nodes(graph); + if (changed) idx.rebuild(graph); + changed |= resolve_lambdas(graph); + if (!changed) break; // Fixed point: delta is zero +} +``` + +Each sub-phase returns `true` only if it actually modified a type. The phases are: + +1. **propagate_connections**: Forward-propagate types through wires. Returns `changed = true` + only if a pin's type was refined (never widened — types only become more specific). +2. **infer_expr_nodes**: Evaluate expression ASTs and resolve output types. Returns `changed` + if any expression resolved to a new type. +3. **resolve_lambdas**: Determine lambda boundaries and parameter types. Returns `changed` + if any lambda's signature changed. + +**Monotonicity guarantee**: Types move from generic → specific, never the reverse. This +guarantees convergence. The 10-iteration cap is a safety net; most graphs converge in 2-3 +iterations. + +**Differential index rebuild**: `GraphIndex` (fast pin/node lookup) is rebuilt only when +pins were added or removed (`if (changed) idx.rebuild()`), not every iteration. + +### 4. Wire Cache Invalidation + +The editor maintains a precomputed wire list for rendering and hit-testing. Rather than +rebuilding it every frame, it uses a dirty flag: + +```cpp +bool wires_dirty_ = true; + +void rebuild_wires(ImVec2 canvas_origin) { + if (!wires_dirty_) return; // No delta since last rebuild + cached_wires_.clear(); + // ... rebuild from current graph state ... + wires_dirty_ = false; +} +``` + +Observer callbacks set `wires_dirty_ = true` when the graph changes. Frames where nothing +changed skip the rebuild entirely. This is differential rendering: only re-derive visual +state when the underlying model has a non-zero delta. + +### 5. Shadow Node Differential Expansion + +Shadow nodes (internal expression nodes for inline args) can be updated per-node rather +than regenerating the entire graph: + +``` +User edits node args string + │ + ▼ +update_shadows_for_node(node) + ├── Remove old shadow nodes for this node + ├── Parse new args + └── Generate new shadow nodes + links +``` + +This is a localized delta: only the shadow nodes belonging to the edited node are +regenerated. All other shadow nodes remain untouched. + +### 6. Version Migration as Format Delta + +Each version migration function encodes the *difference* between two format versions: + +``` +nanoprog@0 ──Δ1──> nanoprog@1 ──Δ2──> attoprog@0 ──Δ3──> attoprog@1 ──Δ4──> instrument@atto:0 +``` + +Each Δ is a transformation function: +- **Δ1**: Shadow node generation (v0 has inline args, v1 has shadow nodes) +- **Δ2**: Rename nano→atto in node types and paths +- **Δ3**: Strip `$` from variable refs, convert `@N` to `$N` +- **Δ4**: Add explicit GUIDs, structured net entries + +The deserializer composes deltas: loading a `nanoprog@0` file applies Δ1∘Δ2∘Δ3∘Δ4. +Each delta is idempotent on files already at its target version. + +--- + +## Part II: Differential Debugging During Development + +### Git Commit Pattern Analysis + +The commit history reveals a distinctive differential debugging approach. Looking at +158 commits across 5 days of development: + +#### The "towards X" Pattern + +Progress commits that acknowledge incomplete work: + +``` +817a1bd towards lambdas +ed06b67 towards reusable lambas +4120050 towards lambda link rendering +bf4d01f towards editor2 +57e6805 towards evaluated decls +1812167 towards first class pins +``` + +These are differential checkpoints: "the system is closer to X than it was before, but +X is not yet complete." They preserve the delta so far, allowing rollback if the next +step breaks something. This is differential debugging in the version control sense — +each commit captures a known-good (or at least known-better) intermediate state. + +#### The "more X work" Pattern + +Incremental progress without claiming completion: + +``` +022c4a4 more graphbuilder work +4772975 more graphbuilder work +d0c9eb1 work on nets and nodes +28bcd4c more net work +8dd992d more shadow node work +974a5fa more progress on literals +095d426 progress in literals? +da506db more progress on literals? +6c9c0cc more literal work +ac639d5 more literal work +079fc8b more literals +``` + +The question marks (`progress in literals?`) reveal uncertainty — the developer isn't +sure if the delta is positive. Committing anyway preserves the ability to diff against +the previous state and evaluate whether the change helped. + +#### The Emotional Honesty Pattern + +``` +3b7e936 this feels dodgy +6325002 questionable progress +fb3d96d not perfect, but progress is progress +489adb7 klavier is now a clusterfuck +``` + +These commits are explicit about the quality of the delta. "This feels dodgy" is a +signal to future-self: the diff from this commit should be examined carefully. It's +a differential annotation — metadata about the *quality* of the change, not just its +content. + +#### The Phased Refactor Pattern + +``` +3b1f9df refactor: phase 1 +37d881c refactor: phase 2 +bf0424b refactor: phase3 +1656fac refactor: phase 3b +0b30130 refactor: make pin ptrs stable +6bcdad6 refactor: phase 4a +b3bfee2 refactor: phase 4b +0f5ab4e refactor: Phase 5 +``` + +Large refactors are decomposed into numbered phases, each a separate commit. This is +explicit differential decomposition: the total change (Δtotal) is split into ordered +sub-deltas (Δ1, Δ2, ..., Δ5) that can be reviewed and reverted independently. Phase 3 +even has a sub-phase (3b), showing adaptive decomposition when a phase turned out to +be larger than expected. + +#### The Naming Migration Trail + +``` +d480da9 graphbuilder -> graph_builder +bf8c9cd graph rename +d55cf36 wire -> net in grahp_builder +753c051 as_Node/Net -> as_node/net, dead code deletion +f296f08 editor->window.cpp/h +722a591 move editor1 to legacy folder +``` + +Renames are pure-delta commits: the behavior doesn't change, only the names. These are +isolated into their own commits so the diff is unambiguous — every change is a rename, +no logic changes mixed in. This makes the diff self-documenting. + +#### The Fix-After-Commit Pattern + +``` +3e9d3b1 alias fix from last commit +4423514 fixes +70e7621 fixes in shadow +``` + +Bugs discovered immediately after a commit are fixed in a separate commit rather than +amending. This preserves the delta between "broken state" and "fixed state" in the +history, which is useful for understanding *what* broke and *why*. + +#### Commit Velocity as a Signal + +``` +Day 1 (Mar 23): 16 commits — Foundation (new project, broad strokes) +Day 2 (Mar 24): 28 commits — Deep work (lambdas, expressions, refactoring) +Day 3 (Mar 25): 36 commits — Peak velocity (type system, graphbuilder, editor2) +Day 4 (Mar 26): 72 commits — Highest output (editor2 features, 72 commits in one day) +Day 5 (Mar 27): 6 commits — Integration (wire connections, delete, nets) +``` + +The velocity curve shows a ramp-up phase (days 1-2), peak throughput (days 3-4), and +a consolidation phase (day 5, fewer but more substantial commits). This is characteristic +of differential development: early commits are exploratory (small deltas, uncertain +direction), middle commits are confident (large deltas, clear direction), and late +commits are integrative (connecting previously separate deltas). + +#### Commit Message Style Taxonomy + +The messages fall into distinct categories revealing the nature of each delta: + +| Style | Example | Meaning | +|-------|---------|---------| +| `Add X` | `Add scroll pan speed to Editor2Style` | New feature, additive delta | +| `fix: X` | `fix: shorten node ids` | Bug fix, corrective delta | +| `refactor: X` | `refactor: phase 4a` | Structure change, zero-semantic delta | +| `towards X` | `towards lambdas` | Incomplete progress, partial delta | +| `more X work` | `more graphbuilder work` | Continuation, incremental delta | +| `X -> Y` | `wire -> net in graph_builder` | Rename, identity delta | +| `X!` | `Editor2Pane!` | Milestone, significant delta | +| `cosmetics` | `cosmetics` | Visual-only, zero-functional delta | +| `X concept` | `nets_editor concept` | Proof-of-concept, exploratory delta | +| bare noun | `Folding` | Feature name as commit, self-evident delta | + +--- + +## Part III: Differentiation and Instruments + +### The Mathematical Connection + +Differentiation in calculus is about **rates of change**. This is not an abstract analogy +for instruments — it is literally what audio synthesis computes. + +#### Phase Accumulation (Integration) + +An oscillator generates a waveform by accumulating a phase delta each sample: + +``` +phase += 2π * frequency / sample_rate // Integration: Δphase per sample +output = sin(phase) // Evaluate at current phase +``` + +In the klavier instrument, this appears as: + +```toml +[[node]] +type = "expr" +args = ["2*pi/$0"] # Compute phase step (Δphase) from frequency + +[[node]] +type = "store!" +args = ["$0.p"] # Accumulate: phase = phase + Δphase +``` + +The phase step (`2π/frequency`) is the **derivative** of the waveform's position with +respect to time. The store operation performs **numerical integration** — accumulating +the derivative to produce the signal. + +#### Envelope Generators (Piecewise Differentiation) + +An ADSR envelope (Attack, Decay, Sustain, Release) is defined by four rates of change: + +``` +Attack: amplitude += attack_rate * dt (positive derivative) +Decay: amplitude -= decay_rate * dt (negative derivative) +Sustain: amplitude = sustain_level (zero derivative) +Release: amplitude -= release_rate * dt (negative derivative) +``` + +Each segment is a piecewise-constant derivative. The envelope itself is the integral +of these piecewise derivatives. In attolang, this is expressed as a state machine +where each state stores the current rate (derivative) and the `store!` node +integrates it per sample. + +#### Frequency as Derivative of Phase + +The fundamental relationship in audio synthesis: + +``` +frequency = d(phase) / dt +``` + +Frequency IS the derivative of phase. When an instrument changes pitch (e.g., a +glissando or vibrato), it's modifying the derivative. The `store!` node that +accumulates phase is performing Euler integration of this derivative. + +#### Audio Mixing as Superposition (Linearity of Differentiation) + +The runtime's `output_mix!` node accumulates contributions from multiple oscillators: + +```cpp +inline f32 _atto_mix_accum = 0.0f; + +inline void output_mix(f32 value) { + _atto_mix_accum += value; // Sum of individual contributions +} + +inline f32 atto_consume_mix() { + f32 v = _atto_mix_accum; + _atto_mix_accum = 0.0f; // Reset for next sample + return v; +} +``` + +This exploits the **linearity of differentiation**: if each oscillator independently +computes its output via phase accumulation (integration), their sum is the integral of +the sum of their derivatives. You can mix signals by adding them because integration +is a linear operator. + +#### Wire Inspection as Sampling (Discrete Differentiation) + +The planned `wire` inspection system samples wire values at discrete points in time: + +``` +wire value at frame N: v[N] +wire value at frame N-1: v[N-1] +discrete derivative: Δv = v[N] - v[N-1] +``` + +An oscilloscope view of a wire IS the discrete derivative visualization. The inspector +can show: +- **Raw value**: v[N] (the signal itself) +- **Rate of change**: v[N] - v[N-1] (the discrete derivative) +- **Accumulation**: Σ v[i] (the discrete integral) + +This makes `wire` a **differential probe**: it doesn't just show the value, it +enables computing derivatives and integrals of any signal in the instrument. + +### Differential Dataflow + +The broader connection between differential programming and visual dataflow: + +#### Forward Mode (How Instruments Compute) + +In forward-mode automatic differentiation, you compute the derivative alongside the +value as data flows through the computation graph. An instrument's dataflow graph +does exactly this when tracking rates of change: + +``` +[frequency] ──> [phase_step = 2π*freq/sr] ──> [phase += step] ──> [sin(phase)] + value derivative integral output +``` + +Each node transforms both the value and its derivative. The `expr` node computes +the phase step (derivative), the `store!` node integrates it. + +#### Reverse Mode (How Type Inference Works) + +In reverse-mode automatic differentiation (backpropagation), gradients flow backward +through the graph from outputs to inputs. attolang's type inference does the same +thing with types: + +``` +Forward: producer type ──> wire ──> consumer input type +Reverse: consumer expected type ──> wire ──> producer output type (backprop) +``` + +The bidirectional inference engine is structurally identical to forward+reverse mode +automatic differentiation, with types playing the role of values/gradients. + +#### Incremental Computation (How the Editor Stays Fast) + +Incremental computation frameworks (like Adapton or differential dataflow) track +which outputs depend on which inputs and only recompute what changed. The Organic +Assembler's dirty tracking system is a manual implementation of this: + +``` +Input changed (arg mutation) + │ + ▼ +Which nodes depend on this input? (dirty propagation) + │ + ▼ +Recompute only those nodes (selective re-inference) + │ + ▼ +Which visual elements changed? (wire cache invalidation) + │ + ▼ +Re-render only those elements (selective re-draw) +``` + +The three-level dirty cascade (arg → node → graph) is an approximation of +true incremental computation. It's coarser than full dependency tracking +(it invalidates at the node level, not the pin level) but much simpler to +implement and reason about. + +### The Differential Development Cycle + +Putting it all together, the Organic Assembler project exhibits a fractal +differential structure at every level: + +| Level | Delta Unit | Accumulation | Convergence Check | +|-------|-----------|--------------|-------------------| +| Audio sample | Phase step | `store!` integration | Waveform continuity | +| Type inference | Type refinement | Fixed-point iteration | `!changed` | +| Graph editing | Arg mutation | `edit_commit()` batch | Observer notification | +| Wire rendering | Layout change | Cache rebuild | `!wires_dirty_` | +| Git commit | Code change | Branch history | Tests pass / builds green | +| Version format | Migration delta | Δ1∘Δ2∘...∘Δn chain | Latest format | +| Project phase | Feature set | Daily development | Phase completion | + +At every level, the pattern is the same: +1. **Compute a small delta** (one sample, one type refinement, one arg change, one commit) +2. **Accumulate it** (integrate the phase, propagate the type, batch the mutations, push the branch) +3. **Check convergence** (is the waveform smooth? did types stabilize? is the build green?) +4. **Repeat or stop** (next sample, next iteration, next edit, next feature) + +This is differentiation in the deepest sense: the system evolves through small, tracked +changes, and the quality of the whole is determined by the quality of each increment. diff --git a/docs/instructions.md b/docs/instructions.md new file mode 100644 index 0000000..40784c6 --- /dev/null +++ b/docs/instructions.md @@ -0,0 +1,599 @@ +# Organic Assembler — Instructions + +How to read a codebase. How to follow an instruction. How to make an instrument. + +This document is addressed to anyone — human or machine — who intends to work on or +with the Organic Assembler. It is philosophical because instructions are philosophical: +every act of following an instruction is also an act of interpretation, and interpretation +requires understanding what the instruction *means*, not just what it *says*. + +--- + +## Part I: How to Interpret Instructions + +### 1. Context Is Everything + +An instruction does not exist in isolation. "Add a node" means nothing without knowing +which graph, which node type, which purpose. The Organic Assembler is an ecosystem — +language, compiler, editor, runtime, standard library — and any instruction touches +multiple subsystems. Before acting, identify the subsystem boundary your change lives in: + +| Subsystem | Responsibility | Instruction flavor | +|-------------|-----------------------------------|---------------------------------| +| **attolang** | Types, parsing, inference, model | "Support a new type kind" | +| **attoc** | Code generation (.atto → C++) | "Emit correct code for X" | +| **attoflow** | Visual editing, rendering, UX | "Add a control to the editor" | +| **attohost** | Runtime, hot-reload, wire inspect | "Support a new runtime feature" | +| **attostd** | Standard library (.atto modules) | "Add an FFI binding" | + +If an instruction spans multiple subsystems, start from the core and work outward. +attolang first, then attoc, then attoflow. The dependency arrow flows inward: the editor +depends on the language library, never the reverse. This is the first instruction about +instructions: **respect the dependency direction**. + +### 2. Read Before You Write + +The codebase is structured around the principle that **the graph is the truth**. The +FlowGraph data model is the canonical representation of every instrument. There is no +separate AST, no hidden intermediate form. What you see in the `.atto` file is what gets +compiled. What you see in the editor is what the `.atto` file contains. + +Before modifying anything: + +1. **Read the model** — `src/atto/model.h` defines FlowGraph, FlowNode, FlowLink, FlowPin. + Every other file either reads from or writes to this structure. +2. **Read the types** — `src/atto/types.h` defines the type system. If your change + involves any kind of value, you will interact with TypeExpr. +3. **Read the node types** — `src/atto/node_types.h` and `node_types2.h` define what + nodes exist and what their ports look like. +4. **Read the relevant .atto file** — If you are changing behavior, find an instrument + that exercises the relevant code path. `scenes/klavier/main.atto` is the most + comprehensive test instrument. + +Do not guess at code structure. Read it. The codebase is not so large that reading is +a burden, and it is structured enough that reading is rewarding. + +### 3. The Instruction Behind the Instruction + +When someone says "add a slider node type," the real instruction is: + +1. Add a `NodeTypeID` enum value +2. Add a `NodeType` descriptor with port definitions +3. Teach the inference engine about the node's type semantics +4. Teach the code generator to emit code for it +5. Teach the editor how to render it (or let the default renderer handle it) +6. Add it to the standard library if it wraps an FFI call +7. Test it in an actual instrument + +The surface instruction is one sentence. The real work is seven steps across four +subsystems. **Always decompose instructions into the subsystem-level steps they imply.** + +### 4. Conventions Are Instructions Too + +The coding conventions documented in [coding.md](coding.md) and [style.md](style.md) +are not suggestions. They are instructions that apply to every change: + +- PascalCase for types, snake_case for functions, trailing underscore for private members +- `#pragma once`, not `#ifndef` guards +- `enum class`, not bare enums +- Smart pointers for ownership, raw pointers only for non-owning within-scope references +- One concept per file pair (`.h` / `.cpp`) +- Accessors on one line: `ArgKind kind() const { return kind_; }` + +When you encounter code that violates these conventions, do not "fix" it unless fixing +it is your explicit task. Consistency within a change is more important than global +consistency. A cleanup commit is a separate commit. + +### 5. Commit Messages Are Instructions to the Future + +The git log is a narrative. Read it before contributing: + +``` +Observer Pattern +as_Node/Net -> as_node/net, dead code deletion +refactor node ID and net name handling; introduce sentinel entries +towards lambda link rendering +not perfect, but progress is progress +``` + +These commit messages follow patterns: + +- **Feature introductions** name the pattern or concept: "Observer Pattern", "multi select" +- **Rename commits** use arrow notation: `as_Node/Net -> as_node/net` +- **Incremental progress** says so honestly: "towards X", "more X work", "groundwork" +- **Emotional honesty** is acceptable: "not perfect, but progress is progress" +- **Fix commits** follow their cause closely + +Write commit messages for someone reading the log six months from now. They should be +able to reconstruct the *story* of the project, not just the diff. + +--- + +## Part II: How to Operate on the Codebase + +### 6. The Build System + +CMake 3.25+ with C++20. Three build targets matter for daily work: + +```bash +# Full build (editor included) +cmake -B build -DATTOLANG_BUILD_EDITOR=ON +cmake --build build --parallel + +# Core + compiler only (faster, no SDL/ImGui dependency) +cmake -B build -DATTOLANG_BUILD_EDITOR=OFF +cmake --build build --parallel + +# Run tests +./build/test_inference +``` + +On Windows, use vcpkg for SDL3 and ImGui: +```bash +cmake -B build -DCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake +``` + +On Linux/macOS, FetchContent handles dependencies automatically. + +The build is fast. The core library has zero external dependencies. If you find yourself +waiting, something is misconfigured. + +### 7. The Four Layers of Change + +Any meaningful change touches one or more of these layers, in this order: + +#### Layer 1: Model (`src/atto/model.h`, `types.h`, `node_types.h`) + +The data model is the foundation. Changes here ripple everywhere. If you add a field +to FlowNode, you must update serialization, inference, codegen, and the editor. Do not +add fields casually. Every field is a commitment. + +The type system (`types.h`) is the most complex part of the model. TypeExpr carries +kind, category, genericity, literal values, symbol names, function signatures, struct +fields — all in one structure. This is deliberate: a unified type representation means +one parser, one comparison function, one serializer. The cost is complexity in TypeExpr +itself; the benefit is simplicity everywhere else. + +#### Layer 2: Inference (`src/atto/inference.h`, `inference.cpp`) + +The inference engine is a multi-pass fixed-point computation. It iterates phases until +no types change: + +1. Clear all resolved types +2. Parse type annotations from strings +3. Forward-propagate through wires +4. Infer expression nodes +5. Backpropagate from consumers +6. Resolve lambda boundaries +7. Fix up iterator dereferences +8. Insert shadow deref nodes + +When modifying inference, the critical invariant is **monotonicity**: types only become +more specific, never less. A pin that resolved to `f32` never reverts to `generic`. +If your change can cause type regression, the fixed-point loop may not converge. + +#### Layer 3: Code Generation (`src/attoc/codegen.h`, `codegen.cpp`) + +The code generator is a straightforward tree walk. It visits each node and emits C++. +The output is human-readable — this is a feature, not an accident. When something goes +wrong at runtime, the developer reads the generated C++ to understand what happened. + +Code generation follows the type system closely. Every TypeKind maps to a C++ type. +Every NodeTypeID maps to an emit function. If you add a type kind, add a C++ mapping. +If you add a node type, add an emit function. + +#### Layer 4: Editor (`src/attoflow/`) + +The editor is three layers deep: + +``` +FlowEditorWindow → Editor2Pane → VisualEditor +(chrome) (semantics) (canvas) +``` + +- **VisualEditor** handles pan, zoom, hover detection, and drawing primitives +- **Editor2Pane** wraps GraphBuilder and implements graph editing semantics +- **FlowEditorWindow** manages tabs, file browser, and build toolbar + +The editor communicates with the model through the **observer pattern**. GraphBuilder +fires callbacks (via IGraphEditor, INodeEditor, INetEditor) when the graph changes. +The editor implements these interfaces. The model never imports editor code. + +When adding editor features, ask: does this belong in canvas rendering (VisualEditor), +graph semantics (Editor2Pane), or application chrome (FlowEditorWindow)? + +### 8. The Type System as Instruction Set + +The type system is the instruction set of the Organic Assembler. Every value has a type, +and that type dictates what operations are valid, how the value is stored, how it flows +through wires, and how it renders in the editor. + +**TypeKind** is *what* a value is: +- Scalar, String, Bool — primitive data +- Container, Array, Tensor — collections +- Function, Struct — composite structures +- Symbol, MetaType — compile-time abstractions +- Named — user-defined types via `decl_type` + +**TypeCategory** is *how* a value is accessed: +- `%` Data — plain value (default, the sigil is usually omitted) +- `&` Reference — mutable location +- `^` Iterator — position into a container +- `@` Lambda — callable function reference +- `#` Enum — enumeration value +- `!` Bang — trigger signal carrying no data +- `~` Event — event source + +These two dimensions are orthogonal. A `&vector` is a mutable reference to a +vector of floats. A `@(f32)->f32` is a callable that takes and returns a float. A +`!` is a bang trigger. Understanding this grid is prerequisite to working on any part +of the system. + +### 9. The .atto File Format + +Instruments are stored as `.atto` files in a TOML-like format: + +```toml +version = "instrument@atto:0" + +[viewport] +x = -200.0 +y = -100.0 +zoom = 1.0 + +[[node]] +id = "$auto-a1b2c3d4e5f67890" +type = "expr" +args = ["$0 + $1"] +inputs = ["$auto-a1b2c3d4e5f67890-in0", "$auto-a1b2c3d4e5f67890-in1"] +outputs = ["$auto-a1b2c3d4e5f67890-out0"] +position = [100.0, 200.0] + +[[link]] +from = "$auto-aabbccdd-out0" +to = "$auto-a1b2c3d4e5f67890-in0" +``` + +Key conventions: +- **Node IDs** are `$auto-` for auto-generated, or user-assigned names +- **Pin IDs** are `-` (e.g., `$auto-abc123-in0`, `$auto-abc123-out0`) +- **Net names** are `$` for named nets (e.g., `$osc-signal`) +- **Version** is always `instrument@atto:0` when saved; older versions auto-migrate on load +- **Args** is an array of strings — the legacy format for inline arguments + +The serializer (`src/atto/serial.h`) handles reading and writing. It also handles +version migration: `nanoprog@0` → `nanoprog@1` → `attoprog@0` → `attoprog@1` → +`instrument@atto:0`. Each migration step is a function that transforms the data model. + +### 10. Patterns to Follow + +The codebase uses specific patterns documented in [patterns.md](patterns.md). These are +not optional. When your code faces a problem that one of these patterns solves, use the +pattern: + +| Pattern | When to use | +|---------------------------|----------------------------------------------------| +| **Sentinel** | Any optional reference accessed frequently | +| **Observer** | Model changes that the editor needs to know about | +| **Mutation Batching** | Multi-step edits that should appear atomic | +| **Dirty Tracking** | Anything that should trigger partial re-evaluation | +| **Discriminated Union** | Multiple shapes sharing one base (use kind + as_*) | +| **Factory Method** | Complex construction that should be encapsulated | +| **TypePool Interning** | Any type that will be compared by identity | +| **Static Descriptor** | Fixed metadata tables indexed by enum | +| **Pin ID as String** | Structured identifiers with embedded meaning | +| **Version Migration** | File format changes that must load old files | + +### 11. Testing Strategy + +The inference engine has unit tests (`tests/test_inference.cpp`). The testing philosophy +is: **test the inference engine, manually test the editor, compile-test the codegen**. + +- **Inference**: Automated. Build test graphs programmatically, run inference, assert + types. Add a test for any new type interaction or inference behavior. +- **Editor**: Manual. Load an instrument, interact with the UI, verify visually. The + immediate-mode rendering makes automated UI testing impractical. +- **Codegen**: Compile the output. If the generated C++ compiles and runs correctly, + the codegen is correct. The generated code is human-readable for manual inspection. +- **Instruments**: The ultimate integration test. If `scenes/klavier/main.atto` loads, + infers, compiles, and runs, the system is healthy. + +--- + +## Part III: How to Make an Instrument + +### 12. What Is an Instrument? + +An instrument is a self-contained `.atto` program that defines some interactive, +real-time behavior — typically audio synthesis, visual display, or both. The word +"instrument" is deliberate: it evokes a musical instrument, a scientific instrument, +a tool for exploration and expression. + +An instrument is not a general-purpose program. It is a **specific thing that does a +specific thing**. A piano. A spectrum analyzer. A particle system. A fader mixer. The +constraint is the point: instruments are focused, purposeful, alive. + +### 13. The Anatomy of an Instrument + +Every instrument has these structural elements: + +#### Declarations (the vocabulary) + +``` +decl_type — Define a struct type (e.g., oscillator_state with fields) +decl_var — Declare a mutable variable (persistent state across frames) +decl_event — Declare an event (e.g., key_pressed, note_on) +decl_import — Import a standard library module (e.g., "std/gui", "std/imgui") +ffi — Declare an external C function +``` + +Declarations are the *nouns* of the instrument. They name the things that exist. + +#### Expressions (the arithmetic) + +``` +expr — Compute a value from inputs ($0 + $1, sin($0), my_struct.field) +new — Construct a struct or container +dup — Duplicate a value (branch a wire) +str — Format a string +cast — Convert between types +select — Choose between values based on condition +``` + +Expressions are the *adjectives and adverbs* — they describe and transform values. + +#### Bang Chains (the verbs) + +``` +store! — Write a value to a variable +append! — Add an element to a collection +erase! — Remove an element from a collection +iterate! — Loop over a collection +lock! — Acquire a mutex and execute a body +call! — Call a function with side effects +event! — Fire a custom event +output_mix! — Mix audio output (additive) +``` + +Bang nodes are the *verbs*. They have explicit execution order: a bang signal flows +from trigger to next, left to right, defining the temporal sequence of side effects. +This is what makes dataflow compatible with imperative mutation: the bang chain is a +sequential program embedded in a parallel graph. + +#### Events (the stimuli) + +``` +on_key_down! — React to a key press +on_key_up! — React to a key release +event! — React to a custom event +``` + +Events are the *entry points*. An instrument does nothing until an event fires. +The audio_tick callback (from `av_create_window`) fires 48,000 times per second. +Key events fire on user input. Custom events fire when explicitly triggered. + +### 14. Building Your First Instrument + +Start simple. A minimal instrument: + +1. **Import the runtime**: `decl_import "std/gui"` and `decl_import "std/imgui"` +2. **Create a window**: `ffi av_create_window` with audio and video callbacks +3. **Add a variable**: `decl_var` for persistent state (e.g., a counter, a frequency) +4. **Add a UI**: In the video callback, use `imgui_slider_float` to control the variable +5. **Generate audio**: In the audio callback, compute samples from the variable + +The flow looks like this: + +``` +decl_import "std/gui" +decl_import "std/imgui" + │ + ▼ +ffi av_create_window + ├── audio_tick callback (@lambda) + │ ├── read frequency variable + │ ├── compute sine wave: sin(2π × freq × phase) + │ ├── store! phase increment + │ └── output_mix! the sample + │ + └── video_tick callback (@lambda) + ├── imgui_begin "Controls" + ├── imgui_slider_float "Frequency" &freq 20.0 2000.0 + └── imgui_end +``` + +### 15. The Audio Callback Pattern + +Audio runs at 48kHz. Each invocation of the audio_tick lambda produces one sample +(or one sample per channel). The pattern: + +1. **Read state** — variables declared outside the callback persist across invocations +2. **Compute** — pure math on the inputs (oscillators, filters, envelopes) +3. **Write state** — `store!` the updated phase, amplitude, filter state +4. **Output** — `output_mix!` adds the computed sample to the output buffer + +The bang chain within audio_tick is the inner loop of the instrument. Every node in +this chain runs 48,000 times per second. Keep it minimal. No allocations, no string +operations, no collection mutations in the hot path. + +### 16. The Video Callback Pattern + +Video runs at 60 FPS (or whatever the display refresh rate is). The video_tick lambda +builds an ImGui frame: + +1. **Begin windows** — `imgui_begin "Window Title"` +2. **Add controls** — sliders, buttons, text, plots +3. **Read/write state** — controls bind to variables via `&` references +4. **End windows** — `imgui_end` + +ImGui is immediate-mode: you rebuild the entire UI every frame. There is no retained +widget tree. This is simple and robust — the UI always reflects the current state. + +### 17. Working with Collections + +Collections (vector, map, set) require explicit mutation via bang nodes: + +``` +decl_var my_list : vector — Declare the collection +append! my_list value — Add an element (in a bang chain) +erase! my_list index — Remove an element (in a bang chain) +iterate! my_list body — Loop over elements (in a bang chain) +lock! my_list body — Acquire mutex for thread-safe access +``` + +The `iterate!` node creates a lambda body that receives each element as an iterator. +Use `next` to advance and dereference the iterator. + +Collections bridge the gap between the functional dataflow graph and imperative +stateful computation. The bang chain ensures mutations happen in a defined order. + +### 18. Named Nets and Long-Distance Wiring + +When an instrument grows large, visual wire routing becomes unwieldy. Named nets +solve this: + +- Assign a net name (e.g., `$osc-signal`) to a link +- Any other link with the same net name is implicitly connected +- The nets editor panel shows all named nets as a table of contents + +Named nets are like global labels in assembly: they provide addressability without +routing. Use them for signals that are consumed in many places (master volume, clock, +transport state) or for connections that span large distances on the canvas. + +### 19. Lambda Capture and Scope + +Lambda nodes (iterate!, lock!, on_key_down!, audio_tick callbacks) create nested scopes. +Nodes inside a lambda body can access: + +- **Lambda parameters** — provided by the enclosing construct (iterator element, key code) +- **Outer variables** — declared outside the lambda (via `decl_var`) +- **Wire inputs** — values flowing in from outside the lambda boundary + +The lambda boundary is defined by the bang chain: nodes reachable only through the +lambda's bang-next chain are *inside* the lambda. Nodes connected via data wires from +outside are *captured*. + +**Known limitation**: Nested lambdas (a lock! inside an iterate! inside a callback) +can have scope leakage where outer parameters appear to belong to inner lambdas. +See [thinking.md](thinking.md) for the technical details and planned fix. + +### 20. Type-Driven Development + +The type system is your development partner. When building an instrument: + +1. **Start with declarations** — Define your types (`decl_type`) and variables (`decl_var`). + The type system will propagate these through the graph. +2. **Connect wires** — As you connect nodes, the inference engine resolves types + bidirectionally. Watch the pin types in the editor — they update live. +3. **Let literals resolve** — Type `42` and the inference engine will figure out if it + should be `u32` or `f32` based on context. You rarely need explicit type annotations. +4. **Read error messages** — When types don't match, the inference engine tells you why. + Error messages include the types that conflicted and the context where it happened. +5. **Use symbols** — Bare identifiers like `sin`, `pi`, `my_var` resolve through the + symbol table. If a symbol is undefined, it becomes `undefined_symbol` — a + compile-time placeholder that errors only when evaluated. + +The type system catches most errors at edit time. If your instrument type-checks, it +will very likely compile and run correctly. + +--- + +## Part IV: Guiding Principles + +### 21. Instruments First + +Every feature, every refactor, every design decision is evaluated against one question: +**does this help someone build a better instrument?** If the answer is no, the work is +not justified. The system exists to serve instruments, not itself. + +### 22. Real-Time or Nothing + +Audio processing at 48kHz with less than 10ms latency is a hard constraint. This is +not a soft goal or a nice-to-have. It is a physical requirement: human ears detect +latency above ~10ms as a distinct echo. Any design that cannot meet this constraint +is rejected, no matter how elegant. + +### 23. Graph as Truth + +The FlowGraph is the canonical representation. There is no separate AST, no hidden +state, no shadow copy. What you see in the editor is what gets compiled. What you +read in the `.atto` file is what the editor displays. This single-source-of-truth +principle eliminates an entire class of synchronization bugs. + +### 24. Types Prevent Errors + +The more information encoded in the type system, the more errors caught at edit time +rather than at runtime. `literal` exists because the type system should know that +`42` is a compile-time constant. `symbol` exists because the type system +should know that `sin` is a function. Every bit of semantic information moved into the +type system is a class of runtime errors eliminated. + +### 25. Simple Until Proven Insufficient + +Start with the simplest possible implementation. Use strings until strings become a +problem. Use single-threaded until profiling demands parallelism. Use manual testing +until the test matrix becomes unmanageable. + +The `node.args` string is the poster child for this principle: it started simple, +it served well, and now it is technical debt. That is the *correct* outcome. The +alternative — designing a complex structured representation before the needs were +clear — would have been worse. Premature abstraction is the root of all evil. + +### 26. Process Boundaries Are Trust Boundaries + +The editor does not trust the runtime. The compile server does not trust user input. +Each process boundary has explicit validation. This is not paranoia; it is the +recognition that instruments can and will crash, and the system that manages them +must survive. + +--- + +## Part V: For the Machine Reader + +If you are an AI assistant operating on this codebase: + +### 27. Respect the Architecture + +Do not add dependencies from attolang to attoflow. Do not add SDL or ImGui includes +to core library files. Do not bypass the observer pattern by having the model call +editor code directly. The architecture diagram in [architecture.md](architecture.md) +is a constraint, not a suggestion. + +### 28. Follow the Patterns + +If a pattern exists for your problem (sentinel, observer, mutation batching, dirty +tracking), use it. Do not invent a new pattern when an existing one applies. The +patterns in [patterns.md](patterns.md) evolved from real problems in this codebase; +they are load-bearing. + +### 29. Minimize Blast Radius + +When making a change, affect the smallest number of files possible. If you are adding +a feature to inference, you should not need to modify the editor. If you are adding a +UI widget, you should not need to modify the type system. The layered architecture +exists to contain changes. + +### 30. Read the Git Log + +The commit history tells you what was tried, what worked, and what was abandoned. Before +proposing a refactor, check if it was already attempted. Before introducing a pattern, +check if an existing pattern already solves the problem. The log is institutional memory. + +### 31. Test with Real Instruments + +The ultimate test is: does `scenes/klavier/main.atto` still load, infer, and compile? +This instrument exercises most of the system: complex types, nested lambdas, collections, +events, audio, GUI, FFI. If it works, the system is healthy. + +### 32. Write for the Human + +Generated code should be human-readable. Commit messages should tell a story. Error +messages should include context. Documentation should explain *why*, not just *what*. +The system is ultimately for humans who want to make instruments. Every artifact — +code, docs, generated output — should serve that human. + +--- + +*The best instruction is the one you no longer need to give, because the system makes +the right choice obvious. That is what the type system, the patterns, and the +architecture are for: making the right choice the easy choice.* diff --git a/docs/names.md b/docs/names.md new file mode 100644 index 0000000..e98b7d3 --- /dev/null +++ b/docs/names.md @@ -0,0 +1,377 @@ +# Organic Assembler — On Names + +Naming things is one of the two hard problems in computer science. The other is cache +invalidation. This project has both (see: TypePool interning and dirty tracking), but +this document is about the first. + +Names in the Organic Assembler are not labels slapped on after the fact. They are +*decisions* — small acts of design that accumulate into the character of the system. +A bad name is a lie that everyone agrees to believe. A good name is a truth that makes +other truths easier to see. + +--- + +## Part I: The Names That Were + +### The Great Rename + +The project was born as **nanolang**. The editor was **nanoflow**. The compiler was +**nanoc**. The runtime was **nanoruntime**. The file format was `nanoprog@0`. + +Then the project grew, and "nano" stopped fitting. The language was not nano — it had +a rich type system, bidirectional inference, lambda capture, compile-time metaprogramming. +The editor was not nano — it had mutation batching, dirty tracking, observer patterns, +three-layer architecture. Nothing about the system was small. + +The rename to **atto** happened in a single phase (Phase 2b, March 24). Every file, +every variable, every comment, every format string. `nanolang` → `attolang`. +`nanoflow` → `attoflow`. `nanoc` → `attoc`. `nanoprog@0` → `attoprog@0`. + +The version markers tell the story: +``` +nanoprog@0 → nanoprog@1 → attoprog@0 → attoprog@1 → instrument@atto:0 +``` + +Each migration step is a function in the serializer. The old names are not forgotten — +they live in the migration code, translating the past into the present. The system can +still load a `nanoprog@0` file. It just calls it `instrument@atto:0` when it saves. + +"Atto" means 10⁻¹⁸. It is smaller than "nano" (10⁻⁹) by nine orders of magnitude. +The irony is intentional: as the project grew more complex, its name became more humble. +The best names have a sense of humor about themselves. + +### The Wire That Became a Net + +In early commits, connections between nodes were called **wires**. A wire connected an +output pin to an input pin. Simple, physical, intuitive. + +Then named connections appeared — the ability to give a name to a signal so that distant +nodes could share it without visual routing. These were not wires anymore; they were +**nets**. The word comes from electronics: a net is a set of electrically connected +points, regardless of the physical routing. + +The rename is visible in the git log: +``` +wire -> net in graph_builder +``` + +This single commit changed the vocabulary of the entire builder layer. `WireBuilder` +became `NetBuilder`. `wire_id` became `net_name`. The old word persisted in the +visual layer — we still *draw* wires on the canvas — but the semantic layer speaks +of nets. + +The lesson: **when the concept changes, the name must change with it**. A wire implies +point-to-point. A net implies broadcast. The old name would have been a lie. + +### BangInput → BangTrigger, BangOutput → BangNext + +Pin directions were originally named from the perspective of the pin itself: +`BangInput` (a pin that receives a bang) and `BangOutput` (a pin that sends a bang). + +But this created confusion when reading node definitions. Does "BangInput" mean "the +input that carries the bang signal" or "the bang that inputs to this node"? Both +readings are valid, and that ambiguity is a naming failure. + +The rename to `BangTrigger` and `BangNext` resolved the ambiguity by naming what +the pin *does*: +- **BangTrigger** — this pin *triggers* the node's execution +- **BangNext** — this pin fires *next*, continuing the bang chain + +The new names are verbs, not nouns. They describe action, not identity. This is +a pattern throughout the codebase: when a name causes confusion, rename it to +describe behavior rather than structure. + +### as_Node/Net → as_node/net + +A small rename, but it illustrates a principle: **naming conventions are not +optional**. The C++ convention in this project is snake_case for functions. `as_Node` +violates that convention. `as_node` does not. The rename happened in a dedicated +commit with dead code deletion, because naming fixes deserve their own commits. + +### graphbuilder → graph_builder + +Another convention fix. Compound names use underscores. `graphbuilder` looks like +a single word; `graph_builder` reveals its structure. The underscore is not +punctuation — it is *meaning*. It says: this is two concepts composed. + +### editor → window + +The top-level editor class was originally called `editor.cpp` / `editor.h`. But it +managed tabs, file browsing, build toolbars, and child processes — it was a *window*, +not an editor. The actual editing happened in `Editor2Pane`. The rename aligned +the filename with its responsibility. + +--- + +## Part II: The Names That Are + +### Project and Subsystem Names + +| Name | Meaning | Why this name | +|------|---------|---------------| +| **Organic Assembler** | The project as a whole | "Organic" because instruments grow organically from nodes; "Assembler" because the system assembles them into running programs | +| **orgasm** | Repository / directory name | The abbreviation. Memorable, irreverent, honest about the creative pleasure of building instruments | +| **attolang** | The core language library | "atto" (10⁻¹⁸) + "lang" (language). The language is a building block, deliberately small in scope | +| **attoflow** | The visual editor | "atto" + "flow" (dataflow). You author instruments by directing the flow of data | +| **attoc** | The compiler | "atto" + "c" (compiler). Following the Unix tradition: cc, gcc, rustc, attoc | +| **attohost** | The instrument runtime | "atto" + "host" (host process). The runtime hosts the instrument, providing it a world to live in | +| **attostd** | The standard library | "atto" + "std" (standard). Following the C++/Rust convention of a `std` module | + +### Type System Names + +| Name | Meaning | Why this name | +|------|---------|---------------| +| **TypeExpr** | A type expression | Not "Type" (too generic) or "TypeNode" (implies a tree). TypeExpr is a self-contained expression that fully describes a type | +| **TypeKind** | What a value *is* | "Kind" in the type-theory sense: the shape of a type. Not to be confused with "kind" in Haskell (types of types) | +| **TypeCategory** | How a value is *accessed* | "Category" as in grammatical category. Data, Reference, Iterator, Lambda — these are ways of being, not types of thing | +| **TypePool** | Intern cache for types | "Pool" as in object pool. Types are interned (deduplicated) so that pointer equality implies structural equality | +| **TypeParser** | Recursive-descent type parser | Self-documenting. Parses type strings into TypeExpr structures | +| **ScalarType** | Numeric primitive types | "Scalar" as opposed to "vector" or "composite". u8, s32, f64 are scalars | +| **literal\** | Compile-time constant | "Literal" as in "the literal value 42". T is the type domain, V is the value. The angle brackets echo C++ template syntax | +| **symbol\** | Named reference | "Symbol" as in symbol table. A name that refers to something, carrying both the name and what it resolves to | + +### Graph Model Names + +| Name | Meaning | Why this name | +|------|---------|---------------| +| **FlowGraph** | The top-level program representation | "Flow" because it is a dataflow graph. Not "Program" (too abstract) or "Scene" (too visual) | +| **FlowNode** | A computation step in the graph | "Node" is the standard graph-theory term. "Flow" prefix distinguishes it from GUI nodes | +| **FlowLink** | A connection between pins | "Link" rather than "edge" because links carry type information and net names — they are richer than mathematical edges | +| **FlowPin** | An input or output on a node | "Pin" from electronics: a connection point on a component. Familiar to anyone who has used a visual programming tool | +| **GraphBuilder** | High-level editing API | "Builder" as in builder pattern — it constructs and modifies graphs with structured operations, not raw mutations | +| **GraphIndex** | Fast lookup structure | "Index" as in database index — it accelerates queries without changing the underlying data | +| **GraphInference** | Type inference engine | Self-documenting. Runs inference over a graph | + +### Editor Names + +| Name | Meaning | Why this name | +|------|---------|---------------| +| **VisualEditor** | Canvas rendering layer | "Visual" because this layer knows only about drawing — coordinates, shapes, colors | +| **Editor2Pane** | Semantic editing layer | "Editor2" because it is the second-generation editor (replacing editor1). "Pane" because it is one panel in a tabbed window | +| **FlowEditorWindow** | Top-level application window | "Window" because it manages the OS window, tabs, toolbars, and child processes | +| **NodeEditorImpl** | Per-node editor state | "Impl" because it implements the INodeEditor interface. Each node in the graph has one | +| **NetEditorImpl** | Per-net editor state | Same pattern as NodeEditorImpl, but for named nets | + +### Sentinel Names + +| Name | Meaning | Why this name | +|------|---------|---------------| +| **$empty** | Node sentinel (no real node) | "Empty" because it has no content. The `$` prefix marks it as a system-generated ID | +| **$unconnected** | Net sentinel (no real net) | "Unconnected" because the pin has no wire. Descriptive of the situation, not the object | + +### Pin and ID Names + +| Name | Meaning | Why this name | +|------|---------|---------------| +| **$auto-\** | Auto-generated node ID | "$auto" says "the system chose this". The hex GUID ensures uniqueness | +| **\-in0** | Input pin reference | The dash-separated format encodes structure: which node, which pin, which index | +| **\-out0** | Output pin reference | Same convention as input, for outputs | +| **\-bang0** | Bang trigger pin | Same convention, for bang triggers | +| **$\** | Named net | The `$` prefix distinguishes net names from node IDs. Short, scannable | + +--- + +## Part III: The Art of Naming + +### Rule 1: Name the Concept, Not the Implementation + +`TypeCategory` is better than `TypeAccessMode`. The concept is categorical (Data, +Reference, Iterator, Lambda are categories of being), not modal (they don't "switch +modes"). The name should survive a refactor of the implementation. + +`FlowGraph` is better than `NodeArray`. The concept is a graph with dataflow semantics. +The implementation happens to use arrays, but that is incidental. If the implementation +changed to use a hash map, `FlowGraph` would still be correct; `NodeArray` would be a lie. + +### Rule 2: Prefer Verbs for Actions, Nouns for Things + +- `mark_dirty()` — verb, describes what happens +- `is_dirty()` — adjective (via "is"), describes a state +- `edit_start()` / `edit_commit()` — verbs, describe the operation +- `FlowGraph` — noun, names a thing +- `GraphBuilder` — noun (agent noun: "one who builds"), names a thing that acts + +Do not use verbs for things or nouns for actions. `BuildGraph` is confusing: is it a +command (build the graph!) or a noun (a built graph)? `GraphBuilder` is unambiguous. + +### Rule 3: Abbreviate Only When the Abbreviation Is More Familiar Than the Full Name + +- `f32` > `float32` > `single_precision_float` — everyone knows f32 +- `u8` > `uint8` > `unsigned_eight_bit_integer` — everyone knows u8 +- `ffi` > `foreign_function_interface` — everyone knows FFI +- `gui` > `graphical_user_interface` — everyone knows GUI +- `expr` > `expression` — used so often that brevity matters +- `decl` > `declaration` — same reason + +But: +- `FlowGraph` > `FG` — the abbreviation is meaningless +- `TypeCategory` > `TC` — the abbreviation is meaningless +- `GraphBuilder` > `GB` — the abbreviation is meaningless + +The test: if someone seeing the abbreviation for the first time cannot guess its meaning, +spell it out. + +### Rule 4: Use Prefixes for Namespacing, Suffixes for Classification + +- `Flow` prefix: FlowGraph, FlowNode, FlowLink, FlowPin — all part of the flow model +- `Arg` prefix: ArgNet2, ArgNumber2, ArgString2, ArgExpr2 — all argument types +- `Node` prefix: NodeTypeID, NodeType, NodeEditorImpl — all node-related +- `Type` prefix: TypeExpr, TypeKind, TypeCategory, TypePool, TypeParser — all type-related +- `2` suffix: FlowArg2, Editor2Pane, node_types2.h — second generation (replaces v1) +- `Ptr` suffix: TypePtr, ExprPtr, FlowArg2Ptr — smart pointer aliases +- `Bang` suffix: StoreBang, CallBang, ExprBang — bang-chain variants of base nodes +- `Impl` suffix: NodeEditorImpl, NetEditorImpl — interface implementations + +Prefixes group by domain. Suffixes classify within a domain. Together they create a +two-dimensional naming grid that is scannable and predictable. + +### Rule 5: The Name Should Survive the Rename + +Names that describe *what* something is are more stable than names that describe *where* +it lives or *when* it was created. + +- `GraphBuilder` will still make sense if the file moves from `src/atto/` to `src/core/` +- `Editor2Pane` will stop making sense when Editor3 arrives (but by then it will be renamed) +- `FlowGraph` will still make sense if the serialization format changes +- `nanoprog@0` stopped making sense when the language was renamed (and was migrated) + +When choosing a name, ask: "Will this name still be true after the next refactor?" + +### Rule 6: Sigils Are Names Too + +The type category sigils are single-character names: + +| Sigil | Name | Mnemonic | +|-------|------|----------| +| `%` | Data | Percent — the "default" (as in "100% of values are data") | +| `&` | Reference | Ampersand — borrowed from C/C++ reference syntax | +| `^` | Iterator | Caret — points "up" to the current position | +| `@` | Lambda | At — a function "at" a callable address | +| `#` | Enum | Hash — enumerated, numbered items | +| `!` | Bang | Exclamation — an imperative command, "do this!" | +| `~` | Event | Tilde — a wave, something that comes and goes | + +Each sigil was chosen because it has a visual or linguistic mnemonic. `!` for bang is +the most natural: an exclamation mark *is* a bang. `~` for event evokes a signal wave. +`&` for reference comes from C++ and needs no explanation. + +Sigils are the shortest possible names. They work because the set is small (seven), +the context is constrained (type strings), and the mnemonics are strong. Do not add +new sigils without strong justification — the cognitive load scales faster than the +count. + +### Rule 7: Error Messages Are Names for Problems + +Error messages in the Organic Assembler follow a convention: +- Lowercase +- Descriptive +- Context-included + +``` +"type mismatch: expected f32, got u8" +"undefined symbol: foo" +"cannot iterate over non-collection type f32" +``` + +Each error message *names* the problem in a way that suggests the fix. "type mismatch: +expected f32, got u8" tells you exactly which types conflicted and which direction the +expectation flows. "undefined symbol: foo" tells you which name is missing. The error +message is a name for a specific situation, and like all names, it should be precise, +honest, and helpful. + +### Rule 8: File Names Are Architecture + +The file organization of the codebase is itself a naming system: + +``` +src/atto/ — core language (no external deps) +src/attoc/ — compiler (depends on atto) +src/attoflow/ — editor (depends on atto, SDL3, ImGui) +src/attoruntime/ — runtime (depends on atto) +attostd/ — standard library (.atto files) +scenes/ — example instruments +tests/ — automated tests +docs/ — documentation +``` + +Each directory name is a boundary declaration. `src/atto/` says: "everything in here +has zero external dependencies." `src/attoflow/` says: "everything in here can use +SDL3 and ImGui." The directory name is not just organization — it is a *constraint* +that the build system enforces. + +When you create a new file, its location is its first name. Choose carefully: putting +a file in `src/atto/` is a promise that it will never depend on SDL3. Putting it in +`src/attoflow/` is a promise that it is editor-specific. + +--- + +## Part IV: Naming as Practice + +### The Naming Moment + +Every variable, function, type, file, commit message, and error message is a naming +moment. Most naming moments are small and forgettable — `int i`, `auto& node`, `bool ok`. +These are fine. Not every name needs to be profound. + +But some naming moments are *load-bearing*. The name `FlowGraph` shapes how everyone +thinks about the data model. The name `instrument` shapes how everyone thinks about +the programs. The name `bang` shapes how everyone thinks about execution order. These +names, once established, become the vocabulary of the project. Changing them requires +a Great Rename. + +Recognize the load-bearing naming moments. Spend time on them. Sleep on them. The +name you choose today will be repeated ten thousand times in code, docs, conversations, +and commit messages. It will shape how people think about the concept it names. + +### When to Rename + +Rename when: +- The name is actively misleading (wire → net, when connections became broadcast) +- The name violates an established convention (as_Node → as_node) +- The concept has genuinely changed (editor → window, when the class became a window manager) + +Do not rename when: +- You merely *prefer* a different name (preference is not justification) +- The name is "not perfect but fine" (perfection is the enemy of stability) +- The rename would touch 50+ files for marginal clarity (blast radius matters) + +A rename is a coordinated migration. Do it in one commit, touch all references, update +docs and comments. Half-renamed code is worse than badly-named code — at least badly-named +code is consistent. + +### The Compound Name Test + +When a name becomes a compound (`GraphBuilderEntry`, `FlowNodeBuilderPtr`, +`INodeEditorImpl`), check if the compounds are load-bearing: + +- `GraphBuilder` + `Entry` = a thing in the graph builder. Good: both parts carry meaning. +- `FlowNodeBuilder` + `Ptr` = a pointer to a flow node builder. Good: `Ptr` is a standard suffix. +- `I` + `NodeEditor` + `Impl` = an implementation of the node editor interface. Good: `I` prefix for interfaces, `Impl` suffix for implementations. + +But beware compound names that merely concatenate context: +- `EditorGraphBuilderNodeArgNet2Ptr` — too long, too many levels of nesting +- If a name requires more than three components, the concept may need decomposition + +### The Instrument Name + +The most important name in the Organic Assembler is **instrument**. Not "program." Not +"project." Not "sketch" or "patch" or "scene." + +"Instrument" carries specific connotations: +- A musical instrument is played, not executed +- A scientific instrument measures, not computes +- An instrument has a *purpose* — it does one thing well +- An instrument is *physical* — it exists in the world, not just in memory +- An instrument is *expressive* — the same instrument in different hands produces + different results + +Every `.atto` file is an instrument. The system is an operating system *for* instruments. +This name frames the entire project. It tells the user: you are not writing code, you +are building an instrument. Treat it with the care and intentionality that word implies. + +--- + +*The name is the first line of documentation. It is the shortest possible explanation. +It is the thing that makes everything else easier to understand — or harder, if chosen +poorly. Name things right, and the code explains itself. Name things wrong, and no +amount of documentation can save you.* diff --git a/docs/patterns.md b/docs/patterns.md new file mode 100644 index 0000000..179039c --- /dev/null +++ b/docs/patterns.md @@ -0,0 +1,593 @@ +# Organic Assembler — Design Patterns + +This document catalogs the recurring design patterns, idioms, and structural motifs +found throughout the Organic Assembler codebase. + +## 1. Sentinel Pattern + +**Problem**: Many graph operations return references to nodes or nets that might not +exist. Checking for null at every call site is error-prone and verbose. + +**Solution**: Pre-allocate sentinel objects (`$empty`, `$unconnected`) that serve as +always-valid stand-ins for missing entities. + +```cpp +// Sentinels created once at graph builder initialization +void ensure_sentinels() { + // $empty — default node (returned when no real node is assigned) + // $unconnected — default net (returned when no real net is assigned) +} + +// Every FlowArg2 always has valid references +struct FlowArg2 { + FlowNodeBuilderPtr node_; // always valid ($empty if unassigned) + NetBuilderPtr net_; // always valid ($unconnected if unassigned) +}; +``` + +**Usage**: Code can unconditionally call `arg->node()->id()` without null checks. +The sentinel returns safe defaults (empty strings, zero positions, etc.). + +**When to use**: Any time an object reference is "optional" but accessed frequently. +Sentinels eliminate the most common source of null pointer bugs. + +**Trade-off**: Requires `is_sentinel()` checks in the few places where the distinction +matters (serialization, rendering). + +--- + +## 2. Observer Pattern (via Interfaces) + +**Problem**: The graph model is shared between the compiler and editor. The editor +needs to react to graph changes (re-render, re-layout), but the model shouldn't +depend on UI code. + +**Solution**: Abstract observer interfaces defined in the core library, implemented +by the editor. + +```cpp +// Core library defines the interface +struct IGraphEditor { + virtual std::shared_ptr node_added( + const FlowNodeBuilderPtr& node) = 0; + virtual void node_removed(const NodeId& id) = 0; + virtual std::shared_ptr net_added( + const NetBuilderPtr& net) = 0; + virtual void net_removed(const NodeId& id) = 0; +}; + +struct INodeEditor { + virtual void on_dirty() = 0; + virtual void on_layout_dirty() = 0; +}; + +struct INetEditor { + // ... +}; +``` + +```cpp +// Editor implements the interface +class Editor2Pane : public IGraphEditor { + std::shared_ptr node_added( + const FlowNodeBuilderPtr& node) override { + // Create visual representation, cache layout + return std::make_shared(node); + } +}; +``` + +**Registration**: Editors register via weak pointers to avoid preventing cleanup: + +```cpp +void GraphBuilder::add_editor(std::weak_ptr editor); +``` + +**When to use**: Whenever a core data structure needs to notify UI or other +subsystems of changes without depending on them. + +--- + +## 3. Mutation Batching + +**Problem**: A single user action (connecting a wire, deleting a node) may require +multiple internal mutations. Firing observer notifications after each micro-mutation +causes flickering, inconsistent state, and redundant re-inference. + +**Solution**: Bracket multi-step operations with `edit_start()` / `edit_commit()`. +All observer callbacks are queued and fired in order only on commit. + +```cpp +void GraphBuilder::edit_start() { + if (!mutations_.empty()) + throw std::logic_error("edit_start: uncommitted mutations"); +} + +void GraphBuilder::add_mutation_call(void* ptr, std::function&& fn) { + mutations_.emplace_back(ptr, std::move(fn)); +} + +void GraphBuilder::edit_commit() { + auto batch = std::move(mutations_); + for (auto& [_, fn] : batch) + fn(); +} +``` + +**Usage**: +```cpp +gb->edit_start(); +gb->add_node(...); // queues notification +gb->add_link(...); // queues notification +gb->edit_commit(); // fires both notifications +``` + +**When to use**: Any time multiple related changes should appear atomic to observers. + +**Invariant**: `edit_start()` throws if there are uncommitted mutations — this catches +cases where a previous edit was abandoned without committing. + +--- + +## 4. Dirty Tracking (Three-Level Cascade) + +**Problem**: The editor needs to know *what* changed to minimize re-rendering and +re-inference work. A global "something changed" flag is too coarse. + +**Solution**: Three levels of dirty flags that cascade upward: + +``` +FlowArg2 (pin/arg) ──dirty──> FlowNodeBuilder (node) ──dirty──> GraphBuilder (graph) +``` + +```cpp +// Arg level: any mutation marks the arg dirty +void FlowArg2::mark_dirty() { + // Mark owning node dirty + node_->mark_dirty(); +} + +// Node level: dirty means "re-render this node" +void FlowNodeBuilder::mark_dirty() { + dirty_ = true; + // Notify node editor + if (auto ed = editor_.lock()) + ed->on_dirty(); +} + +// Layout-only dirty: position changed but semantics didn't +void FlowNodeBuilder::mark_layout_dirty() { + layout_dirty_ = true; + // Does NOT cascade to graph (no re-inference needed) + if (auto ed = editor_.lock()) + ed->on_layout_dirty(); +} +``` + +**When to use**: Any system with hierarchical state where changes at one level +affect ancestors. + +**Key insight**: Layout changes (dragging a node) are tracked separately from +semantic changes (modifying an expression). This prevents node movement from +triggering expensive type re-inference. + +--- + +## 5. Discriminated Union (Kind Enum + as_* Methods) + +**Problem**: A set of related types share a common base but have different fields +and behaviors. `std::variant` works for simple cases but doesn't support +`shared_from_this()` or deep inheritance. + +**Solution**: A base class with a `kind()` enum and `as_*()` downcasting methods. + +```cpp +enum class ArgKind : uint8_t { Net, Number, String, Expr }; + +struct FlowArg2 : std::enable_shared_from_this { + 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(); + +protected: + FlowArg2(ArgKind kind, ...); +private: + ArgKind kind_; +}; + +// Concrete types inherit from the base +struct ArgNet2 : FlowArg2 { /* net-specific fields */ }; +struct ArgNumber2 : FlowArg2 { /* number-specific fields */ }; +``` + +**Compared to std::variant**: +- Supports `shared_from_this()` (variants can't) +- Supports inheritance-based polymorphism +- More explicit control over memory layout +- Trade-off: manual dispatch instead of `std::visit` + +**When to use**: When the types need shared ownership, self-references, or when +more than 4-5 alternative types make `std::variant` unwieldy. + +--- + +## 6. Variant-Based Results + +**Problem**: Functions that parse or construct objects can fail. Exceptions are too +heavy for expected failures. Error codes lose type information. + +**Solution**: Return a `std::variant` of success type and error type. + +```cpp +using BuilderResult = std::variant< + std::pair, + BuilderError +>; + +using ParseAttoResult = std::variant< + std::shared_ptr, + BuilderError +>; + +using ParseResult = std::variant< + std::shared_ptr, + std::string // error message +>; +``` + +**Usage**: +```cpp +auto result = parse_atto(stream); +if (auto* err = std::get_if(&result)) { + report_error(*err); + return; +} +auto& gb = std::get>(result); +``` + +**When to use**: Any function that can fail for expected reasons (parsing, validation, +I/O). Reserve exceptions for invariant violations (programmer errors). + +--- + +## 7. Factory Method with Friend Access + +**Problem**: Objects should only be created through a central manager (GraphBuilder) +to ensure proper registration, initialization, and tracking. + +**Solution**: Private constructors with `friend` access for the factory. + +```cpp +struct ArgNet2 : FlowArg2 { + friend struct GraphBuilder; // only GraphBuilder can construct + + // Public API + const std::string& value() const; + void set_value(const std::string& v); + +private: + ArgNet2(const std::shared_ptr& owner); +}; + +// Factory method on GraphBuilder +std::shared_ptr GraphBuilder::build_arg_net(...) { + auto arg = std::shared_ptr(new ArgNet2(shared_from_this())); + // register in pin tracking, set up sentinels, etc. + return arg; +} +``` + +**When to use**: When object creation involves side effects (registration, tracking) +that must happen consistently. + +--- + +## 8. TypePool Interning + +**Problem**: Type expressions are compared by value throughout the codebase (inference, +codegen, editor display). Creating identical TypeExpr objects wastes memory and makes +equality checks expensive. + +**Solution**: A TypePool that parses type strings once and caches the result. + +```cpp +struct TypePool { + // Pre-cached common types + TypePtr t_void, t_bool, t_string; + TypePtr t_u8, t_u16, t_u32, t_u64; + TypePtr t_s8, t_s16, t_s32, t_s64; + TypePtr t_f32, t_f64; + + // Cache for all parsed types + std::map cache; + + TypePtr intern(const std::string& type_str) { + auto it = cache.find(type_str); + if (it != cache.end()) return it->second; + + TypeParser parser(type_str); + auto type = parser.parse(); + if (type) cache[type_str] = type; + return type; + } +}; +``` + +**Benefits**: +- Type equality reduces to pointer comparison (`t1 == t2`) +- No redundant parsing of the same type string +- Common types (f32, u32, bool) are always available without lookup + +**When to use**: Any system with many small, frequently-compared objects +(types, symbols, identifiers). + +--- + +## 9. Multi-Pass Fixed-Point Inference + +**Problem**: In a dataflow graph, types flow in both directions. Node A's output +type depends on node B's input, but node B's input type depends on node A's output. +A single pass can't resolve all dependencies. + +**Solution**: Iterate the inference passes until no types change (fixed point). + +```cpp +void GraphInference::run() { + clear_all(); + resolve_pin_type_names(); + + bool changed = true; + while (changed) { + changed = false; + changed |= propagate_connections(); + changed |= infer_expr_nodes(); + changed |= propagate_pin_ref_types(); + } + + resolve_lambdas(); + fixup_expr_derefs(); + insert_deref_nodes(); +} +``` + +**Properties**: +- **Monotonic**: Types only become more specific, never less. This guarantees convergence. +- **Order-independent**: The fixed-point result is the same regardless of node ordering. +- **Efficient**: Most graphs converge in 2-3 iterations. Linear chains converge in 1. + +**When to use**: Any constraint propagation system where dependencies are cyclic. + +--- + +## 10. Shadow Node Pattern + +**Problem**: Inline expressions on nodes (e.g., `store! $value` where `$value` is an +expression) need to participate in type inference and codegen, but they aren't +separate nodes in the user-visible graph. + +**Solution**: Auto-generate hidden "shadow" expression nodes at load time. + +```cpp +// User writes: store! $my_value +// System generates: +// 1. A shadow expr node with the inline expression +// 2. A link from the shadow node's output to the store! input +// 3. The shadow node is invisible in the editor +``` + +**Lifecycle**: +- Created at load time (`parse_args()`) +- Participate in inference and codegen +- Removed before serialization +- Invisible in the editor (not rendered) + +**When to use**: When user-facing syntax is more compact than the internal +representation, and the translation is mechanical. + +--- + +## 11. Two-Layer Model (Model + Builder) + +**Problem**: The serialization format needs a simple, fast data structure. The editor +needs a rich, observable, transactional data structure. These are conflicting +requirements. + +**Solution**: Two layers with different trade-offs. + +``` +FlowGraph (model layer) GraphBuilder (builder layer) +├── Simple structs ├── shared_ptr ownership +├── Vectors and strings ├── Dirty tracking +├── Fast to serialize ├── Observer interfaces +├── No smart pointers ├── Mutation batching +├── No callbacks ├── Sentinel objects +└── Used by compiler └── Used by editor +``` + +The builder wraps the model and adds editor-specific capabilities. The compiler +works directly with the model layer, avoiding builder overhead. + +**When to use**: When the same data is consumed by systems with very different +performance and functionality requirements. + +--- + +## 12. Recursive Descent Parsing + +**Problem**: Type strings, expressions, and .atto files all need parsing. Each has +different grammar rules but similar parsing needs. + +**Solution**: Hand-written recursive descent parsers with a consistent structure. + +```cpp +struct TypeParser { + std::string src; + size_t pos = 0; + std::string error; + + bool eof() const { return pos >= src.size(); } + char peek() const { return eof() ? 0 : src[pos]; } + char advance() { return eof() ? 0 : src[pos++]; } + void skip_ws(); + std::string read_ident(); + + TypePtr parse(); // entry point + TypePtr parse_function(); // (args) -> ret + TypePtr parse_struct(); // {fields} + TypePtr parse_container(); // name + TypePtr parse_scalar(); // u8, f32, etc. +}; +``` + +All parsers share this pattern: +- String source + position cursor +- `eof()`, `peek()`, `advance()`, `skip_ws()` primitives +- Error string for diagnostics +- Recursive methods for grammar rules + +**Why not a parser generator?** The grammars are small enough that hand-written +parsers are clearer, faster, and easier to debug. Parser generators add a build +step and obscure the grammar. + +--- + +## 13. Static Descriptor Tables + +**Problem**: Node types have fixed properties (port names, port types, categories) +that are the same for every instance. Storing this per-node wastes memory. + +**Solution**: Static arrays of node type descriptors, indexed by NodeTypeID. + +```cpp +static const NodeType2 NODE_TYPES_V2[] = { + { NodeTypeID::Expr, "expr", NodeKind2::Flow, + { /* input ports */ }, { /* output ports */ }, + /* flags */ }, + { NodeTypeID::New, "new", NodeKind2::Flow, + { /* input ports */ }, { /* output ports */ }, + /* flags */ }, + // ... 38 total +}; +``` + +Each node instance stores only its `NodeTypeID`. All type-specific information +comes from the descriptor table. + +**When to use**: When many instances share the same schema and the schema is +known at compile time. + +--- + +## 14. Pin ID as Structured String + +**Problem**: Pins need globally unique identifiers for serialization, linking, and +inspector subscriptions. Integer IDs would be compact but opaque. + +**Solution**: Structured string IDs with embedded meaning. + +``` +"934e3b98bb914e95.out0" — node GUID + output pin name +"$auto-df6e4aa3d0d8d2bc-out0" — auto-generated pin ID +"$a-3F" — compact re-ID after import +"$empty" — sentinel node +"$unconnected" — sentinel net +``` + +Benefits: +- Human-readable (useful for debugging) +- Self-documenting (you can tell which node owns a pin) +- Stable across edits (GUID-based, not index-based) +- Prefixed sentinels ($) are easy to identify + +Trade-off: Larger than integer IDs, slightly slower to compare. In practice, +the type pool interning pattern mitigates the comparison cost. + +--- + +## 15. Category Sigil Dispatch + +**Problem**: Type categories (Data, Reference, Iterator, Lambda, etc.) need to be +encoded in type strings and dispatched on in rendering, inference, and codegen. + +**Solution**: Single-character sigils with parse/render helpers. + +```cpp +// Parsing +inline TypeCategory parse_category(char c) { + switch (c) { + case '%': return TypeCategory::Data; + case '&': return TypeCategory::Reference; + case '^': return TypeCategory::Iterator; + case '@': return TypeCategory::Lambda; + case '#': return TypeCategory::Enum; + case '!': return TypeCategory::Bang; + case '~': return TypeCategory::Event; + default: return TypeCategory::Data; + } +} + +// Rendering (pin shapes) +switch (category) { +case TypeCategory::Data: draw_circle(pos, radius); break; +case TypeCategory::Bang: draw_square(pos, size); break; +case TypeCategory::Lambda: draw_triangle(pos, size); break; +// ... +} +``` + +The sigils serve triple duty: +1. **Serialization**: Compact type representation in .atto files +2. **Dispatch**: Switch on category for type-specific behavior +3. **Visual**: Pin shapes in the editor correspond to categories + +--- + +## 16. Version Migration Chain + +**Problem**: The .atto file format evolves over time. Old files need to be readable +by new versions of the editor and compiler. + +**Solution**: A migration chain that upgrades files one version at a time. + +``` +nanoprog@0 → nanoprog@1 → attoprog@0 → attoprog@1 → instrument@atto:0 +``` + +Each version step has a dedicated migration function: + +```cpp +void migrate_v0_to_v1(FlowGraph& graph) { + // Strip $ from variable references + // Convert @N to $N in expressions + // Rename ports using old→new descriptor mapping + // Fold shadow nodes +} +``` + +The deserializer detects the version header, applies all necessary migrations in +sequence, and returns the result in the latest format. The serializer always writes +the latest version. + +**When to use**: Any persistent format that evolves over time and has existing files +that must remain readable. + +--- + +## Pattern Interactions + +These patterns don't exist in isolation. They interact to form larger structures: + +- **Sentinel + Observer**: Sentinels ensure observers always receive valid references +- **Dirty Tracking + Mutation Batching**: Dirty flags accumulate during a batch, observers fire on commit +- **Factory + Sentinel**: Factories ensure newly created objects start with sentinel defaults +- **TypePool + Inference**: Interned types enable efficient comparison during fixed-point iteration +- **Shadow Nodes + Two-Layer Model**: Shadows exist in the builder layer, stripped before model serialization +- **Discriminated Union + Descriptor Tables**: Node type descriptors define which arg kinds are valid +- **Version Migration + Serialization**: Migration chain runs between deserialization and builder construction + +Understanding these interactions is key to making changes that work *with* the existing +architecture rather than against it. diff --git a/docs/podcasts/growing_Instruments_with_the_Organic_Assembler.m4a b/docs/podcasts/growing_Instruments_with_the_Organic_Assembler.m4a new file mode 100644 index 0000000..9c7c984 Binary files /dev/null and b/docs/podcasts/growing_Instruments_with_the_Organic_Assembler.m4a differ diff --git a/docs/podcasts/introducing-orgasm.md b/docs/podcasts/introducing-orgasm.md new file mode 100644 index 0000000..0e64b06 --- /dev/null +++ b/docs/podcasts/introducing-orgasm.md @@ -0,0 +1,11 @@ +# Growing Instruments with the Organic Assembler + +[growing Instruments with the Organic Assembler](Growing_instruments_with_the_Organic_Assembler.m4a) + +https://soundcloud.com/poiitidis/growing-instruments-with-the + +https://youtu.be/ymzuD-oekFM + +A podcast introducing the Organic Assembler — an Operating System for Instruments. Covers the core metaphor, attolang's type system, the bang chain, bidirectional type inference, the four subsystems, and the design philosophy behind the project. + +Generated via NotebookLM from [the source notes](notes/introducing-orgasm.md). diff --git a/docs/podcasts/notes/introducing-orgasm.md b/docs/podcasts/notes/introducing-orgasm.md new file mode 100644 index 0000000..73da066 --- /dev/null +++ b/docs/podcasts/notes/introducing-orgasm.md @@ -0,0 +1,167 @@ +# Introducing the Organic Assembler: An Operating System for Instruments + +## What Is the Organic Assembler? + +The Organic Assembler is an operating system for instruments. Not applications, not programs, not sketches -- instruments. That word choice is deliberate and load-bearing. A musical instrument is played, not executed. A scientific instrument measures, not computes. An instrument has a purpose -- it does one thing well. An instrument is expressive -- the same instrument in different hands produces different results. + +Every instrument in the Organic Assembler is a self-contained `.atto` file that defines some interactive, real-time behavior -- typically audio synthesis, visual display, or both. The system compiles these programs, runs them with hot-reload support, and provides a visual node-graph editor for authoring them. The project's repository is called "orgasm" -- short for Organic Assembler. Memorable, irreverent, honest about the creative pleasure of building instruments. + +The "Organic" in the name reflects how instruments grow. You don't write an instrument top-to-bottom like a text program. You grow it organically: place a node, connect a wire, hear the result, add another node, adjust a parameter, watch types propagate through the graph in real time. The "Assembler" reflects what the system does: it assembles your graph of nodes into a running, real-time program. + +## The Core Idea: Visual Dataflow for Real-Time Systems + +The fundamental insight behind the Organic Assembler is that instruments are inherently about signal flow. Audio signals, control signals, event triggers, and UI state all flow between processing nodes. A textual programming language forces you to name intermediate values and linearize a fundamentally parallel, graph-shaped computation. A visual dataflow graph shows topology directly -- you see what connects to what. Wires are the variables. Related nodes are placed near each other. Unconnected subgraphs run independently. You change a connection, you see (and hear) the result immediately. + +But pure visual programming has a weakness: complex mathematical expressions are awkward as node graphs. Nobody wants to wire up `a + b * sin(c)` as six separate nodes. So the Organic Assembler supports inline expressions within nodes. You can type `$0 + $1 * sin($2)` directly inside an expression node, where `$0`, `$1`, `$2` refer to the node's input pins. The graph handles topology and signal routing; inline expressions handle the math. It's a hybrid that takes the best of both worlds. + +## The Language: attolang + +The language is called attolang. "Atto" means 10 to the minus 18 -- smaller than "nano" (10 to the minus 9) by nine orders of magnitude. The project was originally called "nanolang," but as the system grew more complex, its name became more humble. The best names have a sense of humor about themselves. + +attolang has a rich type system with two orthogonal dimensions. The first dimension is TypeKind -- what a value IS. Scalars like u8 through s64, f32 and f64. Booleans, strings. Containers like vector, map, list, set, queue. Fixed-size arrays and tensors. Functions and structs. Symbols and meta-types for compile-time abstractions. + +The second dimension is TypeCategory -- HOW a value is accessed. This is where it gets interesting. There are seven categories, each with its own single-character sigil: + +- Percent sign for Data -- plain values, the default +- Ampersand for Reference -- mutable locations, borrowed from C++ syntax +- Caret for Iterator -- a position into a container, pointing "up" to the current element +- At-sign for Lambda -- callable function references, a function "at" an address +- Hash for Enum -- enumerated, numbered items +- Exclamation mark for Bang -- trigger signals carrying no data, an imperative command: "do this!" +- Tilde for Event -- something that comes and goes, like a wave + +These two dimensions are orthogonal. An ampersand-vector-of-f32 is a mutable reference to a vector of floats. An at-sign-f32-arrow-f32 is a callable that takes and returns a float. An exclamation mark is a bang trigger. This grid of kinds and categories is the DNA of every value in the system. + +## The Bang Chain: Making Dataflow Imperative + +One of the most distinctive features of attolang is the bang chain. In a pure dataflow language, everything happens simultaneously -- data flows through the graph, and every node computes when its inputs are ready. But real-time instruments need side effects: storing values to variables, appending to collections, mixing audio output. Side effects need ordering. You need to say "do this, THEN do that." + +Bang nodes -- nodes postfixed with an exclamation mark, like `store!`, `append!`, `iterate!`, `lock!` -- have explicit execution ordering via bang signals. A bang signal flows from one node's "next" output to another node's "trigger" input, creating a chain. This chain is a sequential program embedded in a parallel graph. + +The naming is perfect. An exclamation mark IS a bang. And the pin names tell you exactly what they do: BangTrigger triggers the node's execution; BangNext fires next, continuing the chain. These are verbs, not nouns -- they describe action, not identity. + +## Bidirectional Type Inference + +attolang's type inference engine is bidirectional, which is unusual and powerful. In most languages, types flow in one direction: the producer determines the type, and the consumer must accept it. But in a visual dataflow graph, producers and consumers are peers. There's no clear "this comes first" ordering. + +Consider a literal `0` in an expression. It could be u8, u16, u32, u64, f32, or f64. The correct type comes from the CONSUMER -- whatever that zero flows into determines what type it should be. Without backpropagation from consumer to producer, every literal would need an explicit type annotation. + +The inference engine runs multiple passes in a fixed-point loop: forward-propagate types through wires, infer expression nodes, backpropagate from consumers -- and repeat until nothing changes. Types only become more specific, never less, which guarantees convergence. Most graphs converge in two or three iterations. + +This is what makes the editing experience feel alive. As you connect nodes, types resolve in real time. You watch pin types update live in the editor. The type system is your development partner, catching errors at edit time rather than at runtime. + +## Literal Types and Symbols: Compile-Time Power + +attolang makes compile-time values first-class citizens of the type system. `literal` represents a compile-time constant with type domain T and value V. The number `42` isn't just "an integer" -- it's `literal,42>`, where the question mark means "the exact unsigned type will be resolved from context." This means the type system knows which values are compile-time constants, enabling optimizations and metaprogramming that would otherwise require a separate constant-folding pass. + +Symbols work similarly. When you type `sin` in an expression, the result isn't immediately "the sine function." It's `symbolf32>` -- a first-class named reference that carries both its name and what it resolves to. An identifier that isn't in the symbol table becomes `undefined_symbol` -- it doesn't error immediately, it errors only when something tries to evaluate it. This means partially-written instruments don't explode with cascading errors. You can have unfinished nodes and the system stays calm. + +## The Four Subsystems + +The Organic Assembler has four major subsystems, each independently buildable with well-defined boundaries. + +**attolang** is the core language library. It defines the data model, type system, expression parser, inference engine, serialization, and graph builder. It has zero external dependencies -- just the C++20 standard library. This is a deliberate constraint: the core is pure computation, no I/O, no GUI, no platform dependencies. + +**attoc** is the compiler. It transforms `.atto` files into self-contained C++ projects. The pipeline loads the file, runs type inference, validates, then generates C++ code: struct definitions from `decl_type` nodes, a program class with event handlers, and a complete CMakeLists.txt. Each compiled instrument is an independent project. + +The decision to compile to C++ rather than interpret or compile to LLVM IR was pragmatic. Audio processing at 48kHz with low latency demands native speed -- an interpreter would add unacceptable overhead. C++ gives access to every audio and graphics library without FFI bridges. Standard debuggers work on the output. And the generated code is human-readable, which aids debugging. When something goes wrong at runtime, you read the generated C++ to understand what happened. + +**attoflow** is the visual node editor, built on SDL3 and Dear ImGui. It has a three-layer architecture: VisualEditor handles canvas rendering (pan, zoom, hover detection, drawing primitives), Editor2Pane wraps the GraphBuilder and implements graph editing semantics (wire connections, node manipulation), and FlowEditorWindow manages the application chrome (tabs, file browser, build toolbar, child processes). This layering means each layer could be reused independently. + +**attohost** is the instrument runtime. It runs instruments in a separate process from the editor, so a crashing instrument doesn't take down your editing session. It supports hot-reload: the compiler generates a DLL, the host loads it, and when you edit and recompile, the host unloads the old DLL and loads the new one. Communication between editor and host happens via named pipes for live value inspection. + +## The Graph as Truth + +A fundamental principle: the FlowGraph is the canonical representation. There is no separate abstract syntax tree, no hidden intermediate form, no shadow copy. What you see in the editor is what gets compiled. What you read in the `.atto` file is what the editor displays. + +This single-source-of-truth principle eliminates an entire class of synchronization bugs. In many development environments, the visual representation can drift from the actual program. In the Organic Assembler, that's structurally impossible. The graph IS the program. + +## The .atto File Format + +Instruments are stored in a TOML-like format. Each file starts with a version marker (`instrument@atto:0`), followed by node definitions and link definitions. Node IDs are hex GUIDs prefixed with `$auto-`. Pin IDs embed their parent node's ID plus a pin name. Named nets use a dollar-sign prefix. + +The version history tells a story of evolution: `nanoprog@0` to `nanoprog@1` to `attoprog@0` to `attoprog@1` to `instrument@atto:0`. Each step has a migration function in the serializer. The system can still load the very first format ever created -- it just saves in the latest format. Old names live on in the migration code, translating the past into the present. + +## Anatomy of an Instrument + +Every instrument has structural elements that map to linguistic categories. + +Declarations are the nouns: `decl_type` defines a struct type, `decl_var` declares a mutable variable that persists across frames, `decl_event` declares an event, `decl_import` imports a standard library module, `ffi` declares an external C function. + +Expressions are the adjectives and adverbs: `expr` computes values from inputs, `new` constructs structs, `dup` duplicates values to branch wires, `cast` converts types, `select` chooses between values based on conditions. + +Bang nodes are the verbs: `store!` writes values, `append!` adds to collections, `iterate!` loops, `lock!` acquires mutexes, `call!` invokes functions with side effects, `output_mix!` writes audio output. + +Events are the stimuli: `on_key_down!`, `on_key_up!`, custom events. An instrument does nothing until an event fires. The audio callback fires 48,000 times per second. Key events fire on user input. + +## The Audio Callback: 48,000 Times Per Second + +Audio runs at 48kHz. Each invocation of the audio callback produces one sample per channel. The pattern is: read state from variables declared outside the callback, compute pure math (oscillators, filters, envelopes), write updated state via `store!`, and output via `output_mix!`. + +The bang chain within the audio callback is the inner loop of the instrument. Every node in this chain runs 48,000 times per second. This is where the real-time constraint bites hardest: no allocations, no string operations, no collection mutations in the hot path. Less than 21 microseconds per sample. Human ears detect latency above about 10 milliseconds as a distinct echo -- this is a physical requirement, not a soft goal. + +The klavier instrument -- a piano synthesizer and the project's most comprehensive test case -- uses oscillator synthesis with per-key state, envelope generators, and additive audio mixing. It exercises complex types, nested lambdas, collections, events, audio, GUI, and FFI. If klavier loads, infers, compiles, and runs, the system is healthy. + +## Design Patterns That Bear Load + +The codebase uses specific design patterns that evolved from real problems. + +The Sentinel pattern pre-allocates always-valid stand-in objects (`$empty` for nodes, `$unconnected` for nets) so that code can unconditionally access references without null checks. Every pin always has a valid node and net reference. This eliminates the most common source of null pointer bugs. + +The Observer pattern connects the model to the editor without creating reverse dependencies. The core library defines interfaces (IGraphEditor, INodeEditor, INetEditor). The editor implements them. The model never imports editor code. When the graph changes, observers are notified. + +Mutation Batching brackets multi-step operations with `edit_start()` and `edit_commit()`. All observer notifications are queued and fired only on commit, so observers always see consistent state. Without this, connecting a wire would fire partial notifications as each micro-step completes. + +Dirty Tracking cascades through three levels: arg to node to graph. A pin mutation marks its owning node dirty, which marks the graph dirty. Layout changes (dragging a node) are tracked separately from semantic changes (editing an expression), so moving a node doesn't trigger expensive type re-inference. + +The TypePool interns all parsed types so that type equality reduces to pointer comparison. No redundant parsing of the same type string. Common types like f32 and bool are always available without lookup. + +## The Naming Philosophy + +Names in the Organic Assembler are not labels slapped on after the fact. They are decisions -- small acts of design that accumulate into the character of the system. The project has a detailed philosophy about naming that permeates every level. + +When connections between nodes changed from point-to-point to broadcast, "wire" was renamed to "net" -- because a net (from electronics) is a set of electrically connected points regardless of physical routing. The old word would have been a lie. + +When pin directions caused confusion (does "BangInput" mean the input that carries a bang, or the bang that inputs to this node?), they were renamed to BangTrigger and BangNext -- verbs that describe what the pins DO, not what they ARE. + +Error messages follow the same philosophy: lowercase, descriptive, context-included. "type mismatch: expected f32, got u8" names the problem in a way that suggests the fix. File names are architecture: putting a file in `src/atto/` is a promise it will never depend on SDL3. Sigils are the shortest possible names, working because the set is small, the context is constrained, and the mnemonics are strong. + +## Differential Everything + +The project has a deep relationship with the concept of differentiation -- rates of change, deltas, accumulation -- at every level. + +At the audio level, an oscillator works by accumulating a phase delta each sample. The phase step is the derivative of the waveform position. The `store!` node performs numerical integration. Frequency IS the derivative of phase. + +At the inference level, the fixed-point loop iterates until the delta between iterations is empty. Types move monotonically from generic to specific. The inference engine is structurally identical to forward-plus-reverse-mode automatic differentiation, with types playing the role of values and gradients. + +At the editing level, dirty tracking computes the delta between "state before edit" and "state after edit" as a deduplicated set of changed items. The wire cache rebuilds only when the underlying model has a non-zero delta. + +At the version level, each format migration encodes the difference between two versions. Loading a `nanoprog@0` file applies a chain of composed deltas. + +Even the git commit history reflects differential development. "towards lambdas," "more graphbuilder work," "not perfect, but progress is progress" -- these are differential checkpoints capturing known-better intermediate states. The question marks in commit messages like "progress in literals?" reveal uncertainty about whether a delta is positive. + +## The Development Story + +The project was built in an intense five-day sprint. Day one (March 23, 2026): 16 commits establishing the foundation -- FlowGraph model, expression parser, type system, code generator, visual editor, first instruments. Day two: 28 commits deepening the type system with literal types, symbol types, shadow nodes, and lambda support. Day three: 36 commits at peak velocity, building out the type system, GraphBuilder, and Editor2. Day four: 72 commits -- the highest output day -- completing Editor2 features. Day five: 6 larger integration commits connecting everything. + +The commit messages tell a story. Feature introductions name the concept: "Observer Pattern," "multi select." Rename commits use arrow notation: "wire -> net in graph_builder." Incremental progress is honest: "towards X," "more X work," "not perfect, but progress is progress." The emotional honesty is a feature, not a bug. + +## The Web Vision + +The future includes web deployment. The editor, built on SDL3 and ImGui, can be compiled to Emscripten for browser execution. A compile server on a hardened Raspberry Pi would accept `.atto` files, compile them through attoc to C++, then through Emscripten to WebAssembly, and send the result back to the browser. + +The server would be containerized with no network access inside, a 60-second timeout, 512MB RAM limit, and read-only root filesystem. Only `.atto` goes in (small attack surface), controlled C++ comes out. This is the "process boundaries are trust boundaries" principle in action. + +## Why It Matters + +The Organic Assembler sits at an intersection that few projects occupy: a system that is both deeply technical (bidirectional type inference, compile-time metaprogramming, multi-pass fixed-point algorithms) and fundamentally creative (building instruments, making sound, exploring expression). + +The type system catches errors at edit time so you can focus on the music, not the debugging. The visual editor shows you the topology of your signal flow so you can reason spatially. The real-time compilation means you hear changes as you make them. The bang chain gives you precise control over execution order without abandoning the parallel nature of dataflow. + +Every feature, every refactor, every design decision is evaluated against one question: does this help someone build a better instrument? If the answer is no, the work is not justified. The system exists to serve instruments, not itself. + +The best instruction is the one you no longer need to give, because the system makes the right choice obvious. That is what the type system, the patterns, and the architecture are for: making the right choice the easy choice. + +--- + +*The Organic Assembler is MIT-licensed and available at github.com/nilware-io/orgasm.* diff --git a/docs/style.md b/docs/style.md new file mode 100644 index 0000000..1697731 --- /dev/null +++ b/docs/style.md @@ -0,0 +1,471 @@ +# Organic Assembler — Style Guide + +This document covers the visual, structural, and API design style of the Organic Assembler +project. It addresses both the C++ code style and the language/editor design aesthetic. + +## Code Formatting + +### Indentation and Braces + +4-space indentation. Opening brace on the same line as the statement: + +```cpp +if (condition) { + do_something(); +} else { + do_other(); +} + +for (auto& node : graph.nodes) { + process(node); +} + +struct FlowArg2 : std::enable_shared_from_this { + // members +}; +``` + +### Single-Line Expressions + +Short accessor methods and inline helpers written on one line: + +```cpp +ArgKind kind() const { return kind_; } +bool is(ArgKind k) const { return kind_ == k; } +const PortDesc2* port() const { return port_; } +bool eof() const { return pos >= src.size(); } +char peek() const { return eof() ? 0 : src[pos]; } +char advance() { return eof() ? 0 : src[pos++]; } +``` + +### Switch Statements + +Case labels aligned with the switch, body indented: + +```cpp +switch (c) { +case '%': return TypeCategory::Data; +case '&': return TypeCategory::Reference; +case '^': return TypeCategory::Iterator; +case '@': return TypeCategory::Lambda; +case '#': return TypeCategory::Enum; +case '!': return TypeCategory::Bang; +case '~': return TypeCategory::Event; +default: return TypeCategory::Data; +} +``` + +For longer cases, braces around each case body: + +```cpp +switch (kind) { +case TypeKind::Scalar: { + auto name = scalar_name(t.scalar); + return name; +} +case TypeKind::Container: { + auto inner = type_to_string(t.value_type); + return container_name(t.container) + "<" + inner + ">"; +} +default: + return "void"; +} +``` + +### Blank Lines + +One blank line between: +- Function definitions +- Logical sections within a function +- Struct members of different categories + +No blank lines between: +- Tightly related one-liners +- Consecutive case labels + +### Section Comments + +ASCII box-drawing characters for major sections within a file: + +```cpp +// ─── Forward declarations ─── + +// ─── FlowArg2: base class for all pin/arg types ─── + +// ─── Concrete arg types ─── +``` + +## API Design Style + +### Accessor Pairs + +Getters and setters share the same name. Getter is const, setter takes const ref: + +```cpp +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; } +``` + +### Boolean Queries + +Prefixed with `is_` or `has_`: + +```cpp +bool is(ArgKind k) const; +bool is_remap() const; +bool is_dirty() const; +bool is_numeric(const TypePtr& t); +bool is_integer(const TypePtr& t); +bool is_float(const TypePtr& t); +bool is_generic(const TypePtr& t); +bool is_category_sigil(char c); +bool has_error() const; +``` + +### Factory Methods + +Object creation through named factory methods on the owning container: + +```cpp +// GraphBuilder owns creation of all arg types +auto arg = gb->build_arg_net(...); +auto arg = gb->build_arg_number(...); +auto arg = gb->build_arg_string(...); +auto arg = gb->build_arg_expr(...); + +// Private constructors prevent external creation +struct ArgNet2 : FlowArg2 { + friend struct GraphBuilder; +private: + ArgNet2(const std::shared_ptr& owner); +}; +``` + +### Conversion Methods + +`as_*` for type-safe downcasting: + +```cpp +std::shared_ptr as_net(); +std::shared_ptr as_number(); +std::shared_ptr as_string(); +std::shared_ptr as_expr(); + +FlowNodeBuilder* as_node(); +NetBuilder* as_net(); +``` + +### Computed Properties + +Methods that derive values from state use descriptive names: + +```cpp +std::string fq_name() const; // fully-qualified name: "node.port_name" +std::string name() const; // short name: "port_name" or "va_name[idx]" +unsigned remap_idx() const; // index in remaps array +unsigned input_pin_idx() const; // index in parsed_args +unsigned output_pin_idx() const; // index in outputs +unsigned input_pin_va_idx() const; // index in parsed_va_args +unsigned output_pin_va_idx() const; // index in outputs_va_args +``` + +## Type System Style + +### Type String Conventions + +Types are represented as strings for serialization and display. Canonical formats: + +``` +literal # no spaces around comma +symbol # no spaces +type # metatype wrapper +vector # container with element type +map # container with key,value (space after comma) +(x:f32 y:f32)->f32 # function type (space between args) +{x:f32 y:f32} # struct type (space between fields) +{x:1.0f, y:2.0f} # struct literal (comma = runtime) +array # fixed-size array +``` + +### Category Sigils + +Value categories are prefix sigils on wire types: + +| Sigil | Category | Example | +|-------|-----------|--------------| +| `%` | Data | `%f32` | +| `&` | Reference | `&f32` | +| `^` | Iterator | `^vector` | +| `@` | Lambda | `@(f32)->f32` | +| `#` | Enum | `#my_enum` | +| `!` | Bang | `!` | +| `~` | Event | `~on_key` | + +Data (`%`) is the default and typically omitted. + +### Pin Reference Style + +In expressions, pin references use `$N` (numeric only): + +``` +$0 + $1 * sin($2) +$0.field_name +$0[$1] +``` + +Lambda references use `@N`: + +``` +@0($1, $2) +``` + +Variable references are bare identifiers: + +``` +my_var + 1.0f +``` + +## Editor Visual Style + +### Style Struct + +All visual constants centralized in a style struct `S`: + +```cpp +struct Editor2Style { + // Colors + ImVec4 node_bg; + ImVec4 node_border; + ImVec4 wire_color; + ImVec4 hover_highlight; + ImVec4 selection_color; + ImVec4 error_color; + ImVec4 grid_color; + + // Sizes + float node_padding; + float pin_radius; + float wire_thickness; + float font_size; + float grid_spacing; + float scroll_pan_speed; + + // Thresholds + float hover_distance; + float snap_distance; + float zoom_min; + float zoom_max; +}; +``` + +### Pin Shape Language + +Pin shapes communicate type categories at a glance: + +| Shape | Meaning | Used For | +|----------|----------------------|----------------------------| +| Circle | Data value | Standard typed pins | +| Square | Trigger signal | Bang pins (!-category) | +| Triangle | Function reference | Lambda pins (@-category) | +| Diamond | Extensible/optional | Variadic args, optional inputs | + +### Wire Drawing + +Wires use bezier curves with consistent control point offsets: + +``` +Source pin ───╮ + │ (horizontal offset based on distance) + ╰──── Target pin +``` + +Wire hover detection uses `WireInfo` structs storing precomputed bezier points +for efficient hit testing. + +### Node Layout + +Nodes are rectangles with: +- Header bar (node type name, background tinted by category) +- Input pins on the left edge +- Output pins on the right edge +- Inline arg values displayed next to pins +- Side-bang on the left (for banged nodes) +- Lambda grab handle on the left (for lambda-capturing nodes) +- Error indicator (red border or error text) + +### Hover Priority + +When multiple elements overlap, hover priority is: +1. Pins (highest priority — biased toward selection) +2. Wires +3. Nodes (lowest priority) + +This ensures pins are always selectable even when overlapping with their parent node. + +## Serialization Style + +### .atto File Format + +TOML-like with specific conventions: + +```toml +version = "instrument@atto:0" + +[viewport] +x = -6131.11 +y = -1999.86 +zoom = 1.4641 + +[[node]] +guid = "934e3b98bb914e95" +type = "decl_type" +args = ["osc_res", "s:f32", "e:bool"] +position = [757.077, 266.729] + +[[node]] +guid = "e073eb5950485587" +type = "new" +args = ["osc_def"] +outputs = ["$auto-df6e4aa3d0d8d2bc-out0"] +position = [1746.02, 2025.51] + +[[link]] +from = "$auto-df6e4aa3d0d8d2bc-out0" +to = "$auto-831e483b4e4602dc_s1-out0" +net_name = "$osc-signal" +``` + +### ID Format + +- Node GUIDs: 16 hex characters, generated from `mt19937_64` +- Auto-generated pin IDs: `$auto--` +- Compact IDs after import: `$a-N` (hex counter) +- Named nets: `$name` prefix for system nets, bare names for user nets +- Sentinels: `$empty`, `$unconnected` + +### Version Markers + +Version strings follow the pattern `format@version`: +- `nanoprog@0` — original format +- `attoprog@1` — post-rename with extended pins +- `instrument@atto:0` — current formal specification + +The serializer always writes the latest version. The deserializer auto-migrates +from any known version. + +## Naming Conventions in the Language + +### Node Types + +Node type names are lowercase with underscores, banged variants have `!` suffix: + +``` +expr select new cast +store! append! erase! iterate! +lock! select! event! output_mix! +decl_type decl_var decl_event decl_import +on_key_down! on_key_up! +call call! +``` + +The `!` suffix indicates the node participates in bang chains (has execution ordering). + +### Standard Library FFI + +FFI function names use snake_case with module prefix: + +``` +av_create_window av_audio_tick av_video_tick +imgui_begin imgui_end imgui_text +imgui_button imgui_slider_float +imgui_begin_child imgui_same_line +``` + +### Scalar Type Names + +Scalar types follow a terse, lowercase convention: + +``` +u8 u16 u32 u64 # unsigned integers +s8 s16 s32 s64 # signed integers +f32 f64 # floating point +``` + +This mirrors Rust's naming convention and avoids C++'s verbose `uint8_t` / `int32_t`. + +## Comment Style + +### No Excessive Documentation + +The codebase uses comments sparingly. Code should be self-documenting through: +- Clear naming +- Small functions +- Obvious data flow + +Comments are used when: +- The logic is genuinely non-obvious +- A design decision needs explanation +- A workaround or hack is present + +```cpp +// Struct types must have at least one field +// (empty struct would be ambiguous with void) +if (fields.empty()) { + error = "struct must have at least one field"; + return nullptr; +} +``` + +### Section Dividers + +ASCII line dividers (not Doxygen-style blocks): + +```cpp +// ─── Type parsing ─── + +// ─── Inference phases ─── + +// ─── Code generation ─── +``` + +### No Doxygen + +The project does not use Doxygen comments (`///`, `/** */`). Function documentation, +when needed, is a plain `//` comment above the declaration. + +## Error Message Style + +Error messages are lowercase, descriptive, and include context: + +``` +"empty type string" +"struct must have at least one field" +"ArgNet2: entry must not be null" +"unknown node type: " +"type mismatch: expected , got " +``` + +No error codes — all errors are human-readable strings. + +## Build System Style + +### CMake Conventions + +- `project()` at top level only +- Targets named after their subsystem: `attolang`, `attoc`, `attoflow` +- `target_include_directories` with PRIVATE/PUBLIC correctly scoped +- Platform-specific logic via `if(WIN32)` / `else()` +- Optional features gated behind `option()` flags + +### Dependency Management + +- Windows: vcpkg (`vcpkg.json` manifest mode) +- Linux/macOS: CMake `FetchContent` for SDL3 and ImGui +- No vendored dependencies in the repo (all fetched at build time) +- Core library has zero external dependencies diff --git a/docs/thinking.md b/docs/thinking.md new file mode 100644 index 0000000..db48a4f --- /dev/null +++ b/docs/thinking.md @@ -0,0 +1,365 @@ +# Organic Assembler — Design Thinking + +This document captures the design philosophy, trade-offs, and reasoning behind the +key decisions in the Organic Assembler project. Understanding *why* things are built +this way is essential for making coherent future decisions. + +## The Core Metaphor: Operating System for Instruments + +Organic Assembler is not just a language or an editor. It is an **Operating System for +Instruments**. This framing drives every major architectural decision: + +- **Instruments** (not "programs") are the unit of authorship. Each `.atto` file is a + self-contained instrument — potentially audio, visual, interactive, or all three. +- **The OS** is the ecosystem: compiler, editor, runtime, standard library. It manages + the instrument lifecycle from creation through editing, compilation, execution, and + hot-reload. +- **attolang** is the language, but the language is subservient to the OS concept. The + language exists to make instruments expressible, not as an end in itself. + +This is why the project includes an editor, a runtime with audio/video callbacks, and +FFI bindings for GUI and audio. A language-only project would stop at the compiler. + +## Why a Visual Dataflow Language? + +Instruments are inherently about **signal flow**. Audio signals, control signals, event +triggers, and UI state all flow between processing nodes. A textual language forces the +programmer to name intermediate values and linearize a fundamentally parallel, graph-shaped +computation. + +A visual dataflow graph: +1. **Shows topology directly** — you see what connects to what +2. **Eliminates naming burden** — wires *are* the variables +3. **Supports spatial reasoning** — related nodes are placed near each other +4. **Makes parallelism obvious** — unconnected subgraphs run independently +5. **Enables live editing** — change a connection, see the result immediately + +The trade-off is that text is better for complex expressions. That's why attolang supports +inline expressions within nodes (`$0 + $1 * sin($2)`). The graph handles topology, the +expressions handle math. + +## Why Compile to C++? + +The decision to compile `.atto` → C++ → native binary (rather than interpret or compile +to a custom IR) was driven by pragmatism: + +1. **Performance**: Audio processing at 48kHz with low latency requires native speed. + An interpreter would add unacceptable overhead for real-time audio. +2. **Ecosystem**: C++ gives access to every audio/graphics library. No FFI bridge needed + for SDL, ImGui, or custom DSP code. +3. **Debugging**: Standard C++ debuggers (MSVC, GDB, LLDB) work on the output. No custom + debug tooling needed initially. +4. **Simplicity**: The codegen is a straightforward tree walk. No register allocation, no + instruction selection, no optimization passes. C++ handles all of that. + +The cost is compilation latency (seconds instead of milliseconds). The hot-reload +architecture (DLL swap) mitigates this for the edit-test cycle. + +### Why Not LLVM? + +LLVM would give faster compilation and avoid the C++ intermediate step. But: +- LLVM is a massive dependency (~100MB+ binary) +- LLVM's API changes frequently between versions +- The generated C++ is human-readable, which aids debugging +- LLVM doesn't help with web deployment (Emscripten compiles C++) + +If compilation latency becomes a bottleneck, a WASM codegen backend is the planned +escape hatch — but that's future work. + +### Why Not Interpret? + +An interpreter was considered (and would be the fastest path to "runs in browser"). +The blocker is audio: at 48kHz sample rate, the audio callback runs every ~21 +microseconds per sample. An interpreter would need to be extremely fast, and any +garbage collection pause would cause audio glitches. + +For non-audio instruments (pure GUI tools, data processing), interpretation might +be viable in the future. + +## The Type System Philosophy + +### Why Bidirectional Inference? + +Unidirectional inference (forward-only, Hindley-Milner-style) doesn't work well for +dataflow graphs because: + +1. **Producers and consumers are peers** — in a graph, there's no clear "this comes first" + ordering. A node's output type might depend on its input types (forward), but its input + types might also depend on what its outputs connect to (backward). + +2. **Literal types need context** — `0` could be u8, u16, u32, u64, f32, or f64. The + correct type comes from the *consumer*. Without backpropagation, every literal would + need explicit type annotation. + +3. **Generic containers need both directions** — `vector` resolves when either a + producer provides `vector` (forward) or a consumer expects `f32` elements (backward). + +The multi-pass fixed-point approach (iterate until no types change) handles mutual +dependencies gracefully. + +### Why literal Instead of Constant Folding? + +Traditional compilers fold constants at the IR level. attolang makes literals a *type-level* +concept for several reasons: + +1. **Type resolution**: `42` in `$0 + 42` needs to resolve to the same type as `$0`. If `42` + is just an integer, the type system can't help. As `literal,42>`, the type + system resolves `unsigned` from context. + +2. **Compile-time metaprogramming**: Literal types flow through the graph as type information. + A `decl_type` node receives field names as `literal` inputs. This makes the + type system the compile-time evaluation mechanism. + +3. **No separate constant-folding pass**: The type system handles what would otherwise require + a separate optimization pass. Less code, fewer passes, more unified semantics. + +### Why Symbols? + +Bare identifiers (`sin`, `my_var`, `f32`) produce symbol types rather than immediately +resolving to values. This two-phase approach (parse → symbol, then symbol → value on +consumption) enables: + +1. **Deferred resolution**: `decl_var` receives a *name* as input, not a value. The name is + an `undefined_symbol` until the declaration is processed. This lets the declaration + system work without special-casing the expression parser. + +2. **First-class type references**: `f32` as a symbol decays to `type`. This means type + names and value names live in the same expression language. No separate "type expression" + syntax needed (though struct types `{...}` and function types `(...)→...` do have special + syntax because they contain embedded type references). + +3. **Graceful error handling**: An `undefined_symbol` doesn't error at parse time. It + errors only when something tries to *evaluate* it. This means partial programs (still + being edited) don't explode with cascading errors. + +## The Graph Builder Philosophy + +### Why Two Layers (Model + Builder)? + +The original `FlowGraph` (model layer) is a simple, serializable data structure. It was +designed for file I/O and compiler consumption. But the editor needs: + +- Dirty tracking (what changed since last render?) +- Observer notifications (tell the UI when something mutates) +- Structured access (not just raw string arrays) +- Transactional edits (batch multiple changes atomically) +- Sentinels (never-null references for safe UI code) + +Rather than adding all this to the model (making it complex and slow for the compiler), +a second layer (GraphBuilder) was created. The builder wraps the model and adds +editor-specific concerns. + +This separation means: +- The compiler never pays for editor overhead +- The model remains simple and fast to serialize +- The builder can evolve independently (new UI features don't affect codegen) +- Tests can use either layer (model for fast unit tests, builder for integration tests) + +### Why Sentinels Over Nulls? + +Every `FlowArg2` always has a valid `node()` and `net()`. If no real node/net is assigned, +sentinels (`$empty`, `$unconnected`) are used instead. + +This eliminates an entire class of bugs: null pointer dereferences in UI code. When +rendering a pin, you can always call `arg->node()->id()` without checking for null. +The sentinel returns safe default values. + +The trade-off is a small memory cost (two extra entries in the graph) and the need to +check `is_sentinel()` when the distinction matters. In practice, sentinel checks are +rare — most code just uses the values. + +### Why Mutation Batching? + +Graph edits often involve multiple changes that should appear atomic: +- Moving a node updates its position (one change) and may adjust connected wires (more changes) +- Connecting a wire creates a link and updates both endpoint pins +- Deleting a node removes the node, all its links, and all its pins + +Without batching, each individual change would trigger observer notifications, causing +the UI to re-render partially updated state. This leads to visual glitches and, worse, +can trigger cascading re-inference on incomplete graphs. + +`edit_start()` / `edit_commit()` brackets these multi-step operations. All observer +notifications are queued and fired only on commit, ensuring observers always see +consistent state. + +## The Editor Philosophy + +### Why SDL3 + ImGui? + +SDL3 provides: +- Cross-platform windowing (Windows, macOS, Linux) +- Audio output with low-latency callbacks +- Input handling (keyboard, mouse, gamepad) +- Emscripten support for web deployment + +ImGui provides: +- Immediate-mode UI rendering (no retained widget tree) +- Custom drawing primitives (lines, beziers, filled shapes) +- Text rendering with custom fonts +- Docking and multi-viewport support + +Together they give a lightweight, fast, cross-platform rendering stack that can +run at 60 FPS while drawing complex node graphs. The immediate-mode approach means +the editor re-renders every frame from scratch — no stale cached widgets. + +The alternative (Qt, GTK, etc.) would provide more built-in widgets but add a +massive dependency, slower build times, and less control over the rendering pipeline. +For a node graph editor where everything is custom-drawn anyway, ImGui is ideal. + +### Why Three Editor Layers? + +``` +FlowEditorWindow → Editor2Pane → VisualEditor +(app chrome) (semantics) (rendering) +``` + +1. **VisualEditor** knows about canvas coordinates, zoom, hover detection, and drawing + primitives. It doesn't know about graph semantics. + +2. **Editor2Pane** knows about the GraphBuilder, node types, pin connections, and + wire logic. It doesn't know about window management or file I/O. + +3. **FlowEditorWindow** handles tabs, file browser, build toolbar, and child processes. + It delegates all graph work to Editor2Pane instances. + +This layering means: +- VisualEditor could be reused for a different graph system +- Editor2Pane could be embedded in a different window framework +- The window can manage multiple editors (tabs) without code duplication + +### Why Distance-Based Hover Priority? + +The hover system evaluates all elements (pins, wires, nodes) and returns the best +match by distance, with pins getting a priority bias. + +This is better than z-order-based hit testing because: +1. Pins are small and hard to click — the bias helps +2. Wires can overlap nodes — distance sorting resolves ambiguity naturally +3. No need to maintain a z-order for every element +4. Works correctly at any zoom level (distances scale with zoom) + +## The Wire Philosophy + +### Why First-Class Wires? + +In the original model, connections were arrays on nodes (`inputs`, `outputs`). Wires +had no identity — they were implicit relationships between pins. + +Making wires first-class entities (with their own GUID, metadata, and editor properties) +enables: + +1. **Wire selection**: Click a wire to inspect its type, rename it, add probes +2. **Stable inspector subscriptions**: A wire's GUID persists across recompiles, so + the value inspector can track a specific wire across edit-compile-run cycles +3. **Wire metadata**: Color coding, logging toggles, probe flags, user-assigned names +4. **Decoupled ownership**: Wires don't belong to either endpoint — they're independent + graph entities + +### Why Named Nets? + +Named nets (`$osc-signal`, `$freq`) are a higher-level abstraction over wires. A named +net connects all pins that share the same net name, without requiring explicit wire routing. + +This is useful for: +1. **Long-distance connections**: Connecting nodes far apart without visual wire spaghetti +2. **Bus-like patterns**: Multiple consumers reading the same signal +3. **Modularity**: Reference a signal by name instead of by wire topology + +Named nets are displayed in the nets editor panel, providing a table-of-contents view +of all named signals in the instrument. + +## The Runtime Philosophy + +### Why Process Isolation? + +The editor and runtime run in separate processes (attoflow.exe and attohost.exe) for +one critical reason: **a crashing instrument must not crash the editor**. + +Audio programming inevitably involves buffer overruns, division by zero, and infinite +loops. In a single-process model, any of these would kill the editor, losing unsaved work. +In the two-process model, the host crashes, the editor reports the error, and the user +can fix their instrument and try again. + +### Why wire Instead of Raw Values? + +In release builds, `wire` is a zero-cost typedef to `T`. In inspect builds, it wraps +the value with metadata (name, GUID) and notifies an IPC channel on assignment. + +This dual-mode design means: +- **Release performance**: No overhead. The generated code runs as fast as hand-written C++. +- **Debug observability**: Every signal can be monitored, recorded, and overridden from + the editor without modifying the instrument code. + +The alternative (always wrapping, or separate debug/release codegen paths) would either +add permanent overhead or require maintaining two code generators. + +## Trade-Offs and Known Limitations + +### args String vs Structured Fields + +`node.args` is still a string that gets tokenized at runtime in ~40+ call sites. This is +the single largest source of technical debt. The fix (structured pre-parsed fields) is +planned but is a large refactor touching all node type handling. + +The original design used strings for simplicity: quick to serialize, easy to display, +simple to parse. But as the type system grew more sophisticated, the string became a +liability — it encodes semantic information (type names, variable names, field lists) +in an untyped format that requires error-prone tokenization. + +### Nested Lambda Scope Leakage + +When a `lock` node's lambda body shares nodes with an outer stored lambda, the +parameter collector incorrectly identifies outer parameters as belonging to the +inner lambda. This is because `collect_lambda_params` doesn't respect lambda +boundaries. + +The proper fix requires a pre-inference "lambda ownership" pass that assigns each +node to its innermost enclosing lambda. This is non-trivial because nodes can be +shared between scopes (the graph is not a tree). + +### Single-Threaded Inference + +The inference engine runs single-threaded on the main thread. For large instruments +(100+ nodes), this can cause a noticeable pause. Moving inference to a background +thread would require making the graph immutable during inference (copy-on-write) +or using fine-grained locking. + +### No Undo/Redo + +The editor currently has no undo/redo system. The mutation batching infrastructure +(`edit_start`/`edit_commit`) provides the foundation for command-pattern undo, but +the actual command recording is not yet implemented. + +### Web Compilation Latency + +The planned web deployment relies on a remote compile server, introducing network +latency in the edit-run cycle. For audio instruments, this latency may be +unacceptable. The long-term fix is either a WASM codegen backend in attoc +(eliminating the server) or an in-browser interpreter for rapid prototyping with +a compile step for production. + +## Guiding Principles + +1. **Instruments first**: Every feature is evaluated by whether it helps people + build better instruments. + +2. **Real-time or nothing**: Audio processing at 48kHz with <10ms latency is a + hard constraint. Any design that can't meet this is rejected. + +3. **Graph as truth**: The FlowGraph is the canonical representation. There is no + separate AST, no hidden state. What you see in the editor is what gets compiled. + +4. **Types prevent errors**: The more information encoded in the type system, the + more errors are caught at edit time rather than at runtime. + +5. **Zero-cost abstractions**: Features like `wire` that add development-time + capabilities without production-time overhead. + +6. **Simple until proven insufficient**: Start with strings, switch to structured + types when the strings become a problem. Start with single-threaded, add + parallelism when profiling demands it. + +7. **Process boundaries are trust boundaries**: The editor doesn't trust the + runtime. The compile server doesn't trust user input. Each boundary has + explicit validation. diff --git a/scenes/klavier/main.atto b/scenes/klavier/main.atto index 69ee963..105b641 100644 --- a/scenes/klavier/main.atto +++ b/scenes/klavier/main.atto @@ -701,26 +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 = ["->"] -outputs = ["$auto-e073eb5950485587_s2-out0"] -position = [551.91, 302.962] - -[[node]] -id = "$auto-e073eb5950485587_s3" -type = "expr" -shadow = true -args = ["osc_res"] -outputs = ["$auto-e073eb5950485587_s3-out0"] -position = [551.91, 242.962] - [[node]] id = "$auto-fe155835bba6cd45_s0" type = "expr" @@ -733,26 +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 = ["->"] -outputs = ["$auto-fe155835bba6cd45_s2-out0"] -position = [554.325, 347.55] - -[[node]] -id = "$auto-fe155835bba6cd45_s3" -type = "expr" -shadow = true -args = ["void"] -outputs = ["$auto-fe155835bba6cd45_s3-out0"] -position = [554.325, 287.55] - [[node]] id = "$auto-09f161f1210cec4f_s0" type = "expr" @@ -957,26 +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 = ["->"] -outputs = ["$auto-20115e980dcd5b53_s2-out0"] -position = [1467.98, 269.477] - -[[node]] -id = "$auto-20115e980dcd5b53_s3" -type = "expr" -shadow = true -args = ["void"] -outputs = ["$auto-20115e980dcd5b53_s3-out0"] -position = [1467.98, 209.477] - [[node]] id = "$auto-0e02c497002f40c2_s0" type = "expr" @@ -1013,26 +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 = ["->"] -outputs = ["$auto-c5373cf3d77e7979_s2-out0"] -position = [557.37, 731.95] - -[[node]] -id = "$auto-c5373cf3d77e7979_s3" -type = "expr" -shadow = true -args = ["void"] -outputs = ["$auto-c5373cf3d77e7979_s3-out0"] -position = [557.37, 671.95] - [[node]] id = "$auto-48a2c13cec7e5013_s0" type = "expr" @@ -1045,26 +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 = ["->"] -outputs = ["$auto-48a2c13cec7e5013_s2-out0"] -position = [561, 794] - -[[node]] -id = "$auto-48a2c13cec7e5013_s3" -type = "expr" -shadow = true -args = ["void"] -outputs = ["$auto-48a2c13cec7e5013_s3-out0"] -position = [561, 734] - [[node]] id = "$auto-9facb8e5368e52c0_s0" type = "expr" @@ -1085,26 +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 = ["->"] -outputs = ["$auto-c17ebd09a44700e1_s2-out0"] -position = [563.229, 850.563] - -[[node]] -id = "$auto-c17ebd09a44700e1_s3" -type = "expr" -shadow = true -args = ["void"] -outputs = ["$auto-c17ebd09a44700e1_s3-out0"] -position = [563.229, 790.563] - [[node]] id = "$auto-50417175624d2751_s0" type = "expr" @@ -1117,25 +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 = ["->"] -outputs = ["$auto-50417175624d2751_s2-out0"] -position = [566.535, 908.41] - -[[node]] -id = "$auto-50417175624d2751_s3" -type = "expr" -shadow = true -args = ["void"] -outputs = ["$auto-50417175624d2751_s3-out0"] -position = [566.535, 848.41] [[node]] id = "$auto-decl_on_quit_s0" @@ -1149,26 +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 = ["->"] -outputs = ["$auto-decl_on_quit_s2-out0"] -position = [566.535, 960] - -[[node]] -id = "$auto-decl_on_quit_s3" -type = "expr" -shadow = true -args = ["void"] -outputs = ["$auto-decl_on_quit_s3-out0"] -position = [566.535, 900] - [[node]] id = "$auto-445319c565ebdaa8_s1" type = "expr" 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..43bc138 --- /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)); + } + + // Ensure all optional ports have $unconnected args (so they're always hoverable/connectable) + { + auto* fill_nt = find_node_type2(nb.type_id); + if (fill_nt && nb.parsed_args) { + auto uncon = gb->unconnected_net(); + auto uncon_entry = std::static_pointer_cast(uncon); + for (int i = (int)nb.parsed_args->size(); i < fill_nt->total_inputs(); i++) { + auto* pd = fill_nt->input_port(i); + nb.parsed_args->push_back(gb->build_arg_net("$unconnected", uncon_entry, pd)); + } + } + } + + 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/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/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 1e1440b..0000000 --- a/src/attoflow/editor.h +++ /dev/null @@ -1,185 +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_; - - // Link/wire name editing - int editing_link_ = -1; - std::string link_edit_buf_; - bool link_edit_just_opened_ = false; - - // 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..0a8311d --- /dev/null +++ b/src/attoflow/editor2.cpp @@ -0,0 +1,604 @@ +#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) 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; +} + +// ─── Wire connection hooks ─── + +ImVec2 Editor2Pane::get_pin_screen_pos(const FlowArg2Ptr& pin) { + if (!pin) return {0, 0}; + auto node = pin->node(); + auto it = node_editors_.find(node->id()); + if (it == node_editors_.end()) return {0, 0}; + auto& ned = it->second; + + // Check if it's the side-bang + if (ned->vpm.has_side_bang && ned->vpm.side_bang_arg == pin) + return ned->layout.side_bang_pos(); + + // Search inputs + for (int i = 0; i < (int)ned->vpm.inputs.size(); i++) + if (ned->vpm.inputs[i].arg == pin) + return ned->layout.input_pin_pos(i); + + // Search outputs + for (int i = 0; i < (int)ned->vpm.outputs.size(); i++) + if (ned->vpm.outputs[i].arg == pin) + return ned->layout.output_pin_pos(i); + + return {0, 0}; +} + +PortPosition2 Editor2Pane::get_pin_position(const FlowArg2Ptr& pin) { + if (!pin) return PortPosition2::Input; + auto node = pin->node(); + auto it = node_editors_.find(node->id()); + if (it == node_editors_.end()) return PortPosition2::Input; + auto& ned = it->second; + + // Side-bang is an output + if (ned->vpm.has_side_bang && ned->vpm.side_bang_arg == pin) + return PortPosition2::Output; + + // Check outputs + for (auto& p : ned->vpm.outputs) + if (p.arg == pin) return PortPosition2::Output; + + return PortPosition2::Input; +} + +bool Editor2Pane::pin_is_connected(const FlowArg2Ptr& pin) { + if (!pin) return false; + auto an = pin->as_net(); + if (!an) return false; + auto entry = an->second(); + if (!entry) return false; + // Connected if entry is a node (lambda) or a non-unconnected net + if (entry->as_node()) return true; + auto net = entry->as_net(); + return net && !net->is_the_unconnected(); +} + +bool Editor2Pane::do_connect_pins(const FlowArg2Ptr& from_pin, PortPosition2 from_pos, + const FlowArg2Ptr& to_pin, PortPosition2 to_pos) { + if (!from_pin || !to_pin || !gb_) return false; + + auto from_port = from_pin->port(); + auto to_port = to_pin->port(); + PortKind2 from_kind = from_port ? from_port->kind : PortKind2::Data; + PortKind2 to_kind = to_port ? to_port->kind : PortKind2::Data; + + // Determine which is source and which is dest + FlowArg2Ptr src_pin, dst_pin; + if (is_wire_source(from_kind, from_pos) && is_wire_dest(to_kind, to_pos)) { + src_pin = from_pin; + dst_pin = to_pin; + } else if (is_wire_source(to_kind, to_pos) && is_wire_dest(from_kind, from_pos)) { + src_pin = to_pin; + dst_pin = from_pin; + } else { + return false; + } + + auto src_node = src_pin->node(); + auto dst_node = dst_pin->node(); + + gb_->edit_start(); + + // Get or create net for source + auto src_an = src_pin->as_net(); + BuilderEntryPtr net_entry; + NodeId net_id; + + if (src_an) { + auto existing = src_an->second(); + auto existing_net = existing ? existing->as_net() : nullptr; + if (existing_net && !existing_net->is_the_unconnected()) { + // Fan-out: reuse existing net + net_entry = existing; + net_id = src_an->first(); + } + } + + if (!net_entry) { + // Create new auto-wire net + auto [new_id, entry] = gb_->find_or_create_net(gb_->next_id(), true); + net_id = new_id; + net_entry = entry; + auto net = entry->as_net(); + net->auto_wire(true); + net->source(src_node); + + // Update source pin to point to this net + if (src_an) { + src_an->net_id(net_id); + src_an->entry(net_entry); + } + } + + // Handle fan-in: if dst already connected and doesn't allow multi, disconnect old + auto dst_an = dst_pin->as_net(); + auto dst_port = dst_pin->port(); + PortKind2 dst_kind = dst_port ? dst_port->kind : PortKind2::Data; + if (dst_an && !allows_multi_input(dst_kind)) { + auto old_entry = dst_an->second(); + auto old_net = old_entry ? old_entry->as_net() : nullptr; + if (old_net && !old_net->is_the_unconnected()) { + // Remove dst_node from old net's destinations + auto& dests = old_net->destinations(); + dests.erase(std::remove_if(dests.begin(), dests.end(), + [&](auto& w) { return w.lock() == dst_node; }), dests.end()); + } + } + + // Connect destination pin to net + if (dst_an) { + dst_an->net_id(net_id); + dst_an->entry(net_entry); + } + + // Add dst_node to net's destinations + auto net = net_entry->as_net(); + if (net) { + net->destinations().push_back(dst_node); + } + + gb_->edit_commit(); + wires_dirty_ = true; + return true; +} + +bool Editor2Pane::do_disconnect_pin(const FlowArg2Ptr& pin, PortPosition2 pos) { + if (!pin || !gb_) return false; + auto an = pin->as_net(); + if (!an) return false; + auto entry = an->second(); + auto net = entry ? entry->as_net() : nullptr; + if (!net || net->is_the_unconnected()) return false; + + // Save undo state + grab_undo_.pin = pin; + grab_undo_.old_net_id = an->first(); + grab_undo_.old_entry = entry; + + gb_->edit_start(); + + // Remove from net's destinations if this is a dest pin + auto node = pin->node(); + auto& dests = net->destinations(); + dests.erase(std::remove_if(dests.begin(), dests.end(), + [&](auto& w) { return w.lock() == node; }), dests.end()); + + // Point pin to $unconnected + auto unconnected = gb_->unconnected_net(); + an->net_id(unconnected->id()); + an->entry(std::static_pointer_cast(unconnected)); + + gb_->edit_commit(); + wires_dirty_ = true; + return true; +} + +void Editor2Pane::do_reconnect_pin(const FlowArg2Ptr& pin, PortPosition2 pos) { + if (!pin || !gb_ || !grab_undo_.pin) return; + auto an = pin->as_net(); + if (!an) return; + + gb_->edit_start(); + + // Restore old connection + an->net_id(grab_undo_.old_net_id); + an->entry(grab_undo_.old_entry); + + // Re-add to net's destinations + auto net = grab_undo_.old_entry ? grab_undo_.old_entry->as_net() : nullptr; + if (net) { + net->destinations().push_back(pin->node()); + } + + gb_->edit_commit(); + grab_undo_ = {}; + wires_dirty_ = true; +} + +void Editor2Pane::do_delete_hovered(const HoverItem& item) { + if (!gb_) return; + + gb_->edit_start(); + + if (auto* pin_ptr = std::get_if(&item)) { + // Pin hovered: disconnect it from its net + auto& pin = *pin_ptr; + if (pin) { + auto an = pin->as_net(); + if (an) { + auto entry = an->second(); + auto net = entry ? entry->as_net() : nullptr; + if (net && !net->is_the_unconnected()) { + // Remove this node from the net's destinations + auto node = pin->node(); + auto& dests = net->destinations(); + dests.erase(std::remove_if(dests.begin(), dests.end(), + [&](auto& w) { return w.lock() == node; }), dests.end()); + + // Point pin to $unconnected + auto unconnected = gb_->unconnected_net(); + an->net_id(unconnected->id()); + an->entry(std::static_pointer_cast(unconnected)); + } + } + } + } else if (auto* entry_ptr = std::get_if(&item)) { + auto& entry = *entry_ptr; + if (!entry) { gb_->edit_commit(); return; } + + if (auto net = entry->as_net()) { + // Net hovered: disconnect all pins from this net + auto unconnected = gb_->unconnected_net(); + auto uncon_entry = std::static_pointer_cast(unconnected); + // Disconnect all args referencing this net + for (auto& p : gb_->pins()) { + auto an = p->as_net(); + if (an && an->second() == entry) { + an->net_id(unconnected->id()); + an->entry(uncon_entry); + } + } + net->destinations().clear(); + net->source(BuilderEntryWeak{}); + gb_->entries.erase(net->id()); + } else if (auto node = entry->as_node()) { + // Node hovered: disconnect all its pins, then remove the node + auto unconnected = gb_->unconnected_net(); + auto uncon_entry = std::static_pointer_cast(unconnected); + + // Disconnect all input args + auto disconnect_args = [&](ParsedArgs2* pa) { + if (!pa) return; + for (int i = 0; i < pa->size(); i++) { + auto an = (*pa)[i]->as_net(); + if (!an) continue; + auto net_e = an->second() ? an->second()->as_net() : nullptr; + if (net_e && !net_e->is_the_unconnected()) { + auto& dests = net_e->destinations(); + dests.erase(std::remove_if(dests.begin(), dests.end(), + [&](auto& w) { return w.lock() == node; }), dests.end()); + } + an->net_id(unconnected->id()); + an->entry(uncon_entry); + } + }; + disconnect_args(node->parsed_args.get()); + disconnect_args(node->parsed_va_args.get()); + for (auto& r : node->remaps) { + auto an = r->as_net(); + if (an) { an->net_id(unconnected->id()); an->entry(uncon_entry); } + } + // Disconnect output args and clear net sources + for (auto& o : node->outputs) { + auto an = o->as_net(); + if (!an) continue; + auto net_e = an->second() ? an->second()->as_net() : nullptr; + if (net_e && !net_e->is_the_unconnected()) + net_e->source(BuilderEntryWeak{}); + an->net_id(unconnected->id()); + an->entry(uncon_entry); + } + for (auto& o : node->outputs_va_args) { + auto an = o->as_net(); + if (!an) continue; + auto net_e = an->second() ? an->second()->as_net() : nullptr; + if (net_e && !net_e->is_the_unconnected()) + net_e->source(BuilderEntryWeak{}); + an->net_id(unconnected->id()); + an->entry(uncon_entry); + } + + // Remove from selection + if (shared_) shared_->selected_nodes.erase(node); + + // Remove node from entries + gb_->entries.erase(node->id()); + } + } + + gb_->edit_commit(); + wires_dirty_ = true; +} diff --git a/src/attoflow/editor2.h b/src/attoflow/editor2.h new file mode 100644 index 0000000..2fe1f7a --- /dev/null +++ b/src/attoflow/editor2.h @@ -0,0 +1,139 @@ +#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; } + + // Wire connection hooks + ImVec2 get_pin_screen_pos(const FlowArg2Ptr& pin) override; + PortPosition2 get_pin_position(const FlowArg2Ptr& pin) override; + bool pin_is_connected(const FlowArg2Ptr& pin) override; + bool do_connect_pins(const FlowArg2Ptr& from_pin, PortPosition2 from_pos, + const FlowArg2Ptr& to_pin, PortPosition2 to_pos) override; + bool do_disconnect_pin(const FlowArg2Ptr& pin, PortPosition2 pos) override; + void do_reconnect_pin(const FlowArg2Ptr& pin, PortPosition2 pos) override; + void do_delete_hovered(const HoverItem& item) override; + +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; + + // Wire grab undo state (for restoring on cancel) + struct GrabUndoState { + FlowArg2Ptr pin; + NodeId old_net_id; + BuilderEntryPtr old_entry; + }; + GrabUndoState grab_undo_; + + 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 5e44c50..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)) { diff --git a/src/attoflow/nets_editor.cpp b/src/attoflow/nets_editor.cpp new file mode 100644 index 0000000..02963ba --- /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) 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..5e70526 --- /dev/null +++ b/src/attoflow/node_renderer.cpp @@ -0,0 +1,591 @@ +#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; +} + +// ─── Wire direction helpers ─── + +bool can_connect_pins(const FlowArg2Ptr& a, PortPosition2 a_pos, + const FlowArg2Ptr& b, PortPosition2 b_pos) { + if (!a || !b) return false; + if (a->node() == b->node()) return false; // no self-connection + + auto a_port = a->port(); + auto b_port = b->port(); + PortKind2 a_kind = a_port ? a_port->kind : PortKind2::Data; + PortKind2 b_kind = b_port ? b_port->kind : PortKind2::Data; + + bool a_src = is_wire_source(a_kind, a_pos); + bool a_dst = is_wire_dest(a_kind, a_pos); + bool b_src = is_wire_source(b_kind, b_pos); + bool b_dst = is_wire_dest(b_kind, b_pos); + + // One must be source, other must be dest + if (a_src && b_dst) { + // Check kind compatibility: bang↔bang, data↔data, lambda↔lambda + if (a_kind == PortKind2::BangTrigger && b_kind == PortKind2::BangNext) return true; + if (a_kind == PortKind2::Data && b_kind == PortKind2::Data) return true; + if (a_kind == PortKind2::Data && b_kind == PortKind2::Lambda) return true; + return false; + } + if (b_src && a_dst) { + if (b_kind == PortKind2::BangTrigger && a_kind == PortKind2::BangNext) return true; + if (b_kind == PortKind2::Data && a_kind == PortKind2::Data) return true; + if (b_kind == PortKind2::Data && a_kind == PortKind2::Lambda) return true; + return false; + } + return false; +} + +// ─── 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}); + } + } + } + // 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.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); + // Show "?" if unconnected optional + bool is_unconnected = false; + if (pin.arg) { + auto an = pin.arg->as_net(); + if (an) { + auto net = an->second() ? an->second()->as_net() : nullptr; + is_unconnected = net && net->is_the_unconnected(); + } + } + if (is_unconnected) { + 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) 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) 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]; + 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..244b856 --- /dev/null +++ b/src/attoflow/node_renderer.h @@ -0,0 +1,169 @@ +#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); + +// ─── Wire direction helpers ─── +// BangTrigger inputs are wire SOURCES (they produce the function call) +// BangNext outputs are wire DESTINATIONS (they consume the continuation) + +inline bool is_wire_source(PortKind2 kind, PortPosition2 pos) { + if (kind == PortKind2::Data && pos == PortPosition2::Output) return true; + if (kind == PortKind2::BangTrigger) return true; + return false; +} + +inline bool is_wire_dest(PortKind2 kind, PortPosition2 pos) { + if (kind == PortKind2::Data && pos == PortPosition2::Input) return true; + if (kind == PortKind2::BangNext) return true; + if (kind == PortKind2::Lambda) return true; + return false; +} + +// Check if a pin allows multiple incoming connections (fan-in) +inline bool allows_multi_input(PortKind2 kind) { + return kind == PortKind2::BangTrigger || kind == PortKind2::Lambda; +} + +// Determine if two pins can be connected (one must be source, other dest, compatible kinds) +bool can_connect_pins(const FlowArg2Ptr& a, PortPosition2 a_pos, + const FlowArg2Ptr& b, PortPosition2 b_pos); + +// ─── 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..57ee752 --- /dev/null +++ b/src/attoflow/visual_editor.cpp @@ -0,0 +1,230 @@ +#include "visual_editor.h" +#include +#include + +static FlowArg2Ptr hover_to_pin(const HoverItem& item) { + if (auto* pp = std::get_if(&item)) + return *pp; + return nullptr; +} + +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_); + + FlowNodeBuilderPtr hover_node = hover_to_node(hover_item_); + FlowArg2Ptr hover_pin = hover_to_pin(hover_item_); + + // ─── Wire drag preview ─── + if (wire_drag_active_ || wire_grab_active_) { + ImVec2 from = wire_drag_active_ ? wire_drag_start_ : wire_grab_anchor_; + ImVec2 to = ImGui::GetIO().MousePos; + float dy = std::max(std::abs(to.y - from.y) * 0.5f, 30.0f * canvas_zoom_); + ImVec2 cp1 = {from.x, from.y + dy}; + ImVec2 cp2 = {to.x, to.y - dy}; + dl->AddBezierCubic(from, cp1, cp2, to, S.col_wire, S.wire_thickness * canvas_zoom_); + } + + // ─── Left-click: wire creation from pin, OR selection/node drag ─── + if (canvas_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + bool ctrl = ImGui::GetIO().KeyCtrl; + + if (hover_pin && !ctrl && !wire_drag_active_ && !wire_grab_active_) { + // Left-click on pin → start new wire creation + auto pos = get_pin_position(hover_pin); + auto port = hover_pin->port(); + PortKind2 kind = port ? port->kind : PortKind2::Data; + if (is_wire_source(kind, pos) || is_wire_dest(kind, pos)) { + wire_drag_active_ = true; + wire_drag_pin_ = hover_pin; + wire_drag_pin_pos_ = pos; + wire_drag_start_ = get_pin_screen_pos(hover_pin); + wire_drag_is_source_ = is_wire_source(kind, pos); + } + } else 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; + + 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 if (!hover_pin) { + 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_}; + } + } + + // Left-click release + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + if (wire_drag_active_) { + // Complete wire creation + if (hover_pin && hover_pin != wire_drag_pin_) { + auto to_pos = get_pin_position(hover_pin); + if (can_connect_pins(wire_drag_pin_, wire_drag_pin_pos_, hover_pin, to_pos)) + do_connect_pins(wire_drag_pin_, wire_drag_pin_pos_, hover_pin, to_pos); + } + wire_drag_active_ = false; + wire_drag_pin_ = nullptr; + } + dragging_started_ = false; + selection_rect_active_ = false; + } + + // ─── Right-click: wire grab/move from connected pin, OR pan ─── + bool wire_grab_just_started = false; + if (canvas_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + if (hover_pin && pin_is_connected(hover_pin) && !wire_grab_active_) { + // Right-click on connected pin → grab and move the wire + auto pos = get_pin_position(hover_pin); + wire_grab_active_ = true; + wire_grab_just_started = true; + wire_grab_pin_ = hover_pin; + wire_grab_pin_pos_ = pos; + wire_grab_anchor_ = get_pin_screen_pos(hover_pin); + do_disconnect_pin(hover_pin, pos); + } + } + + // Right-click release (skip if grab just started this frame) + if (!wire_grab_just_started && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) { + if (wire_grab_active_) { + bool connected = false; + if (hover_pin && hover_pin != wire_grab_pin_) { + auto to_pos = get_pin_position(hover_pin); + if (can_connect_pins(wire_grab_pin_, wire_grab_pin_pos_, hover_pin, to_pos)) + connected = do_connect_pins(wire_grab_pin_, wire_grab_pin_pos_, hover_pin, to_pos); + } + if (!connected) + do_reconnect_pin(wire_grab_pin_, wire_grab_pin_pos_); + wire_grab_active_ = false; + wire_grab_pin_ = nullptr; + } else if (canvas_hovered && ImGui::GetIO().KeyCtrl && + !std::holds_alternative(hover_item_)) { + // Ctrl+right-click release: delete hovered item + do_delete_hovered(hover_item_); + } + } + + // Right-click drag: pan (only if not wire-grabbing) + if (!wire_grab_active_ && canvas_hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Right)) { + canvas_offset_.x += ImGui::GetIO().MouseDelta.x; + canvas_offset_.y += ImGui::GetIO().MouseDelta.y; + } + + // ─── Node dragging (left-click, not wire-dragging) ─── + if (!wire_drag_active_ && !wire_grab_active_ && 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); + } + } + + // Pan with middle mouse + if (canvas_hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Middle)) { + 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..d68c4da --- /dev/null +++ b/src/attoflow/visual_editor.h @@ -0,0 +1,96 @@ +#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/wire-connect; 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_; + + // Node drag state + bool dragging_started_ = false; + bool drag_was_overlapping_ = false; + bool selection_rect_active_ = false; + ImVec2 selection_rect_start_ = {0, 0}; + + // Wire drag state (right-click: create new wire) + bool wire_drag_active_ = false; + FlowArg2Ptr wire_drag_pin_; // pin being dragged from + PortPosition2 wire_drag_pin_pos_; // Input or Output + ImVec2 wire_drag_start_; // screen position of the pin + bool wire_drag_is_source_ = false; // whether this pin is a wire source + + // Wire grab state (left-click: move existing wire) + bool wire_grab_active_ = false; + FlowArg2Ptr wire_grab_pin_; // pin whose wire was grabbed + PortPosition2 wire_grab_pin_pos_; + ImVec2 wire_grab_anchor_; // screen pos of the anchored end + + // ─── Subclass hooks ─── + + virtual void draw_content(const CanvasFrame& frame) = 0; + virtual HoverItem do_detect_hover(ImVec2 mouse, ImVec2 canvas_origin) = 0; + virtual void do_draw_hover_effects(ImDrawList* dl, ImVec2 canvas_origin, const HoverItem& hover) = 0; + virtual FlowNodeBuilderPtr hover_to_node(const HoverItem& item) = 0; + virtual bool test_drag_overlap(const FlowNodeBuilderPtr& sel, float nx, float ny) = 0; + + struct BoxTestNode { + FlowNodeBuilderPtr node; + float x, y, w, h; + }; + virtual std::vector get_box_test_nodes() = 0; + virtual void on_nodes_moved() {} + + // Wire connection hooks (subclass implements actual graph mutations) + + // Get the screen position of a pin (for wire preview drawing) + virtual ImVec2 get_pin_screen_pos(const FlowArg2Ptr& pin) { return {0,0}; } + + // Determine if a pin is in the inputs or outputs of its node + virtual PortPosition2 get_pin_position(const FlowArg2Ptr& pin) { return PortPosition2::Input; } + + // Check if a pin has an existing connection (not $unconnected) + virtual bool pin_is_connected(const FlowArg2Ptr& pin) { return false; } + + // Execute a wire connection between two pins. Return true if successful. + virtual bool do_connect_pins(const FlowArg2Ptr& from_pin, PortPosition2 from_pos, + const FlowArg2Ptr& to_pin, PortPosition2 to_pos) { return false; } + + // Disconnect a pin from its current net. Return true if was connected. + virtual bool do_disconnect_pin(const FlowArg2Ptr& pin, PortPosition2 pos) { return false; } + + // Reconnect a previously disconnected pin (undo grab). Called on cancel. + virtual void do_reconnect_pin(const FlowArg2Ptr& pin, PortPosition2 pos) {} + + // Delete a node, net, or pin's connection. Called on ctrl+right-click release. + virtual void do_delete_hovered(const HoverItem& item) {} +}; 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 61% rename from src/attoflow/editor.cpp rename to src/legacy/editor1.cpp index 6ec968b..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,7 +190,6 @@ 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); } -// Sample a cubic bezier at parameter t 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; @@ -220,7 +201,6 @@ static ImVec2 bezier_sample(ImVec2 p0, ImVec2 p1, ImVec2 p2, ImVec2 p3, float t) 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; - // Pre-sample curve points and cumulative arc lengths ImVec2 pts[N + 1]; float arc[N + 1]; pts[0] = p0; arc[0] = 0; @@ -233,11 +213,9 @@ static void draw_dashed_bezier(ImDrawList* dl, ImVec2 p0, ImVec2 p1, ImVec2 p2, if (total < 1.0f) return; float cycle = dash_len + gap_len; - // Interpolate a point at a given arc distance auto lerp_at = [&](float d) -> ImVec2 { if (d <= 0) return pts[0]; if (d >= total) return pts[N]; - // Binary search for segment 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]; @@ -246,19 +224,16 @@ static void draw_dashed_bezier(ImDrawList* dl, ImVec2 p0, ImVec2 p1, ImVec2 p2, pts[lo].y + t * (pts[hi].y - pts[lo].y)}; }; - // Draw dashes float d = 0; while (d < total) { float d_end = std::min(d + dash_len, total); - // Draw the dash as a series of short line segments ImVec2 prev = lerp_at(d); - float step = 3.0f; // pixels per sub-segment + 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; } - // Final segment to exact end ImVec2 end = lerp_at(d_end); dl->AddLine(prev, end, col, thickness); d += cycle; @@ -271,182 +246,107 @@ static void draw_dashed_vbezier(ImDrawList* dl, ImVec2 from, ImVec2 to, ImU32 co col, thickness * zoom, 8.0f * zoom, 4.0f * 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; -} - -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()); -} +// ============================================================ +// Editor1Pane methods +// ============================================================ -void FlowEditorWindow::open_tab(const std::string& file_path) { +bool Editor1Pane::load(const std::string& path) { namespace fs = std::filesystem; - std::string abs_path = fs::absolute(file_path).string(); - - // Check if already open - for (int i = 0; i < (int)tabs_.size(); i++) { - if (tabs_[i].file_path == abs_path) { - active_tab_ = i; - return; - } - } + 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 (!load_atto(abs_path, graph_)) + return false; } - if (tab.graph.has_viewport) { - tab.canvas_offset = {tab.graph.viewport_x, tab.graph.viewport_y}; - tab.canvas_zoom = tab.graph.viewport_zoom; - } - 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++; } @@ -456,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) { @@ -469,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++; } @@ -478,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++; } @@ -487,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) @@ -517,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; } @@ -532,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++; @@ -563,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; @@ -572,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}; @@ -582,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(); @@ -629,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; @@ -678,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(); } @@ -689,21 +572,18 @@ 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; - // Dim named wires so the label stands out more auto dim = [](ImU32 c) -> ImU32 { return (c & 0x00FFFFFF) | (((c >> 24) * 100 / 255) << 24); }; @@ -712,57 +592,54 @@ void FlowEditorWindow::draw_link(ImDrawList* dl, const FlowLink& link, ImVec2 or auto wire_col = [&](ImU32 c) { return named ? dim(c) : c; }; if (from_trigger) { - float dy = std::max(std::abs(tp.y - fp.y) * 0.5f, 40.0f * 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 * active().canvas_zoom; + 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 * active().canvas_zoom, 4.0f * active().canvas_zoom); + 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); + 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 * active().canvas_zoom; + 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 * active().canvas_zoom, 4.0f * active().canvas_zoom); + 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); + 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 * active().canvas_zoom; + 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 * active().canvas_zoom, 4.0f * active().canvas_zoom); + 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) { 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, active().canvas_zoom); + draw_dashed_vbezier(dl, fp, tp, col, 2.5f, canvas_zoom_); else - draw_vbezier(dl, fp, tp, col, 2.5f, active().canvas_zoom); + draw_vbezier(dl, fp, tp, col, 2.5f, canvas_zoom_); } else { ImU32 col = type_error ? col_error : wire_col(COL_LINK); if (named) - draw_dashed_vbezier(dl, fp, tp, col, 2.5f, active().canvas_zoom); + draw_dashed_vbezier(dl, fp, tp, col, 2.5f, canvas_zoom_); else - draw_vbezier(dl, fp, tp, col, 2.5f, active().canvas_zoom); + draw_vbezier(dl, fp, tp, col, 2.5f, canvas_zoom_); } - // Draw net name label at midpoint if the wire has a user-assigned name if (!link.net_name.empty() && !link.auto_wire) { - float font_size = ImGui::GetFontSize() * active().canvas_zoom * 0.8f; + float font_size = ImGui::GetFontSize() * canvas_zoom_ * 0.8f; if (font_size > 5.0f) { - // Compute midpoint of the bezier (approximate with lerp) 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; - // Background pill 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()); @@ -770,265 +647,459 @@ void FlowEditorWindow::draw_link(ImDrawList* dl, const FlowLink& link, ImVec2 or } } -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; - } - } - - // 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 && editing_link_ < 0 && 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 { - // Check if clicking a wire — if so, don't start box select - int wire_hit = hit_test_link(mouse_pos, canvas_origin); - if (wire_hit >= 0) { - // Wire clicked — will be handled by link rename on mouse up - dragging_node_ = -1; - dragging_selection_ = false; - } 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 + ")"; + } + } + } + + 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; + } } } } @@ -1040,7 +1111,6 @@ void FlowEditorWindow::draw() { 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; @@ -1051,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; @@ -1085,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; @@ -1108,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)) { @@ -1122,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(); } } @@ -1147,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}); @@ -1158,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(); } @@ -1176,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; } @@ -1191,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(); @@ -1235,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_; } } } @@ -1259,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)) { @@ -1279,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(); } } @@ -1291,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(); @@ -1303,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; @@ -1348,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); @@ -1360,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; @@ -1370,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; @@ -1400,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; } @@ -1423,36 +1469,35 @@ 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_); } } } @@ -1460,10 +1505,8 @@ void FlowEditorWindow::draw() { // --- Tooltips --- 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; @@ -1484,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()); @@ -1493,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); @@ -1510,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(); @@ -1521,8 +1561,7 @@ void FlowEditorWindow::draw() { type_err = !types_compatible(fp->resolved_type, tp->resolved_type); ImGui::BeginTooltip(); - ImGui::SetWindowFontScale(active().canvas_zoom); - // Show net name prominently if it has one + 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()); } @@ -1535,7 +1574,6 @@ void FlowEditorWindow::draw() { ImGui::TextDisabled("Click to rename wire"); ImGui::EndTooltip(); - // Left-click on wire opens rename editor (on release) if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { editing_link_ = link.id; link_edit_buf_ = link.net_name.empty() ? "$" : link.net_name; @@ -1545,15 +1583,14 @@ void FlowEditorWindow::draw() { 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); @@ -1575,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_; @@ -1589,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(); @@ -1611,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; @@ -1630,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(' '); @@ -1639,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; } } } @@ -1668,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; @@ -1680,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_; @@ -1690,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(); } }; @@ -1731,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) @@ -1746,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++) { @@ -1783,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; @@ -1796,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; @@ -1824,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) @@ -1849,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; } @@ -1870,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; } @@ -1878,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(); @@ -1894,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; @@ -1908,15 +1914,14 @@ void FlowEditorWindow::draw() { // --- Wire name editing popup --- if (editing_link_ >= 0) { FlowLink* edit_link = nullptr; - for (auto& link : active().graph.links) { + for (auto& link : graph_.links) { if (link.id == editing_link_) { edit_link = &link; break; } } if (!edit_link) { editing_link_ = -1; } else { - // Position popup near the wire midpoint ImVec2 fp = {}, tp = {}; - for (auto& n : active().graph.nodes) { + 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); @@ -1926,16 +1931,16 @@ void FlowEditorWindow::draw() { } 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 * active().canvas_zoom + 40.0f * active().canvas_zoom; - float popup_w = std::max(200.0f * active().canvas_zoom, text_w); - ImGui::SetNextWindowPos({mid.x - popup_w * 0.5f, mid.y - 15.0f * active().canvas_zoom}); + 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 * active().canvas_zoom, 4 * active().canvas_zoom}); + 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(active().canvas_zoom); + ImGui::SetWindowFontScale(canvas_zoom_); bool was_just_opened = link_edit_just_opened_; if (link_edit_just_opened_) { @@ -1951,7 +1956,6 @@ void FlowEditorWindow::draw() { ImGuiInputTextFlags_EnterReturnsTrue); link_edit_buf_ = buf; - // Validate: must start with $, must be unique among net names bool valid = true; std::string error_msg; std::string new_name = link_edit_buf_; @@ -1962,8 +1966,7 @@ void FlowEditorWindow::draw() { valid = false; error_msg = "Name too short"; } else { - // Check uniqueness: no other link with a different source pin should have this net name - for (auto& other : active().graph.links) { + 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; @@ -1978,16 +1981,15 @@ void FlowEditorWindow::draw() { } if (committed && valid) { - // Update net name on this link AND all links from the same source pin std::string old_from = edit_link->from_pin; - for (auto& link : active().graph.links) { + for (auto& link : graph_.links) { if (link.from_pin == old_from) { link.net_name = new_name; link.auto_wire = false; } } editing_link_ = -1; - rebuild_all_inline_display(active().graph); + rebuild_all_inline_display(graph_); mark_dirty(); } @@ -1995,7 +1997,6 @@ void FlowEditorWindow::draw() { editing_link_ = -1; } - // Dismiss if clicked outside the rename window (skip first frame) if (!was_just_opened && !ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { @@ -2006,770 +2007,4 @@ void FlowEditorWindow::draw() { ImGui::PopStyleVar(1); } } - - 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; - } - - // 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; - } - - // 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; - } - } - - // 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; - 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 (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)); - } - } - 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; - } - } - } - } - - 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; - } -#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"; - } - } - - // 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; - } - } -#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; +};