diff --git a/Cargo.lock b/Cargo.lock index 33df1cdfc7..47d910fe10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1399,6 +1399,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastnoise-lite" version = "1.1.1" @@ -1990,7 +2001,6 @@ dependencies = [ "node-macro", "raster-types", "serde", - "serde_json", "tsify", "wasm-bindgen", ] @@ -3082,6 +3092,7 @@ version = "0.1.0" dependencies = [ "core-types", "glam", + "graphic-types", "log", "math-parser", "node-macro", @@ -4440,9 +4451,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -4452,9 +4463,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -5497,15 +5508,21 @@ dependencies = [ name = "text-nodes" version = "0.1.0" dependencies = [ + "convert_case 0.8.0", "core-types", "dyn-any", + "fancy-regex", "glam", "log", "node-macro", "parley", + "raster-types", "serde", + "serde_json", "skrifa 0.40.0", + "titlecase", "tsify", + "unicode-segmentation", "vector-types", "wasm-bindgen", ] @@ -5673,6 +5690,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "titlecase" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb567088a91d59b492520c8149e2be5ce10d5deb2d9a383f3378df3259679d40" +dependencies = [ + "regex", +] + [[package]] name = "tokio" version = "1.47.1" @@ -5990,9 +6016,9 @@ checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-vo" diff --git a/Cargo.toml b/Cargo.toml index b97a288f75..8127857301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,9 @@ log = "0.4" bitflags = { version = "2.4", features = ["serde"] } ctor = "0.2" convert_case = "0.8" +titlecase = "3.6" +fancy-regex = "0.17" +unicode-segmentation = "1.13.2" indoc = "2.0.5" derivative = "2.2" thiserror = "2" diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 5ede5649e5..bd5ba6d556 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -537,7 +537,7 @@ fn document_node_definitions() -> HashMap HashMap) + NodeInput::node(NodeId(2), 0), + ], + nodes: [ + // Node 0: regex_find proto node — returns Vec of [whole_match, ...capture_groups] + DocumentNode { + inputs: vec![ + NodeInput::import(concrete!(String), 0), + NodeInput::import(concrete!(String), 1), + NodeInput::import(concrete!(f64), 2), + NodeInput::import(concrete!(bool), 3), + NodeInput::import(concrete!(bool), 4), + ], + implementation: DocumentNodeImplementation::ProtoNode(text_nodes::regex_find::IDENTIFIER), + ..Default::default() + }, + // Node 1: index_elements at index 0 — extracts the whole match as a String + DocumentNode { + inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::value(TaggedValue::F64(0.), false)], + implementation: DocumentNodeImplementation::ProtoNode(graphic::index_elements::IDENTIFIER), + ..Default::default() + }, + // Node 2: omit_element at index 0 — returns capture groups as Vec + DocumentNode { + inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::value(TaggedValue::F64(0.), false)], + implementation: DocumentNodeImplementation::ProtoNode(graphic::omit_element::IDENTIFIER), + ..Default::default() + }, + ] + .into_iter() + .enumerate() + .map(|(id, node)| (NodeId(id as u64), node)) + .collect(), + ..Default::default() + }), + inputs: vec![ + NodeInput::value(TaggedValue::String(String::new()), true), + NodeInput::value(TaggedValue::String(String::new()), false), + NodeInput::value(TaggedValue::F64(0.), false), + NodeInput::value(TaggedValue::Bool(false), false), + NodeInput::value(TaggedValue::Bool(false), false), + ], + ..Default::default() + }, + persistent_node_metadata: DocumentNodePersistentMetadata { + input_metadata: vec![ + ("String", "The string to search within.").into(), + ("Pattern", "The regular expression pattern to search for.").into(), + ( + "Match Index", + "Which non-overlapping occurrence of the pattern to return, starting from 0 for the first match. Negative indices count backwards from the last match.", + ) + .into(), + ("Case Insensitive", "Match letters regardless of case.").into(), + ("Multiline", "Make `^` and `$` match the start and end of each line, not just the whole string.").into(), + ], + output_names: vec!["Match".to_string(), "Captures".to_string()], + network_metadata: Some(NodeNetworkMetadata { + persistent_metadata: NodeNetworkPersistentMetadata { + node_metadata: [ + DocumentNodeMetadata { + persistent_metadata: DocumentNodePersistentMetadata { + node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 0)), + ..Default::default() + }, + ..Default::default() + }, + DocumentNodeMetadata { + persistent_metadata: DocumentNodePersistentMetadata { + node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(8, 0)), + ..Default::default() + }, + ..Default::default() + }, + DocumentNodeMetadata { + persistent_metadata: DocumentNodePersistentMetadata { + node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(8, 2)), + ..Default::default() + }, + ..Default::default() + }, + ] + .into_iter() + .enumerate() + .map(|(id, node)| (NodeId(id as u64), node)) + .collect(), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }, + }, + description: Cow::Borrowed( + r#"Finds a portion of the string matching a regular expression pattern. With "Match Index" at its default 0, it selects the first non-overlapping occurrence, but others may be selected. Capture groups, if any, are produced as a list in the "Captures" output."#, + ), + properties: None, + }, // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels-,%27curv%27%20%3D%20Curves,-%27expA%27%20%3D%20Exposure // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Max%20input%20range-,Curves,-Curves%20settings%20files @@ -2035,6 +2142,8 @@ fn static_node_properties() -> NodeProperties { map.insert("selective_color_properties".to_string(), Box::new(node_properties::selective_color_properties)); map.insert("exposure_properties".to_string(), Box::new(node_properties::exposure_properties)); map.insert("math_properties".to_string(), Box::new(node_properties::math_properties)); + map.insert("format_number_properties".to_string(), Box::new(node_properties::format_number_properties)); + map.insert("string_capitalization_properties".to_string(), Box::new(node_properties::string_capitalization_properties)); map.insert("rectangle_properties".to_string(), Box::new(node_properties::rectangle_properties)); map.insert("grid_properties".to_string(), Box::new(node_properties::grid_properties)); map.insert("spiral_properties".to_string(), Box::new(node_properties::spiral_properties)); diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 788f6e5e8b..8cdea9b0a1 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -23,6 +23,7 @@ use graphene_std::raster::{ }; use graphene_std::table::{Table, TableRow}; use graphene_std::text::{Font, TextAlign}; +use graphene_std::text_nodes::StringCapitalization; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::QRCodeErrorCorrectionLevel; use graphene_std::vector::misc::BooleanOperation; @@ -195,7 +196,6 @@ pub(crate) fn property_from_type( Some("Fraction") => number_widget(default_info, number_input.mode_range().min(min(0.)).max(max(1.))).into(), Some("Progression") => progression_widget(default_info, number_input.min(min(0.))).into(), Some("SignedInteger") => number_widget(default_info, number_input.int()).into(), - Some("IntegerCount") => number_widget(default_info, number_input.int().min(min(1.))).into(), Some("SeedValue") => number_widget(default_info, number_input.int().min(min(0.))).into(), Some("PixelSize") => vec2_widget(default_info, "X", "Y", unit.unwrap_or(" px"), None, false), Some("TextArea") => text_area_widget(default_info).into(), @@ -244,6 +244,7 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).disabled(false).property_row(), @@ -1588,6 +1589,189 @@ pub(crate) fn exposure_properties(node_id: NodeId, context: &mut NodePropertiesC vec![LayoutGroup::row(exposure), LayoutGroup::row(offset), LayoutGroup::row(gamma_correction)] } +pub(crate) fn format_number_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::text_nodes::format_number::{DecimalPlacesInput, DecimalSeparatorInput, FixedDecimalsInput, StartAt10000Input, ThousandsSeparatorInput, UseThousandsSeparatorInput}; + + // Read current values before borrowing context mutably for widgets + let (no_decimals, decimal_sep_value, use_thousands, thousands_sep_value) = match get_document_node(node_id, context) { + Ok(document_node) => { + let decimal_places = match document_node.inputs.get(DecimalPlacesInput::INDEX).and_then(|input| input.as_value()) { + Some(&TaggedValue::U32(x)) => x, + _ => 2, + }; + let decimal_sep = match document_node.inputs.get(DecimalSeparatorInput::INDEX).and_then(|input| input.as_non_exposed_value()) { + Some(TaggedValue::String(x)) => Some(x.clone()), + _ => None, + }; + let use_thousands = match document_node.inputs.get(UseThousandsSeparatorInput::INDEX).and_then(|input| input.as_value()) { + Some(&TaggedValue::Bool(x)) => x, + _ => false, + }; + let thousands_sep = match document_node.inputs.get(ThousandsSeparatorInput::INDEX).and_then(|input| input.as_non_exposed_value()) { + Some(TaggedValue::String(x)) => Some(x.clone()), + _ => None, + }; + (decimal_places == 0, decimal_sep, use_thousands, thousands_sep) + } + Err(err) => { + log::error!("Could not get document node in format_number_properties: {err}"); + return Vec::new(); + } + }; + + let decimal_places = number_widget(ParameterWidgetsInfo::new(node_id, DecimalPlacesInput::INDEX, true, context), NumberInput::default().min(0.).int()); + + // Fixed decimals and decimal separator are disabled when decimal places is 0 + let fixed_decimals = bool_widget( + ParameterWidgetsInfo::new(node_id, FixedDecimalsInput::INDEX, true, context), + CheckboxInput::default().disabled(no_decimals), + ); + let mut decimal_sep_widgets = start_widgets(ParameterWidgetsInfo::new(node_id, DecimalSeparatorInput::INDEX, true, context)); + if let Some(sep) = decimal_sep_value { + decimal_sep_widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextInput::new(sep) + .disabled(no_decimals) + .on_update(update_value(|x: &TextInput| TaggedValue::String(x.value.clone()), node_id, DecimalSeparatorInput::INDEX)) + .on_commit(commit_value) + .widget_instance(), + ]); + } + + // Thousands separator — checkbox in assist area + let mut thousands_sep_widgets = start_widgets(ParameterWidgetsInfo::new(node_id, ThousandsSeparatorInput::INDEX, false, context)); + if let Some(sep) = thousands_sep_value { + thousands_sep_widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + CheckboxInput::new(use_thousands) + .on_update(update_value(|x: &CheckboxInput| TaggedValue::Bool(x.checked), node_id, UseThousandsSeparatorInput::INDEX)) + .on_commit(commit_value) + .widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextInput::new(sep) + .disabled(!use_thousands) + .on_update(update_value(|x: &TextInput| TaggedValue::String(x.value.clone()), node_id, ThousandsSeparatorInput::INDEX)) + .on_commit(commit_value) + .widget_instance(), + ]); + } + + // Start at 10,000 — disabled when thousands separator is off + let start_at_10000 = bool_widget( + ParameterWidgetsInfo::new(node_id, StartAt10000Input::INDEX, true, context), + CheckboxInput::default().disabled(!use_thousands), + ); + + vec![ + LayoutGroup::row(decimal_places), + LayoutGroup::row(decimal_sep_widgets), + LayoutGroup::row(fixed_decimals), + LayoutGroup::row(thousands_sep_widgets), + LayoutGroup::row(start_at_10000), + ] +} + +pub(crate) fn string_capitalization_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::text_nodes::string_capitalization::*; + + // Read the current values before borrowing context mutably for widgets + let (is_simple_case, use_joiner_enabled, joiner_value) = match get_document_node(node_id, context) { + Ok(document_node) => { + let capitalization_input = document_node.inputs.get(CapitalizationInput::INDEX); + let capitalization_exposed = capitalization_input.is_some_and(|input| input.is_exposed()); + // When exposed, the capitalization mode may change dynamically, so we can't assume it's a simple (joiner-inapplicable) mode + let is_simple = !capitalization_exposed + && matches!( + capitalization_input.and_then(|input| input.as_value()), + Some(TaggedValue::StringCapitalization(StringCapitalization::LowerCase | StringCapitalization::UpperCase)) + ); + let use_joiner = match document_node.inputs.get(UseJoinerInput::INDEX).and_then(|input| input.as_value()) { + Some(&TaggedValue::Bool(x)) => x, + _ => true, + }; + let joiner = match document_node.inputs.get(JoinerInput::INDEX).and_then(|input| input.as_non_exposed_value()) { + Some(TaggedValue::String(x)) => Some(x.clone()), + _ => None, + }; + (is_simple, use_joiner, joiner) + } + Err(err) => { + log::error!("Could not get document node in string_capitalization_properties: {err}"); + return Vec::new(); + } + }; + + // The joiner controls are disabled when lowercase/UPPERCASE are selected (they don't use word boundaries) + let joiner_disabled = is_simple_case || !use_joiner_enabled; + + let capitalization = enum_choice::() + .for_socket(ParameterWidgetsInfo::new(node_id, CapitalizationInput::INDEX, true, context)) + .property_row(); + + // Joiner row: the UseJoiner checkbox is drawn in the assist area, followed by the Joiner text input + let mut joiner_widgets = start_widgets(ParameterWidgetsInfo::new(node_id, JoinerInput::INDEX, false, context)); + if let Some(joiner) = joiner_value { + let joiner_is_empty = joiner.is_empty(); + joiner_widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + CheckboxInput::new(use_joiner_enabled) + .disabled(is_simple_case) + .on_update(update_value(|x: &CheckboxInput| TaggedValue::Bool(x.checked), node_id, UseJoinerInput::INDEX)) + .on_commit(commit_value) + .widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextInput::new(joiner) + .placeholder(if joiner_is_empty { "Empty" } else { "" }) + .disabled(joiner_disabled) + .on_update(update_value(|x: &TextInput| TaggedValue::String(x.value.clone()), node_id, JoinerInput::INDEX)) + .on_commit(commit_value) + .widget_instance(), + ]); + } + + // Preset buttons for common joiner values, indented to align with the input field + let mut joiner_preset_buttons = vec![TextLabel::new("").widget_instance()]; + add_blank_assist(&mut joiner_preset_buttons); + joiner_preset_buttons.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + for (label, value, tooltip) in [ + ("Empty", "", "Join words without any separator."), + ("Space", " ", "Join words with a space."), + ("Kebab", "-", "Join words with a hyphen."), + ("Snake", "_", "Join words with an underscore."), + ] { + let value = value.to_string(); + joiner_preset_buttons.push( + TextButton::new(label) + .tooltip_description(tooltip) + .disabled(is_simple_case) + .on_update(move |_: &TextButton| Message::Batched { + messages: Box::new([ + NodeGraphMessage::SetInputValue { + node_id, + input_index: UseJoinerInput::INDEX, + value: TaggedValue::Bool(true), + } + .into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: JoinerInput::INDEX, + value: TaggedValue::String(value.clone()), + } + .into(), + ]), + }) + .on_commit(commit_value) + .widget_instance(), + ); + } + + vec![capitalization, LayoutGroup::row(joiner_widgets), LayoutGroup::row(joiner_preset_buttons)] +} + pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::vector::generator_nodes::rectangle::*; @@ -2314,7 +2498,12 @@ pub mod choice { .map(|(item, metadata)| { let updater = updater_factory(); let committer = committer_factory(); - MenuListEntry::new(metadata.name).label(metadata.label).on_update(move |_| updater(item)).on_commit(committer) + MenuListEntry::new(metadata.name) + .label(metadata.label) + .tooltip_label(metadata.label) + .tooltip_description(metadata.description.unwrap_or_default()) + .on_update(move |_| updater(item)) + .on_commit(committer) }) .collect() }) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 4d2e81a9c8..e42ea8f437 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -110,8 +110,8 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &["graphene_core::animation::RealTimeNode"], }, NodeReplacement { - node: graphene_std::logic::serialize::IDENTIFIER, - aliases: &["graphene_core::logic::SerializeNode"], + node: graphene_std::text_nodes::serialize::IDENTIFIER, + aliases: &["graphene_core::logic::SerializeNode", "graphene_core::text::SerializeNode"], }, NodeReplacement { node: graphene_std::debug::size_of::IDENTIFIER, @@ -122,32 +122,36 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &["graphene_core::ops::SomeNode"], }, NodeReplacement { - node: graphene_std::logic::string_concatenate::IDENTIFIER, - aliases: &["graphene_core::logic::StringConcatenateNode"], + node: graphene_std::text_nodes::string_concatenate::IDENTIFIER, + aliases: &["graphene_core::logic::StringConcatenateNode", "graphene_core::text::StringConcatenateNode"], }, NodeReplacement { - node: graphene_std::logic::string_length::IDENTIFIER, - aliases: &["graphene_core::logic::StringLengthNode"], + node: graphene_std::text_nodes::string_length::IDENTIFIER, + aliases: &["graphene_core::logic::StringLengthNode", "graphene_core::text::StringLengthNode"], }, NodeReplacement { - node: graphene_std::logic::string_replace::IDENTIFIER, - aliases: &["graphene_core::logic::StringReplaceNode"], + node: graphene_std::text_nodes::string_replace::IDENTIFIER, + aliases: &["graphene_core::logic::StringReplaceNode", "graphene_core::text::StringReplaceNode"], }, NodeReplacement { - node: graphene_std::logic::string_slice::IDENTIFIER, - aliases: &["graphene_core::logic::StringSliceNode"], + node: graphene_std::text_nodes::string_slice::IDENTIFIER, + aliases: &["graphene_core::logic::StringSliceNode", "graphene_core::text::StringSliceNode"], }, NodeReplacement { - node: graphene_std::logic::string_split::IDENTIFIER, - aliases: &["graphene_core::logic::StringSplitNode"], + node: graphene_std::text_nodes::string_split::IDENTIFIER, + aliases: &["graphene_core::logic::StringSplitNode", "graphene_core::text::StringSplitNode"], }, NodeReplacement { - node: graphene_std::logic::switch::IDENTIFIER, + node: graphene_std::math_nodes::switch::IDENTIFIER, aliases: &["graphene_core::logic::SwitchNode"], }, NodeReplacement { - node: graphene_std::logic::to_string::IDENTIFIER, - aliases: &["graphene_core::logic::ToStringNode"], + node: graphene_std::text_nodes::to_string::IDENTIFIER, + aliases: &["graphene_core::logic::ToStringNode", "graphene_core::text::ToStringNode"], + }, + NodeReplacement { + node: graphene_std::text_nodes::json_get::IDENTIFIER, + aliases: &["graphene_core::logic::JsonGetNode", "graphene_core::text::JsonGetNode"], }, NodeReplacement { node: graphene_std::debug::unwrap_option::IDENTIFIER, @@ -418,8 +422,8 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &["graphene_math_nodes::SineInverseNode", "graphene_core::ops::SineInverseNode"], }, NodeReplacement { - node: graphene_std::math_nodes::string_value::IDENTIFIER, - aliases: &["graphene_math_nodes::StringValueNode", "graphene_core::ops::StringValueNode"], + node: graphene_std::text_nodes::string_value::IDENTIFIER, + aliases: &["graphene_math_nodes::StringValueNode", "graphene_core::ops::StringValueNode", "math_nodes::StringValueNode"], }, NodeReplacement { node: graphene_std::math_nodes::subtract::IDENTIFIER, @@ -1675,7 +1679,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction) -> Table { ... } // // 4 inputs - even older signature (commit 80b8df8d4298b6669f124b929ce61bfabfc44e41): - // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction, #[min(0.)] start_index: IntegerCount) -> Table { ... } + // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction, #[min(0.)] start_index: u32) -> Table { ... } // // v2 signature: // async fn morph(_: impl Ctx, #[implementations(Table, Table)] content: I, progression: Progression) -> Table { ... } diff --git a/frontend/src/components/panels/Data.svelte b/frontend/src/components/panels/Data.svelte index d4876e4fe5..b524ecefa8 100644 --- a/frontend/src/components/panels/Data.svelte +++ b/frontend/src/components/panels/Data.svelte @@ -54,11 +54,16 @@ .widget-span:has(.text-area-input) { flex: 1 1 100%; - .text-area-input textarea { - height: 100%; - margin-top: 0; - margin-bottom: 0; - resize: none; + .text-area-input { + margin: 0; + padding: 4px 0; + + textarea { + height: 100%; + margin-top: 0; + margin-bottom: 0; + resize: none; + } } } } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 511710a597..922d66307b 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -233,6 +233,7 @@ tagged_value! { LuminanceCalculation(raster_nodes::adjustments::LuminanceCalculation), QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel), XY(graphene_core::extract_xy::XY), + StringCapitalization(text_nodes::StringCapitalization), RedGreenBlue(raster_nodes::adjustments::RedGreenBlue), RedGreenBlueAlpha(raster_nodes::adjustments::RedGreenBlueAlpha), RealTimeMode(graphene_core::animation::RealTimeMode), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 38512bece9..4427beddb0 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -110,6 +110,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::blending::BlendMode]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::LuminanceCalculation]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::extract_xy::XY]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::text_nodes::StringCapitalization]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::RedGreenBlue]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::RedGreenBlueAlpha]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::animation::RealTimeMode]), @@ -192,6 +193,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::raster::LuminanceCalculation]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::QRCodeErrorCorrectionLevel]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::extract_xy::XY]), + async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::text_nodes::StringCapitalization]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::raster::RedGreenBlue]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::raster::RedGreenBlueAlpha]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::animation::RealTimeMode]), diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 001c0c33a2..878d702fdf 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -397,6 +397,41 @@ impl AtIndex for Table { } } +pub trait OmitIndex { + fn omit_index(&self, index: usize) -> Self; + fn omit_index_from_end(&self, index: usize) -> Self; +} +impl OmitIndex for Vec { + fn omit_index(&self, index: usize) -> Self { + self.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, v)| v.clone()).collect() + } + + fn omit_index_from_end(&self, index: usize) -> Self { + if index == 0 || index > self.len() { + return self.clone(); + } + self.omit_index(self.len() - index) + } +} +impl OmitIndex for Table { + fn omit_index(&self, index: usize) -> Self { + let mut result = Self::default(); + for (i, row) in self.iter().enumerate() { + if i != index { + result.push(row.into_cloned()); + } + } + result + } + + fn omit_index_from_end(&self, index: usize) -> Self { + if index == 0 || index > self.len() { + return self.clone(); + } + self.omit_index(self.len() - index) + } +} + // TODO: Eventually remove this migration document upgrade code pub fn migrate_graphic<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { use serde::Deserialize; diff --git a/node-graph/libraries/no-std-types/src/registry.rs b/node-graph/libraries/no-std-types/src/registry.rs index 9fde16ff77..1f12a428be 100644 --- a/node-graph/libraries/no-std-types/src/registry.rs +++ b/node-graph/libraries/no-std-types/src/registry.rs @@ -23,8 +23,6 @@ pub mod types { pub type Progression = f64; /// Signed integer that's actually a float because we don't handle type conversions very well yet pub type SignedInteger = f64; - /// Unsigned integer - pub type IntegerCount = u32; /// Unsigned integer to be used for random seeds pub type SeedValue = u32; /// DVec2 with px unit diff --git a/node-graph/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 9d2fbabb03..740a021b59 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -27,7 +27,6 @@ node-macro = { workspace = true } dyn-any = { workspace = true } glam = { workspace = true } log = { workspace = true } -serde_json = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 4498737130..50c682d0b3 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -60,7 +60,7 @@ fn animation_time( ctx.try_animation_time().unwrap_or_default() * rate } -#[node_macro::node(category("Animation"))] +#[node_macro::node(category("Debug"))] async fn quantize_real_time( ctx: impl Ctx + ExtractAll + CloneVarArgs, #[implementations( @@ -103,7 +103,7 @@ async fn quantize_real_time( value.eval(Some(new_context.into())).await } -#[node_macro::node(category("Animation"))] +#[node_macro::node(category("Debug"))] async fn quantize_animation_time( ctx: impl Ctx + ExtractAll + CloneVarArgs, #[implementations( diff --git a/node-graph/nodes/gcore/src/lib.rs b/node-graph/nodes/gcore/src/lib.rs index 9d1c0e132b..aaad739620 100644 --- a/node-graph/nodes/gcore/src/lib.rs +++ b/node-graph/nodes/gcore/src/lib.rs @@ -3,16 +3,13 @@ pub mod context; pub mod context_modification; pub mod debug; pub mod extract_xy; -pub mod logic; pub mod memo; pub mod ops; - // Re-export all nodes pub use animation::*; pub use context::*; pub use context_modification::*; pub use debug::*; pub use extract_xy::*; -pub use logic::*; pub use memo::*; pub use ops::*; diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs deleted file mode 100644 index 68391d5e01..0000000000 --- a/node-graph/nodes/gcore/src/logic.rs +++ /dev/null @@ -1,180 +0,0 @@ -use core_types::Color; -use core_types::registry::types::TextArea; -use core_types::table::Table; -use core_types::{Context, Ctx}; -use glam::{DAffine2, DVec2}; -use graphic_types::vector_types::GradientStops; -use graphic_types::{Artboard, Graphic, Vector}; -use raster_types::{CPU, GPU, Raster}; - -/// Type-asserts a value to be a string. -#[node_macro::node(category("Debug"))] -fn to_string(_: impl Ctx, value: String) -> String { - value -} - -/// Converts a value to a JSON string representation. -#[node_macro::node(category("Text"))] -fn serialize( - _: impl Ctx, - #[implementations(String, bool, f64, u32, u64, DVec2, DAffine2, /* Table, Table, Table, */ Table>, Table /* , Table */)] value: T, -) -> String { - serde_json::to_string(&value).unwrap_or_else(|_| "Serialization Error".to_string()) -} - -/// Joins two strings together. -#[node_macro::node(category("Text"))] -fn string_concatenate(_: impl Ctx, #[implementations(String)] first: String, second: TextArea) -> String { - first.clone() + &second -} - -/// Replaces all occurrences of "From" with "To" in the input string. -#[node_macro::node(category("Text"))] -fn string_replace(_: impl Ctx, string: String, from: TextArea, to: TextArea) -> String { - string.replace(&from, &to) -} - -/// Extracts a substring from the input string, starting at "Start" and ending before "End". -/// Negative indices count from the end of the string. -/// If "Start" equals or exceeds "End", the result is an empty string. -#[node_macro::node(category("Text"))] -fn string_slice(_: impl Ctx, string: String, start: f64, end: f64) -> String { - let total_chars = string.chars().count(); - - let start = if start < 0. { - total_chars.saturating_sub(start.abs() as usize) - } else { - (start as usize).min(total_chars) - }; - let end = if end <= 0. { - total_chars.saturating_sub(end.abs() as usize) - } else { - (end as usize).min(total_chars) - }; - - if start >= end { - return String::new(); - } - - string.chars().skip(start).take(end - start).collect() -} - -// TODO: Return u32, u64, or usize instead of f64 after #1621 is resolved and has allowed us to implement automatic type conversion in the node graph for nodes with generic type inputs. -// TODO: (Currently automatic type conversion only works for concrete types, via the Graphene preprocessor and not the full Graphene type system.) -/// Counts the number of characters in a string. -#[node_macro::node(category("Text"))] -fn string_length(_: impl Ctx, string: String) -> f64 { - string.chars().count() as f64 -} - -/// Splits a string into a list of substrings based on the specified delimeter. -/// For example, the delimeter "," will split "a,b,c" into the strings "a", "b", and "c". -#[node_macro::node(category("Text"))] -fn string_split( - _: impl Ctx, - /// The string to split into substrings. - string: String, - /// The character(s) that separate the substrings. These are not included in the outputs. - #[default("\\n")] - delimeter: String, - /// Whether to convert escape sequences found in the delimeter into their corresponding characters: - /// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash). - #[default(true)] - delimeter_escaping: bool, -) -> Vec { - let delimeter = if delimeter_escaping { - delimeter.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\0", "\0").replace("\\\\", "\\") - } else { - delimeter - }; - - string.split(&delimeter).map(str::to_string).collect() -} - -/// Gets a value from either a json object or array given as a string input. -/// For example, for the input {"name": "ferris"} the key "name" will return "ferris". -#[node_macro::node(category("Text"))] -fn json_get( - _: impl Ctx, - /// The json data. - data: String, - /// The key to index the object with. - key: String, -) -> String { - use serde_json::Value; - let Ok(value): Result = serde_json::from_str(&data) else { - return "Input is not valid json".into(); - }; - match value { - Value::Array(ref arr) => { - let Ok(index): Result = key.parse() else { - log::error!("Json input is an array, but key is not a number"); - return String::new(); - }; - let Some(value) = arr.get(index) else { - log::error!("Index {} out of bounds for len {}", index, arr.len()); - return String::new(); - }; - value.to_string() - } - Value::Object(map) => { - let Some(value) = map.get(&key) else { - log::error!("Key {key} not found in object"); - return String::new(); - }; - match value { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - complex => complex.to_string(), - } - } - _ => String::new(), - } -} - -/// Evaluates either the "If True" or "If False" input branch based on whether the input condition is true or false. -#[node_macro::node(category("Math: Logic"))] -async fn switch( - #[implementations(Context)] ctx: C, - condition: bool, - #[expose] - #[implementations( - Context -> String, - Context -> bool, - Context -> f32, - Context -> f64, - Context -> u32, - Context -> u64, - Context -> DVec2, - Context -> DAffine2, - Context -> Table, - Context -> Table, - Context -> Table, - Context -> Table>, - Context -> Table>, - Context -> Table, - Context -> Table, - )] - if_true: impl Node, - #[expose] - #[implementations( - Context -> String, - Context -> bool, - Context -> f32, - Context -> f64, - Context -> u32, - Context -> u64, - Context -> DVec2, - Context -> DAffine2, - Context -> Table, - Context -> Table, - Context -> Table, - Context -> Table>, - Context -> Table>, - Context -> Table, - Context -> Table, - )] - if_false: impl Node, -) -> T { - if condition { if_true.eval(ctx).await } else { if_false.eval(ctx).await } -} diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index f939df8ee8..c370b25a5f 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -46,6 +46,39 @@ where .unwrap_or_default() } +/// Returns the collection with the element at the specified index removed. +/// If no value exists at that index, the collection is returned unchanged. +#[node_macro::node(category("General"))] +pub fn omit_element( + _: impl Ctx, + /// The collection of data, such as a list or table. + #[implementations( + Vec, + Vec, + Vec, + Vec, + Vec, + Table, + Table, + Table, + Table>, + Table>, + Table, + Table, + )] + collection: T, + /// The index of the item to remove, starting from 0 for the first item. Negative indices count backwards from the end of the collection, starting from -1 for the last item. + index: SignedInteger, +) -> T { + let index = index as i32; + + if index < 0 { + collection.omit_index_from_end(-index as usize) + } else { + collection.omit_index(index as usize) + } +} + #[node_macro::node(category("General"))] async fn map( ctx: impl Ctx + CloneVarArgs + ExtractAll, diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index f7d36139d6..7f1f059117 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -78,10 +78,6 @@ pub mod math { } } -pub mod logic { - pub use graphene_core::logic::*; -} - pub mod context { pub use graphene_core::context::*; } diff --git a/node-graph/nodes/math/Cargo.toml b/node-graph/nodes/math/Cargo.toml index 3aeb8f37f0..6455301cd0 100644 --- a/node-graph/nodes/math/Cargo.toml +++ b/node-graph/nodes/math/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT OR Apache-2.0" [dependencies] core-types = { workspace = true } node-macro = { workspace = true } +graphic-types = { workspace = true } vector-types = { workspace = true } # Workspace dependencies diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index a5d92ec1ac..295e46d083 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1,8 +1,11 @@ -use core_types::registry::types::{Fraction, Percentage, PixelSize, TextArea}; +use core_types::Context; +use core_types::registry::types::{Fraction, Percentage, PixelSize}; use core_types::table::Table; use core_types::transform::Footprint; use core_types::{Color, Ctx, num_traits}; use glam::{DAffine2, DVec2}; +use graphic_types::raster_types::{CPU, GPU, Raster}; +use graphic_types::{Artboard, Graphic, Vector}; use log::warn; use math_parser::ast; use math_parser::context::{EvalContext, NothingMap, ValueProvider}; @@ -735,6 +738,53 @@ fn logical_not( !input } +/// Evaluates either the "If True" or "If False" input branch based on whether the input condition is true or false. +#[node_macro::node(category("Math: Logic"))] +async fn switch( + #[implementations(Context)] ctx: C, + condition: bool, + #[expose] + #[implementations( + Context -> String, + Context -> bool, + Context -> f32, + Context -> f64, + Context -> u32, + Context -> u64, + Context -> DVec2, + Context -> DAffine2, + Context -> Table, + Context -> Table, + Context -> Table, + Context -> Table>, + Context -> Table>, + Context -> Table, + Context -> Table, + )] + if_true: impl Node, + #[expose] + #[implementations( + Context -> String, + Context -> bool, + Context -> f32, + Context -> f64, + Context -> u32, + Context -> u64, + Context -> DVec2, + Context -> DAffine2, + Context -> Table, + Context -> Table, + Context -> Table, + Context -> Table>, + Context -> Table>, + Context -> Table, + Context -> Table, + )] + if_false: impl Node, +) -> T { + if condition { if_true.eval(ctx).await } else { if_false.eval(ctx).await } +} + /// Constructs a bool value which may be set to true or false. #[node_macro::node(category("Value"))] fn bool_value(_: impl Ctx, _primary: (), #[name("Bool")] bool_value: bool) -> bool { @@ -823,12 +873,6 @@ fn sample_gradient(_: impl Ctx, _primary: (), gradient: Table, po Table::new_from_element(color) } -/// Constructs a string value which may be set to any plain text. -#[node_macro::node(category("Value"))] -fn string_value(_: impl Ctx, _primary: (), string: TextArea) -> String { - string -} - /// Constructs a footprint value which may be set to any transformation of a unit square describing a render area, and a render resolution at least 1x1 integer pixels. #[node_macro::node(category("Value"))] fn footprint_value(_: impl Ctx, _primary: (), transform: DAffine2, #[default(100., 100.)] resolution: PixelSize) -> Footprint { diff --git a/node-graph/nodes/raster/src/image_color_palette.rs b/node-graph/nodes/raster/src/image_color_palette.rs index ef20170591..58e976ad25 100644 --- a/node-graph/nodes/raster/src/image_color_palette.rs +++ b/node-graph/nodes/raster/src/image_color_palette.rs @@ -1,11 +1,16 @@ use core_types::color::Color; use core_types::context::Ctx; -use core_types::registry::types::IntegerCount; use core_types::table::{Table, TableRow}; use raster_types::{CPU, Raster}; #[node_macro::node(category("Color"))] -async fn image_color_palette(_: impl Ctx, image: Table>, #[default(4)] count: IntegerCount) -> Table { +async fn image_color_palette( + _: impl Ctx, + image: Table>, + #[default(4)] + #[min(1)] + count: u32, +) -> Table { const GRID: f32 = 3.; let bins = GRID * GRID * GRID; diff --git a/node-graph/nodes/repeat/src/repeat_nodes.rs b/node-graph/nodes/repeat/src/repeat_nodes.rs index 466f66152b..38c92c69a3 100644 --- a/node-graph/nodes/repeat/src/repeat_nodes.rs +++ b/node-graph/nodes/repeat/src/repeat_nodes.rs @@ -1,6 +1,6 @@ use crate::gcore::Context; use core::f64::consts::TAU; -use core_types::registry::types::{Angle, IntegerCount, PixelSize}; +use core_types::registry::types::{Angle, PixelSize}; use core_types::table::{Table, TableRowRef}; use core_types::{CloneVarArgs, Color, Ctx, ExtractAll, InjectVarArgs, OwnedContextImpl}; use glam::{DAffine2, DVec2}; @@ -19,7 +19,9 @@ async fn repeat + Default + Send + Clone + 'static>( Context -> Table, )] instance: impl Node<'n, Context<'static>, Output = Table>, - #[default(1)] count: u64, + #[default(1)] + #[min(1)] + count: u32, reverse: bool, ) -> Table { // Someday this node can have the option to generate infinitely instead of a fixed count (basically `std::iter::repeat`). @@ -57,7 +59,9 @@ pub async fn repeat_array + Default + Send + Clone + 'static>( // TODO: When using a custom Properties panel layout in document_node_definitions.rs and this default is set, the widget weirdly doesn't show up in the Properties panel. Investigation is needed. direction: PixelSize, angle: Angle, - #[default(5)] count: IntegerCount, + #[default(5)] + #[min(1)] + count: u32, ) -> Table { let angle = angle.to_radians(); let count = count.max(1); @@ -102,7 +106,9 @@ async fn repeat_radial + Default + Send + Clone + 'static>( #[unit(" px")] #[default(5)] radius: f64, - #[default(5)] count: IntegerCount, + #[default(5)] + #[min(1)] + count: u32, ) -> Table { let count = count.max(1); diff --git a/node-graph/nodes/text/Cargo.toml b/node-graph/nodes/text/Cargo.toml index 4537425a80..e5558c741d 100644 --- a/node-graph/nodes/text/Cargo.toml +++ b/node-graph/nodes/text/Cargo.toml @@ -13,6 +13,7 @@ wasm = ["core-types/wasm", "tsify", "wasm-bindgen"] [dependencies] # Local dependencies core-types = { workspace = true } +raster-types = { workspace = true } vector-types = { workspace = true } node-macro = { workspace = true } @@ -22,6 +23,11 @@ glam = { workspace = true } parley = { workspace = true } skrifa = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } +convert_case = { workspace = true } +titlecase = { workspace = true } +fancy-regex = { workspace = true } +unicode-segmentation = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index ca4738ffa1..f81453de8d 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -3,13 +3,21 @@ mod path_builder; mod text_context; mod to_path; +use convert_case::{Boundary, Converter, pattern}; +use core_types::Color; +use core_types::registry::types::{SignedInteger, TextArea}; +use core_types::table::Table; +use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractVarArgs, OwnedContextImpl}; use dyn_any::DynAny; -pub use font_cache::*; -pub use text_context::TextContext; -pub use to_path::*; +use glam::{DAffine2, DVec2}; +use raster_types::{CPU, Raster}; +use unicode_segmentation::UnicodeSegmentation; // Re-export for convenience pub use core_types as gcore; +pub use font_cache::*; +pub use text_context::TextContext; +pub use to_path::*; pub use vector_types; /// Alignment of lines of type within a text block. @@ -62,3 +70,937 @@ impl Default for TypesettingConfig { } } } + +/// Converts escape sequence representations (`\n`, `\r`, `\t`, `\0`, `\\`) into their corresponding control characters. +/// Unrecognized escape sequences (e.g. `\x`) are preserved as-is. +fn unescape_string(input: String) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars(); + + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') => result.push('\n'), + Some('r') => result.push('\r'), + Some('t') => result.push('\t'), + Some('0') => result.push('\0'), + Some('\\') => result.push('\\'), + Some(unrecognized) => result.extend(['\\', unrecognized]), + None => result.push('\\'), + } + } else { + result.push(c); + } + } + + result +} + +/// Converts control characters (newline, carriage return, tab, null, backslash) back into their escape sequence representations. +fn escape_string(input: String) -> String { + let mut result = String::with_capacity(input.len()); + + for c in input.chars() { + match c { + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\0' => result.push_str("\\0"), + '\\' => result.push_str("\\\\"), + other => result.push(other), + } + } + + result +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, serde::Serialize, serde::Deserialize)] +#[widget(Dropdown)] +pub enum StringCapitalization { + /// "on the origin of species" — Converts all letters to lower case. + #[default] + #[label("lower case")] + LowerCase, + /// "ON THE ORIGIN OF SPECIES" — Converts all letters to upper case. + #[label("UPPER CASE")] + UpperCase, + /// "On The Origin Of Species" — Converts the first letter of every word to upper case. + #[label("Capital Case")] + CapitalCase, + /// "On the Origin of Species" — Converts the first letter of significant words to upper case. + #[label("Headline Case")] + HeadlineCase, + /// "On the origin of species" — Converts the first letter of every word to lower case, except the initial word which is made upper case. + #[label("Sentence case")] + SentenceCase, + /// "on The Origin Of Species" — Converts the first letter of every word to upper case, except the initial word which is made lower case. + #[label("camel Case")] + CamelCase, +} + +/// Constructs a string value which may be set to any plain text. +#[node_macro::node(category("Value"))] +fn string_value(_: impl Ctx, _primary: (), string: TextArea) -> String { + string +} + +/// Type-asserts a value to be a string. +#[node_macro::node(category("Debug"))] +fn to_string(_: impl Ctx, value: String) -> String { + value +} + +/// Joins two strings together. +#[node_macro::node(category("Text"))] +fn string_concatenate(_: impl Ctx, #[implementations(String)] first: String, second: TextArea) -> String { + first.clone() + &second +} + +/// Replaces all occurrences of "From" with "To" in the input string. +#[node_macro::node(category("Text"))] +fn string_replace(_: impl Ctx, string: String, from: TextArea, to: TextArea) -> String { + string.replace(&from, &to) +} + +/// Extracts a substring from the input string, starting at "Start" and ending before "End". +/// +/// Negative indices count from the end of the string. If the index of "Start" equals or exceeds "End", the result is an empty string. +#[node_macro::node(category("Text"))] +fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedInteger) -> String { + let total_graphemes = string.graphemes(true).count(); + + let start = if start < 0. { + total_graphemes.saturating_sub(start.abs() as usize) + } else { + (start as usize).min(total_graphemes) + }; + let end = if end <= 0. { + total_graphemes.saturating_sub(end.abs() as usize) + } else { + (end as usize).min(total_graphemes) + }; + + if start >= end { + return String::new(); + } + + string.graphemes(true).skip(start).take(end - start).collect() +} + +/// Clips the string to a maximum character length, optionally appending a suffix (like "…") when truncation occurs. Strings already within the limit are not modified. +#[node_macro::node(category("Text"))] +fn string_truncate( + _: impl Ctx, + /// The string to truncate. + string: String, + /// The maximum number of characters allowed, including the suffix if one is appended. + #[default(80)] + #[min(0)] + length: u32, + /// A suffix appended to indicate truncation occurred, unless empty. Its length counts towards the character budget. + #[default("…")] + suffix: String, +) -> String { + let max_length = length as usize; + let grapheme_count = string.graphemes(true).count(); + + if grapheme_count <= max_length { + return string; + } + + let suffix_length = suffix.graphemes(true).count(); + let keep = max_length.saturating_sub(suffix_length); + + let mut truncated: String = string.graphemes(true).take(keep).collect(); + truncated.push_str(&suffix); + truncated +} + +/// Formats a number as a string with control over decimal places, decimal separator, and thousands grouping. +#[node_macro::node(category("Text"), properties("format_number_properties"))] +fn format_number( + _: impl Ctx, + /// The number to format as a string. + number: f64, + /// The amount of digits after the decimal point. The value is rounded to fit. Set to 0 to show only whole numbers. + #[default(2)] + #[min(0)] + decimal_places: u32, + /// The character(s) used as the decimal point. + #[default(".")] + decimal_separator: String, + /// Always show the exact number of decimal places, even if they are trailing zeros. + #[default(true)] + fixed_decimals: bool, + /// Whether to group digits with a thousands separator. + use_thousands_separator: bool, + /// The character(s) inserted between digit groups. + #[default(",")] + thousands_separator: String, + /// Don't group 4-digit numbers with a thousands separator (only start grouping at 10,000 and above). + #[name("Start at 10,000")] + start_at_10000: bool, +) -> String { + // Find the maximum meaningful decimal precision by detecting where float noise begins. + // This works correctly whether the value originated as f32 or f64, since we find the + // shortest decimal representation that round-trips back to the same f64 value. + let requested_places = decimal_places as usize; + let max_places = { + let whole_digits = if number == 0. { 1 } else { (number.abs().log10().floor() as usize).saturating_add(1) }; + let upper_bound = 17_usize.saturating_sub(whole_digits); + let mut meaningful = upper_bound; + for p in 0..=upper_bound { + let s = format!("{number:.p$}"); + if s.parse::() == Ok(number) { + meaningful = p; + break; + } + } + meaningful + }; + let places = requested_places.min(max_places); + let formatted = format!("{number:.places$}"); + + // If the user requested more decimal places than the float can represent, pad with zeros + let extra_zeros = requested_places.saturating_sub(places); + + // Split into sign, whole, and decimal parts + let (sign, unsigned) = if formatted.starts_with('-') { ("-", &formatted[1..]) } else { ("", formatted.as_str()) }; + + let (whole_string, decimal_string) = match unsigned.split_once('.') { + Some((w, d)) => { + let padded = if extra_zeros > 0 { format!("{d}{:0>width$}", "", width = extra_zeros) } else { d.to_string() }; + (w.to_string(), Some(padded)) + } + None => (unsigned.to_string(), None), + }; + + // Apply thousands grouping to the whole number part + let grouped_whole = if use_thousands_separator && !thousands_separator.is_empty() { + let skip = start_at_10000 && whole_string.len() <= 4; + if skip { + whole_string.clone() + } else { + let mut result = String::new(); + for (i, ch) in whole_string.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push_str(&thousands_separator.chars().rev().collect::()); + } + result.push(ch); + } + result.chars().rev().collect() + } + } else { + whole_string + }; + + // Build the final string + let Some(decimal_string) = decimal_string else { + return format!("{sign}{grouped_whole}"); + }; + + if fixed_decimals { + format!("{sign}{grouped_whole}{decimal_separator}{decimal_string}") + } else { + let trimmed = decimal_string.trim_end_matches('0'); + if trimmed.is_empty() { + format!("{sign}{grouped_whole}") + } else { + format!("{sign}{grouped_whole}{decimal_separator}{trimmed}") + } + } +} + +/// Parses a string into a number. Falls back to the chosen value if the string is not a valid number. +#[node_macro::node(category("Text"))] +fn string_to_number( + _: impl Ctx, + /// The string containing a number. Surrounding whitespace is ignored, a decimal point (.) may be included, sign prefixes (+/-) are respected, and scientific notation (e.g. "1e-3") is supported. + string: String, + /// The value of the result if the string cannot be parsed as a valid number. + fallback: f64, +) -> f64 { + string.trim().parse::().unwrap_or(fallback) +} + +/// Removes leading and/or trailing whitespace from a string. Common whitespace characters include spaces, tabs, and newlines. +#[node_macro::node(category("Text"))] +fn string_trim( + _: impl Ctx, + /// The string that may contain leading and trailing whitespace that should be removed. + string: String, + /// Whether the start of the string should have its whitespace removed. + #[default(true)] + start: bool, + /// Whether the end of the string should have its whitespace removed. + #[default(true)] + end: bool, +) -> String { + match (start, end) { + (true, true) => string.trim().to_string(), + (true, false) => string.trim_start().to_string(), + (false, true) => string.trim_end().to_string(), + (false, false) => string, + } +} + +/// Converts between literal escape sequences and their corresponding control characters within a string. +/// +/// Unescape: `\n` (newline), `\r` (carriage return), `\t` (tab), `\0` (null), and `\\` (backslash) are converted into the actual special characters. +/// Escape: the actual special characters are converted back into their escape sequence representations. +#[node_macro::node(category("Text"))] +fn string_escape( + _: impl Ctx, + /// The string that contains either literal escape sequences or control characters to be converted to the opposite representation. + string: String, + /// Convert the control characters back into their escape sequence representations. + #[default(true)] + unescape: bool, +) -> String { + if unescape { unescape_string(string) } else { escape_string(string) } +} + +/// Reverses the sequence of characters making up the string so it reads back-to-front. ("Backwards text" becomes "txet sdrawkcaB".) +#[node_macro::node(category("Text"))] +fn string_reverse( + _: impl Ctx, + /// The string to be reversed. + string: String, +) -> String { + string.graphemes(true).rev().collect() +} + +/// Repeats the string a given number of times, optionally with a separator between each repetition. +#[node_macro::node(category("Text"))] +fn string_repeat( + _: impl Ctx, + /// The string to be repeated. + string: String, + /// The number of times the string should appear in the output. + #[default(2)] + #[min(1)] + count: u32, + /// The string placed between each repetition. + #[default("\\n")] + separator: String, + /// Whether to convert escape sequences found in the separator into their corresponding characters: + /// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash). + #[default(true)] + separator_escaping: bool, +) -> String { + let separator = if separator_escaping { unescape_string(separator) } else { separator }; + + let count = count.max(1) as usize; + + let mut result = String::with_capacity((string.len() + separator.len()) * count); + for i in 0..count { + if i > 0 { + result.push_str(&separator); + } + result.push_str(&string); + } + result +} + +/// Pads the string to a target length by filling with the given repeated substring. If the string already meets or exceeds the target length, it is returned unchanged. +#[node_macro::node(category("Text"))] +fn string_pad( + _: impl Ctx, + /// The string to be padded to a target length. + string: String, + /// The target character length after padding. When "Up To" is set, this length concerns only the portion before (or after) that substring. + #[default(10)] + length: u32, + /// The repeated substring used to fill the remaining space. A multi-charcter substring may end partway through its final repetition. + #[default("#")] + padding: String, + /// Pad only the length of the string encountered before the start of the first (or after the end of the last) occurrence of this substring, if given and present (otherwise the full string is considered). + /// + /// For example, this can pad numbers with leading zeros to align them before the decimal point. + up_to: String, + /// Pad at the end of the string instead of the start. + from_end: bool, +) -> String { + let target_length = length as usize; + + if padding.is_empty() { + return string; + } + + // Split the string at the "up to" substring if provided, and only pad that portion + if !up_to.is_empty() + && let Some(position) = if from_end { string.rfind(&*up_to) } else { string.find(&*up_to) } + { + let (before, after) = string.split_at(position); + + if from_end { + // Pad the portion after the substring + let after_substring = &after[up_to.len()..]; + let current_length = after_substring.graphemes(true).count(); + if current_length >= target_length { + return string; + } + let pad_length = target_length - current_length; + let padding: String = padding.graphemes(true).cycle().take(pad_length).collect(); + return format!("{before}{up_to}{after_substring}{padding}"); + } else { + // Pad the portion before the substring + let current_length = before.graphemes(true).count(); + if current_length >= target_length { + return string; + } + let pad_length = target_length - current_length; + let padding: String = padding.graphemes(true).cycle().take(pad_length).collect(); + return format!("{padding}{before}{after}"); + } + } + + let current_length = string.graphemes(true).count(); + if current_length >= target_length { + return string; + } + + let pad_length = target_length - current_length; + let padding: String = padding.graphemes(true).cycle().take(pad_length).collect(); + + if from_end { string + &padding } else { padding + &string } +} + +/// Checks whether the string contains the given substring. Optionally restricts the match to only the start and/or end of the string. +#[node_macro::node(category("Text"))] +fn string_contains( + _: impl Ctx, + /// The string to search within. + string: String, + /// The substring to search for. + substring: String, + /// Only match if the substring appears at the start of the string. + at_start: bool, + /// Only match if the substring appears at the end of the string. + at_end: bool, +) -> bool { + match (at_start, at_end) { + (true, true) => string.starts_with(&*substring) && string.ends_with(&*substring), + (true, false) => string.starts_with(&*substring), + (false, true) => string.ends_with(&*substring), + (false, false) => string.contains(&*substring), + } +} + +/// Similar to the **String Contains** node, this searches within the input string for the first (or last) occurrence of a substring and returns the index of where that begins, or -1 if not found. +#[node_macro::node(category("Text"))] +fn string_find_index( + _: impl Ctx, + /// The string to search within. + string: String, + /// The substring to search for. + substring: String, + /// Find the start index of the last occurrence instead of the first. + from_end: bool, +) -> f64 { + if substring.is_empty() { + return if from_end { string.graphemes(true).count() as f64 } else { 0. }; + } + + if from_end { + // Search backwards by finding all byte-level matches and taking the last one + string + .rmatch_indices(&*substring) + .next() + .map_or(-1., |(byte_index, _)| string[..byte_index].graphemes(true).count() as f64) + } else { + string + .match_indices(&*substring) + .next() + .map_or(-1., |(byte_index, _)| string[..byte_index].graphemes(true).count() as f64) + } +} + +/// Counts the number of occurrences of a substring within the string. +#[node_macro::node(category("Text"))] +fn string_occurrences( + _: impl Ctx, + /// The string to search within. + string: String, + /// The substring to count occurrences of. + substring: String, + /// Whether to count overlapping occurrences, using the substring as a sliding window. + /// + /// For example, "aa" occurs twice in "aaaa" without overlapping but three times with overlapping. + overlapping: bool, +) -> f64 { + if substring.is_empty() { + return 0.; + } + + // NON-OVERLAPPING: Simple linear scan. + // O(n), where n = string length + if !overlapping { + return string.matches(&*substring).count() as f64; + } + + // OVERLAPPING: KMP (Knuth-Morris-Pratt) algorithm. + // O(n + m), where n = string length, m = substring length + + let pattern: Vec = substring.chars().collect(); + let text: Vec = string.chars().collect(); + + // Build the KMP failure function: + // For each position in the pattern, the length of the longest proper prefix that is also a suffix. + // This lets us skip ahead on mismatches instead of restarting from scratch. + let mut failure = vec![0_usize; pattern.len()]; + let mut k = 0; + for i in 1..pattern.len() { + while k > 0 && pattern[k] != pattern[i] { + k = failure[k - 1]; + } + + if pattern[k] == pattern[i] { + k += 1; + } + + failure[i] = k; + } + + // Scan the text, advancing the pattern cursor without ever backtracking in the text + let mut count: usize = 0; + let mut pattern_cursor = 0; + for &text_char in &text { + while pattern_cursor > 0 && pattern[pattern_cursor] != text_char { + pattern_cursor = failure[pattern_cursor - 1]; + } + + if pattern[pattern_cursor] == text_char { + pattern_cursor += 1; + } + + if pattern_cursor == pattern.len() { + count += 1; + + // Reset using failure function to allow overlapping matches + pattern_cursor = failure[pattern_cursor - 1]; + } + } + + count as f64 +} + +/// Converts a string's capitalization style to another of the common upper and lower case patterns, optionally joining words with a chosen separator. +#[node_macro::node(category("Text"), properties("string_capitalization_properties"))] +fn string_capitalization( + _: impl Ctx, + /// The string to have its letter capitalization converted. + string: String, + /// The capitalization style to apply. + capitalization: StringCapitalization, + /// Whether to split the string into words and reconnect with the chosen joiner. When disabled, the existing word structure separators are preserved. + use_joiner: bool, + /// The string placed between each word. + joiner: String, +) -> String { + // When the joiner is disabled, apply only character-level casing while preserving the string's existing structure + if use_joiner { + match capitalization { + // Simple case mappings that preserve the string's existing structure + StringCapitalization::LowerCase => string.to_lowercase(), + StringCapitalization::UpperCase => string.to_uppercase(), + + // Word-aware capitalizations that split on word boundaries and rejoin with the joiner + StringCapitalization::CapitalCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(&joiner).convert(&string), + StringCapitalization::HeadlineCase => { + // First split into words with convert_case so word boundaries like "AlphaNumeric" are detected consistently with other modes, + // then apply the titlecase crate for smart capitalization (lowercasing short words like "of", "the", etc.), + // then rejoin with the custom joiner without mangling the capitalization + let spaced = Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(" ").convert(&string); + let headline = titlecase::titlecase(&spaced); + Converter::new().set_boundaries(&[Boundary::SPACE]).set_pattern(pattern::noop).set_delim(&joiner).convert(&headline) + } + StringCapitalization::SentenceCase => Converter::new() + .set_boundaries(&Boundary::defaults()) + .set_pattern(pattern::sentence) + .set_delim(&joiner) + .convert(&string), + StringCapitalization::CamelCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::camel).set_delim(&joiner).convert(&string), + } + } else { + match capitalization { + StringCapitalization::LowerCase => string.to_lowercase(), + StringCapitalization::UpperCase => string.to_uppercase(), + StringCapitalization::CapitalCase => { + let mut capitalize_next = true; + string.chars().fold(String::with_capacity(string.len()), |mut result, c| { + if c.is_whitespace() || c == '_' || c == '-' { + capitalize_next = true; + result.push(c); + } else if capitalize_next { + capitalize_next = false; + result.extend(c.to_uppercase()); + } else { + result.push(c); + } + result + }) + } + StringCapitalization::HeadlineCase => titlecase::titlecase(&string), + StringCapitalization::SentenceCase => { + let mut chars = string.chars(); + match chars.next() { + Some(first) => first.to_uppercase().to_string() + &chars.as_str().to_lowercase(), + None => String::new(), + } + } + StringCapitalization::CamelCase => { + let mut capitalize_next = false; + string.chars().fold(String::with_capacity(string.len()), |mut result, c| { + if c.is_whitespace() || c == '_' || c == '-' { + capitalize_next = true; + result.push(c); + } else if capitalize_next { + capitalize_next = false; + result.extend(c.to_uppercase()); + } else { + result.extend(c.to_lowercase()); + } + result + }) + } + } + } +} + +// TODO: Return u32, u64, or usize instead of f64 after #1621 is resolved and has allowed us to implement automatic type conversion in the node graph for nodes with generic type inputs. +// TODO: (Currently automatic type conversion only works for concrete types, via the Graphene preprocessor and not the full Graphene type system.) +/// Counts the number of characters in a string. +#[node_macro::node(category("Text"))] +fn string_length(_: impl Ctx, string: String) -> f64 { + string.graphemes(true).count() as f64 +} + +/// Splits a string into a list of substrings based on the specified delimeter. This is the inverse of the **String Join** node. +/// +/// For example, splitting "a, b, c" with delimeter ", " produces `["a", "b", "c"]`. +#[node_macro::node(category("Text"))] +fn string_split( + _: impl Ctx, + /// The string to split into substrings. + string: String, + /// The character(s) that separate the substrings. These are not included in the outputs. + #[default("\\n")] + delimeter: String, + /// Whether to convert escape sequences found in the delimeter into their corresponding characters: + /// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash). + #[default(true)] + delimeter_escaping: bool, +) -> Vec { + let delimeter = if delimeter_escaping { unescape_string(delimeter) } else { delimeter }; + + string.split(&delimeter).map(str::to_string).collect() +} + +/// Joins a list of strings together with a separator between each pair. This is the inverse of the **String Split** node. +/// +/// For example, joining `["a", "b", "c"]` with separator ", " produces "a, b, c". +#[node_macro::node(category("Text"))] +fn string_join( + _: impl Ctx, + /// The list of strings to join together. + strings: Vec, + /// The text placed between each pair of strings. + #[default(", ")] + separator: String, + /// Whether to convert escape sequences found in the separator into their corresponding characters: + /// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash). + #[default(true)] + separator_escaping: bool, +) -> String { + let separator = if separator_escaping { unescape_string(separator) } else { separator }; + + strings.join(&separator) +} + +/// Checks whether the string contains a match for the given regular expression pattern. Optionally restricts the match to only the start and/or end of the string. +#[node_macro::node(category("Text: Regex"))] +fn regex_contains( + _: impl Ctx, + /// The string to search within. + string: String, + /// The regular expression pattern to search for. + pattern: String, + /// Match letters regardless of case. + case_insensitive: bool, + /// Make `^` and `$` match the start and end of each line, not just the whole string. + multiline: bool, + /// Only match if the pattern appears at the start of the string. + at_start: bool, + /// Only match if the pattern appears at the end of the string. + at_end: bool, +) -> bool { + let flags = match (case_insensitive, multiline) { + (false, false) => "", + (true, false) => "(?i)", + (false, true) => "(?m)", + (true, true) => "(?im)", + }; + let anchored_pattern = match (at_start, at_end) { + (true, true) => format!("{flags}\\A(?:{pattern})\\z"), + (true, false) => format!("{flags}\\A(?:{pattern})"), + (false, true) => format!("{flags}(?:{pattern})\\z"), + (false, false) => format!("{flags}{pattern}"), + }; + + let Ok(regex) = fancy_regex::Regex::new(&anchored_pattern) else { + log::error!("Invalid regex pattern: {pattern}"); + return false; + }; + + regex.is_match(&string).unwrap_or(false) +} + +/// Replaces matches of a regular expression pattern in the string. The replacement string can reference captures: `$0` for the whole match and `$1`, `$2`, etc. for capture groups. +#[node_macro::node(category("Text: Regex"))] +fn regex_replace( + _: impl Ctx, + string: String, + /// The regular expression pattern to search for. + pattern: String, + /// The replacement string. Use `$0` for the whole match and `$1`, `$2`, etc. for capture groups. + replacement: String, + /// Replace all matches. When disabled, only the first match is replaced. + #[default(true)] + replace_all: bool, + /// Match letters regardless of case. + case_insensitive: bool, + /// Make `^` and `$` match the start and end of each line, not just the whole string. + multiline: bool, +) -> String { + let flags = match (case_insensitive, multiline) { + (false, false) => "", + (true, false) => "(?i)", + (false, true) => "(?m)", + (true, true) => "(?im)", + }; + let full_pattern = format!("{flags}{pattern}"); + + let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else { + log::warn!("Invalid regex pattern: {pattern}"); + return string; + }; + + if replace_all { + regex.replace_all(&string, replacement.as_str()).into_owned() + } else { + regex.replace(&string, replacement.as_str()).into_owned() + } +} + +/// Finds a regex match in the string and returns its components. The result is a list where the first element is the whole match (`$0`) and subsequent elements are the capture groups (`$1`, `$2`, etc., if any). +/// +/// The match index selects which non-overlapping occurrence to return (0 for the first match). Returns an empty list if no match is found at the given index. +#[node_macro::node(category(""))] +fn regex_find( + _: impl Ctx, + /// The string to search within. + string: String, + /// The regular expression pattern to search for. + pattern: String, + /// Which non-overlapping occurrence of the pattern to return, starting from 0 for the first match. Negative indices count backwards from the last match. + match_index: SignedInteger, + /// Match letters regardless of case. + case_insensitive: bool, + /// Make `^` and `$` match the start and end of each line, not just the whole string. + multiline: bool, +) -> Vec { + if pattern.is_empty() { + return Vec::new(); + } + + let flags = match (case_insensitive, multiline) { + (false, false) => "", + (true, false) => "(?i)", + (false, true) => "(?m)", + (true, true) => "(?im)", + }; + let full_pattern = format!("{flags}{pattern}"); + + let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else { + log::error!("Invalid regex pattern: {pattern}"); + return Vec::new(); + }; + + // Collect all matches since we need to support negative indexing + let matches: Vec<_> = regex.captures_iter(&string).filter_map(|c| c.ok()).collect(); + + let match_index = match_index as i32; + let resolved_index = if match_index < 0 { + let from_end = (-match_index) as usize; + if from_end > matches.len() { + return Vec::new(); + } + matches.len() - from_end + } else { + match_index as usize + }; + + let Some(captures) = matches.get(resolved_index) else { + return Vec::new(); + }; + + // Index 0 is the whole match, 1+ are capture groups + (0..captures.len()).map(|i| captures.get(i).map_or(String::new(), |m| m.as_str().to_string())).collect() +} + +/// Finds all non-overlapping matches of a regular expression pattern in the string, returning a list of the matched substrings. +#[node_macro::node(category("Text: Regex"))] +fn regex_find_all( + _: impl Ctx, + /// The string to search within. + string: String, + /// The regular expression pattern to search for. + pattern: String, + /// Match letters regardless of case. + case_insensitive: bool, + /// Make `^` and `$` match the start and end of each line, not just the whole string. + multiline: bool, +) -> Vec { + if pattern.is_empty() { + return Vec::new(); + } + + let flags = match (case_insensitive, multiline) { + (false, false) => "", + (true, false) => "(?i)", + (false, true) => "(?m)", + (true, true) => "(?im)", + }; + let full_pattern = format!("{flags}{pattern}"); + + let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else { + log::error!("Invalid regex pattern: {pattern}"); + return Vec::new(); + }; + + regex.find_iter(&string).filter_map(|m| m.ok()).map(|m| m.as_str().to_string()).collect() +} + +/// Splits a string into a list of substrings pulled from between separator characters as matched by a regular expression. +/// +/// For example, splitting "Three, two, one... LIFTOFF" with pattern `\W+` (non-word characters) produces `["Three", "two", "one", "LIFTOFF"]`. +#[node_macro::node(category("Text: Regex"))] +fn regex_split( + _: impl Ctx, + /// The string to split into substrings. + string: String, + /// The regular expression pattern to split on. Matches are consumed and not included in the output. + pattern: String, + /// Match letters regardless of case. + case_insensitive: bool, + /// Make `^` and `$` match the start and end of each line, not just the whole string. + multiline: bool, +) -> Vec { + if pattern.is_empty() { + return vec![string]; + } + + let flags = match (case_insensitive, multiline) { + (false, false) => "", + (true, false) => "(?i)", + (false, true) => "(?m)", + (true, true) => "(?im)", + }; + let full_pattern = format!("{flags}{pattern}"); + + let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else { + log::error!("Invalid regex pattern: {pattern}"); + return vec![string]; + }; + + regex.split(&string).filter_map(|s| s.ok()).map(|s| s.to_string()).collect() +} + +/// Iterates over a list of strings, evaluating the mapped operation for each one. Use the **Read String** node to access the current string inside the loop. +#[node_macro::node(category("Text"))] +async fn map_string( + ctx: impl Ctx + CloneVarArgs + ExtractAll, + strings: Vec, + #[expose] + #[implementations(Context -> String)] + mapped: impl Node, Output = String>, +) -> Vec { + let mut result = Vec::new(); + + for (i, string) in strings.into_iter().enumerate() { + let owned_ctx = OwnedContextImpl::from(ctx.clone()); + let owned_ctx = owned_ctx.with_vararg(Box::new(string)).with_index(i); + let mapped_strings = mapped.eval(owned_ctx.into_context()).await; + + result.push(mapped_strings); + } + + result +} + +/// Reads the current string from within a **Map String** node's loop. +#[node_macro::node(category("Context"))] +fn read_string(ctx: impl Ctx + ExtractVarArgs) -> String { + let Ok(var_arg) = ctx.vararg(0) else { return String::new() }; + let var_arg = var_arg as &dyn std::any::Any; + + var_arg.downcast_ref::().cloned().unwrap_or_default() +} + +/// Converts a value to a JSON string representation. +#[node_macro::node(category("Debug"))] +fn serialize( + _: impl Ctx, + #[implementations( + String, + bool, + f64, + u32, + u64, + DVec2, + DAffine2, + // Table, + // Table, + // Table, + Table>, + Table, + // Table, + )] + value: T, +) -> String { + serde_json::to_string(&value).unwrap_or_else(|_| "Serialization Error".to_string()) +} + +#[node_macro::node(name("JSON Get"), category("Debug"))] +fn json_get(_: impl Ctx, data: String, key: String) -> String { + use serde_json::Value; + let Ok(value): Result = serde_json::from_str(&data) else { + return "Input is not valid json".into(); + }; + match value { + Value::Array(ref arr) => { + let Ok(index): Result = key.parse() else { + log::error!("Json input is an array, but key is not a number"); + return String::new(); + }; + let Some(value) = arr.get(index) else { + log::error!("Index {} out of bounds for len {}", index, arr.len()); + return String::new(); + }; + value.to_string() + } + Value::Object(map) => { + let Some(value) = map.get(&key) else { + log::error!("Key {key} not found in object"); + return String::new(); + }; + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + complex => complex.to_string(), + } + } + _ => String::new(), + } +} diff --git a/tools/node-docs/src/utility.rs b/tools/node-docs/src/utility.rs index b50b84866c..ed07b0d16d 100644 --- a/tools/node-docs/src/utility.rs +++ b/tools/node-docs/src/utility.rs @@ -34,6 +34,7 @@ pub fn category_description(category: &str) -> &str { "Raster: Pattern" => "Nodes in this category generate procedural raster patterns, fractals, textures, and noise.", "Raster" => "Nodes in this category deal with fundamental raster image operations.", "Text" => "Nodes in this category support the manipulation, formatting, and rendering of text strings.", + "Text: Regex" => "Nodes in this category perform string operations involving regular expressions, such as pattern matching and replacement.", "Value" => "Nodes in this category supply data values of common types such as numbers, colors, booleans, and strings.", "Vector: Measure" => "Nodes in this category perform measurements and analysis on vector graphics, such as length/area calculations, path traversal, and hit testing.", "Vector: Modifier" => "Nodes in this category modify the geometry of vector graphics, such as boolean operations, smoothing, and morphing.",