use crate::easing::Easing;
use crate::timeline::{Timeline, stagger};
use crate::traits::Animatable;
use crate::tween::Tween;
#[cfg(not(feature = "std"))]
use alloc::{
string::{String, ToString},
vec::Vec,
};
#[derive(Debug, Clone)]
pub struct SplitTextOptions {
pub word_delimiter: Option<String>,
pub chars_class: Option<String>,
pub words_class: Option<String>,
pub lines_class: Option<String>,
pub split_whitespace: bool,
pub preserve_original: bool,
}
impl Default for SplitTextOptions {
fn default() -> Self {
Self {
word_delimiter: None,
chars_class: None,
words_class: None,
lines_class: None,
split_whitespace: false,
preserve_original: true,
}
}
}
impl SplitTextOptions {
pub fn new() -> Self {
Self::default()
}
pub fn word_delimiter(mut self, delimiter: impl Into<String>) -> Self {
self.word_delimiter = Some(delimiter.into());
self
}
pub fn chars_class(mut self, class: impl Into<String>) -> Self {
self.chars_class = Some(class.into());
self
}
pub fn words_class(mut self, class: impl Into<String>) -> Self {
self.words_class = Some(class.into());
self
}
pub fn lines_class(mut self, class: impl Into<String>) -> Self {
self.lines_class = Some(class.into());
self
}
pub fn split_whitespace(mut self, split: bool) -> Self {
self.split_whitespace = split;
self
}
pub fn preserve_original(mut self, preserve: bool) -> Self {
self.preserve_original = preserve;
self
}
}
#[derive(Debug, Clone)]
pub struct SplitChar {
pub ch: char,
pub index: usize,
pub word_index: usize,
}
#[derive(Debug, Clone)]
pub struct SplitWord {
pub text: String,
pub index: usize,
pub char_start: usize,
pub char_end: usize,
}
#[derive(Debug, Clone)]
pub struct SplitText {
chars: Vec<SplitChar>,
words: Vec<SplitWord>,
original: String,
options: SplitTextOptions,
}
impl SplitText {
#[allow(clippy::should_implement_trait)]
pub fn from_str(text: &str) -> Self {
Self::from_str_with_options(text, SplitTextOptions::default())
}
pub fn from_str_with_options(text: &str, options: SplitTextOptions) -> Self {
let mut chars = Vec::new();
let mut words = Vec::new();
let mut word_index = 0;
let mut char_index = 0;
let word_strs: Vec<&str> = if let Some(ref delim) = options.word_delimiter {
text.split(delim.as_str()).collect()
} else {
text.split_whitespace().collect()
};
for word_str in word_strs {
if word_str.is_empty() {
continue;
}
let char_start = text[char_index..]
.find(word_str)
.map(|i| i + char_index)
.unwrap_or(char_index);
while char_index < char_start {
if let Some(ch) = text.chars().nth(char_index) {
chars.push(SplitChar {
ch,
index: char_index,
word_index: if word_index > 0 { word_index - 1 } else { 0 },
});
}
char_index += 1;
}
let word_char_start = char_index;
for ch in word_str.chars() {
chars.push(SplitChar {
ch,
index: char_index,
word_index,
});
char_index += 1;
}
words.push(SplitWord {
text: word_str.to_string(),
index: word_index,
char_start: word_char_start,
char_end: char_index,
});
word_index += 1;
}
while char_index < text.len() {
if let Some(ch) = text.chars().nth(char_index) {
chars.push(SplitChar {
ch,
index: char_index,
word_index: if word_index > 0 { word_index - 1 } else { 0 },
});
}
char_index += 1;
}
Self {
chars,
words,
original: text.to_string(),
options,
}
}
pub fn chars(&self) -> &[SplitChar] {
&self.chars
}
pub fn words(&self) -> &[SplitWord] {
&self.words
}
pub fn char_count(&self) -> usize {
self.chars.len()
}
pub fn word_count(&self) -> usize {
self.words.len()
}
pub fn original(&self) -> &str {
&self.original
}
pub fn options(&self) -> &SplitTextOptions {
&self.options
}
pub fn rebuild(&self, options: SplitTextOptions) -> Self {
Self::from_str_with_options(&self.original, options)
}
pub fn stagger_chars<T: Animatable + Clone>(
&self,
from: T,
to: T,
duration: f32,
delay: f32,
easing: Easing,
) -> Timeline {
let tweens: Vec<_> = (0..self.chars.len())
.map(|_| {
let t = Tween::new(from.clone(), to.clone())
.duration(duration)
.easing(easing.clone())
.build();
(t, duration)
})
.collect();
stagger(tweens, delay)
}
pub fn stagger_words<T: Animatable + Clone>(
&self,
from: T,
to: T,
duration: f32,
delay: f32,
easing: Easing,
) -> Timeline {
let tweens: Vec<_> = (0..self.words.len())
.map(|_| {
let t = Tween::new(from.clone(), to.clone())
.duration(duration)
.easing(easing.clone())
.build();
(t, duration)
})
.collect();
stagger(tweens, delay)
}
#[cfg(feature = "wasm-dom")]
pub fn inject_chars(&self, parent: &web_sys::Element) {
parent.set_inner_html("");
let doc = web_sys::window().unwrap().document().unwrap();
for sc in &self.chars {
let span = doc.create_element("span").unwrap();
let _ = span.set_attribute("style", "display:inline-block");
let _ = span.set_attribute("data-char-index", &sc.index.to_string());
if let Some(ref class) = self.options.chars_class {
let _ = span.set_attribute("class", class);
}
if sc.ch == ' ' {
span.set_inner_html(" ");
} else {
span.set_text_content(Some(&sc.ch.to_string()));
}
let _ = parent.append_child(&span);
}
}
#[cfg(feature = "wasm-dom")]
pub fn inject_words(&self, parent: &web_sys::Element) {
parent.set_inner_html("");
let doc = web_sys::window().unwrap().document().unwrap();
for (i, sw) in self.words.iter().enumerate() {
if i > 0 {
let space = doc.create_text_node(" ");
let _ = parent.append_child(&space);
}
let span = doc.create_element("span").unwrap();
let _ = span.set_attribute("style", "display:inline-block");
let _ = span.set_attribute("data-word-index", &sw.index.to_string());
if let Some(ref class) = self.options.words_class {
let _ = span.set_attribute("class", class);
}
span.set_text_content(Some(&sw.text));
let _ = parent.append_child(&span);
}
}
#[cfg(feature = "wasm-dom")]
pub fn detect_lines(container: &web_sys::Element) -> Vec<Vec<usize>> {
let spans = container.children();
let mut lines: Vec<Vec<usize>> = Vec::new();
let mut current_top: Option<f32> = None;
for i in 0..spans.length() {
if let Some(span) = spans.item(i) {
let rect = span.get_bounding_client_rect();
let top = rect.top() as f32;
match current_top {
Some(ct) if (top - ct).abs() < 2.0 => {
if let Some(line) = lines.last_mut() {
line.push(i as usize);
}
}
_ => {
current_top = Some(top);
lines.push(vec![i as usize]);
}
}
}
}
lines
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Update;
#[test]
fn split_basic() {
let split = SplitText::from_str("Hello world");
assert_eq!(split.word_count(), 2);
assert_eq!(split.words()[0].text, "Hello");
assert_eq!(split.words()[1].text, "world");
}
#[test]
fn split_chars_count() {
let split = SplitText::from_str("Hi");
assert_eq!(split.char_count(), 2);
assert_eq!(split.chars()[0].ch, 'H');
assert_eq!(split.chars()[1].ch, 'i');
}
#[test]
fn split_empty_string() {
let split = SplitText::from_str("");
assert_eq!(split.char_count(), 0);
assert_eq!(split.word_count(), 0);
}
#[test]
fn split_single_word() {
let split = SplitText::from_str("Rust");
assert_eq!(split.word_count(), 1);
assert_eq!(split.chars()[0].word_index, 0);
}
#[test]
fn stagger_chars_creates_timeline() {
let split = SplitText::from_str("ABC");
let mut timeline = split.stagger_chars(0.0_f32, 1.0, 0.5, 0.1, Easing::Linear);
timeline.play();
timeline.update(0.1);
}
#[test]
fn stagger_words_creates_timeline() {
let split = SplitText::from_str("One Two Three");
let mut timeline = split.stagger_words(0.0_f32, 1.0, 0.5, 0.2, Easing::Linear);
timeline.play();
timeline.update(0.3);
}
}