1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::Canvas;
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{
9 draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
10 parse_hex_color, typeface_with_fallback,
11};
12use rustmotion_core::schema::TimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15const DEFAULT_PALETTE: &[&str] = &[
16 "#3B82F6", "#EF4444", "#22C55E", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316",
17];
18
19fn default_min_font_size() -> f32 {
20 14.0
21}
22
23fn default_max_font_size() -> f32 {
24 64.0
25}
26
27fn default_animated() -> bool {
28 true
29}
30
31fn default_animation_duration() -> f64 {
32 1.5
33}
34
35#[derive(Debug, Serialize, Deserialize, JsonSchema)]
36pub struct TagItem {
37 pub text: String,
38 #[serde(default)]
39 pub weight: f64,
40 #[serde(default)]
41 pub color: Option<String>,
42}
43
44#[derive(Debug, Serialize, Deserialize, JsonSchema)]
45#[deprecated(
46 since = "0.7.1",
47 note = "`tag_cloud` is a frozen composition (issue #333). Compose weighted tags from a \
48 `for-each` over `text` nodes with `font-size` scaled by weight instead — see \
49 crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
50 scheduled for removal in a future major version via `rustmotion migrate` \
51 (#335)."
52)]
53pub struct TagCloud {
54 pub tags: Vec<TagItem>,
55 #[serde(default = "default_min_font_size")]
56 pub min_font_size: f32,
57 #[serde(default = "default_max_font_size")]
58 pub max_font_size: f32,
59 #[serde(default)]
60 pub colors: Option<Vec<String>>,
61 #[serde(default = "default_animated")]
62 pub animated: bool,
63 #[serde(default = "default_animation_duration")]
64 pub animation_duration: f64,
65 #[serde(flatten)]
66 pub timing: TimingConfig,
67 #[serde(default)]
68 pub style: CssStyle,
69 #[serde(default)]
70 pub timeline: Vec<TimelineStep>,
71 #[serde(default)]
72 pub stagger: Option<f32>,
73}
74
75rustmotion_core::impl_traits!(TagCloud {
76 Animatable => animation,
77 Timed => timing,
78 Styled => style,
79});
80
81impl TagCloud {
82 fn progress_at(&self, time: f64) -> f32 {
83 if !self.animated {
84 return 1.0;
85 }
86 let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
87 1.0 - (1.0 - p).powi(3)
88 }
89
90 fn palette(&self) -> Vec<&str> {
91 match &self.colors {
92 Some(colors) if !colors.is_empty() => colors.iter().map(|s| s.as_str()).collect(),
93 _ => DEFAULT_PALETTE.to_vec(),
94 }
95 }
96
97 fn font_size_for_weight(&self, weight: f64, min_weight: f64, max_weight: f64) -> f32 {
98 let range = (max_weight - min_weight).max(0.001);
99 let normalized = ((weight - min_weight) / range) as f32;
100 self.min_font_size + normalized * (self.max_font_size - self.min_font_size)
101 }
102}
103
104impl TagCloud {
105 fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
106 let w = layout_w;
107 let h = layout_h;
108
109 if self.tags.is_empty() {
110 return;
111 }
112
113 let progress = self.progress_at(time);
114 let palette = self.palette();
115
116 let min_weight = self.tags.iter().map(|t| t.weight).fold(f64::MAX, f64::min);
117 let max_weight = self.tags.iter().map(|t| t.weight).fold(f64::MIN, f64::max);
118
119 let mut sorted_indices: Vec<usize> = (0..self.tags.len()).collect();
120 sorted_indices.sort_by(|&a, &b| {
121 self.tags[b]
122 .weight
123 .partial_cmp(&self.tags[a].weight)
124 .unwrap_or(std::cmp::Ordering::Equal)
125 });
126
127 let tag_count = self.tags.len();
128 let h_gap = 12.0_f32;
129 let v_gap = 8.0_f32;
130
131 struct TagMetrics {
132 index: usize,
133 font_size: f32,
134 text_width: f32,
135 ascent: f32,
136 height: f32,
137 }
138
139 let mut metrics_list: Vec<TagMetrics> = Vec::with_capacity(tag_count);
140 for &idx in &sorted_indices {
141 let tag = &self.tags[idx];
142 let font_size = self.font_size_for_weight(tag.weight, min_weight, max_weight);
143 let font_style = skia_safe::FontStyle::bold();
144 let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
145 continue;
146 };
147 let font = skia_safe::Font::from_typeface(typeface, font_size);
148 let emoji_font =
149 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
150
151 let text_width = measure_text_with_fallback(&tag.text, &font, &emoji_font, 0.0);
152 let (_, fm_metrics) = font.metrics();
153 let ascent = -fm_metrics.ascent;
154 let height = ascent + fm_metrics.descent;
155
156 metrics_list.push(TagMetrics {
157 index: idx,
158 font_size,
159 text_width,
160 ascent,
161 height,
162 });
163 }
164
165 struct PlacedTag {
166 index: usize,
167 x: f32,
168 y: f32,
169 font_size: f32,
170 ascent: f32,
171 line_height: f32,
172 }
173
174 let mut placed: Vec<PlacedTag> = Vec::with_capacity(tag_count);
175 let mut cursor_x = 0.0_f32;
176 let mut cursor_y = 0.0_f32;
177 let mut line_max_height = 0.0_f32;
178
179 for m in &metrics_list {
180 let item_width = m.text_width + h_gap;
181
182 if cursor_x + m.text_width > w && cursor_x > 0.0 {
183 cursor_y += line_max_height + v_gap;
184 cursor_x = 0.0;
185 line_max_height = 0.0;
186 }
187
188 placed.push(PlacedTag {
189 index: m.index,
190 x: cursor_x,
191 y: cursor_y,
192 font_size: m.font_size,
193 ascent: m.ascent,
194 line_height: m.height,
195 });
196
197 if m.height > line_max_height {
198 line_max_height = m.height;
199 }
200
201 cursor_x += item_width;
202 }
203
204 let total_height = if let Some(last) = placed.last() {
205 last.y
206 + placed.iter().fold(0.0_f32, |max_h, p| {
207 if (p.y - last.y).abs() < 0.01 {
208 max_h.max(p.line_height)
209 } else {
210 max_h
211 }
212 })
213 } else {
214 0.0
215 };
216 let y_offset = ((h - total_height) / 2.0).max(0.0);
217
218 for (draw_order, pt) in placed.iter().enumerate() {
219 let tag = &self.tags[pt.index];
220
221 let tag_alpha = if self.animated {
222 let stagger_delay = (draw_order as f64 / tag_count as f64) * 0.6;
223 let tag_progress = ((time - stagger_delay) / (self.animation_duration * 0.4))
224 .clamp(0.0, 1.0) as f32;
225 tag_progress * progress
226 } else {
227 1.0
228 };
229
230 if tag_alpha <= 0.0 {
231 continue;
232 }
233
234 let color_str = tag
235 .color
236 .as_deref()
237 .unwrap_or(palette[pt.index % palette.len()]);
238
239 let (_r, _g, _b, _) = parse_hex_color(color_str);
240 let mut text_paint = paint_from_hex(color_str);
241 text_paint.set_anti_alias(true);
242 text_paint.set_alpha((tag_alpha * 255.0) as u8);
243
244 let font_style = skia_safe::FontStyle::bold();
245 let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
246 continue;
247 };
248 let font = skia_safe::Font::from_typeface(typeface, pt.font_size);
249 let emoji_font =
250 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, pt.font_size));
251
252 let text_x = pt.x;
253 let text_y = y_offset + pt.y + pt.ascent;
254
255 draw_text_with_fallback(
256 canvas,
257 &tag.text,
258 &font,
259 &emoji_font,
260 0.0,
261 text_x,
262 text_y,
263 &text_paint,
264 );
265 }
266 }
267}
268
269impl Painter for TagCloud {
270 fn paint_content(
271 &self,
272 canvas: &Canvas,
273 layout: &BoxLayout,
274 _props: &AnimatedProperties,
275 ctx: &PaintCtx,
276 ) {
277 self.paint(canvas, layout.width, layout.height, ctx.time);
278 }
279}