From 0a4c697d3ac6cffddc216d6387260070af3dcb2c Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 5 Apr 2026 05:35:44 -0700 Subject: [PATCH 01/14] Add new string processing nodes --- Cargo.lock | 16 +- Cargo.toml | 2 + .../node_graph/document_node_definitions.rs | 1 + .../document/node_graph/node_properties.rs | 97 +++++++ node-graph/graph-craft/src/document/value.rs | 1 + .../interpreted-executor/src/node_registry.rs | 2 + node-graph/nodes/gcore/Cargo.toml | 3 + node-graph/nodes/gcore/src/logic.rs | 258 +++++++++++++++++- 8 files changed, 375 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 33df1cdfc7..5453dcc402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1982,6 +1982,7 @@ dependencies = [ name = "graphene-core" version = "0.1.0" dependencies = [ + "convert_case 0.8.0", "core-types", "dyn-any", "glam", @@ -1991,7 +1992,9 @@ dependencies = [ "raster-types", "serde", "serde_json", + "titlecase", "tsify", + "unicode-segmentation", "wasm-bindgen", ] @@ -5673,6 +5676,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 +6002,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..de565064cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,8 @@ log = "0.4" bitflags = { version = "2.4", features = ["serde"] } ctor = "0.2" convert_case = "0.8" +titlecase = "3.6" +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..aa4434f3db 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 @@ -2035,6 +2035,7 @@ 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("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..0dff572dfd 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -16,6 +16,7 @@ use graph_craft::{Type, concrete}; use graphene_std::NodeInputDecleration; use graphene_std::animation::RealTimeMode; use graphene_std::extract_xy::XY; +use graphene_std::logic::StringCapitalization; use graphene_std::raster::curve::Curve; use graphene_std::raster::{ BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, @@ -244,6 +245,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 +1590,101 @@ 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 string_capitalization_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::logic::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 is_simple = matches!( + document_node.inputs.get(CapitalizationInput::INDEX).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::*; diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 511710a597..663fa3d6e7 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(graphene_core::logic::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..abe6954efa 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::logic::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::logic::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/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 9d2fbabb03..520608325e 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -28,6 +28,9 @@ dyn-any = { workspace = true } glam = { workspace = true } log = { workspace = true } serde_json = { workspace = true } +convert_case = { workspace = true } +titlecase = { workspace = true } +unicode-segmentation = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index 68391d5e01..f048076705 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -1,11 +1,37 @@ +use convert_case::{Boundary, Converter, pattern}; use core_types::Color; -use core_types::registry::types::TextArea; +use core_types::registry::types::{IntegerCount, SignedInteger, 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}; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, serde::Serialize, serde::Deserialize)] +#[widget(Dropdown)] +pub enum StringCapitalization { + /// "a tale of two cities" — Converts all letters to lower case. + #[default] + #[label("lower case")] + LowerCase, + /// "A TALE OF TWO CITIES" — Converts all letters to upper case. + #[label("UPPER CASE")] + UpperCase, + /// "A Tale Of Two Cities" — Converts the first letter of every word to upper case. + #[label("Capital Case")] + CapitalCase, + /// "A Tale of Two Cities" — Converts the first letter of significant words to upper case. + #[label("Headline Case")] + HeadlineCase, + /// "A tale of two cities" — Converts the first letter of every word to lower case, except the first which is made upper case. + #[label("Sentence case")] + SentenceCase, + /// "a Tale Of Two Cities" — Converts the first letter of every word to upper case, except the first which is made lower case. + #[label("camel Case")] + CamelCase, +} /// Type-asserts a value to be a string. #[node_macro::node(category("Debug"))] @@ -36,9 +62,9 @@ fn string_replace(_: impl Ctx, string: String, from: TextArea, to: TextArea) -> /// 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. +/// 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: f64, end: f64) -> String { +fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedInteger) -> String { let total_chars = string.chars().count(); let start = if start < 0. { @@ -59,6 +85,208 @@ fn string_slice(_: impl Ctx, string: String, start: f64, end: f64) -> String { string.chars().skip(start).take(end - start).collect() } +/// Parses a string into a number. Returns the fallback value if the string is not a valid number. +#[node_macro::node(category("Text"))] +fn string_to_number(_: impl Ctx, string: String, fallback: f64) -> f64 { + string.trim().parse::().unwrap_or(fallback) +} + +/// Removes leading and/or trailing whitespace from a string. +#[node_macro::node(category("Text"))] +fn string_trim(_: impl Ctx, string: String, #[default(true)] start: bool, #[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, + } +} + +/// Reverses the order of grapheme clusters (visual characters) in the string. +#[node_macro::node(category("Text"))] +fn string_reverse(_: impl Ctx, 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, + string: String, + /// The number of times the string should appear in the output. + #[default(2.)] + count: f64, + /// The string placed between each repetition. + separator: String, +) -> String { + let count = count.max(0.) as usize; + + if count == 0 { + return String::new(); + } + + 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 string. If the string is already at or exceeds the target length, it is returned unchanged. +#[node_macro::node(category("Text"))] +fn string_pad( + _: impl Ctx, + string: String, + /// The desired total character length after padding. + #[default(10.)] + length: f64, + /// The string used to fill the remaining space. Repeats and trims to fit if multi-character. + #[default("0")] + padding: String, + /// Pad at the end of the string instead of the start. + from_end: bool, +) -> String { + let target_length = length.max(0.) as usize; + let current_length = string.chars().count(); + + if current_length >= target_length || padding.is_empty() { + return string; + } + + let pad_length = target_length - current_length; + let padding: String = padding.chars().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, + string: String, + 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 finds the first (or last) occurrence of a substring within the string and returns its start index, 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.chars().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].chars().count() as f64) + } else { + string.match_indices(&*substring).next().map_or(-1., |(byte_index, _)| string[..byte_index].chars().count() as f64) + } +} + +/// Converts a string's capitalization style, optionally joining words with a specified separator. +#[node_macro::node(category("Text"), properties("string_capitalization_properties"))] +fn string_capitalization( + _: impl Ctx, + string: String, + capitalization: StringCapitalization, + /// Whether to split the string into words and rejoin with the specified joiner. + /// When disabled, the existing separators and word structure are preserved. + use_joiner: bool, + /// The string placed between each word. Common choices: " " (space), "_" (underscore), "-" (hyphen), or "" (none). + joiner: String, +) -> String { + // When the joiner is disabled, apply only character-level casing while preserving the string's existing structure + if !use_joiner { + return match capitalization { + StringCapitalization::LowerCase => string.to_lowercase(), + StringCapitalization::UpperCase => string.to_uppercase(), + StringCapitalization::CapitalCase | StringCapitalization::HeadlineCase => { + let mut capitalize_next = true; + string + .chars() + .map(|c| { + if c.is_whitespace() || c == '_' || c == '-' { + capitalize_next = true; + c + } else if capitalize_next { + capitalize_next = false; + c.to_uppercase().next().unwrap_or(c) + } else { + c + } + }) + .collect() + } + 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() + .map(|c| { + if c.is_whitespace() || c == '_' || c == '-' { + capitalize_next = true; + c + } else if capitalize_next { + capitalize_next = false; + c.to_uppercase().next().unwrap_or(c) + } else { + c.to_lowercase().next().unwrap_or(c) + } + }) + .collect() + } + }; + } + + 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 => { + // Headline case uses the `titlecase` crate for smart capitalization (lowercasing short words like "of", "the", etc.), + // then a second pass rejoins with the custom joiner without mangling the capitalization + let headline = titlecase::titlecase(&string); + 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), + } +} + // 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. @@ -91,6 +319,30 @@ fn string_split( string.split(&delimeter).map(str::to_string).collect() } +/// Joins a list of strings together with a separator between each pair. +/// 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 character(s) 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 { + separator.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\0", "\0").replace("\\\\", "\\") + } else { + separator + }; + + strings.join(&separator) +} + /// 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"))] From 7fbe392fff0701dc08a135164039730b46b985fc Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Apr 2026 03:44:20 -0700 Subject: [PATCH 02/14] Remove the IntegerCount = u32 type alias --- .../document/node_graph/node_properties.rs | 1 - .../messages/portfolio/document_migration.rs | 2 +- .../libraries/no-std-types/src/registry.rs | 2 -- node-graph/nodes/gcore/src/logic.rs | 28 +++++++++++++------ .../nodes/raster/src/image_color_palette.rs | 9 ++++-- node-graph/nodes/repeat/src/repeat_nodes.rs | 14 +++++++--- 6 files changed, 38 insertions(+), 18 deletions(-) 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 0dff572dfd..c53421ffcd 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -196,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(), diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 4d2e81a9c8..b026dac9fc 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1675,7 +1675,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/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/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index f048076705..d631fa3d75 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -1,6 +1,6 @@ use convert_case::{Boundary, Converter, pattern}; use core_types::Color; -use core_types::registry::types::{IntegerCount, SignedInteger, TextArea}; +use core_types::registry::types::{SignedInteger, TextArea}; use core_types::table::Table; use core_types::{Context, Ctx}; use glam::{DAffine2, DVec2}; @@ -43,7 +43,22 @@ fn to_string(_: impl Ctx, value: String) -> String { #[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, + #[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()) } @@ -115,15 +130,12 @@ fn string_repeat( string: String, /// The number of times the string should appear in the output. #[default(2.)] - count: f64, + #[min(1.)] + count: u32, /// The string placed between each repetition. separator: String, ) -> String { - let count = count.max(0.) as usize; - - if count == 0 { - return String::new(); - } + let count = count.max(1) as usize; let mut result = String::with_capacity((string.len() + separator.len()) * count); for i in 0..count { 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); From c22ca70c31194efeb04d30f67f71238d22aeceb8 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Apr 2026 06:01:41 -0700 Subject: [PATCH 03/14] Add the 'Format Number' node --- .../node_graph/document_node_definitions.rs | 1 + .../document/node_graph/node_properties.rs | 84 +++++++++++++++++ node-graph/nodes/gcore/src/logic.rs | 94 +++++++++++++++++++ 3 files changed, 179 insertions(+) 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 aa4434f3db..095b23f39c 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 @@ -2035,6 +2035,7 @@ 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)); 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 c53421ffcd..312b316356 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1589,6 +1589,90 @@ 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::logic::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::logic::string_capitalization::*; diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index d631fa3d75..33dfb7a075 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -100,6 +100,100 @@ fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedIn string.chars().skip(start).take(end - start).collect() } +/// 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, + number: f64, + /// The number 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 (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. Returns the fallback value if the string is not a valid number. #[node_macro::node(category("Text"))] fn string_to_number(_: impl Ctx, string: String, fallback: f64) -> f64 { From 6d742476ae3a3f3fce13498fdeeebcc3345fd6bb Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Apr 2026 06:02:47 -0700 Subject: [PATCH 04/14] Add "Up To" parameter to the 'String Pad' node --- frontend/src/components/panels/Data.svelte | 15 ++++-- node-graph/nodes/gcore/src/logic.rs | 56 ++++++++++++++++++---- 2 files changed, 56 insertions(+), 15 deletions(-) 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/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index 33dfb7a075..899280a454 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -223,8 +223,8 @@ fn string_repeat( _: impl Ctx, string: String, /// The number of times the string should appear in the output. - #[default(2.)] - #[min(1.)] + #[default(2)] + #[min(1)] count: u32, /// The string placed between each repetition. separator: String, @@ -246,19 +246,55 @@ fn string_repeat( fn string_pad( _: impl Ctx, string: String, - /// The desired total character length after padding. - #[default(10.)] - length: f64, - /// The string used to fill the remaining space. Repeats and trims to fit if multi-character. - #[default("0")] + /// The target character length after padding. When "Up To" is set, this applies to the portion before (or after) that substring. + #[default(10)] + length: u32, + /// The string used to fill the remaining space. Repeats and trims to fit, if multi-character. + #[default("#")] padding: String, + /// Pad only the length of the string encountered before (or after) 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.max(0.) as usize; - let current_length = string.chars().count(); + 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.chars().count(); + if current_length >= target_length { + return string; + } + let pad_length = target_length - current_length; + let padding: String = padding.chars().cycle().take(pad_length).collect(); + return format!("{before}{up_to}{after_substring}{padding}"); + } else { + // Pad the portion before the substring + let current_length = before.chars().count(); + if current_length >= target_length { + return string; + } + let pad_length = target_length - current_length; + let padding: String = padding.chars().cycle().take(pad_length).collect(); + return format!("{padding}{before}{after}"); + } + } - if current_length >= target_length || padding.is_empty() { + let current_length = string.chars().count(); + if current_length >= target_length { return string; } From 73f84fa90176bb14a6c639a7026e09dd197219aa Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Apr 2026 06:03:31 -0700 Subject: [PATCH 05/14] Fix 'String Capitalization' behavior --- .../document/node_graph/node_properties.rs | 7 ++- node-graph/nodes/gcore/src/logic.rs | 63 ++++++++++--------- 2 files changed, 39 insertions(+), 31 deletions(-) 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 312b316356..7ca196e963 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -2494,7 +2494,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/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index 899280a454..261b86e5b6 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -12,23 +12,23 @@ use unicode_segmentation::UnicodeSegmentation; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, serde::Serialize, serde::Deserialize)] #[widget(Dropdown)] pub enum StringCapitalization { - /// "a tale of two cities" — Converts all letters to lower case. + /// "on the origin of species" — Converts all letters to lower case. #[default] #[label("lower case")] LowerCase, - /// "A TALE OF TWO CITIES" — Converts all letters to upper case. + /// "ON THE ORIGIN OF SPECIES" — Converts all letters to upper case. #[label("UPPER CASE")] UpperCase, - /// "A Tale Of Two Cities" — Converts the first letter of every word to upper case. + /// "On The Origin Of Species" — Converts the first letter of every word to upper case. #[label("Capital Case")] CapitalCase, - /// "A Tale of Two Cities" — Converts the first letter of significant words to upper case. + /// "On the Origin of Species" — Converts the first letter of significant words to upper case. #[label("Headline Case")] HeadlineCase, - /// "A tale of two cities" — Converts the first letter of every word to lower case, except the first which is made upper case. + /// "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, - /// "a Tale Of Two Cities" — Converts the first letter of every word to upper case, except the first which is made lower case. + /// "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, } @@ -359,11 +359,34 @@ fn string_capitalization( joiner: String, ) -> String { // When the joiner is disabled, apply only character-level casing while preserving the string's existing structure - if !use_joiner { - return match capitalization { + 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(), - StringCapitalization::CapitalCase | StringCapitalization::HeadlineCase => { + + // 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() @@ -380,6 +403,7 @@ fn string_capitalization( }) .collect() } + StringCapitalization::HeadlineCase => titlecase::titlecase(&string), StringCapitalization::SentenceCase => { let mut chars = string.chars(); match chars.next() { @@ -404,28 +428,7 @@ fn string_capitalization( }) .collect() } - }; - } - - 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 => { - // Headline case uses the `titlecase` crate for smart capitalization (lowercasing short words like "of", "the", etc.), - // then a second pass rejoins with the custom joiner without mangling the capitalization - let headline = titlecase::titlecase(&string); - 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), } } From 271422bba799ec07df34959874e44a3a4348ce58 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Apr 2026 06:46:58 -0700 Subject: [PATCH 06/14] Add 'Map String' and 'Read String' nodes --- node-graph/nodes/gcore/src/logic.rs | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index 261b86e5b6..ea70f36be8 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -2,7 +2,7 @@ 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::{Context, Ctx}; +use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractVarArgs, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphic_types::vector_types::GradientStops; use graphic_types::{Artboard, Graphic, Vector}; @@ -575,3 +575,34 @@ async fn switch( ) -> T { if condition { if_true.eval(ctx).await } else { if_false.eval(ctx).await } } + +/// 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() +} From 8b95f7b03b12fa8e1e1be573a969d75c720623a6 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Apr 2026 06:47:10 -0700 Subject: [PATCH 07/14] Add separator_escaping to 'String Repeat' --- node-graph/nodes/gcore/src/logic.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index ea70f36be8..e38c7df685 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -227,8 +227,19 @@ fn string_repeat( #[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 { + separator.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\0", "\0").replace("\\\\", "\\") + } else { + separator + }; + let count = count.max(1) as usize; let mut result = String::with_capacity((string.len() + separator.len()) * count); @@ -355,7 +366,7 @@ fn string_capitalization( /// Whether to split the string into words and rejoin with the specified joiner. /// When disabled, the existing separators and word structure are preserved. use_joiner: bool, - /// The string placed between each word. Common choices: " " (space), "_" (underscore), "-" (hyphen), or "" (none). + /// 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 From 243a3b6f4566a45f78c1c2fe9db7e04e873461fc Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Apr 2026 07:21:28 -0700 Subject: [PATCH 08/14] Add 'Regex Replace' --- Cargo.lock | 20 ++++++++++++---- Cargo.toml | 1 + node-graph/nodes/gcore/Cargo.toml | 1 + node-graph/nodes/gcore/src/logic.rs | 37 +++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5453dcc402..692db6e39f 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" @@ -1985,6 +1996,7 @@ dependencies = [ "convert_case 0.8.0", "core-types", "dyn-any", + "fancy-regex", "glam", "graphic-types", "log", @@ -4443,9 +4455,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", @@ -4455,9 +4467,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", diff --git a/Cargo.toml b/Cargo.toml index de565064cb..8127857301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,6 +107,7 @@ 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" diff --git a/node-graph/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 520608325e..49b99e162b 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -30,6 +30,7 @@ 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 diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index e38c7df685..810019ef8a 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -587,6 +587,43 @@ async fn switch( if condition { if_true.eval(ctx).await } else { if_false.eval(ctx).await } } +/// Replaces matches of a regular expression pattern in the string. The replacement string supports backreferences: `$0` for the whole match, `$1`, `$2`, etc. for capture groups. +#[node_macro::node(category("Text"))] +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, `$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() + } +} + /// 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( From 7b6c624918d9ed1c1ed10087813bb715d7e8ea27 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Apr 2026 23:54:22 -0700 Subject: [PATCH 09/14] Add 'Regex Match' node --- node-graph/nodes/gcore/src/logic.rs | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index 810019ef8a..3459629c4b 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -587,6 +587,37 @@ async fn switch( if condition { if_true.eval(ctx).await } else { if_false.eval(ctx).await } } +/// Tests whether a regular expression pattern matches within the string, returning true or false. +#[node_macro::node(category("Text"))] +fn regex_match( + _: impl Ctx, + /// The string to test against. + string: String, + /// The regular expression pattern to match. + pattern: String, + /// Require the pattern to match the entire string, not just any portion. + entire_string: 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, +) -> bool { + let flags = match (case_insensitive, multiline) { + (false, false) => "", + (true, false) => "(?i)", + (false, true) => "(?m)", + (true, true) => "(?im)", + }; + let wrapped_pattern = if entire_string { format!("{flags}\\A(?:{pattern})\\z") } else { format!("{flags}{pattern}") }; + + let Ok(regex) = fancy_regex::Regex::new(&wrapped_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 supports backreferences: `$0` for the whole match, `$1`, `$2`, etc. for capture groups. #[node_macro::node(category("Text"))] fn regex_replace( From 950d2a1e7cfbced77f7bd7f9ec0c6fb201071baa Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 7 Apr 2026 00:34:03 -0700 Subject: [PATCH 10/14] Regex Find and Regex Find All --- .../node_graph/document_node_definitions.rs | 105 ++++++++++++++++++ .../libraries/graphic-types/src/graphic.rs | 35 ++++++ node-graph/nodes/gcore/src/logic.rs | 81 ++++++++++++++ node-graph/nodes/graphic/src/graphic.rs | 33 ++++++ 4 files changed, 254 insertions(+) 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 095b23f39c..36f1037417 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 @@ -1489,6 +1489,111 @@ fn document_node_definitions() -> 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(logic::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 occurrence of the pattern to return (0 for the first). Negative indices count 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, -2)), + ..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("Finds a regex match in the string. The primary output is the whole match, and the secondary output is the list of capture groups."), + 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 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/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index 3459629c4b..4ca76d81cf 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -655,6 +655,87 @@ fn regex_replace( } } +/// Finds a regex match in the string and returns its components. The result is a list where the first element is the whole match and +/// subsequent elements are the capture groups (if any). The match index selects which 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 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 { + 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"))] +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 { + 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() +} + /// 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( 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, From a838f96354c74dee1dcf3b870d9700ae2b62685b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 14 Apr 2026 15:59:02 -0700 Subject: [PATCH 11/14] Code review --- .../document/node_graph/node_properties.rs | 12 ++- node-graph/nodes/gcore/src/logic.rs | 95 ++++++++++--------- 2 files changed, 60 insertions(+), 47 deletions(-) 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 7ca196e963..9c57023d7b 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1679,10 +1679,14 @@ pub(crate) fn string_capitalization_properties(node_id: NodeId, context: &mut No // 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 is_simple = matches!( - document_node.inputs.get(CapitalizationInput::INDEX).and_then(|input| input.as_value()), - Some(TaggedValue::StringCapitalization(StringCapitalization::LowerCase | StringCapitalization::UpperCase)) - ); + let capitalization_input = document_node.inputs.get(CapitalizationInput::INDEX); + let capitalization_exposed = capitalization_input.map_or(false, |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, diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index 4ca76d81cf..e4883dda11 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -9,6 +9,31 @@ use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; use unicode_segmentation::UnicodeSegmentation; +/// Processes escape sequences in a string, converting `\n`, `\r`, `\t`, `\0`, and `\\` into their corresponding characters. +/// Unrecognized escape sequences (e.g. `\x`) are preserved as-is. +fn unescape(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 +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, dyn_any::DynAny, node_macro::ChoiceType, serde::Serialize, serde::Deserialize)] #[widget(Dropdown)] pub enum StringCapitalization { @@ -234,11 +259,7 @@ fn string_repeat( #[default(true)] separator_escaping: bool, ) -> String { - let separator = if separator_escaping { - separator.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\0", "\0").replace("\\\\", "\\") - } else { - separator - }; + let separator = if separator_escaping { unescape(separator) } else { separator }; let count = count.max(1) as usize; @@ -399,20 +420,18 @@ fn string_capitalization( StringCapitalization::UpperCase => string.to_uppercase(), StringCapitalization::CapitalCase => { let mut capitalize_next = true; - string - .chars() - .map(|c| { - if c.is_whitespace() || c == '_' || c == '-' { - capitalize_next = true; - c - } else if capitalize_next { - capitalize_next = false; - c.to_uppercase().next().unwrap_or(c) - } else { - c - } - }) - .collect() + 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 => { @@ -424,20 +443,18 @@ fn string_capitalization( } StringCapitalization::CamelCase => { let mut capitalize_next = false; - string - .chars() - .map(|c| { - if c.is_whitespace() || c == '_' || c == '-' { - capitalize_next = true; - c - } else if capitalize_next { - capitalize_next = false; - c.to_uppercase().next().unwrap_or(c) - } else { - c.to_lowercase().next().unwrap_or(c) - } - }) - .collect() + 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 + }) } } } @@ -466,11 +483,7 @@ fn string_split( #[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 - }; + let delimeter = if delimeter_escaping { unescape(delimeter) } else { delimeter }; string.split(&delimeter).map(str::to_string).collect() } @@ -490,11 +503,7 @@ fn string_join( #[default(true)] separator_escaping: bool, ) -> String { - let separator = if separator_escaping { - separator.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\0", "\0").replace("\\\\", "\\") - } else { - separator - }; + let separator = if separator_escaping { unescape(separator) } else { separator }; strings.join(&separator) } From f299d2c8791eb4d0d4c831994fedf781d5330718 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 14 Apr 2026 18:22:45 -0700 Subject: [PATCH 12/14] Add the 'Escape String' node --- node-graph/nodes/gcore/src/logic.rs | 44 +++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/node-graph/nodes/gcore/src/logic.rs b/node-graph/nodes/gcore/src/logic.rs index e4883dda11..80dc3a30d8 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -9,9 +9,9 @@ use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; use unicode_segmentation::UnicodeSegmentation; -/// Processes escape sequences in a string, converting `\n`, `\r`, `\t`, `\0`, and `\\` into their corresponding characters. +/// 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(input: String) -> String { +fn unescape_string(input: String) -> String { let mut result = String::with_capacity(input.len()); let mut chars = input.chars(); @@ -34,6 +34,24 @@ fn unescape(input: String) -> String { 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 { @@ -236,6 +254,22 @@ fn string_trim(_: impl Ctx, string: String, #[default(true)] start: bool, #[defa } } +/// 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 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 order of grapheme clusters (visual characters) in the string. #[node_macro::node(category("Text"))] fn string_reverse(_: impl Ctx, string: String) -> String { @@ -259,7 +293,7 @@ fn string_repeat( #[default(true)] separator_escaping: bool, ) -> String { - let separator = if separator_escaping { unescape(separator) } else { separator }; + let separator = if separator_escaping { unescape_string(separator) } else { separator }; let count = count.max(1) as usize; @@ -483,7 +517,7 @@ fn string_split( #[default(true)] delimeter_escaping: bool, ) -> Vec { - let delimeter = if delimeter_escaping { unescape(delimeter) } else { delimeter }; + let delimeter = if delimeter_escaping { unescape_string(delimeter) } else { delimeter }; string.split(&delimeter).map(str::to_string).collect() } @@ -503,7 +537,7 @@ fn string_join( #[default(true)] separator_escaping: bool, ) -> String { - let separator = if separator_escaping { unescape(separator) } else { separator }; + let separator = if separator_escaping { unescape_string(separator) } else { separator }; strings.join(&separator) } From f71278f0ac98fa72aab54a9a4540a8c42c9a566b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 14 Apr 2026 23:42:39 -0700 Subject: [PATCH 13/14] Improve implementations --- Cargo.lock | 1 + .../node_graph/document_node_definitions.rs | 12 +- .../messages/portfolio/document_migration.rs | 2 +- node-graph/nodes/gcore/src/animation.rs | 4 +- node-graph/nodes/gcore/src/logic.rs | 471 +++++++++++------- node-graph/nodes/math/Cargo.toml | 1 + node-graph/nodes/math/src/lib.rs | 50 ++ tools/node-docs/src/utility.rs | 1 + 8 files changed, 361 insertions(+), 181 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 692db6e39f..8c0ba27cc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3097,6 +3097,7 @@ version = "0.1.0" dependencies = [ "core-types", "glam", + "graphic-types", "log", "math-parser", "node-macro", 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 36f1037417..3ccfbe9fc9 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 HashMap HashMap HashMap] = &[ aliases: &["graphene_core::logic::StringSplitNode"], }, NodeReplacement { - node: graphene_std::logic::switch::IDENTIFIER, + node: graphene_std::math_nodes::switch::IDENTIFIER, aliases: &["graphene_core::logic::SwitchNode"], }, NodeReplacement { 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/logic.rs b/node-graph/nodes/gcore/src/logic.rs index 80dc3a30d8..ff8bbf3329 100644 --- a/node-graph/nodes/gcore/src/logic.rs +++ b/node-graph/nodes/gcore/src/logic.rs @@ -4,9 +4,7 @@ use core_types::registry::types::{SignedInteger, TextArea}; use core_types::table::Table; use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractVarArgs, OwnedContextImpl}; use glam::{DAffine2, DVec2}; -use graphic_types::vector_types::GradientStops; -use graphic_types::{Artboard, Graphic, Vector}; -use raster_types::{CPU, GPU, Raster}; +use raster_types::{CPU, Raster}; use unicode_segmentation::UnicodeSegmentation; /// Converts escape sequence representations (`\n`, `\r`, `\t`, `\0`, `\\`) into their corresponding control characters. @@ -82,30 +80,6 @@ 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 { @@ -119,36 +93,66 @@ fn string_replace(_: impl Ctx, string: String, from: TextArea, to: TextArea) -> } /// 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. +/// +/// 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_chars = string.chars().count(); + let total_graphemes = string.graphemes(true).count(); let start = if start < 0. { - total_chars.saturating_sub(start.abs() as usize) + total_graphemes.saturating_sub(start.abs() as usize) } else { - (start as usize).min(total_chars) + (start as usize).min(total_graphemes) }; let end = if end <= 0. { - total_chars.saturating_sub(end.abs() as usize) + total_graphemes.saturating_sub(end.abs() as usize) } else { - (end as usize).min(total_chars) + (end as usize).min(total_graphemes) }; if start >= end { return String::new(); } - string.chars().skip(start).take(end - start).collect() + 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 number of digits after the decimal point. The value is rounded to fit. Set to 0 to show only whole numbers. + /// 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, @@ -163,7 +167,7 @@ fn format_number( /// The character(s) inserted between digit groups. #[default(",")] thousands_separator: String, - /// Don't group 4-digit numbers (only start grouping at 10,000 and above). + /// 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 { @@ -237,15 +241,31 @@ fn format_number( } } -/// Parses a string into a number. Returns the fallback value if the string is not a valid number. +/// 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, string: String, fallback: f64) -> f64 { +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. +/// 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, string: String, #[default(true)] start: bool, #[default(true)] end: bool) -> String { +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(), @@ -256,7 +276,7 @@ fn string_trim(_: impl Ctx, string: String, #[default(true)] start: bool, #[defa /// 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 characters. +/// 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( @@ -270,9 +290,13 @@ fn string_escape( if unescape { unescape_string(string) } else { escape_string(string) } } -/// Reverses the order of grapheme clusters (visual characters) in the 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, string: String) -> String { +fn string_reverse( + _: impl Ctx, + /// The string to be reversed. + string: String, +) -> String { string.graphemes(true).rev().collect() } @@ -280,6 +304,7 @@ fn string_reverse(_: impl Ctx, string: String) -> String { #[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)] @@ -307,18 +332,19 @@ fn string_repeat( result } -/// Pads the string to a target length by filling with the given string. If the string is already at or exceeds the target length, it is returned unchanged. +/// 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 applies to the portion before (or after) that substring. + /// 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 string used to fill the remaining space. Repeats and trims to fit, if multi-character. + /// 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 (or after) this substring, if given and present (otherwise the full string is considered). + /// 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, @@ -340,32 +366,32 @@ fn string_pad( if from_end { // Pad the portion after the substring let after_substring = &after[up_to.len()..]; - let current_length = after_substring.chars().count(); + 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.chars().cycle().take(pad_length).collect(); + 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.chars().count(); + 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.chars().cycle().take(pad_length).collect(); + let padding: String = padding.graphemes(true).cycle().take(pad_length).collect(); return format!("{padding}{before}{after}"); } } - let current_length = string.chars().count(); + 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.chars().cycle().take(pad_length).collect(); + let padding: String = padding.graphemes(true).cycle().take(pad_length).collect(); if from_end { string + &padding } else { padding + &string } } @@ -374,7 +400,9 @@ fn string_pad( #[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, @@ -389,7 +417,7 @@ fn string_contains( } } -/// Similar to the **String Contains** node, this finds the first (or last) occurrence of a substring within the string and returns its start index, or -1 if not found. +/// 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, @@ -401,25 +429,101 @@ fn string_find_index( from_end: bool, ) -> f64 { if substring.is_empty() { - return if from_end { string.chars().count() as f64 } else { 0. }; + 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].chars().count() as f64) + 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].chars().count() as f64) + string + .match_indices(&*substring) + .next() + .map_or(-1., |(byte_index, _)| string[..byte_index].graphemes(true).count() as f64) } } -/// Converts a string's capitalization style, optionally joining words with a specified separator. +/// 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 rejoin with the specified joiner. - /// When disabled, the existing separators and word structure are preserved. + /// 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, @@ -499,11 +603,12 @@ fn string_capitalization( /// 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 + string.graphemes(true).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". +/// 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, @@ -522,14 +627,15 @@ fn string_split( string.split(&delimeter).map(str::to_string).collect() } -/// Joins a list of strings together with a separator between each pair. -/// For example, joining ["a", "b", "c"] with separator ", " produces "a, b, c". +/// 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 character(s) placed between each pair of strings. + /// The text placed between each pair of strings. #[default(", ")] separator: String, /// Whether to convert escape sequences found in the separator into their corresponding characters: @@ -542,108 +648,22 @@ fn string_join( strings.join(&separator) } -/// 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( +/// 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 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 } -} - -/// Tests whether a regular expression pattern matches within the string, returning true or false. -#[node_macro::node(category("Text"))] -fn regex_match( - _: impl Ctx, - /// The string to test against. + /// The string to search within. string: String, - /// The regular expression pattern to match. + /// The regular expression pattern to search for. pattern: String, - /// Require the pattern to match the entire string, not just any portion. - entire_string: 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, + /// 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) => "", @@ -651,9 +671,14 @@ fn regex_match( (false, true) => "(?m)", (true, true) => "(?im)", }; - let wrapped_pattern = if entire_string { format!("{flags}\\A(?:{pattern})\\z") } else { format!("{flags}{pattern}") }; + 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(&wrapped_pattern) else { + let Ok(regex) = fancy_regex::Regex::new(&anchored_pattern) else { log::error!("Invalid regex pattern: {pattern}"); return false; }; @@ -661,14 +686,14 @@ fn regex_match( regex.is_match(&string).unwrap_or(false) } -/// Replaces matches of a regular expression pattern in the string. The replacement string supports backreferences: `$0` for the whole match, `$1`, `$2`, etc. for capture groups. -#[node_macro::node(category("Text"))] +/// 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, `$1`, `$2`, etc. for capture groups. + /// 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)] @@ -698,9 +723,9 @@ fn regex_replace( } } -/// Finds a regex match in the string and returns its components. The result is a list where the first element is the whole match and -/// subsequent elements are the capture groups (if any). The match index selects which occurrence to return (0 for the first match). -/// Returns an empty list if no match is found at the given index. +/// 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, @@ -708,13 +733,17 @@ fn regex_find( string: String, /// The regular expression pattern to search for. pattern: String, - /// Which occurrence of the pattern to return, starting from 0 for the first match. Negative indices count backwards from the last match. + /// 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)", @@ -751,7 +780,7 @@ fn regex_find( } /// 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"))] +#[node_macro::node(category("Text: Regex"))] fn regex_find_all( _: impl Ctx, /// The string to search within. @@ -763,6 +792,10 @@ fn regex_find_all( /// 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)", @@ -779,7 +812,42 @@ fn regex_find_all( regex.find_iter(&string).filter_map(|m| m.ok()).map(|m| m.as_str().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. +/// 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, @@ -809,3 +877,60 @@ fn read_string(ctx: impl Ctx + ExtractVarArgs) -> String { 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/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..00f9040756 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1,8 +1,11 @@ +use core_types::Context; use core_types::registry::types::{Fraction, Percentage, PixelSize, TextArea}; 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 { 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.", From 8e0ffd22ad77a3ad79a6a2aab112cac3bbe83b1d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 15 Apr 2026 00:20:20 -0700 Subject: [PATCH 14/14] Move nodes from logic.rs to text/src/lib.rs --- Cargo.lock | 11 +- .../node_graph/document_node_definitions.rs | 2 +- .../document/node_graph/node_properties.rs | 8 +- .../messages/portfolio/document_migration.rs | 36 +- node-graph/graph-craft/src/document/value.rs | 2 +- .../interpreted-executor/src/node_registry.rs | 4 +- node-graph/nodes/gcore/Cargo.toml | 5 - node-graph/nodes/gcore/src/lib.rs | 3 - node-graph/nodes/gcore/src/logic.rs | 936 ----------------- node-graph/nodes/gstd/src/lib.rs | 4 - node-graph/nodes/math/src/lib.rs | 8 +- node-graph/nodes/text/Cargo.toml | 6 + node-graph/nodes/text/src/lib.rs | 948 +++++++++++++++++- 13 files changed, 986 insertions(+), 987 deletions(-) delete mode 100644 node-graph/nodes/gcore/src/logic.rs diff --git a/Cargo.lock b/Cargo.lock index 8c0ba27cc4..47d910fe10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1993,20 +1993,15 @@ dependencies = [ name = "graphene-core" version = "0.1.0" dependencies = [ - "convert_case 0.8.0", "core-types", "dyn-any", - "fancy-regex", "glam", "graphic-types", "log", "node-macro", "raster-types", "serde", - "serde_json", - "titlecase", "tsify", - "unicode-segmentation", "wasm-bindgen", ] @@ -5513,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", ] 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 3ccfbe9fc9..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 @@ -1511,7 +1511,7 @@ fn document_node_definitions() -> HashMap Vec { - use graphene_std::logic::format_number::{DecimalPlacesInput, DecimalSeparatorInput, FixedDecimalsInput, StartAt10000Input, ThousandsSeparatorInput, UseThousandsSeparatorInput}; + 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) { @@ -1674,13 +1674,13 @@ pub(crate) fn format_number_properties(node_id: NodeId, context: &mut NodeProper } pub(crate) fn string_capitalization_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { - use graphene_std::logic::string_capitalization::*; + 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.map_or(false, |input| input.is_exposed()); + 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!( diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 7f23f10c30..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::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, diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 663fa3d6e7..922d66307b 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -233,7 +233,7 @@ tagged_value! { LuminanceCalculation(raster_nodes::adjustments::LuminanceCalculation), QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel), XY(graphene_core::extract_xy::XY), - StringCapitalization(graphene_core::logic::StringCapitalization), + 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 abe6954efa..4427beddb0 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -110,7 +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::logic::StringCapitalization]), + 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]), @@ -193,7 +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::logic::StringCapitalization]), + 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/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 49b99e162b..740a021b59 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -27,11 +27,6 @@ node-macro = { workspace = true } dyn-any = { workspace = true } glam = { 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/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 ff8bbf3329..0000000000 --- a/node-graph/nodes/gcore/src/logic.rs +++ /dev/null @@ -1,936 +0,0 @@ -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 glam::{DAffine2, DVec2}; -use raster_types::{CPU, Raster}; -use unicode_segmentation::UnicodeSegmentation; - -/// 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, -} - -/// 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/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/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 00f9040756..295e46d083 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1,5 +1,5 @@ use core_types::Context; -use core_types::registry::types::{Fraction, Percentage, PixelSize, TextArea}; +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}; @@ -873,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/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(), + } +}