From 9c622ee9a663f37c554a131bc40aa38dcd4db467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Mon, 8 Jun 2026 17:32:51 -0300 Subject: [PATCH 1/2] feat(flow editor): Format / Concat / IntToString / FloatToString palette nodes (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the four built-in string reporter nodes in the flow editor so they can be authored (codegen shipped in flow-codegen#26). All four are `.other`-kind REPORTERS (rounded, data-only) producing a single `value` ([]const u8) output that wires into command data inputs (Log / Emit / SetVariable, …). They carry no exec pins — they sit off the exec spine. - flow_doc.isReporterTypeName: new type-name reporter classifier for the `.other` expression nodes (BinOp/Compare/Logic/Literal/Identifier/ GetComponent + the four string reporters). `nodeVisual` routes `.other` nodes through it so the string nodes get the rounded silhouette + reporter border. They're not `isControlNode`, so `needs_exec_in` stays false and they never get an exec-in anchor. - flow_doc render `.other` arms: - IntToString / FloatToString: one `value` data INPUT + one `value` data OUTPUT (same name, distinguished by pin direction in the id — matches codegen). - Concat / Format: a dynamic set of `arg` data INPUT pins + one `value` data OUTPUT. The arg count is derived from the data edges already wired into the node plus one spare slot (`varArgInputCount`, mirroring `switchCaseOutputCount`), rendered from the static `var_arg_pin_names` table so `recordPin`'s borrowed names outlive the frame. - flow_doc palette: new "Text" cluster — + Format / + Concat / + IntToString / + FloatToString. Format seeds an empty `template` string literal (`""`, like Identifier's name seed). - flow_io.other_field_specs: Format exposes `template` (the std.fmt format string) as a `.text` field, edited + round-tripped as a quoted JSON string the same way Identifier.name is. - tests: appendOtherNode shape + seeded template, reporter classification (rounded, no exec-in), varArgInputCount / argIndexOf derivation, and parse/render round-trip (template persists, load -> save byte-stable). --- src/flow_io.zig | 8 ++ src/modules/flow_doc.zig | 163 +++++++++++++++++++++++++++++++++++++- src/tests.zig | 166 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 2 deletions(-) diff --git a/src/flow_io.zig b/src/flow_io.zig index 83c7186..c6803f1 100644 --- a/src/flow_io.zig +++ b/src/flow_io.zig @@ -358,6 +358,14 @@ pub const other_field_specs = [_]OtherFieldSpec{ // field, so it needs no spec — its inspector shows the verbatim view. .{ .type_name = "Cooldown", .key = "seconds", .label = "Seconds", .widget = .literal }, .{ .type_name = "Delay", .key = "seconds", .label = "Seconds", .widget = .literal }, + // String/Text reporter nodes (flow-codegen#26). `Format` carries a + // `template` field — the `std.fmt` format string (e.g. `"hits={d}"`) — + // stored as a JSON string and edited through the `.text` widget the + // same way `Identifier.name` is, so it encodes/round-trips as a quoted + // string codegen parses verbatim. `Concat`/`IntToString`/`FloatToString` + // carry no editable field — their inputs are data pins — so they need + // no spec. + .{ .type_name = "Format", .key = "template", .label = "Template", .widget = .text }, }; /// Return the editable-field spec for a node `type_name`, or null when diff --git a/src/modules/flow_doc.zig b/src/modules/flow_doc.zig index e93f377..5eedaad 100644 --- a/src/modules/flow_doc.zig +++ b/src/modules/flow_doc.zig @@ -800,6 +800,69 @@ pub fn switchCaseOutputCount(node_id: u32, exec_edges: []const flow_io.ExecEdge) return @min(count, max_case_outputs); } +/// Upper bound on the number of `arg` data inputs a variadic reporter +/// (`Concat` / `Format`, flow-codegen#26) will render, so a hand-edited +/// file with an absurd arg index can't ask the editor to draw thousands +/// of pins. +const max_arg_inputs: u32 = 64; + +/// Static `arg0`..`arg` pin-name literals. Built at +/// comptime so each rendered/recorded `arg` pin name is a 'static +/// slice that outlives the frame — `recordPin` borrows the name into the +/// pin registry, so a per-frame formatted string would dangle (mirrors +/// the `case_pin_names` table the Switch node uses, and the inline +/// `arg0..arg15` table the CustomNode renderer uses). +const var_arg_pin_names: [max_arg_inputs][]const u8 = blk: { + @setEvalBranchQuota(max_arg_inputs * 2000); + var names: [max_arg_inputs][]const u8 = undefined; + for (0..max_arg_inputs) |i| { + names[i] = std.fmt.comptimePrint("arg{d}", .{i}); + } + break :blk names; +}; + +/// Parse an `arg` data-pin name to its index `N`, or null if `pin` +/// isn't a well-formed `arg` name. The digit run must be non-empty +/// and all-decimal; overflow yields null rather than wrapping. Mirrors +/// `caseIndexOf`'s shape for the variadic reporters. +pub fn argIndexOf(pin: []const u8) ?u32 { + if (!std.mem.startsWith(u8, pin, "arg")) return null; + const digits = pin[3..]; + if (digits.len == 0) return null; + return std.fmt.parseInt(u32, digits, 10) catch null; +} + +/// How many `arg` data inputs a variadic reporter (`Concat` / +/// `Format`, flow-codegen#26) should render, given the document's data +/// edges. The arg count is dynamic — codegen joins however many are +/// wired — so we can't statically enumerate them; instead we derive the +/// count from which `arg` pins already have an incoming data edge +/// *into this node*, then add one spare slot so a fresh (or +/// just-extended) node is always wireable. +/// +/// Rule: count = (highest wired `arg` index + 1) + 1 spare. The +/// rendered arg pins are then `arg0` .. `arg`. Consequences: +/// - No data edges → count 1 → renders `arg0` (the spare), so a +/// brand-new `Concat`/`Format` is immediately wireable. +/// - `arg0` + `arg2` wired (sparse) → highest is 2 → count 4 → +/// renders `arg0`,`arg1`,`arg2` (filling the gap) + `arg3` (spare). +/// Bounded by `max_arg_inputs` so a hand-edited file with an absurd +/// index can't ask the editor to draw thousands of pins. +pub fn varArgInputCount(node_id: u32, edges: []const flow_io.Edge) u32 { + var highest: ?u32 = null; + for (edges) |e| { + if (e.to_node != node_id) continue; + const idx = argIndexOf(e.to_pin) orelse continue; + if (highest == null or idx > highest.?) highest = idx; + } + // Saturating adds mirror `switchCaseOutputCount`: a hand-edited + // `arg` pin can parse to a huge index, and an unchecked `+ 1` + // would panic in safe builds before the clamp runs. + const wired_span: u32 = if (highest) |h| h +| 1 else 0; + const count = wired_span +| 1; // + one spare empty arg slot + return @min(count, max_arg_inputs); +} + /// Whether `pin` is a legal exec-output pin name on a node of /// `type_name`. Branch/loop pins are matched against /// `controlExecPinNames`; `Switch` arm pins (`case` / `default`) @@ -2020,6 +2083,47 @@ fn renderNodeBody(s: *FlowDocState, allocator: std.mem.Allocator, n: flow_io.Nod zgui.text("value >", .{}); ne.endPin(); recordPin(s, n.id, "value", .output); + } else if (std.mem.eql(u8, n.type_name, "IntToString") or + std.mem.eql(u8, n.type_name, "FloatToString")) + { + // Scalar-to-string reporters (flow-codegen#26). One data + // INPUT `value` (the number to stringify) + one data + // OUTPUT `value` (the `[]const u8` result). Both share the + // name `value`; the pin id encodes direction, so input and + // output are distinct ids and codegen reads them by + // direction the same way. No exec pins — they render as + // rounded reporters (see `nodeVisual` / `isReporterTypeName`). + ne.beginPin(pinId(n.id, "value", .input), .input); + zgui.text("> value", .{}); + ne.endPin(); + recordPin(s, n.id, "value", .input); + ne.beginPin(pinId(n.id, "value", .output), .output); + zgui.text("value >", .{}); + ne.endPin(); + recordPin(s, n.id, "value", .output); + } else if (std.mem.eql(u8, n.type_name, "Concat") or + std.mem.eql(u8, n.type_name, "Format")) + { + // Variadic string reporters (flow-codegen#26). A dynamic + // set of `arg` data INPUT pins (codegen joins them, in + // order) + one `value` data OUTPUT (`[]const u8`). The arg + // count is derived from the data edges already wired into + // this node, plus one spare slot so a fresh node is always + // wireable (`varArgInputCount`). `Format` additionally + // carries a `template` field (the `std.fmt` format string), + // shown in the inspector + the extras hint below. No exec + // pins — rounded reporters (see `isReporterTypeName`). + const arg_count = varArgInputCount(n.id, s.doc.edges); + for (0..arg_count) |i| { + ne.beginPin(pinId(n.id, var_arg_pin_names[i], .input), .input); + zgui.text("> {s}", .{var_arg_pin_names[i]}); + ne.endPin(); + recordPin(s, n.id, var_arg_pin_names[i], .input); + } + ne.beginPin(pinId(n.id, "value", .output), .output); + zgui.text("value >", .{}); + ne.endPin(); + recordPin(s, n.id, "value", .output); } else if (std.mem.eql(u8, n.type_name, "SetField")) { ne.beginPin(pinId(n.id, "entity", .input), .input); zgui.text("> entity", .{}); @@ -2117,16 +2221,37 @@ fn renderNodeBody(s: *FlowDocState, allocator: std.mem.Allocator, n: flow_io.Nod /// Per-node visual style — corner radius + border color. Drives the /// command vs reporter visual difference (RFC §6, phase 4 item 6). -const NodeVisual = struct { +pub const NodeVisual = struct { rounding: f32, border: [4]f32, }; +/// Whether a built-in `.other`-kind node renders as a *reporter* (rounded +/// silhouette, data-only, off the exec spine) rather than a command. +/// `.other` carries no command/reporter polarity in `NodeKind`, so the +/// editor classifies these expression nodes by `type_name` — the same set +/// flow-codegen treats as pure-value reporters. The string/text reporters +/// (`Concat`/`Format`/`IntToString`/`FloatToString`, flow-codegen#26) join +/// this set so `nodeVisual` gives them the rounded shape and the generic +/// classifier never awards them an exec-in anchor (they're not +/// `isControlNode`, so `needs_exec_in` stays false). +pub fn isReporterTypeName(type_name: []const u8) bool { + const reporters = [_][]const u8{ + "BinOp", "Compare", "Logic", "Literal", + "Identifier", "GetComponent", "Concat", "Format", + "IntToString", "FloatToString", + }; + for (reporters) |r| { + if (std.mem.eql(u8, type_name, r)) return true; + } + return false; +} + /// Map a node kind to its visual treatment. Commands are rectangular /// (rounding 4), reporters rounded (rounding 14), the `Event` trigger /// stays command-shaped but with a warm border color so the entry point /// reads at a glance. Unknown kinds get the neutral default. -fn nodeVisual(n: flow_io.Node) NodeVisual { +pub fn nodeVisual(n: flow_io.Node) NodeVisual { const command_round: f32 = 4.0; const reporter_round: f32 = 14.0; const neutral_border: [4]f32 = .{ 0.4, 0.4, 0.45, 1.0 }; @@ -2134,6 +2259,15 @@ fn nodeVisual(n: flow_io.Node) NodeVisual { const command_border: [4]f32 = .{ 0.4, 0.55, 0.85, 1.0 }; const trigger_border: [4]f32 = .{ 0.95, 0.74, 0.2, 1.0 }; + // `.other` expression reporters (BinOp/Compare/Logic/Literal/Identifier + // and the string/text reporters, flow-codegen#26) are classified by + // `type_name` — they carry no polarity in `NodeKind`. Round them and + // give them the reporter border so they read as pure-value nodes. + if (n.kind == .other and isReporterTypeName(n.type_name)) return .{ + .rounding = reporter_round, + .border = reporter_border, + }; + switch (n.kind) { // Reporter ops (RFC §6) — rounded, green-ish border. .get_variable, .has_value_variable, .param => return .{ @@ -2946,6 +3080,31 @@ fn renderNodePalette(s: *FlowDocState) void { addOtherNode(s, "Delay", &.{.{ .key = "seconds", .value_text = "1" }}) catch |err| nodeAddErr(err); } + // ── Text section (flow-codegen#26) ── + // String/text reporter nodes — rounded, produce a `value` ([]const u8) + // output that wires into command data inputs (`Log`/`Emit`/ + // `SetVariable`, …). `Format` seeds an empty Zig string literal + // `template` (the `std.fmt` format string, edited via the `.text` + // widget — matches `Identifier`'s `""` name seed); the others carry no + // editable field — their inputs are data pins (`Concat`/`Format` are + // variadic `arg`; `IntToString`/`FloatToString` take one `value`). + zgui.text("Text", .{}); + if (zgui.button("+ Format", .{})) { + addOtherNode(s, "Format", &.{.{ .key = "template", .value_text = "\"\"" }}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ Concat", .{})) { + addOtherNode(s, "Concat", &.{}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ IntToString", .{})) { + addOtherNode(s, "IntToString", &.{}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ FloatToString", .{})) { + addOtherNode(s, "FloatToString", &.{}) catch |err| nodeAddErr(err); + } + // Raw `Call` escape hatch (RFC §7) — surfaced separately so a user // who finds it has knowingly opted into raw Zig source rather than // mistaking it for a normal palette entry. diff --git a/src/tests.zig b/src/tests.zig index 84bdaef..dbf0cbc 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -7147,6 +7147,172 @@ pub const FlowDocExecEdgeTests = struct { try expect.toBeTrue(saw_delay_seconds); } + // ── String/Text reporter nodes (flow-codegen#26) ── + // + // Editor-side palette work for the four string reporters: + // `Format`, `Concat`, `IntToString`, `FloatToString`. They are + // `.other`-kind REPORTERS (rounded, data-only) producing a `value` + // ([]const u8) output that wires into command data inputs. `Format` + // carries a `template` (`std.fmt` format string) field; `Concat` / + // `Format` take variadic `arg` data inputs; `IntToString` / + // `FloatToString` take one `value` data input. + + test "appendOtherNode creates fieldless Concat / IntToString / FloatToString" { + const a = std.testing.allocator; + const src = + \\{ "event": { "type": "OnCreate" }, "nodes": [], "edges": [] } + ; + var doc = try flow_io.parse(a, src); + defer doc.deinit(); + + const cat = try flow_io.appendOtherNode(&doc, "Concat", &.{}); + const its = try flow_io.appendOtherNode(&doc, "IntToString", &.{}); + const fts = try flow_io.appendOtherNode(&doc, "FloatToString", &.{}); + + try expect.equal(doc.nodes.len, @as(usize, 3)); + for (doc.nodes, [_]u32{ cat, its, fts }) |n, id| { + try expect.equal(n.id, id); + try expect.toBeTrue(n.kind == .other); + // No editable field — no spec, no seeded extras. + try expect.toBeTrue(flow_io.otherFieldSpec(n.type_name) == null); + try expect.equal(n.extras.len, @as(usize, 0)); + } + try expect.toBeTrue(std.mem.eql(u8, doc.nodes[0].type_name, "Concat")); + try expect.toBeTrue(std.mem.eql(u8, doc.nodes[1].type_name, "IntToString")); + try expect.toBeTrue(std.mem.eql(u8, doc.nodes[2].type_name, "FloatToString")); + } + + test "appendOtherNode seeds Format with a template field" { + const a = std.testing.allocator; + const src = + \\{ "event": { "type": "OnCreate" }, "nodes": [], "edges": [] } + ; + var doc = try flow_io.parse(a, src); + defer doc.deinit(); + + const fmt = try flow_io.appendOtherNode( + &doc, + "Format", + &.{.{ .key = "template", .value_text = "\"hits={d}\"" }}, + ); + const n = doc.nodes[0]; + try expect.equal(n.id, fmt); + try expect.toBeTrue(std.mem.eql(u8, n.type_name, "Format")); + // The `template` field is the inspector-editable text field; it is + // stored verbatim as a JSON string (matching codegen's + // `{ "type": "Format", "template": "hits={d}" }`). + const spec = flow_io.otherFieldSpec(n.type_name).?; + try expect.toBeTrue(spec.widget == .text); + try expect.toBeTrue(std.mem.eql(u8, spec.key, "template")); + try expect.toBeTrue(std.mem.eql(u8, flow_io.extraValue(n, "template").?, "\"hits={d}\"")); + } + + test "string reporters classify as rounded reporters with no exec-in" { + // All four render as REPORTERS (rounded silhouette) — `nodeVisual` + // returns the reporter rounding (14) via `isReporterTypeName`, and + // they carry no exec pins so they're never swept onto the exec + // spine (`isReporterTypeName` set is disjoint from the control set). + const reporter_round: f32 = 14.0; + const names = [_][]const u8{ "Format", "Concat", "IntToString", "FloatToString" }; + for (names) |name| { + try expect.toBeTrue(flow_doc.isReporterTypeName(name)); + const n: flow_io.Node = .{ .id = 1, .type_name = name, .kind = .other }; + try expect.equal(flow_doc.nodeVisual(n).rounding, reporter_round); + } + // A command-y `.other` (SetField) is NOT classified as a reporter, + // confirming the helper is a closed set and didn't over-match. + try expect.toBeTrue(!flow_doc.isReporterTypeName("SetField")); + } + + test "varArgInputCount: fresh Concat/Format shows one spare arg pin" { + // No data edges → count 1 → a brand-new node renders `arg0` (the + // spare) so it's immediately wireable. + const empty: []const flow_io.Edge = &.{}; + try expect.equal(flow_doc.varArgInputCount(7, empty), @as(u32, 1)); + } + + test "varArgInputCount: derives from wired arg data edges + one spare" { + const a = std.testing.allocator; + // arg0 + arg2 wired into node 2 (sparse). Highest is 2 → count 4 + // (arg0..arg2 filled + arg3 spare). Edges into other nodes / other + // pins are ignored. + const src = + \\{ + \\ "event": { "type": "OnUpdate" }, + \\ "nodes": [ + \\ { "id": 1, "type": "Literal", "value": "a", "pos": [0, 0] }, + \\ { "id": 2, "type": "Concat", "pos": [10, 0] }, + \\ { "id": 3, "type": "IntToString", "pos": [0, 10] } + \\ ], + \\ "edges": [ + \\ { "from": { "node": 1, "pin": "value" }, "to": { "node": 2, "pin": "arg0" } }, + \\ { "from": { "node": 1, "pin": "value" }, "to": { "node": 2, "pin": "arg2" } }, + \\ { "from": { "node": 1, "pin": "value" }, "to": { "node": 3, "pin": "value" } } + \\ ] + \\} + ; + var doc = try flow_io.parse(a, src); + defer doc.deinit(); + try expect.equal(flow_doc.varArgInputCount(2, doc.edges), @as(u32, 4)); + // Node 3's `value` input isn't an `arg` pin → 0 wired → count 1. + try expect.equal(flow_doc.varArgInputCount(3, doc.edges), @as(u32, 1)); + } + + test "argIndexOf parses arg and rejects non-arg names" { + try expect.equal(flow_doc.argIndexOf("arg0").?, @as(u32, 0)); + try expect.equal(flow_doc.argIndexOf("arg12").?, @as(u32, 12)); + try expect.toBeTrue(flow_doc.argIndexOf("arg") == null); + try expect.toBeTrue(flow_doc.argIndexOf("value") == null); + try expect.toBeTrue(flow_doc.argIndexOf("argx") == null); + } + + test "string reporter nodes round-trip through parse / render; template persists" { + const a = std.testing.allocator; + const src = + \\{ "event": { "type": "OnCreate" }, "nodes": [], "edges": [] } + ; + var doc = try flow_io.parse(a, src); + defer doc.deinit(); + + _ = try flow_io.appendOtherNode(&doc, "Concat", &.{}); + _ = try flow_io.appendOtherNode(&doc, "IntToString", &.{}); + _ = try flow_io.appendOtherNode(&doc, "FloatToString", &.{}); + _ = try flow_io.appendOtherNode( + &doc, + "Format", + &.{.{ .key = "template", .value_text = "\"hits={d}\"" }}, + ); + + const text1 = try flow_io.render(a, doc); + defer a.free(text1); + + // The node types survive the writer, and `Format.template` is + // emitted as the codegen contract's quoted string. + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"Concat\"") != null); + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"IntToString\"") != null); + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"FloatToString\"") != null); + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"Format\"") != null); + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"template\": \"hits={d}\"") != null); + + // Load → save is byte-stable (these carry only string / no extras, + // so there's no numeric normalisation to settle). + var doc2 = try flow_io.parse(a, text1); + defer doc2.deinit(); + const text2 = try flow_io.render(a, doc2); + defer a.free(text2); + try expect.toBeTrue(std.mem.eql(u8, text1, text2)); + + // `Format.template` persists across the load → save cycle. + var saw_template = false; + for (doc2.nodes) |n| { + if (std.mem.eql(u8, n.type_name, "Format")) { + const v = flow_io.extraValue(n, "template") orelse continue; + saw_template = std.mem.eql(u8, v, "\"hits={d}\""); + } + } + try expect.toBeTrue(saw_template); + } + // ── Switch case-output derivation (#199) ── // // A Switch's cases are dynamic, so the editor derives how many From 19977655d0069777d0fb0c396ea01b6f9b427103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Mon, 8 Jun 2026 17:55:35 -0300 Subject: [PATCH 2/2] fix(palette): strict arg digit check + idiomatic max (gemini #207) argIndexOf rejects non-digit suffixes (parseInt accepted a leading '+'); varArgInputCount tracks the highest index via @max. --- src/modules/flow_doc.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/modules/flow_doc.zig b/src/modules/flow_doc.zig index 5eedaad..7ad25da 100644 --- a/src/modules/flow_doc.zig +++ b/src/modules/flow_doc.zig @@ -829,6 +829,12 @@ pub fn argIndexOf(pin: []const u8) ?u32 { if (!std.mem.startsWith(u8, pin, "arg")) return null; const digits = pin[3..]; if (digits.len == 0) return null; + // Digits-only — `parseInt` would otherwise accept a leading `+` + // (`arg+1` → 1). Matches `caseIndexOf`/codegen's `isCaseExecPin` + // strictness (gemini #207). + for (digits) |c| { + if (!std.ascii.isDigit(c)) return null; + } return std.fmt.parseInt(u32, digits, 10) catch null; } @@ -853,7 +859,7 @@ pub fn varArgInputCount(node_id: u32, edges: []const flow_io.Edge) u32 { for (edges) |e| { if (e.to_node != node_id) continue; const idx = argIndexOf(e.to_pin) orelse continue; - if (highest == null or idx > highest.?) highest = idx; + highest = if (highest) |h| @max(h, idx) else idx; } // Saturating adds mirror `switchCaseOutputCount`: a hand-edited // `arg` pin can parse to a huge index, and an unchecked `+ 1`