Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/flow_io.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/gui_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +229 to +232
// 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);
Expand Down
94 changes: 89 additions & 5 deletions src/modules/flow_doc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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", .{});
Expand Down Expand Up @@ -2240,22 +2254,47 @@ 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;
}
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
Expand Down Expand Up @@ -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.
Expand Down
158 changes: 158 additions & 0 deletions src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +7505 to +7507
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
Expand Down
Loading