diff --git a/src/flow_io.zig b/src/flow_io.zig index aeb519c..6a18ee1 100644 --- a/src/flow_io.zig +++ b/src/flow_io.zig @@ -366,6 +366,21 @@ pub const other_field_specs = [_]OtherFieldSpec{ // carry no editable field — their inputs are data pins — so they need // no spec. .{ .type_name = "Format", .key = "template", .label = "Template", .widget = .text }, + // Input reporter nodes (labelle-gui#208 / flow-codegen#51). The + // `IsKey*` nodes carry a `key` field — a `KeyboardKey` enum-tag name + // (e.g. `"space"`) — and the `IsMouseButton*` nodes a `button` field — + // a `MouseButton` enum-tag name (e.g. `"left"`). Both are stored as a + // JSON string and edited through the `.text` widget the same way + // `Identifier.name`/`Format.template` are, so they round-trip as the + // quoted string codegen splices inline (`{ "type": "IsKeyDown", + // "key": "space" }`). `GetMouseX`/`GetMouseY`/`GetMouseWheel` carry no + // editable field — they take no inputs — so they need no spec. + .{ .type_name = "IsKeyDown", .key = "key", .label = "Key", .widget = .text }, + .{ .type_name = "IsKeyPressed", .key = "key", .label = "Key", .widget = .text }, + .{ .type_name = "IsKeyReleased", .key = "key", .label = "Key", .widget = .text }, + .{ .type_name = "IsMouseButtonDown", .key = "button", .label = "Button", .widget = .text }, + .{ .type_name = "IsMouseButtonPressed", .key = "button", .label = "Button", .widget = .text }, + .{ .type_name = "IsMouseButtonReleased", .key = "button", .label = "Button", .widget = .text }, }; /// Return the editable-field spec for a node `type_name`, or null when diff --git a/src/gui_tests.zig b/src/gui_tests.zig index 1e51231..e412bfb 100644 --- a/src/gui_tests.zig +++ b/src/gui_tests.zig @@ -226,7 +226,13 @@ pub fn main() !void { zglfw.windowHint(.doublebuffer, true); zglfw.windowHint(.visible, false); - const window = try zglfw.createWindow(1280, 720, "labelle-gui tests", null, null); + // Tall enough that the flow-editor inspector's node palette — which has + // grown several reporter clusters (String / Input, #207 / #208) — fits + // without the bottom "Add raw call…" escape hatch being clipped below + // the scrolling child's fold. TE can't auto-scroll a child to an item + // ImGui culled (it's never registered), so `palette_has_plugin_section`'s + // wildcard click on that button only resolves while it's on-screen. + const window = try zglfw.createWindow(1280, 1080, "labelle-gui tests", null, null); defer zglfw.destroyWindow(window); zglfw.makeContextCurrent(window); diff --git a/src/modules/flow_doc.zig b/src/modules/flow_doc.zig index cf7a477..a147cab 100644 --- a/src/modules/flow_doc.zig +++ b/src/modules/flow_doc.zig @@ -2133,6 +2133,20 @@ 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 (isInputReporter(n.type_name)) { + // Input reporters (labelle-gui#208 / flow-codegen#51). They + // poll the raylib-style input mixin and produce a single + // `value` data OUTPUT — bool for the `IsKey*`/`IsMouseButton*` + // predicates, f32 for the `GetMouse*` getters. They take NO + // data input pins: the `IsKey*` `key` (a `KeyboardKey` tag) + // and `IsMouseButton*` `button` (a `MouseButton` tag) are + // on-node FIELDs (see `other_field_specs`), spliced inline by + // codegen; `GetMouse*` carry no field at all. No exec pins — + // rounded reporters (see `isReporterTypeName`). + 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", .{}); @@ -2240,15 +2254,23 @@ pub const NodeVisual = struct { /// `.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 +/// (`Concat`/`Format`/`IntToString`/`FloatToString`, flow-codegen#26) and the +/// input reporters (`IsKeyDown`/`IsKeyPressed`/`IsKeyReleased`, +/// `IsMouseButtonDown`/`IsMouseButtonPressed`/`IsMouseButtonReleased`, +/// `GetMouseX`/`GetMouseY`/`GetMouseWheel`, labelle-gui#208 / flow-codegen#51) +/// 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", + "BinOp", "Compare", "Logic", "Literal", + "Identifier", "GetComponent", "Concat", "Format", + "IntToString", "FloatToString", + // Input reporters (labelle-gui#208 / flow-codegen#51): bool key/mouse + // predicates + f32 mouse getters, read inside per-frame flows. + "IsKeyDown", "IsKeyPressed", "IsKeyReleased", + "IsMouseButtonDown", "IsMouseButtonPressed", "IsMouseButtonReleased", + "GetMouseX", "GetMouseY", "GetMouseWheel", }; for (reporters) |r| { if (std.mem.eql(u8, type_name, r)) return true; @@ -2256,6 +2278,23 @@ pub fn isReporterTypeName(type_name: []const u8) bool { return false; } +/// The input reporter type names (labelle-gui#208 / flow-codegen#51, #52) — +/// `IsKey*`/`IsMouseButton*` predicates + `GetMouse*` getters. Each renders +/// a single `value` data OUTPUT and no data inputs. Kept as a small set +/// (mirrors `isReporterTypeName`) so the `.other` pin-render arm reads as a +/// call, not a 9-way `or` chain (gemini #212). +fn isInputReporter(type_name: []const u8) bool { + const names = [_][]const u8{ + "IsKeyDown", "IsKeyPressed", "IsKeyReleased", + "IsMouseButtonDown", "IsMouseButtonPressed", "IsMouseButtonReleased", + "GetMouseX", "GetMouseY", "GetMouseWheel", + }; + for (names) |n| { + if (std.mem.eql(u8, type_name, n)) 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 @@ -3114,6 +3153,51 @@ fn renderNodePalette(s: *FlowDocState) void { addOtherNode(s, "FloatToString", &.{}) catch |err| nodeAddErr(err); } + // ── Input section (labelle-gui#208 / flow-codegen#51) ── + // Input reporter nodes — rounded, poll the input mixin and produce a + // `value` output read inside per-frame flows. `IsKey*` carry a `key` + // FIELD (a `KeyboardKey` tag, seeded `"space"`); `IsMouseButton*` carry + // a `button` FIELD (a `MouseButton` tag, seeded `"left"`) — both stored + // as JSON strings, edited via the `.text` widget the same way + // `Identifier.name` is. `GetMouse*` take no field. The seeded + // `key`/`button` are quoted JSON-string value-text (`"\"space\""` → + // `"key": "space"`), matching codegen's `{ "type": "IsKeyDown", + // "key": "space" }` contract. + zgui.text("Input", .{}); + if (zgui.button("+ IsKeyDown", .{})) { + addOtherNode(s, "IsKeyDown", &.{.{ .key = "key", .value_text = "\"space\"" }}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ IsKeyPressed", .{})) { + addOtherNode(s, "IsKeyPressed", &.{.{ .key = "key", .value_text = "\"space\"" }}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ IsKeyReleased", .{})) { + addOtherNode(s, "IsKeyReleased", &.{.{ .key = "key", .value_text = "\"space\"" }}) catch |err| nodeAddErr(err); + } + if (zgui.button("+ IsMouseButtonDown", .{})) { + addOtherNode(s, "IsMouseButtonDown", &.{.{ .key = "button", .value_text = "\"left\"" }}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ IsMouseButtonPressed", .{})) { + addOtherNode(s, "IsMouseButtonPressed", &.{.{ .key = "button", .value_text = "\"left\"" }}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ IsMouseButtonReleased", .{})) { + addOtherNode(s, "IsMouseButtonReleased", &.{.{ .key = "button", .value_text = "\"left\"" }}) catch |err| nodeAddErr(err); + } + if (zgui.button("+ GetMouseX", .{})) { + addOtherNode(s, "GetMouseX", &.{}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ GetMouseY", .{})) { + addOtherNode(s, "GetMouseY", &.{}) catch |err| nodeAddErr(err); + } + zgui.sameLine(.{}); + if (zgui.button("+ GetMouseWheel", .{})) { + addOtherNode(s, "GetMouseWheel", &.{}) 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 e292278..2f723ad 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -7368,6 +7368,164 @@ pub const FlowDocExecEdgeTests = struct { try expect.toBeTrue(saw_template); } + // ── Input reporter nodes (labelle-gui#208 / flow-codegen#51) ── + // + // Editor-side palette work for the nine input reporters: the key + // predicates `IsKeyDown`/`IsKeyPressed`/`IsKeyReleased` (each carries a + // `key` field, a `KeyboardKey` tag), the mouse-button predicates + // `IsMouseButtonDown`/`IsMouseButtonPressed`/`IsMouseButtonReleased` + // (each carries a `button` field, a `MouseButton` tag), and the + // fieldless mouse getters `GetMouseX`/`GetMouseY`/`GetMouseWheel`. All + // are `.other`-kind REPORTERS (rounded, data-only) producing a single + // `value` output (bool / f32) read inside per-frame flows. The + // key/button is an on-node FIELD, not a data pin — these nodes carry no + // data input pins. + + test "appendOtherNode seeds IsKey* nodes with a key field" { + const a = std.testing.allocator; + const src = + \\{ "event": { "type": "OnUpdate" }, "nodes": [], "edges": [] } + ; + var doc = try flow_io.parse(a, src); + defer doc.deinit(); + + const names = [_][]const u8{ "IsKeyDown", "IsKeyPressed", "IsKeyReleased" }; + for (names) |name| { + const id = try flow_io.appendOtherNode( + &doc, + name, + &.{.{ .key = "key", .value_text = "\"space\"" }}, + ); + const n = doc.nodes[doc.nodes.len - 1]; + try expect.equal(n.id, id); + try expect.toBeTrue(n.kind == .other); + try expect.toBeTrue(std.mem.eql(u8, n.type_name, name)); + // `key` is the inspector-editable text field, stored verbatim as + // a JSON string (matching codegen's `{ "type": ..., "key": + // "space" }`). + const spec = flow_io.otherFieldSpec(n.type_name).?; + try expect.toBeTrue(spec.widget == .text); + try expect.toBeTrue(std.mem.eql(u8, spec.key, "key")); + try expect.toBeTrue(std.mem.eql(u8, flow_io.extraValue(n, "key").?, "\"space\"")); + } + } + + test "appendOtherNode seeds IsMouseButton* nodes with a button field" { + const a = std.testing.allocator; + const src = + \\{ "event": { "type": "OnUpdate" }, "nodes": [], "edges": [] } + ; + var doc = try flow_io.parse(a, src); + defer doc.deinit(); + + const names = [_][]const u8{ "IsMouseButtonDown", "IsMouseButtonPressed", "IsMouseButtonReleased" }; + for (names) |name| { + const id = try flow_io.appendOtherNode( + &doc, + name, + &.{.{ .key = "button", .value_text = "\"left\"" }}, + ); + const n = doc.nodes[doc.nodes.len - 1]; + try expect.equal(n.id, id); + try expect.toBeTrue(n.kind == .other); + try expect.toBeTrue(std.mem.eql(u8, n.type_name, name)); + const spec = flow_io.otherFieldSpec(n.type_name).?; + try expect.toBeTrue(spec.widget == .text); + try expect.toBeTrue(std.mem.eql(u8, spec.key, "button")); + try expect.toBeTrue(std.mem.eql(u8, flow_io.extraValue(n, "button").?, "\"left\"")); + } + } + + test "appendOtherNode creates fieldless GetMouseX / GetMouseY / GetMouseWheel" { + const a = std.testing.allocator; + const src = + \\{ "event": { "type": "OnUpdate" }, "nodes": [], "edges": [] } + ; + var doc = try flow_io.parse(a, src); + defer doc.deinit(); + + const mx = try flow_io.appendOtherNode(&doc, "GetMouseX", &.{}); + const my = try flow_io.appendOtherNode(&doc, "GetMouseY", &.{}); + const mw = try flow_io.appendOtherNode(&doc, "GetMouseWheel", &.{}); + + try expect.equal(doc.nodes.len, @as(usize, 3)); + for (doc.nodes, [_]u32{ mx, my, mw }) |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, "GetMouseX")); + try expect.toBeTrue(std.mem.eql(u8, doc.nodes[1].type_name, "GetMouseY")); + try expect.toBeTrue(std.mem.eql(u8, doc.nodes[2].type_name, "GetMouseWheel")); + } + + test "input reporters classify as rounded reporters with no exec-in" { + // All nine 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{ + "IsKeyDown", "IsKeyPressed", "IsKeyReleased", + "IsMouseButtonDown", "IsMouseButtonPressed", "IsMouseButtonReleased", + "GetMouseX", "GetMouseY", "GetMouseWheel", + }; + 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); + } + } + + test "input reporter nodes round-trip through parse / render; key/button persist" { + const a = std.testing.allocator; + const src = + \\{ "event": { "type": "OnUpdate" }, "nodes": [], "edges": [] } + ; + var doc = try flow_io.parse(a, src); + defer doc.deinit(); + + _ = try flow_io.appendOtherNode(&doc, "IsKeyDown", &.{.{ .key = "key", .value_text = "\"space\"" }}); + _ = try flow_io.appendOtherNode(&doc, "IsMouseButtonDown", &.{.{ .key = "button", .value_text = "\"left\"" }}); + _ = try flow_io.appendOtherNode(&doc, "GetMouseX", &.{}); + + const text1 = try flow_io.render(a, doc); + defer a.free(text1); + + // The node types survive the writer, and the key/button fields are + // emitted as the codegen contract's quoted strings. + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"IsKeyDown\"") != null); + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"key\": \"space\"") != null); + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"IsMouseButtonDown\"") != null); + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"button\": \"left\"") != null); + try expect.toBeTrue(std.mem.indexOf(u8, text1, "\"type\": \"GetMouseX\"") != 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)); + + // The `key` / `button` fields persist across the load → save cycle. + var saw_key = false; + var saw_button = false; + for (doc2.nodes) |n| { + if (std.mem.eql(u8, n.type_name, "IsKeyDown")) { + const v = flow_io.extraValue(n, "key") orelse continue; + saw_key = std.mem.eql(u8, v, "\"space\""); + } else if (std.mem.eql(u8, n.type_name, "IsMouseButtonDown")) { + const v = flow_io.extraValue(n, "button") orelse continue; + saw_button = std.mem.eql(u8, v, "\"left\""); + } + } + try expect.toBeTrue(saw_key); + try expect.toBeTrue(saw_button); + } + // ── Switch case-output derivation (#199) ── // // A Switch's cases are dynamic, so the editor derives how many