diff --git a/Cargo.lock b/Cargo.lock index 2615f275a0..9f7cd78dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5497,6 +5497,7 @@ dependencies = [ name = "text-nodes" version = "0.1.0" dependencies = [ + "convert_case 0.8.0", "core-types", "dyn-any", "glam", @@ -5507,7 +5508,9 @@ dependencies = [ "serde", "serde_json", "skrifa 0.40.0", + "titlecase", "tsify", + "unicode-segmentation", "vector-types", "wasm-bindgen", ] @@ -5675,6 +5678,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" @@ -5992,9 +6004,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 9b54c952d5..01e7e56c08 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 83ccfa3f48..af867c8b35 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -24,6 +24,7 @@ use graphene_std::raster::{ use graphene_std::raster_types::Image; use graphene_std::table::{Table, TableRow}; use graphene_std::text::{Font, TextAlign}; +use graphene_std::text_nodes::StringCapitalization; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::misc::BooleanOperation; use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; @@ -246,6 +247,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(), @@ -1628,6 +1630,105 @@ 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::text_nodes::string_capitalization::*; + + // Read the current values before borrowing context mutably for widgets + let (is_simple_case, use_joiner_enabled, joiner_value) = match get_document_node(node_id, context) { + Ok(document_node) => { + let capitalization_input = document_node.inputs.get(CapitalizationInput::INDEX); + let capitalization_exposed = capitalization_input.is_some_and(|input| input.is_exposed()); + // When exposed, the capitalization mode may change dynamically, so we can't assume it's a simple (joiner-inapplicable) mode + let is_simple = !capitalization_exposed + && matches!( + capitalization_input.and_then(|input| input.as_value()), + Some(TaggedValue::StringCapitalization(StringCapitalization::LowerCase | StringCapitalization::UpperCase)) + ); + let use_joiner = match document_node.inputs.get(UseJoinerInput::INDEX).and_then(|input| input.as_value()) { + Some(&TaggedValue::Bool(x)) => x, + _ => true, + }; + let joiner = match document_node.inputs.get(JoinerInput::INDEX).and_then(|input| input.as_non_exposed_value()) { + Some(TaggedValue::String(x)) => Some(x.clone()), + _ => None, + }; + (is_simple, use_joiner, joiner) + } + Err(err) => { + log::error!("Could not get document node in string_capitalization_properties: {err}"); + return Vec::new(); + } + }; + + // The joiner controls are disabled when lowercase/UPPERCASE are selected (they don't use word boundaries) + let joiner_disabled = is_simple_case || !use_joiner_enabled; + + let capitalization = enum_choice::() + .for_socket(ParameterWidgetsInfo::new(node_id, CapitalizationInput::INDEX, true, context)) + .property_row(); + + // Joiner row: the UseJoiner checkbox is drawn in the assist area, followed by the Joiner text input + let mut joiner_widgets = start_widgets(ParameterWidgetsInfo::new(node_id, JoinerInput::INDEX, false, context)); + if let Some(joiner) = joiner_value { + let joiner_is_empty = joiner.is_empty(); + joiner_widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + CheckboxInput::new(use_joiner_enabled) + .disabled(is_simple_case) + .on_update(update_value(|x: &CheckboxInput| TaggedValue::Bool(x.checked), node_id, UseJoinerInput::INDEX)) + .on_commit(commit_value) + .widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextInput::new(joiner) + .placeholder(if joiner_is_empty { "Empty" } else { "" }) + .disabled(joiner_disabled) + .on_update(update_value(|x: &TextInput| TaggedValue::String(x.value.clone()), node_id, JoinerInput::INDEX)) + .on_commit(commit_value) + .widget_instance(), + ]); + } + + // Preset buttons for common joiner values, indented to align with the input field + let mut joiner_preset_buttons = vec![TextLabel::new("").widget_instance()]; + add_blank_assist(&mut joiner_preset_buttons); + joiner_preset_buttons.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + for (label, value, tooltip) in [ + ("Empty", "", "Join words without any separator."), + ("Space", " ", "Join words with a space."), + ("Kebab", "-", "Join words with a hyphen."), + ("Snake", "_", "Join words with an underscore."), + ] { + let value = value.to_string(); + joiner_preset_buttons.push( + TextButton::new(label) + .tooltip_description(tooltip) + .disabled(is_simple_case) + .on_update(move |_: &TextButton| Message::Batched { + messages: Box::new([ + NodeGraphMessage::SetInputValue { + node_id, + input_index: UseJoinerInput::INDEX, + value: TaggedValue::Bool(true), + } + .into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: JoinerInput::INDEX, + value: TaggedValue::String(value.clone()), + } + .into(), + ]), + }) + .on_commit(commit_value) + .widget_instance(), + ); + } + + vec![capitalization, LayoutGroup::row(joiner_widgets), LayoutGroup::row(joiner_preset_buttons)] +} + pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::vector::generator_nodes::rectangle::*; diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 565409937a..d89e8f7996 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -234,6 +234,7 @@ tagged_value! { LuminanceCalculation(raster_nodes::adjustments::LuminanceCalculation), QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel), XY(graphene_core::extract_xy::XY), + StringCapitalization(text_nodes::StringCapitalization), RedGreenBlue(raster_nodes::adjustments::RedGreenBlue), RedGreenBlueAlpha(raster_nodes::adjustments::RedGreenBlueAlpha), RealTimeMode(graphene_core::animation::RealTimeMode), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 68770a9a03..cae79a1aed 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -111,6 +111,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::blending::BlendMode]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::LuminanceCalculation]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::extract_xy::XY]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::text_nodes::StringCapitalization]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::RedGreenBlue]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::RedGreenBlueAlpha]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::animation::RealTimeMode]), @@ -193,6 +194,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::raster::LuminanceCalculation]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::QRCodeErrorCorrectionLevel]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::extract_xy::XY]), + async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::text_nodes::StringCapitalization]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::raster::RedGreenBlue]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::raster::RedGreenBlueAlpha]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::animation::RealTimeMode]), diff --git a/node-graph/nodes/text/Cargo.toml b/node-graph/nodes/text/Cargo.toml index fa87f241ba..2216c488e9 100644 --- a/node-graph/nodes/text/Cargo.toml +++ b/node-graph/nodes/text/Cargo.toml @@ -24,6 +24,9 @@ parley = { workspace = true } skrifa = { 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/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index d631a527db..e34c5ae07a 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -3,13 +3,15 @@ mod path_builder; mod text_context; mod to_path; +use convert_case::{Boundary, Converter, pattern}; use core_types::Color; use core_types::Ctx; -use core_types::registry::types::TextArea; +use core_types::registry::types::{SignedInteger, TextArea}; use core_types::table::Table; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use raster_types::{CPU, Raster}; +use unicode_segmentation::UnicodeSegmentation; // Re-export for convenience pub use core_types as gcore; @@ -69,6 +71,30 @@ impl Default for TypesettingConfig { } } +#[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 { @@ -84,7 +110,7 @@ fn to_string(_: impl Ctx, value: String) -> String { /// Joins two strings together. #[node_macro::node(category("Text"))] fn string_concatenate(_: impl Ctx, #[implementations(String)] first: String, second: TextArea) -> String { - first.clone() + &second + first + &second } /// Replaces all occurrences of "From" with "To" in the input string. @@ -94,28 +120,113 @@ 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. +/// +/// 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: f64, end: f64) -> String { - let total_chars = string.chars().count(); +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_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() +} + +/// 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 enabled, apply word-level casing and optionally reconnect words with the selected joiner + 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), + } + } + // When the joiner is disabled, apply only character-level casing while preserving the string's existing structure + 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. @@ -123,7 +234,7 @@ fn string_slice(_: impl Ctx, string: String, start: f64, end: f64) -> String { /// 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.