diff --git a/diagram-editor/frontend/api.preprocessed.schema.json b/diagram-editor/frontend/api.preprocessed.schema.json index d600e125..d899484f 100644 --- a/diagram-editor/frontend/api.preprocessed.schema.json +++ b/diagram-editor/frontend/api.preprocessed.schema.json @@ -114,6 +114,22 @@ } ] }, + "ConfigExample": { + "properties": { + "config": { + "description": "The value of the config" + }, + "description": { + "description": "A description of what this config is for", + "type": "string" + } + }, + "required": [ + "description", + "config" + ], + "type": "object" + }, "DebugSessionMessage": { "oneOf": [ { @@ -780,6 +796,18 @@ "description": "If the user does not specify a default display text, the node ID will\n be used here.", "type": "string" }, + "description": { + "type": [ + "string", + "null" + ] + }, + "example_configs": { + "items": { + "$ref": "#/$defs/ConfigExample" + }, + "type": "array" + }, "request": { "type": "string" }, @@ -798,7 +826,8 @@ "request", "response", "streams", - "config_schema" + "config_schema", + "example_configs" ], "type": "object" }, @@ -1027,6 +1056,18 @@ "default_display_text": { "type": "string" }, + "description": { + "type": [ + "string", + "null" + ] + }, + "example_configs": { + "items": { + "$ref": "#/$defs/ConfigExample" + }, + "type": "array" + }, "metadata": { "$ref": "#/$defs/SectionMetadata" } @@ -1034,7 +1075,8 @@ "required": [ "default_display_text", "metadata", - "config_schema" + "config_schema", + "example_configs" ], "type": "object" }, diff --git a/diagram-editor/frontend/forms/node-form.tsx b/diagram-editor/frontend/forms/node-form.tsx index a7e94920..d4411e81 100644 --- a/diagram-editor/frontend/forms/node-form.tsx +++ b/diagram-editor/frontend/forms/node-form.tsx @@ -1,5 +1,13 @@ -import { Autocomplete, TextField } from '@mui/material'; +import { + Autocomplete, + Box, + ListItem, + Stack, + TextField, + Tooltip, +} from '@mui/material'; import { useMemo, useState } from 'react'; +import { MaterialSymbol } from '../nodes'; import { useRegistry } from '../registry-provider'; import BaseEditOperationForm, { type BaseEditOperationFormProps, @@ -60,6 +68,25 @@ function NodeForm(props: NodeFormProps) { renderInput={(params) => ( )} + renderOption={({ key, ...otherProps }, value) => { + const nodeMetadata = registry.nodes[value]; + return ( + + + {value} + {nodeMetadata?.description && ( + + + + )} + + + ); + }} /> + {symbol} ); diff --git a/diagram-editor/frontend/types/api.d.ts b/diagram-editor/frontend/types/api.d.ts index 816b95b4..87f94e8d 100644 --- a/diagram-editor/frontend/types/api.d.ts +++ b/diagram-editor/frontend/types/api.d.ts @@ -352,6 +352,23 @@ export interface BufferSettings { retention: RetentionPolicy; [k: string]: unknown; } +/** + * This interface was referenced by `DiagramEditorApi`'s JSON-Schema + * via the `definition` "ConfigExample". + */ +export interface ConfigExample { + /** + * The value of the config + */ + config: { + [k: string]: unknown; + }; + /** + * A description of what this config is for + */ + description: string; + [k: string]: unknown; +} /** * This interface was referenced by `DiagramEditorApi`'s JSON-Schema * via the `definition` "Diagram". @@ -1111,6 +1128,8 @@ export interface NodeRegistration { * be used here. */ default_display_text: string; + description?: string | null; + example_configs: ConfigExample[]; request: string; response: string; streams: { @@ -1125,6 +1144,8 @@ export interface NodeRegistration { export interface SectionRegistration { config_schema: Schema; default_display_text: string; + description?: string | null; + example_configs: ConfigExample[]; metadata: SectionMetadata; [k: string]: unknown; } diff --git a/examples/diagram/calculator/diagrams/split_and_join.json b/examples/diagram/calculator/diagrams/split_and_join.json index 9028ccd2..87403f1c 100644 --- a/examples/diagram/calculator/diagrams/split_and_join.json +++ b/examples/diagram/calculator/diagrams/split_and_join.json @@ -8,14 +8,16 @@ "type": "node", "builder": "mul", "next": "a00aa305-9763-4602-b638-6cb190e6c452", - "config": 10 + "config": 100, + "display_text": "x100" }, "fee7f385-2a74-4a87-b8a7-87b8bd03fdf8": { "type": "node", "builder": "add", "next": { "builtin": "terminate" - } + }, + "display_text": "sum" }, "31fb7423-5300-447a-b259-49c5c79654e7": { "type": "join", @@ -29,7 +31,8 @@ "type": "node", "builder": "mul", "next": "305f46be-cf0d-42ac-bd28-26d63746abc3", - "config": 100 + "config": 10, + "display_text": "x10" }, "74764679-1f94-49f3-8080-259e57f78be9": { "type": "split", diff --git a/examples/diagram/calculator_ops_catalog/src/lib.rs b/examples/diagram/calculator_ops_catalog/src/lib.rs index 09409f0f..f128f9de 100644 --- a/examples/diagram/calculator_ops_catalog/src/lib.rs +++ b/examples/diagram/calculator_ops_catalog/src/lib.rs @@ -1,9 +1,11 @@ use std::fmt::Write; -use bevy_impulse::{AsyncMap, DiagramElementRegistry, JsonMessage, NodeBuilderOptions, StreamPack}; +use bevy_impulse::{ + AsyncMap, ConfigExample, DiagramElementRegistry, JsonMessage, NodeBuilderOptions, StreamPack, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use serde_json::{Number, Value}; +use serde_json::{json, Number, Value}; #[derive(Clone, Copy, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -137,8 +139,25 @@ pub struct FibonacciStream { } pub fn register(registry: &mut DiagramElementRegistry) { + let add_description = "Add together any set of numbers passed as input and \ + then add the value in the config. If only one number is passed in as \ + input, it will be added to the value set in the config."; + let add_examples = [ + ConfigExample::new( + "Simply sum the set of numbers passed as input.", + json!(null), + ), + ConfigExample::new( + "Sum the set of numbers passed as input, and then add 5.", + json!(5.0), + ), + ]; + registry.register_node_builder( - NodeBuilderOptions::new("add").with_default_display_text("Add"), + NodeBuilderOptions::new("add") + .with_default_display_text("Add") + .with_description(add_description) + .with_examples_configs(add_examples), |builder, config: Option| { builder.create_map_block(move |req: JsonMessage| { let input = match req { @@ -162,8 +181,23 @@ pub fn register(registry: &mut DiagramElementRegistry) { }, ); + let sub_description = "Subtract some numbers. If an array of numbers is \ + passed as input then the first number will be subtracted by every \ + subsequent number. If a number is set in the config, that will also be \ + subtracted from the output."; + let sub_examples = [ + ConfigExample::new( + "Simply subtract the first array element by all subsequent elements.", + json!(null), + ), + ConfigExample::new("Additionally subtract 5 from the output.", json!(5.0)), + ]; + registry.register_node_builder( - NodeBuilderOptions::new("sub").with_default_display_text("Subtract"), + NodeBuilderOptions::new("sub") + .with_default_display_text("Subtract") + .with_description(sub_description) + .with_examples_configs(sub_examples), |builder, config: Option| { builder.create_map_block(move |req: JsonMessage| { let input = match req { @@ -187,8 +221,20 @@ pub fn register(registry: &mut DiagramElementRegistry) { }, ); + let mul_description = "Multiply some numbers. If an array of numbers is \ + passed as input then all the numbers will be multiplied together. If \ + a number is set in the config, that will also be multipled into the \ + output."; + let mul_examples = [ + ConfigExample::new("Simply multiply the input numbers together.", json!(null)), + ConfigExample::new("Additionally multiple the output by 5.", json!(5.0)), + ]; + registry.register_node_builder( - NodeBuilderOptions::new("mul").with_default_display_text("Multiply"), + NodeBuilderOptions::new("mul") + .with_default_display_text("Multiply") + .with_description(mul_description) + .with_examples_configs(mul_examples), |builder, config: Option| { builder.create_map_block(move |req: JsonMessage| { let input = match req { @@ -212,8 +258,23 @@ pub fn register(registry: &mut DiagramElementRegistry) { }, ); + let div_description = "Divide some numbers. If an array of numbers is \ + passed as input then the first number will be divided by all \ + subsequent numbers. If a number is set in the config, the final output \ + will also be divided by that value."; + let div_examples = [ + ConfigExample::new( + "Simply divide the first array element by all subsequent elements.", + json!(null), + ), + ConfigExample::new("Additionally divide the output by 2.", json!(2.0)), + ]; + registry.register_node_builder( - NodeBuilderOptions::new("div").with_default_display_text("Divide"), + NodeBuilderOptions::new("div") + .with_default_display_text("Divide") + .with_description(div_description) + .with_examples_configs(div_examples), |builder, config: Option| { builder.create_map_block(move |req: JsonMessage| { let input = match req { @@ -237,8 +298,27 @@ pub fn register(registry: &mut DiagramElementRegistry) { }, ); + let fibonacci_description = "Stream out a Fibonacci sequence. If a number \ + is given in the config, that will be used as the order of the \ + sequence. If no config is given then the input value will be \ + interpreted as a number and used as the order of the sequence. If no \ + suitable number can be found for the order then this will return an Err + containing the input message."; + let fibonacci_examples = [ + ConfigExample::new( + "Generate a Fibonacci sequence whose order is the input value. If \ + the input message cannot be interpreted as a number then this node \ + will return an Err.", + json!(null), + ), + ConfigExample::new("Generate a Fibonacci sequence of order 10.", json!(10.0)), + ]; + registry.register_node_builder( - NodeBuilderOptions::new("fibonacci").with_default_display_text("Fibonacci"), + NodeBuilderOptions::new("fibonacci") + .with_default_display_text("Fibonacci") + .with_description(fibonacci_description) + .with_examples_configs(fibonacci_examples), |builder, config: Option| { builder.create_map( move |input: AsyncMap| async move { @@ -266,12 +346,26 @@ pub fn register(registry: &mut DiagramElementRegistry) { }, ); + let print_description = "Prints the input to stdout. An optional string \ + can be provided in the config to label the output."; + + let print_examples = [ + ConfigExample::new("Print the input as-is", json!(null)), + ConfigExample::new( + "Add \"printed from node: \" to the printed message", + json!("printed from node"), + ), + ]; + registry .opt_out() .no_serializing() .no_deserializing() .register_node_builder( - NodeBuilderOptions::new("print").with_default_display_text("Print"), + NodeBuilderOptions::new("print") + .with_default_display_text("Print") + .with_description(print_description) + .with_examples_configs(print_examples), |builder, config: Option| { let header = config.clone(); builder.create_map_block(move |request: JsonMessage| { @@ -288,9 +382,40 @@ pub fn register(registry: &mut DiagramElementRegistry) { ) .with_deserialize_request(); + let less_than_description = "Compares for a less-than relationship, \ + returning a Result based on the evaluation. Inputs can be an \ + array of numbers or a single number value. The exact behavior will \ + depend on the config (see examples)."; + + let less_than_examples = [ + ConfigExample::new( + "Verify that every element in the input array is less than the next one.", + ComparisonConfig::None, + ), + ConfigExample::new( + "Verify that every element in the input array is less than OR EQUAL to the next one.", + ComparisonConfig::OrEqual(OrEqualTag::OrEqual), + ), + ConfigExample::new( + "Verify that every element in the input array is less than 10.", + ComparisonConfig::ComparedTo(10.0), + ), + ConfigExample::new( + "Verify that every element in the input array is less than or \ + equal to 10.", + ComparisonConfig::Settings(ComparisonSettings { + compared_to: Some(10.0), + or_equal: true, + }), + ), + ]; + registry .register_node_builder( - NodeBuilderOptions::new("less_than").with_default_display_text("Less Than"), + NodeBuilderOptions::new("less_than") + .with_default_display_text("Less Than") + .with_description(less_than_description) + .with_examples_configs(less_than_examples), |builder, config: ComparisonConfig| { let settings: ComparisonSettings = config.into(); builder.create_map_block(move |request: JsonMessage| { @@ -300,9 +425,40 @@ pub fn register(registry: &mut DiagramElementRegistry) { ) .with_fork_result(); + let greater_than_description = "Compares for a greater-than relationship, \ + returning a Result based on the evaluation. Inputs can be an \ + array of numbers or a single number value. The exact behavior will \ + depend on the config (see examples)."; + + let greater_than_examples = [ + ConfigExample::new( + "Verify that every element in the input array is greater than the next one.", + ComparisonConfig::None, + ), + ConfigExample::new( + "Verify that every element in the input array is greater than OR EQUAL to the next one.", + ComparisonConfig::OrEqual(OrEqualTag::OrEqual), + ), + ConfigExample::new( + "Verify that every element in the input array is greater than 10.", + ComparisonConfig::ComparedTo(10.0), + ), + ConfigExample::new( + "Verify that every element in the input array is greater than or \ + equal to 10.", + ComparisonConfig::Settings(ComparisonSettings { + compared_to: Some(10.0), + or_equal: true, + }), + ), + ]; + registry .register_node_builder( - NodeBuilderOptions::new("greater_than").with_default_display_text("Greater Than"), + NodeBuilderOptions::new("greater_than") + .with_default_display_text("Greater Than") + .with_description(greater_than_description) + .with_examples_configs(greater_than_examples), |builder, config: ComparisonConfig| { let settings: ComparisonSettings = config.into(); builder.create_map_block(move |request: JsonMessage| { diff --git a/registry.schema.json b/registry.schema.json index feb15aa4..34a96bc5 100644 --- a/registry.schema.json +++ b/registry.schema.json @@ -37,6 +37,22 @@ "schemas" ], "$defs": { + "ConfigExample": { + "type": "object", + "properties": { + "description": { + "description": "A description of what this config is for", + "type": "string" + }, + "config": { + "description": "The value of the config" + } + }, + "required": [ + "description", + "config" + ] + }, "MessageOperation": { "type": "object", "properties": { @@ -115,6 +131,12 @@ "NodeRegistration": { "type": "object", "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, "config_schema": { "$ref": "#/$defs/Schema" }, @@ -122,6 +144,12 @@ "description": "If the user does not specify a default display text, the node ID will\n be used here.", "type": "string" }, + "example_configs": { + "type": "array", + "items": { + "$ref": "#/$defs/ConfigExample" + } + }, "request": { "type": "string" }, @@ -140,7 +168,8 @@ "request", "response", "streams", - "config_schema" + "config_schema", + "example_configs" ] }, "Schema": { @@ -213,12 +242,24 @@ "SectionRegistration": { "type": "object", "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, "config_schema": { "$ref": "#/$defs/Schema" }, "default_display_text": { "type": "string" }, + "example_configs": { + "type": "array", + "items": { + "$ref": "#/$defs/ConfigExample" + } + }, "metadata": { "$ref": "#/$defs/SectionMetadata" } @@ -226,7 +267,8 @@ "required": [ "default_display_text", "metadata", - "config_schema" + "config_schema", + "example_configs" ] } } diff --git a/src/diagram/registration.rs b/src/diagram/registration.rs index e13e4b57..64574b91 100644 --- a/src/diagram/registration.rs +++ b/src/diagram/registration.rs @@ -61,6 +61,8 @@ pub struct NodeRegistration { pub(super) response: TypeInfo, pub(super) streams: HashMap, TypeInfo>, pub(super) config_schema: Schema, + pub(super) description: Option, + pub(super) example_configs: Vec, /// Creates an instance of the registered node. #[serde(skip)] @@ -243,6 +245,8 @@ impl<'a, DeserializeImpl, SerializeImpl, Cloneable> Ok(node.into()) })), + description: options.description, + example_configs: options.examples_configs, }; self.registry.nodes.insert(options.id.clone(), registration); @@ -650,6 +654,8 @@ pub struct SectionRegistration { pub(super) default_display_text: DisplayText, pub(super) metadata: SectionMetadata, pub(super) config_schema: Schema, + pub(super) description: Option, + pub(super) example_configs: Vec, #[serde(skip)] create_section_impl: RefCell>, @@ -673,7 +679,7 @@ where { fn into_section_registration( self, - name: BuilderId, + options: &SectionBuilderOptions, schema_generator: &mut SchemaGenerator, ) -> SectionRegistration; } @@ -686,17 +692,23 @@ where { fn into_section_registration( mut self, - name: BuilderId, + options: &SectionBuilderOptions, schema_generator: &mut SchemaGenerator, ) -> SectionRegistration { SectionRegistration { - default_display_text: name, + default_display_text: options + .default_display_text + .as_ref() + .unwrap_or(&options.id) + .clone(), metadata: SectionT::metadata().clone(), config_schema: schema_generator.subschema_for::<()>(), create_section_impl: RefCell::new(Box::new(move |builder, config| { let section = self(builder, serde_json::from_value::(config).unwrap()); Box::new(section) })), + description: options.description.clone(), + example_configs: options.example_configs.clone(), } } } @@ -1461,12 +1473,8 @@ impl DiagramElementRegistry { SectionBuilder: IntoSectionRegistration, SectionT: Section, { - let reg = section_builder.into_section_registration( - options - .default_display_text - .unwrap_or_else(|| options.id.clone()), - &mut self.messages.schema_generator, - ); + let reg = section_builder + .into_section_registration(&options, &mut self.messages.schema_generator); self.sections.insert(options.id, reg); SectionT::on_register(self); } @@ -1587,6 +1595,7 @@ impl DiagramElementRegistry { } } +#[derive(Clone)] #[non_exhaustive] pub struct NodeBuilderOptions { /// The unique identifier for this node builder. Diagrams will use this ID @@ -1595,6 +1604,37 @@ pub struct NodeBuilderOptions { /// If this is not specified, the id field will be used as the default /// display text. pub default_display_text: Option, + /// Optional text to describe the builder. + pub description: Option, + /// Examples of configurations that can be used with this node builder. + pub examples_configs: Vec, +} + +#[derive(Clone, Serialize, JsonSchema)] +pub struct ConfigExample { + /// A description of what this config is for + pub description: String, + /// The value of the config + pub config: JsonMessage, +} + +impl ConfigExample { + /// Create a new config example. + /// + /// Note that this function will panic if the `config` argument fails to be + /// serialized into a [`JsonMessage`], which happens if the data structure + /// contains a map with non-string keys or its [`Serialize`] implementation + /// produces an error. It's recommended to only use this during application + /// startup to avoid runtime failures. + /// + /// To construct a [`ConfigExample`] with no risk of panicking, you can + /// directly use normal structure initialization. + pub fn new(description: impl ToString, config: impl Serialize) -> Self { + Self { + description: description.to_string(), + config: serde_json::to_value(config).expect("failed to serialize example config"), + } + } } impl NodeBuilderOptions { @@ -1602,6 +1642,8 @@ impl NodeBuilderOptions { Self { id: id.into(), default_display_text: None, + description: None, + examples_configs: Default::default(), } } @@ -1609,6 +1651,19 @@ impl NodeBuilderOptions { self.default_display_text = Some(text.into()); self } + + pub fn with_description(mut self, text: impl Into) -> Self { + self.description = Some(text.into()); + self + } + + pub fn with_examples_configs( + mut self, + example_configs: impl IntoIterator, + ) -> Self { + self.examples_configs = example_configs.into_iter().collect(); + self + } } #[non_exhaustive] @@ -1619,6 +1674,10 @@ pub struct SectionBuilderOptions { /// If this is not specified, the id field will be used as the default /// display text. pub default_display_text: Option, + /// Optional text to describe the builder. + pub description: Option, + /// Examples of configurations that can be used with this section builder. + pub example_configs: Vec, } impl SectionBuilderOptions { @@ -1626,6 +1685,8 @@ impl SectionBuilderOptions { Self { id: id.into(), default_display_text: None, + description: None, + example_configs: Default::default(), } } @@ -1633,6 +1694,19 @@ impl SectionBuilderOptions { self.default_display_text = Some(text.into()); self } + + pub fn with_description(mut self, text: impl Into) -> Self { + self.description = Some(text.into()); + self + } + + pub fn with_example_configs( + mut self, + example_configs: impl IntoIterator, + ) -> Self { + self.example_configs = example_configs.into_iter().collect(); + self + } } #[cfg(test)]