dinamika_core/shape/mod.rs
1//! Shapes — scene nodes with flex layout (as in CSS).
2//!
3//! Five kinds are supported — rectangle ([`Shape::rect`]), circle/ellipse
4//! ([`Shape::circle`]), a backgroundless layout container ([`Shape::layout`]),
5//! which lays out children like a `rect` but draws nothing itself, text
6//! ([`Shape::text`]) with CSS-like properties, and code ([`Shape::code`]) — the
7//! same text, but with per-character syntax highlighting instead of a single
8//! color. A shape has a set of signal-backed properties (background, sizes,
9//! min/max size bounds, corner radius, opacity, rotation, scale, padding, gap)
10//! and child-layout parameters ([`Direction`], [`Justify`], [`Align`]) — almost
11//! like a flex container. The axis size
12//! ([`width`](Shape::width)/[`height`](Shape::height)) is set with a [`Length`]
13//! value — in pixels ([`Length::pixel`]) or as a fraction of the parent
14//! ([`Length::percent`], `Length::percent(100.0)` — 100%).
15//!
16//! The submodules split the responsibility:
17//! - [`layout`] — layout parameters ([`Direction`], [`Justify`], [`Align`],
18//! [`Padding`]);
19//! - [`text`] — text state and layout ([`TextAlign`]);
20//! - [`code`] — syntax highlighting of the code shape ([`Palette`], [`Language`]);
21//! - [`tween`] — handles of animatable properties ([`Tween`], [`PaddingTween`])
22//! returned by the setter methods.
23//!
24//! # Set a value or animate it
25//!
26//! Each animatable property has exactly one method taking a **value**. It sets
27//! the property immediately and returns a [`Tween`] — a lightweight handle that
28//! dereferences into the [`Shape`] itself, so the builder chain flows as usual:
29//!
30//! ```
31//! use dinamika_core::*;
32//!
33//! let card = Shape::rect()
34//! .at(40.0, 40.0)
35//! .size(320.0, 120.0)
36//! .background(Color::from_rgba8(40, 44, 52, 255))
37//! .radius(16.0)
38//! .direction(Direction::Row)
39//! .justify(Justify::Center)
40//! .align(Align::Center)
41//! .gap(12.0)
42//! .padding(16.0)
43//! .child(Shape::rect().size(64.0, 64.0).background(Color::from_rgba8(229, 192, 123, 255)))
44//! .child(Shape::rect().size(64.0, 64.0).background(Color::from_rgba8(152, 195, 121, 255)));
45//!
46//! // The same setter method, but with `.over(...)` — builds a tween for the timeline:
47//! let _move = card.x(120.0).over(1.0, Easing::CubicInOut);
48//! ```
49
50use std::cell::RefCell;
51use std::rc::{Rc, Weak};
52
53use dinamika_cpu::Color;
54
55use crate::signal::{Signal, Tweenable};
56use crate::timeline::{Timeline, TimelineState};
57
58mod code;
59mod layout;
60mod text;
61mod tween;
62
63pub use code::{Language, Palette};
64pub use layout::{Align, Direction, Justify, Length, Padding};
65pub use text::{infinite, line, TextAlign, TextPos};
66pub use tween::{HighlightEdit, PaddingTween, TextEdit, Tween};
67
68use code::CodeData;
69use text::{insert_at, rewrite_range, TextData};
70
71/// Panic message for text methods called on a non-text shape.
72const NOT_TEXT: &str = "this property is only available on a text shape — create it via Shape::text(...)";
73
74/// Panic message for code methods ([`palette`](Shape::palette),
75/// [`language`](Shape::language)) called on a non-code shape.
76const NOT_CODE: &str = "this property is only available on a code shape — create it via Shape::code(...)";
77
78/// Panic message when trying to set a color on a code shape: it has no single
79/// color, the highlighting is configured via a palette.
80const CODE_HAS_NO_COLOR: &str =
81 "a code shape has no color — set the highlight palette via .palette(...)";
82
83/// Shape kind.
84#[derive(Copy, Clone, Debug, PartialEq, Eq)]
85pub enum ShapeKind {
86 /// Rectangle (with rounding by [`radius`](Shape::radius)).
87 Rect,
88 /// An ellipse inscribed in the shape's bounding box. With equal width and
89 /// height it is a perfect circle. The [`radius`](Shape::radius) property does
90 /// not affect it.
91 Circle,
92 /// A layout container with no fill of its own: behaves like
93 /// [`Rect`](ShapeKind::Rect) (sizes, padding, gap, child layout) but draws
94 /// nothing itself — it only positions children. Its background is not drawn,
95 /// even if set via [`background`](Shape::background).
96 Layout,
97 /// Text: draws lines with CSS-like properties (font, font size, color,
98 /// alignment, line height, letter spacing). The natural size is taken from
99 /// the content, and the background is transparent by default. See
100 /// [`Shape::text`].
101 Text,
102 /// Code: the same as [`Text`](ShapeKind::Text) in everything (font, layout,
103 /// edits, animations), but instead of a single color glyphs are colored
104 /// per-character by syntax highlighting — [`Palette`] and [`Language`]. See
105 /// [`Shape::code`].
106 Code,
107}
108
109/// The axis when setting a size with a [`Length`] value: selects the size signal
110/// and the fraction field (`width_percent`/`height_percent`).
111#[derive(Copy, Clone)]
112enum Axis {
113 Width,
114 Height,
115}
116
117/// The shape's internal state. Available to the rest of the crate's modules for
118/// layout.
119pub(crate) struct ShapeData {
120 pub kind: ShapeKind,
121 pub x: Signal<f32>,
122 pub y: Signal<f32>,
123 /// Width; `<= 0` means "auto" (by content).
124 pub width: Signal<f32>,
125 /// Height; `<= 0` means "auto" (by content).
126 pub height: Signal<f32>,
127 /// Lower bound of the width; `<= 0` means "no limit".
128 pub min_width: Signal<f32>,
129 /// Upper bound of the width; `<= 0` means "no limit".
130 pub max_width: Signal<f32>,
131 /// Lower bound of the height; `<= 0` means "no limit".
132 pub min_height: Signal<f32>,
133 /// Upper bound of the height; `<= 0` means "no limit".
134 pub max_height: Signal<f32>,
135 /// Width as a fraction of the parent's content area (`1.0` — 100%). `None` —
136 /// the width is taken from [`width`](ShapeData::width). Overrides the
137 /// explicit width when laying out a child; does not affect the natural size
138 /// (used for the parent's auto-size).
139 pub width_percent: Option<f32>,
140 /// Height as a fraction of the parent's content area (`1.0` — 100%). See
141 /// [`width_percent`](ShapeData::width_percent).
142 pub height_percent: Option<f32>,
143 pub background: Signal<Color>,
144 pub radius: Signal<f32>,
145 pub opacity: Signal<f32>,
146 /// Rotation in degrees around the shape's center (together with children).
147 pub rotation: Signal<f32>,
148 /// Scale around the shape's center (together with children); `1.0` — no change.
149 pub scale: Signal<f32>,
150 pub gap: Signal<f32>,
151 pub pad_top: Signal<f32>,
152 pub pad_right: Signal<f32>,
153 pub pad_bottom: Signal<f32>,
154 pub pad_left: Signal<f32>,
155 pub direction: Direction,
156 pub justify: Justify,
157 pub align: Align,
158 pub children: Vec<Shape>,
159 /// Text state — present on shapes of kind [`ShapeKind::Text`] and
160 /// [`ShapeKind::Code`] (code uses the same layout), otherwise `None`.
161 pub text: Option<TextData>,
162 /// Highlighting state — only on shapes of kind [`ShapeKind::Code`],
163 /// otherwise `None`. Stores the palette and language; each glyph's color is
164 /// taken from here instead of the single [`TextData`] color.
165 pub code: Option<CodeData>,
166 /// The timeline on which the shape is registered ([`Shape::on`]), for
167 /// auto-adding animations built from it. Empty (`Weak::new()`) until the
168 /// shape is bound.
169 timeline: RefCell<Weak<TimelineState>>,
170}
171
172/// A scene node. This is a cheap shared handle (`Rc`): a clone points to the
173/// same shape, so it can be held both in the scene tree and in the timeline.
174#[derive(Clone)]
175pub struct Shape {
176 pub(crate) inner: Rc<RefCell<ShapeData>>,
177}
178
179impl Shape {
180 /// Creates a rectangle with default settings: auto size, white background,
181 /// no rounding, fully opaque, `Row` layout.
182 pub fn rect() -> Shape {
183 Shape::new(ShapeKind::Rect)
184 }
185
186 /// Creates a circle (an ellipse inscribed in the bounding box) with the same
187 /// defaults as [`rect`](Shape::rect). The size is set, like a rectangle's,
188 /// via [`size`](Shape::size) / [`width`](Shape::width) /
189 /// [`height`](Shape::height); with equal sides you get a perfect circle.
190 ///
191 /// ```
192 /// # use dinamika_core::*;
193 /// let dot = Shape::circle().size(64.0, 64.0).background(Color::from_rgba8(229, 192, 123, 255));
194 /// ```
195 pub fn circle() -> Shape {
196 Shape::new(ShapeKind::Circle)
197 }
198
199 /// Creates a layout container with no background of its own. Works the same
200 /// as [`rect`](Shape::rect) — the same sizes, padding, gap and child-layout
201 /// rules ([`Direction`], [`Justify`], [`Align`]) — but fills nothing itself,
202 /// only positions children. Handy as a "transparent" wrapper for grouping
203 /// and alignment.
204 ///
205 /// ```
206 /// # use dinamika_core::*;
207 /// let row = Shape::layout()
208 /// .direction(Direction::Row)
209 /// .gap(12.0)
210 /// .child(Shape::rect().size(64.0, 64.0))
211 /// .child(Shape::rect().size(64.0, 64.0));
212 /// ```
213 pub fn layout() -> Shape {
214 Shape::new(ShapeKind::Layout)
215 }
216
217 /// Creates a text shape with the given content and a CSS-like style.
218 ///
219 /// The default size is auto (by content), the background is transparent (as
220 /// in CSS), the font size is 32px, the color is black, and the alignment is
221 /// left. Before drawing you must set a font via [`font`](Shape::font)
222 /// (without a font the text is not drawn).
223 ///
224 /// The style is configured with a fluent chain; the geometry (font size,
225 /// letter spacing, line height) and color are animated like any other
226 /// property — via `.over(...)`.
227 ///
228 /// ```no_run
229 /// use dinamika_core::*;
230 ///
231 /// let bytes = std::fs::read("DejaVuSans.ttf").unwrap();
232 /// let title = Shape::text("Hello,\nworld!")
233 /// .font(bytes)
234 /// .font_size(48.0)
235 /// .color(Color::from_rgba8(33, 33, 33, 255))
236 /// .text_align(TextAlign::Center)
237 /// .letter_spacing(1.0)
238 /// .line_height(1.2);
239 /// ```
240 pub fn text(content: impl Into<String>) -> Shape {
241 let shape = Shape::new(ShapeKind::Text);
242 {
243 let mut d = shape.inner.borrow_mut();
244 // The text background is transparent by default, as in CSS.
245 d.background.set(Color::TRANSPARENT);
246 d.text = Some(TextData::new(content.into()));
247 }
248 shape
249 }
250
251 /// Creates a code shape with the given content.
252 ///
253 /// This is the same as [`text`](Shape::text) in everything — font, font
254 /// size, layout, content edits and animations (spawn, typing, smoothing) —
255 /// except coloring: it has **no single color** ([`color`](Shape::color)
256 /// panics on it), glyphs are colored per-character by syntax highlighting.
257 /// Highlighting is configured with a palette ([`palette`](Shape::palette))
258 /// and a language ([`language`](Shape::language)).
259 ///
260 /// By default there is no highlighting ([`Language::PlainText`] and an empty
261 /// [`Palette`]) — the code is drawn black, like plain text, until the
262 /// palette and language are set. Before drawing you must set a font via
263 /// [`font`](Shape::font).
264 ///
265 /// ```no_run
266 /// use dinamika_core::*;
267 ///
268 /// let bytes = std::fs::read("Consolas.ttf").unwrap();
269 /// let snippet = Shape::code("let answer = 42;")
270 /// .font(bytes)
271 /// .font_size(28.0)
272 /// .language(Language::Rust)
273 /// .palette(
274 /// Palette::new(Color::from_rgba8(212, 212, 212, 255))
275 /// .keyword(Color::from_rgba8(197, 134, 192, 255))
276 /// .number(Color::from_rgba8(181, 206, 168, 255)),
277 /// );
278 /// ```
279 pub fn code(content: impl Into<String>) -> Shape {
280 let shape = Shape::new(ShapeKind::Code);
281 {
282 let mut d = shape.inner.borrow_mut();
283 // As with text, the background is transparent by default.
284 d.background.set(Color::TRANSPARENT);
285 d.text = Some(TextData::new(content.into()));
286 d.code = Some(CodeData::new());
287 }
288 shape
289 }
290
291 /// Creates a shape of the given kind with default settings.
292 fn new(kind: ShapeKind) -> Shape {
293 Shape {
294 inner: Rc::new(RefCell::new(ShapeData {
295 kind,
296 x: Signal::new(0.0),
297 y: Signal::new(0.0),
298 width: Signal::new(0.0),
299 height: Signal::new(0.0),
300 min_width: Signal::new(0.0),
301 max_width: Signal::new(0.0),
302 min_height: Signal::new(0.0),
303 max_height: Signal::new(0.0),
304 width_percent: None,
305 height_percent: None,
306 background: Signal::new(Color::WHITE),
307 radius: Signal::new(0.0),
308 opacity: Signal::new(1.0),
309 rotation: Signal::new(0.0),
310 scale: Signal::new(1.0),
311 gap: Signal::new(0.0),
312 pad_top: Signal::new(0.0),
313 pad_right: Signal::new(0.0),
314 pad_bottom: Signal::new(0.0),
315 pad_left: Signal::new(0.0),
316 direction: Direction::Row,
317 justify: Justify::Start,
318 align: Align::Start,
319 children: Vec::new(),
320 text: None,
321 code: None,
322 timeline: RefCell::new(Weak::new()),
323 })),
324 }
325 }
326
327 // ----- Layout and composition ----------------------------------------
328 //
329 // Structural methods take `&self` and return a clone handle (`Shape` is
330 // cheap — it's an `Rc`). This lets the chain continue even after a property
331 // setter method that returns a [`Tween`] (which dereferences into `Shape`).
332
333 /// Sets the position of the top-left corner. For a single axis (including
334 /// animation) use [`x`](Shape::x) / [`y`](Shape::y).
335 pub fn at(&self, x: f32, y: f32) -> Self {
336 {
337 let d = self.inner.borrow();
338 d.x.set(x);
339 d.y.set(y);
340 }
341 self.clone()
342 }
343
344 /// Sets explicit sizes. A value `<= 0` leaves the axis on "auto". For a
345 /// single axis (including animation) use [`width`](Shape::width) /
346 /// [`height`](Shape::height).
347 pub fn size(&self, w: f32, h: f32) -> Self {
348 {
349 let mut d = self.inner.borrow_mut();
350 d.width.set(w);
351 d.height.set(h);
352 // Explicit pixel sizes cancel any previously set fraction on both axes.
353 d.width_percent = None;
354 d.height_percent = None;
355 }
356 self.clone()
357 }
358
359 /// The children's layout axis.
360 pub fn direction(&self, d: Direction) -> Self {
361 self.inner.borrow_mut().direction = d;
362 self.clone()
363 }
364
365 /// Distribution along the main axis.
366 pub fn justify(&self, j: Justify) -> Self {
367 self.inner.borrow_mut().justify = j;
368 self.clone()
369 }
370
371 /// Alignment along the cross axis.
372 pub fn align(&self, a: Align) -> Self {
373 self.inner.borrow_mut().align = a;
374 self.clone()
375 }
376
377 /// Adds a child shape. Accepts both [`Shape`] and property handles
378 /// ([`Tween`], [`PaddingTween`]) — they dereference into a shape.
379 pub fn child(&self, c: impl Into<Shape>) -> Self {
380 self.inner.borrow_mut().children.push(c.into());
381 self.clone()
382 }
383
384 /// Adds child shapes. Thanks to [`IntoChildren`] it accepts both **one**
385 /// nested shape (including the property handles [`Tween`]/[`PaddingTween`])
386 /// and a **collection/iterator** of any `Into<Shape>` — so it suits both
387 /// nesting one ready-made group into another and adding a list:
388 ///
389 /// ```
390 /// # use dinamika_core::*;
391 /// let group = Shape::rect().children(vec![Shape::circle().size(8.0, 8.0)]);
392 /// // Nest a ready-made group as the single child:
393 /// let window = Shape::rect().children(group);
394 /// ```
395 pub fn children<C: IntoChildren>(&self, cs: C) -> Self {
396 self.inner.borrow_mut().children.extend(cs.into_children());
397 self.clone()
398 }
399
400 /// Registers the shape on the timeline `tl` for drawing and returns it,
401 /// to stay in the fluent chain. Since a shape is an `Rc`, the timeline holds
402 /// only a reference, and properties can still be animated and read.
403 ///
404 /// At the same time the whole subgraph (the shape itself and all its
405 /// children at the moment of the call) remembers this timeline, so an
406 /// animation built from any of them can be added by simply writing it as an
407 /// expression — without [`sequence`]/[`parallel`]:
408 ///
409 /// ```
410 /// # use dinamika_core::*;
411 /// let tl = Timeline::new(320, 160, Color::BLACK, 30.0);
412 /// let box_ = Shape::rect().size(40.0, 40.0).on(&tl);
413 /// // A single animation appends itself to the end of the timeline:
414 /// box_.x(200.0).over(1.0, Easing::CubicInOut);
415 /// // Several simultaneous ones — still via parallel:
416 /// tl.parallel(vec![box_.y(40.0).over(1.0, Easing::CubicInOut)]);
417 /// ```
418 pub fn on(&self, tl: &Timeline) -> Self {
419 tl.register_shape(self.clone());
420 self.bind_timeline(&tl.weak());
421 self.clone()
422 }
423
424 /// Remembers the timeline in this shape and recursively in all its children
425 /// (at the moment of the call). Clone handles share `inner`, so external
426 /// references to nested shapes will also see the binding. Called from
427 /// [`on`](Shape::on).
428 fn bind_timeline(&self, tl: &Weak<TimelineState>) {
429 let children = {
430 let d = self.inner.borrow();
431 *d.timeline.borrow_mut() = tl.clone();
432 d.children.clone()
433 };
434 for child in &children {
435 child.bind_timeline(tl);
436 }
437 }
438
439 /// A `Weak` reference to the timeline on which the shape is registered
440 /// (empty if the shape is not bound). Property handles attach it to the
441 /// built [`Action`](crate::Action) for auto-registration.
442 pub(crate) fn timeline_weak(&self) -> Weak<TimelineState> {
443 self.inner.borrow().timeline.borrow().clone()
444 }
445
446 // ----- Properties: set a value, optionally animate with `.over` -------
447
448 /// Sets the property's value immediately and returns a [`Tween`] for a
449 /// possible animation via [`over`](Tween::over). `from` is captured before
450 /// the value is set.
451 fn set_prop<T: Tweenable>(&self, signal: Signal<T>, value: T) -> Tween<T> {
452 let from = signal.get();
453 signal.set(value.clone());
454 Tween::new(self.clone(), signal, from, value)
455 }
456
457 /// Sets the size along axis `axis` with a [`Length`] value.
458 ///
459 /// A pixel length sets the size signal (resetting any previously set
460 /// fraction on this axis) and returns an animatable [`Tween`] — like a
461 /// regular pixel setter. A fraction is stored as a multiplier (`1.0` — 100%)
462 /// and is not animated: a degenerate handle (`from == to`) is returned so
463 /// the builder chain continues, and [`over`](Tween::over) on it changes
464 /// nothing.
465 fn set_length(&self, axis: Axis, value: Length) -> Tween<f32> {
466 let signal = match axis {
467 Axis::Width => self.width_signal(),
468 Axis::Height => self.height_signal(),
469 };
470 match value {
471 Length::Pixel(v) => {
472 {
473 let mut d = self.inner.borrow_mut();
474 match axis {
475 Axis::Width => d.width_percent = None,
476 Axis::Height => d.height_percent = None,
477 }
478 }
479 self.set_prop(signal, v)
480 }
481 Length::Percent(p) => {
482 let fraction = p / 100.0;
483 {
484 let mut d = self.inner.borrow_mut();
485 match axis {
486 Axis::Width => d.width_percent = Some(fraction),
487 Axis::Height => d.height_percent = Some(fraction),
488 }
489 }
490 let cur = signal.get();
491 Tween::new(self.clone(), signal, cur, cur)
492 }
493 }
494 }
495
496 /// X coordinate. `x(100.0)` sets it immediately;
497 /// `x(100.0).over(1.0, Easing::CubicInOut)` animates.
498 pub fn x(&self, value: f32) -> Tween<f32> {
499 self.set_prop(self.x_signal(), value)
500 }
501 /// Y coordinate. See [`x`](Shape::x).
502 pub fn y(&self, value: f32) -> Tween<f32> {
503 self.set_prop(self.y_signal(), value)
504 }
505 /// Width — as a [`Length`] value: pixels ([`Length::pixel`], `<= 0` — "auto")
506 /// or a fraction of the parent's content area ([`Length::percent`],
507 /// `Length::percent(100.0)` — 100%). A pixel width can be animated via
508 /// [`over`](Tween::over) (see [`x`](Shape::x)), and it cancels any previously
509 /// set fraction; a fraction, on the other hand, is set instantly (not
510 /// animated), resolved on the second layout pass relative to the parent and
511 /// overrides the pixel width (clamped by
512 /// [`min_width`](Shape::min_width)/[`max_width`](Shape::max_width)).
513 pub fn width(&self, value: Length) -> Tween<f32> {
514 self.set_length(Axis::Width, value)
515 }
516 /// Height — as a [`Length`] value (pixels or a fraction of the parent). See
517 /// [`width`](Shape::width).
518 pub fn height(&self, value: Length) -> Tween<f32> {
519 self.set_length(Axis::Height, value)
520 }
521 /// Lower bound of the width (`<= 0` — no limit): the final width does not
522 /// drop below it. On conflict with [`max_width`](Shape::max_width) the
523 /// minimum takes priority (as in CSS). Animatable. See [`x`](Shape::x).
524 pub fn min_width(&self, value: f32) -> Tween<f32> {
525 self.set_prop(self.min_width_signal(), value)
526 }
527 /// Upper bound of the width (`<= 0` — no limit): the final width does not
528 /// exceed it. Animatable. See [`min_width`](Shape::min_width).
529 pub fn max_width(&self, value: f32) -> Tween<f32> {
530 self.set_prop(self.max_width_signal(), value)
531 }
532 /// Lower bound of the height (`<= 0` — no limit). Animatable. See
533 /// [`min_width`](Shape::min_width).
534 pub fn min_height(&self, value: f32) -> Tween<f32> {
535 self.set_prop(self.min_height_signal(), value)
536 }
537 /// Upper bound of the height (`<= 0` — no limit). Animatable. See
538 /// [`min_width`](Shape::min_width).
539 pub fn max_height(&self, value: f32) -> Tween<f32> {
540 self.set_prop(self.max_height_signal(), value)
541 }
542 /// Background color. See [`x`](Shape::x).
543 pub fn background(&self, value: Color) -> Tween<Color> {
544 self.set_prop(self.background_signal(), value)
545 }
546 /// Corner radius. See [`x`](Shape::x).
547 pub fn radius(&self, value: f32) -> Tween<f32> {
548 self.set_prop(self.radius_signal(), value)
549 }
550 /// Opacity `0..=1` (multiplied by the parent's opacity). See
551 /// [`x`](Shape::x).
552 pub fn opacity(&self, value: f32) -> Tween<f32> {
553 self.set_prop(self.opacity_signal(), value)
554 }
555 /// Rotation around the center in degrees. See [`x`](Shape::x).
556 pub fn rotation(&self, value: f32) -> Tween<f32> {
557 self.set_prop(self.rotation_signal(), value)
558 }
559 /// Scale around the center (`1.0` — no change). Applies to the whole
560 /// subtree, like rotation. See [`x`](Shape::x).
561 pub fn scale(&self, value: f32) -> Tween<f32> {
562 self.set_prop(self.scale_signal(), value)
563 }
564 /// Gap between children. See [`x`](Shape::x).
565 pub fn gap(&self, value: f32) -> Tween<f32> {
566 self.set_prop(self.gap_signal(), value)
567 }
568 /// Top inner padding. See [`x`](Shape::x).
569 pub fn pad_top(&self, value: f32) -> Tween<f32> {
570 self.set_prop(self.pad_top_signal(), value)
571 }
572 /// Right inner padding. See [`x`](Shape::x).
573 pub fn pad_right(&self, value: f32) -> Tween<f32> {
574 self.set_prop(self.pad_right_signal(), value)
575 }
576 /// Bottom inner padding. See [`x`](Shape::x).
577 pub fn pad_bottom(&self, value: f32) -> Tween<f32> {
578 self.set_prop(self.pad_bottom_signal(), value)
579 }
580 /// Left inner padding. See [`x`](Shape::x).
581 pub fn pad_left(&self, value: f32) -> Tween<f32> {
582 self.set_prop(self.pad_left_signal(), value)
583 }
584
585 /// Inner padding. Accepts the CSS-like shorthands [`Padding`]:
586 ///
587 /// - `padding(16.0)` — the same on all sides;
588 /// - `padding((10.0, 20.0))` — `(vertical, horizontal)`;
589 /// - `padding((5.0, 10.0, 15.0, 20.0))` — `(top, right, bottom, left)`.
590 ///
591 /// Sets the padding immediately and returns a [`PaddingTween`]: append
592 /// [`over`](PaddingTween::over) to animate it, e.g.
593 /// `padding(24.0).over(1.0, Easing::CubicInOut)`.
594 pub fn padding<P: Into<Padding>>(&self, value: P) -> PaddingTween {
595 let to: Padding = value.into();
596 let from = self.current_padding();
597 self.set_padding(to);
598 PaddingTween::new(self.clone(), from, to)
599 }
600
601 // ----- Text properties (only for a Shape::text shape) ----------------
602 //
603 // Font size, letter spacing, line height and color are animatable: the
604 // setter method sets the value immediately and returns a [`Tween`] (like the
605 // other shape properties). The content-editing methods
606 // (content/append/prepend/insert/rewrite) also set the new text immediately,
607 // but return a [`TextEdit`] — a handle that dereferences into a shape and can
608 // turn the edit into an animation (instant spawn, typing, smoothing). Font
609 // and alignment are set instantly and return the shape itself.
610
611 /// Fully replaces the text content with new content. Lines are separated by
612 /// `\n`.
613 ///
614 /// Sets the text immediately and returns a [`TextEdit`]: append
615 /// [`spawn`](TextEdit::spawn) / [`typing`](TextEdit::typing) /
616 /// [`smooth`](TextEdit::smooth) to animate the change on the timeline.
617 ///
618 /// Panics if called on a non-text shape (see [`Shape::text`]).
619 pub fn content(&self, content: impl Into<String>) -> TextEdit {
620 let content = content.into();
621 self.edit_text(|_old| content)
622 }
623
624 /// Appends `content` to the end of the current content.
625 ///
626 /// Returns a [`TextEdit`] (see [`content`](Shape::content)). When animated
627 /// with [`typing`](TextEdit::typing), only the added "tail" is typed.
628 ///
629 /// Panics if called on a non-text shape.
630 pub fn append(&self, content: impl Into<String>) -> TextEdit {
631 let content = content.into();
632 self.edit_text(|old| {
633 let mut out = String::with_capacity(old.len() + content.len());
634 out.push_str(old);
635 out.push_str(&content);
636 out
637 })
638 }
639
640 /// Inserts `content` at the beginning of the current content.
641 ///
642 /// Returns a [`TextEdit`] (see [`content`](Shape::content)).
643 ///
644 /// Panics if called on a non-text shape.
645 pub fn prepend(&self, content: impl Into<String>) -> TextEdit {
646 let content = content.into();
647 self.edit_text(|old| {
648 let mut out = String::with_capacity(old.len() + content.len());
649 out.push_str(&content);
650 out.push_str(old);
651 out
652 })
653 }
654
655 /// Inserts `content` before the character at index `char_index` (0-based,
656 /// clamped to `[0, length]`).
657 ///
658 /// Returns a [`TextEdit`] (see [`content`](Shape::content)).
659 ///
660 /// Panics if called on a non-text shape.
661 pub fn insert(&self, char_index: usize, content: impl Into<String>) -> TextEdit {
662 let content = content.into();
663 self.edit_text(|old| insert_at(old, char_index, &content))
664 }
665
666 /// Replaces the content of the half-open range `[from, to)` with `content`.
667 ///
668 /// The bounds are [`TextPos`]: a bare character index (0-based, via
669 /// `Into<TextPos>`), the start of a line [`line(n)`](crate::line) or the end
670 /// of the text [`infinite()`](crate::infinite) (for `to`). The range is
671 /// half-open: the character at position `to` is not included in the
672 /// replacement.
673 ///
674 /// ```
675 /// # use dinamika_core::*;
676 /// // "foo\nbar" → replace the whole first line (with its `\n`) with "X":
677 /// let t = Shape::text("foo\nbar").rewrite(0, line(1), "X");
678 /// ```
679 ///
680 /// Returns a [`TextEdit`] (see [`content`](Shape::content)). Panics if called
681 /// on a non-text shape.
682 pub fn rewrite(
683 &self,
684 from: impl Into<TextPos>,
685 to: impl Into<TextPos>,
686 content: impl Into<String>,
687 ) -> TextEdit {
688 let from = from.into();
689 let to = to.into();
690 let content = content.into();
691 self.edit_text(|old| rewrite_range(old, from, to, &content))
692 }
693
694 /// The shared text-editing mechanism: captures the previous content,
695 /// computes the new one with the function `f`, sets it immediately and
696 /// returns a [`TextEdit`] with both values (for a possible animation).
697 fn edit_text(&self, f: impl FnOnce(&str) -> String) -> TextEdit {
698 let (old, new) = {
699 let d = self.inner.borrow();
700 let text = d.text.as_ref().expect(NOT_TEXT);
701 let old = text.get_text();
702 let new = f(&old);
703 text.set_text(new.clone());
704 (old, new)
705 };
706 TextEdit::new(self.clone(), old, new)
707 }
708
709 /// Sets the font from the bytes of a `.ttf`/`.otf` file (CSS `font-family`).
710 /// Accepts a `Vec<u8>` or a shared [`Rc<Vec<u8>>`](std::rc::Rc) — the latter
711 /// is handy for sharing one font across several texts without copying the
712 /// bytes.
713 ///
714 /// Panics if called on a non-text shape.
715 pub fn font(&self, bytes: impl Into<Rc<Vec<u8>>>) -> Self {
716 {
717 let d = self.inner.borrow();
718 d.text.as_ref().expect(NOT_TEXT).set_font(bytes.into(), 0);
719 }
720 self.clone()
721 }
722
723 /// Sets the font from the bytes of a collection (`.ttc`), selecting the face
724 /// by `index`. For a regular single-font file use [`font`](Shape::font).
725 ///
726 /// Panics if called on a non-text shape.
727 pub fn font_collection(&self, bytes: impl Into<Rc<Vec<u8>>>, index: u32) -> Self {
728 {
729 let d = self.inner.borrow();
730 d.text.as_ref().expect(NOT_TEXT).set_font(bytes.into(), index);
731 }
732 self.clone()
733 }
734
735 /// Alignment of lines within the block (CSS `text-align`).
736 ///
737 /// Panics if called on a non-text shape.
738 pub fn text_align(&self, align: TextAlign) -> Self {
739 {
740 let d = self.inner.borrow();
741 d.text.as_ref().expect(NOT_TEXT).align.set(align);
742 }
743 self.clone()
744 }
745
746 /// Font size in pixels (CSS `font-size`). Animatable. See [`x`](Shape::x).
747 ///
748 /// Panics if called on a non-text shape.
749 pub fn font_size(&self, value: f32) -> Tween<f32> {
750 self.set_prop(self.font_size_signal(), value)
751 }
752
753 /// Glyph fill color (CSS `color`). Animatable. See [`x`](Shape::x).
754 ///
755 /// Panics if called on a non-text shape or on a code shape (code has no
756 /// single color — configure the highlighting via
757 /// [`palette`](Shape::palette)).
758 pub fn color(&self, value: Color) -> Tween<Color> {
759 assert!(self.inner.borrow().kind != ShapeKind::Code, "{CODE_HAS_NO_COLOR}");
760 self.set_prop(self.color_signal(), value)
761 }
762
763 /// The code shape's highlight palette (see [`Palette`]). Set instantly and
764 /// returns the shape itself to continue the chain.
765 ///
766 /// Panics if called on a non-code shape (see [`Shape::code`]).
767 pub fn palette(&self, palette: Palette) -> Self {
768 {
769 let d = self.inner.borrow();
770 d.code.as_ref().expect(NOT_CODE).set_palette(palette);
771 }
772 self.clone()
773 }
774
775 /// The code shape's highlight language (see [`Language`]). Set instantly and
776 /// returns the shape itself.
777 ///
778 /// Panics if called on a non-code shape (see [`Shape::code`]).
779 pub fn language(&self, language: Language) -> Self {
780 {
781 let d = self.inner.borrow();
782 d.code.as_ref().expect(NOT_CODE).set_language(language);
783 }
784 self.clone()
785 }
786
787 /// Letter spacing — extra gap between characters in pixels (CSS
788 /// `letter-spacing`). Animatable. See [`x`](Shape::x).
789 ///
790 /// Panics if called on a non-text shape.
791 pub fn letter_spacing(&self, value: f32) -> Tween<f32> {
792 self.set_prop(self.letter_spacing_signal(), value)
793 }
794
795 /// Line spacing as a multiplier of the font's natural line height (CSS
796 /// `line-height`, `1.0` — no change). Animatable. See [`x`](Shape::x).
797 ///
798 /// Panics if called on a non-text shape.
799 pub fn line_height(&self, value: f32) -> Tween<f32> {
800 self.set_prop(self.line_height_signal(), value)
801 }
802
803 /// Marks the highlighted character range `[from, to)` and returns a
804 /// [`HighlightEdit`] — this is a **timeline animation**, not a static
805 /// property. Highlighted glyphs are drawn at full strength, the rest are
806 /// dimmed.
807 ///
808 /// Append [`over`](HighlightEdit::over) to highlight smoothly over a given
809 /// time; without it the edit is applied immediately. Unlike `.selection` in
810 /// Motion Canvas, there can be any number of ranges — highlight each with its
811 /// own `highlight(..).over(..)` in a single [`parallel`](crate::parallel):
812 /// they merge into one consistent transition. The highlighting is removed
813 /// with [`clear_highlight`](Shape::clear_highlight).
814 ///
815 /// The bounds are [`TextPos`]: a bare character index (0-based, via
816 /// `Into<TextPos>`), the start of a line [`line(n)`](crate::line) or the end
817 /// of the text [`infinite()`](crate::infinite). The range is half-open: the
818 /// character at position `to` is not highlighted.
819 ///
820 /// ```
821 /// # use dinamika_core::*;
822 /// let tl = Timeline::new(320, 120, Color::BLACK, 30.0);
823 /// let code = Shape::code("let answer = 42;").on(&tl);
824 /// // Highlight "42" over half a second, dimming the rest:
825 /// code.highlight(13, 15).over(0.5, Easing::CubicInOut);
826 /// ```
827 ///
828 /// Works on both text and code (colors are preserved, only opacity changes).
829 /// Panics if called on a non-text shape (see [`Shape::text`]).
830 pub fn highlight(&self, from: impl Into<TextPos>, to: impl Into<TextPos>) -> HighlightEdit {
831 let (old, new) = {
832 let d = self.inner.borrow();
833 let text = d.text.as_ref().expect(NOT_TEXT);
834 let old = text.get_highlights();
835 text.add_highlight(from.into(), to.into());
836 (old, text.get_highlights())
837 };
838 HighlightEdit::new(self.clone(), old, new)
839 }
840
841 /// Removes the highlighting (see [`highlight`](Shape::highlight)): returns a
842 /// [`HighlightEdit`] whose [`over`](HighlightEdit::over) smoothly returns the
843 /// whole text to full strength; without `over` the highlighting is removed
844 /// immediately.
845 ///
846 /// Panics if called on a non-text shape.
847 pub fn clear_highlight(&self) -> HighlightEdit {
848 let old = {
849 let d = self.inner.borrow();
850 let text = d.text.as_ref().expect(NOT_TEXT);
851 let old = text.get_highlights();
852 text.clear_highlights();
853 old
854 };
855 HighlightEdit::new(self.clone(), old, Vec::new())
856 }
857
858 /// The font-size signal. Panics if called on a non-text shape.
859 pub fn font_size_signal(&self) -> Signal<f32> {
860 self.inner.borrow().text.as_ref().expect(NOT_TEXT).size.clone()
861 }
862 /// The text-color signal. Panics if called on a non-text shape.
863 pub fn color_signal(&self) -> Signal<Color> {
864 self.inner.borrow().text.as_ref().expect(NOT_TEXT).color.clone()
865 }
866 /// The letter-spacing signal. Panics if called on a non-text shape.
867 pub fn letter_spacing_signal(&self) -> Signal<f32> {
868 self.inner.borrow().text.as_ref().expect(NOT_TEXT).letter_spacing.clone()
869 }
870 /// The line-height signal. Panics if called on a non-text shape.
871 pub fn line_height_signal(&self) -> Signal<f32> {
872 self.inner.borrow().text.as_ref().expect(NOT_TEXT).line_height.clone()
873 }
874
875 /// The current values of all four padding sides.
876 fn current_padding(&self) -> Padding {
877 let d = self.inner.borrow();
878 Padding {
879 top: d.pad_top.get(),
880 right: d.pad_right.get(),
881 bottom: d.pad_bottom.get(),
882 left: d.pad_left.get(),
883 }
884 }
885
886 /// Instantly sets all four padding sides from [`Padding`].
887 fn set_padding(&self, p: Padding) {
888 let d = self.inner.borrow();
889 d.pad_top.set(p.top);
890 d.pad_right.set(p.right);
891 d.pad_bottom.set(p.bottom);
892 d.pad_left.set(p.left);
893 }
894
895 // ----- Signal accessors (read/write, building Computed) --------------
896
897 /// The X-coordinate signal.
898 pub fn x_signal(&self) -> Signal<f32> {
899 self.inner.borrow().x.clone()
900 }
901 /// The Y-coordinate signal.
902 pub fn y_signal(&self) -> Signal<f32> {
903 self.inner.borrow().y.clone()
904 }
905 /// The width signal.
906 pub fn width_signal(&self) -> Signal<f32> {
907 self.inner.borrow().width.clone()
908 }
909 /// The height signal.
910 pub fn height_signal(&self) -> Signal<f32> {
911 self.inner.borrow().height.clone()
912 }
913 /// The width lower-bound signal.
914 pub fn min_width_signal(&self) -> Signal<f32> {
915 self.inner.borrow().min_width.clone()
916 }
917 /// The width upper-bound signal.
918 pub fn max_width_signal(&self) -> Signal<f32> {
919 self.inner.borrow().max_width.clone()
920 }
921 /// The height lower-bound signal.
922 pub fn min_height_signal(&self) -> Signal<f32> {
923 self.inner.borrow().min_height.clone()
924 }
925 /// The height upper-bound signal.
926 pub fn max_height_signal(&self) -> Signal<f32> {
927 self.inner.borrow().max_height.clone()
928 }
929 /// The background-color signal.
930 pub fn background_signal(&self) -> Signal<Color> {
931 self.inner.borrow().background.clone()
932 }
933 /// The corner-radius signal.
934 pub fn radius_signal(&self) -> Signal<f32> {
935 self.inner.borrow().radius.clone()
936 }
937 /// The opacity signal.
938 pub fn opacity_signal(&self) -> Signal<f32> {
939 self.inner.borrow().opacity.clone()
940 }
941 /// The rotation signal (degrees).
942 pub fn rotation_signal(&self) -> Signal<f32> {
943 self.inner.borrow().rotation.clone()
944 }
945 /// The scale signal.
946 pub fn scale_signal(&self) -> Signal<f32> {
947 self.inner.borrow().scale.clone()
948 }
949 /// The child-gap signal.
950 pub fn gap_signal(&self) -> Signal<f32> {
951 self.inner.borrow().gap.clone()
952 }
953 /// The top inner-padding signal.
954 pub fn pad_top_signal(&self) -> Signal<f32> {
955 self.inner.borrow().pad_top.clone()
956 }
957 /// The right inner-padding signal.
958 pub fn pad_right_signal(&self) -> Signal<f32> {
959 self.inner.borrow().pad_right.clone()
960 }
961 /// The bottom inner-padding signal.
962 pub fn pad_bottom_signal(&self) -> Signal<f32> {
963 self.inner.borrow().pad_bottom.clone()
964 }
965 /// The left inner-padding signal.
966 pub fn pad_left_signal(&self) -> Signal<f32> {
967 self.inner.borrow().pad_left.clone()
968 }
969}
970
971/// Conversion of a [`Shape::children`] argument into a list of child shapes.
972///
973/// Implemented in two ways, so `children` accepts both **one** nested shape
974/// (including the property handles [`Tween`]/[`PaddingTween`], which dereference
975/// into a shape) and a **collection/iterator** of any `Into<Shape>`. This is
976/// what makes it possible to nest shapes arbitrarily deep:
977/// `outer.children(inner)` takes an already-assembled group as the single child,
978/// while `outer.children(vec![...])` takes several at once.
979pub trait IntoChildren {
980 /// Unfolds the value into a list of child shapes.
981 fn into_children(self) -> Vec<Shape>;
982}
983
984impl IntoChildren for Shape {
985 fn into_children(self) -> Vec<Shape> {
986 vec![self]
987 }
988}
989
990impl<T: Tweenable> IntoChildren for Tween<T> {
991 fn into_children(self) -> Vec<Shape> {
992 vec![self.into()]
993 }
994}
995
996impl IntoChildren for PaddingTween {
997 fn into_children(self) -> Vec<Shape> {
998 vec![self.into()]
999 }
1000}
1001
1002impl IntoChildren for TextEdit {
1003 fn into_children(self) -> Vec<Shape> {
1004 vec![self.into()]
1005 }
1006}
1007
1008impl IntoChildren for HighlightEdit {
1009 fn into_children(self) -> Vec<Shape> {
1010 vec![self.into()]
1011 }
1012}
1013
1014impl<I> IntoChildren for I
1015where
1016 I: IntoIterator,
1017 I::Item: Into<Shape>,
1018{
1019 fn into_children(self) -> Vec<Shape> {
1020 self.into_iter().map(Into::into).collect()
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027 use crate::easing::Easing;
1028 use crate::timeline::Timeline;
1029
1030 #[test]
1031 fn builder_sets_initial_values() {
1032 let s = Shape::rect().at(10.0, 20.0).size(100.0, 50.0).radius(8.0);
1033 assert_eq!(s.x_signal().get(), 10.0);
1034 assert_eq!(s.y_signal().get(), 20.0);
1035 assert_eq!(s.width_signal().get(), 100.0);
1036 assert_eq!(s.height_signal().get(), 50.0);
1037 assert_eq!(s.radius_signal().get(), 8.0);
1038 }
1039
1040 #[test]
1041 fn property_setter_sets_and_returns_handle() {
1042 // The setter sets the value immediately and returns a Tween over the
1043 // same shape (shared `Rc`), so the chain can continue.
1044 let s = Shape::rect();
1045 let same = s.gap(12.0);
1046 assert_eq!(s.gap_signal().get(), 12.0);
1047 same.gap(34.0);
1048 assert_eq!(s.gap_signal().get(), 34.0);
1049 }
1050
1051 #[test]
1052 fn over_builds_tween_from_previous_value() {
1053 // `over` animates from the previous value (20) to the new one (56), not "56 → 56".
1054 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1055 let s = Shape::rect().gap(20.0).on(&tl);
1056 tl.parallel(vec![s.gap(56.0).over(1.0, Easing::Linear)]);
1057
1058 tl.seek(0.0);
1059 assert!((s.gap_signal().get() - 20.0).abs() < 1e-3, "got {}", s.gap_signal().get());
1060 tl.seek(0.5);
1061 assert!((s.gap_signal().get() - 38.0).abs() < 1e-3, "got {}", s.gap_signal().get());
1062 tl.seek(1.0);
1063 assert!((s.gap_signal().get() - 56.0).abs() < 1e-3, "got {}", s.gap_signal().get());
1064 }
1065
1066 #[test]
1067 fn padding_shorthands() {
1068 let uniform = Shape::rect().padding(8.0);
1069 assert_eq!(uniform.pad_top_signal().get(), 8.0);
1070 assert_eq!(uniform.pad_right_signal().get(), 8.0);
1071 assert_eq!(uniform.pad_bottom_signal().get(), 8.0);
1072 assert_eq!(uniform.pad_left_signal().get(), 8.0);
1073
1074 let vh = Shape::rect().padding((10.0, 20.0));
1075 assert_eq!(vh.pad_top_signal().get(), 10.0);
1076 assert_eq!(vh.pad_bottom_signal().get(), 10.0);
1077 assert_eq!(vh.pad_left_signal().get(), 20.0);
1078 assert_eq!(vh.pad_right_signal().get(), 20.0);
1079
1080 let trbl = Shape::rect().padding((1.0, 2.0, 3.0, 4.0));
1081 assert_eq!(trbl.pad_top_signal().get(), 1.0);
1082 assert_eq!(trbl.pad_right_signal().get(), 2.0);
1083 assert_eq!(trbl.pad_bottom_signal().get(), 3.0);
1084 assert_eq!(trbl.pad_left_signal().get(), 4.0);
1085 }
1086
1087 #[test]
1088 fn padding_over_animates_all_sides() {
1089 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1090 let s = Shape::rect().padding(10.0).on(&tl);
1091 tl.parallel(vec![s.padding(20.0).over(1.0, Easing::Linear)]);
1092
1093 tl.seek(0.5);
1094 assert!((s.pad_top_signal().get() - 15.0).abs() < 1e-3);
1095 assert!((s.pad_left_signal().get() - 15.0).abs() < 1e-3);
1096 tl.seek(1.0);
1097 assert!((s.pad_bottom_signal().get() - 20.0).abs() < 1e-3);
1098 }
1099
1100 #[test]
1101 fn scale_defaults_to_one_and_setter_returns_handle() {
1102 let s = Shape::rect();
1103 assert_eq!(s.scale_signal().get(), 1.0);
1104 let same = s.scale(2.0);
1105 assert_eq!(s.scale_signal().get(), 2.0);
1106 // The handle dereferences into the same shape — the chain can continue.
1107 same.scale(0.5);
1108 assert_eq!(s.scale_signal().get(), 0.5);
1109 }
1110
1111 #[test]
1112 fn scale_over_animates_from_previous_value() {
1113 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1114 let s = Shape::rect().scale(1.0).on(&tl);
1115 tl.parallel(vec![s.scale(2.0).over(1.0, Easing::Linear)]);
1116
1117 tl.seek(0.0);
1118 assert!((s.scale_signal().get() - 1.0).abs() < 1e-3);
1119 tl.seek(0.5);
1120 assert!((s.scale_signal().get() - 1.5).abs() < 1e-3);
1121 tl.seek(1.0);
1122 assert!((s.scale_signal().get() - 2.0).abs() < 1e-3);
1123 }
1124
1125 #[test]
1126 fn min_max_setters_set_and_return_handles() {
1127 let s = Shape::rect()
1128 .min_width(10.0)
1129 .max_width(200.0)
1130 .min_height(20.0)
1131 .max_height(300.0);
1132 assert_eq!(s.min_width_signal().get(), 10.0);
1133 assert_eq!(s.max_width_signal().get(), 200.0);
1134 assert_eq!(s.min_height_signal().get(), 20.0);
1135 assert_eq!(s.max_height_signal().get(), 300.0);
1136 }
1137
1138 #[test]
1139 fn min_max_default_to_unbounded() {
1140 // By default the bounds are off (`<= 0` — no limit).
1141 let s = Shape::rect();
1142 assert_eq!(s.min_width_signal().get(), 0.0);
1143 assert_eq!(s.max_width_signal().get(), 0.0);
1144 assert_eq!(s.min_height_signal().get(), 0.0);
1145 assert_eq!(s.max_height_signal().get(), 0.0);
1146 }
1147
1148 #[test]
1149 fn max_width_animates_like_any_property() {
1150 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1151 let s = Shape::rect().max_width(100.0).on(&tl);
1152 tl.parallel(vec![s.max_width(200.0).over(1.0, Easing::Linear)]);
1153 tl.seek(0.0);
1154 assert!((s.max_width_signal().get() - 100.0).abs() < 1e-3);
1155 tl.seek(0.5);
1156 assert!((s.max_width_signal().get() - 150.0).abs() < 1e-3);
1157 tl.seek(1.0);
1158 assert!((s.max_width_signal().get() - 200.0).abs() < 1e-3);
1159 }
1160
1161 #[test]
1162 fn percent_length_stores_fraction() {
1163 // `Length::percent` is given in percent (100 == 100%), stored as a fraction.
1164 let s = Shape::rect()
1165 .width(Length::percent(100.0))
1166 .height(Length::percent(50.0));
1167 assert_eq!(s.inner.borrow().width_percent, Some(1.0));
1168 assert_eq!(s.inner.borrow().height_percent, Some(0.5));
1169 // By default there is no percentage size.
1170 let plain = Shape::rect();
1171 assert_eq!(plain.inner.borrow().width_percent, None);
1172 assert_eq!(plain.inner.borrow().height_percent, None);
1173 }
1174
1175 #[test]
1176 fn pixel_length_sets_signal_and_clears_percent() {
1177 // A pixel length on an axis sets the signal and cancels any previously set fraction.
1178 let s = Shape::rect()
1179 .width(Length::percent(50.0))
1180 .width(Length::pixel(120.0));
1181 assert_eq!(s.width_signal().get(), 120.0);
1182 assert_eq!(s.inner.borrow().width_percent, None);
1183 }
1184
1185 #[test]
1186 fn size_clears_percent() {
1187 // An explicit size also cancels the fractions on both axes.
1188 let s = Shape::rect()
1189 .width(Length::percent(50.0))
1190 .height(Length::percent(50.0))
1191 .size(40.0, 30.0);
1192 assert_eq!(s.inner.borrow().width_percent, None);
1193 assert_eq!(s.inner.borrow().height_percent, None);
1194 }
1195
1196 #[test]
1197 fn accessor_shares_signal_with_shape() {
1198 let s = Shape::rect();
1199 let x = s.x_signal();
1200 x.set(123.0);
1201 assert_eq!(s.x_signal().get(), 123.0);
1202 }
1203
1204 #[test]
1205 fn circle_has_circle_kind_and_shared_defaults() {
1206 let c = Shape::circle().size(64.0, 64.0).background(Color::from_rgba8(1, 2, 3, 255));
1207 assert_eq!(c.inner.borrow().kind, ShapeKind::Circle);
1208 assert_eq!(c.width_signal().get(), 64.0);
1209 assert_eq!(c.height_signal().get(), 64.0);
1210 // A circle participates in layout and animations on par with a rectangle.
1211 assert_eq!(Shape::rect().inner.borrow().kind, ShapeKind::Rect);
1212 }
1213
1214 #[test]
1215 fn layout_has_layout_kind_and_shared_defaults() {
1216 // A layout container behaves like a rect (sizes, children, layout),
1217 // differing only in kind — it has no fill of its own.
1218 let l = Shape::layout()
1219 .direction(Direction::Column)
1220 .gap(8.0)
1221 .child(Shape::rect().size(40.0, 40.0))
1222 .child(Shape::rect().size(40.0, 40.0));
1223 assert_eq!(l.inner.borrow().kind, ShapeKind::Layout);
1224 assert_eq!(l.gap_signal().get(), 8.0);
1225 assert_eq!(l.inner.borrow().children.len(), 2);
1226 }
1227
1228 #[test]
1229 fn text_has_text_kind_and_css_defaults() {
1230 let t = Shape::text("Hello");
1231 assert_eq!(t.inner.borrow().kind, ShapeKind::Text);
1232 // The text background is transparent by default (as in CSS), not white.
1233 assert_eq!(t.background_signal().get(), Color::TRANSPARENT);
1234 // Default style: 32px, black, no letter spacing, line height 1.0.
1235 assert_eq!(t.font_size_signal().get(), 32.0);
1236 assert_eq!(t.color_signal().get(), Color::BLACK);
1237 assert_eq!(t.letter_spacing_signal().get(), 0.0);
1238 assert_eq!(t.line_height_signal().get(), 1.0);
1239 }
1240
1241 #[test]
1242 fn text_setters_set_and_return_handles() {
1243 let t = Shape::text("Hi")
1244 .font_size(48.0)
1245 .color(Color::from_rgba8(10, 20, 30, 255))
1246 .letter_spacing(2.0)
1247 .line_height(1.5)
1248 .text_align(TextAlign::Center)
1249 .content("Bye");
1250 assert_eq!(t.font_size_signal().get(), 48.0);
1251 assert_eq!(t.color_signal().get(), Color::from_rgba8(10, 20, 30, 255));
1252 assert_eq!(t.letter_spacing_signal().get(), 2.0);
1253 assert_eq!(t.line_height_signal().get(), 1.5);
1254 }
1255
1256 #[test]
1257 fn text_size_animates_like_any_property() {
1258 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1259 let t = Shape::text("x").font_size(20.0).on(&tl);
1260 tl.parallel(vec![t.font_size(40.0).over(1.0, Easing::Linear)]);
1261 tl.seek(0.0);
1262 assert!((t.font_size_signal().get() - 20.0).abs() < 1e-3);
1263 tl.seek(0.5);
1264 assert!((t.font_size_signal().get() - 30.0).abs() < 1e-3);
1265 tl.seek(1.0);
1266 assert!((t.font_size_signal().get() - 40.0).abs() < 1e-3);
1267 }
1268
1269 #[test]
1270 #[should_panic(expected = "text shape")]
1271 fn text_methods_panic_on_non_text_shape() {
1272 // Text properties are not available on ordinary shapes.
1273 Shape::rect().font_size(10.0);
1274 }
1275
1276 #[test]
1277 fn highlight_commits_ranges() {
1278 let t = Shape::text("abcdef");
1279 // Each highlight immediately commits its range; the handle dereferences
1280 // into the shape, so the builder chain continues (here — font_size).
1281 t.highlight(0, 2);
1282 let same = t.highlight(4, infinite()).font_size(40.0);
1283 assert_eq!(same.font_size_signal().get(), 40.0);
1284 let committed = t.inner.borrow().text.as_ref().unwrap().get_highlights();
1285 assert_eq!(committed.len(), 2);
1286 // clear_highlight removes all of them.
1287 t.clear_highlight();
1288 assert_eq!(t.inner.borrow().text.as_ref().unwrap().get_highlights().len(), 0);
1289 }
1290
1291 #[test]
1292 fn parallel_highlights_merge_to_common_morph() {
1293 // Several highlight().over() in one parallel share the highlight-stage
1294 // cell. Without merging, the second tween would start with its "from" =
1295 // the first range (already committed) and the highlight would flicker;
1296 // merge_overlapping resets both to a common base (empty) and final (both
1297 // ranges).
1298 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1299 let code = Shape::text("abcdef").on(&tl);
1300 let stage = code.inner.borrow().text.as_ref().unwrap().highlight_stage_handle();
1301 tl.parallel(vec![
1302 code.highlight(0, 2).over(0.5, Easing::Linear),
1303 code.highlight(4, 6).over(0.5, Easing::Linear),
1304 ]);
1305 // committed — both ranges.
1306 assert_eq!(code.inner.borrow().text.as_ref().unwrap().get_highlights().len(), 2);
1307
1308 // At the start of the transition "from" is empty for both — before the
1309 // start nothing is highlighted (everything bright), even though the edits
1310 // share one stage cell.
1311 tl.seek(0.0);
1312 match &*stage.borrow() {
1313 text::HighlightStage::Morph { from, to, p } => {
1314 assert!(from.is_empty(), "group base — no highlighting, from={from:?}");
1315 assert_eq!(to.len(), 2, "group final — both ranges");
1316 assert!((*p - 0.0).abs() < 1e-3, "p={p}");
1317 }
1318 other => panic!("expected Morph, got {other:?}"),
1319 }
1320 // At the end — both ranges, p=1; both tweens write the cell, but consistently.
1321 tl.seek(0.5);
1322 match &*stage.borrow() {
1323 text::HighlightStage::Morph { from, to, p } => {
1324 assert!(from.is_empty(), "from={from:?}");
1325 assert_eq!(to.len(), 2);
1326 assert!((*p - 1.0).abs() < 1e-3, "p={p}");
1327 }
1328 other => panic!("{other:?}"),
1329 };
1330 }
1331
1332 #[test]
1333 fn later_smooth_edit_does_not_suppress_earlier_highlight() {
1334 // Reproduces a bug from the demo: a highlight in an early parallel
1335 // "disappears" if there is a smooth text edit later on the timeline. Its
1336 // reset before the start puts the shared text-stage cell into Crossfade,
1337 // and the colored fill path on Crossfade goes into a morph and ignores
1338 // the highlight.
1339 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1340 let code = Shape::text("abcdef").on(&tl);
1341 let stage = code.inner.borrow().text.as_ref().unwrap().stage_handle();
1342 tl.pause(1.0);
1343 tl.parallel(vec![code.highlight(0, 2).over(0.5, Easing::Linear)]);
1344 tl.pause(1.0);
1345 tl.parallel(vec![code.append("XYZ").smooth(0.5, Easing::Linear)]);
1346
1347 // Sample in the middle of the highlight window (t=1.25); append starts only at 2.5.
1348 tl.seek(1.25);
1349 // The text stage must not be Crossfade: otherwise the highlight won't be drawn.
1350 assert!(
1351 !matches!(&*stage.borrow(), text::TextStage::Crossfade { .. }),
1352 "within the highlight window the text stage must not be Crossfade: {:?}",
1353 &*stage.borrow()
1354 );
1355 }
1356
1357 #[test]
1358 fn highlight_over_drives_stage_on_timeline() {
1359 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1360 let code = Shape::text("abcd").on(&tl);
1361 let stage = code.inner.borrow().text.as_ref().unwrap().highlight_stage_handle();
1362 tl.pause(1.0);
1363 tl.parallel(vec![code.highlight(0, 2).over(0.5, Easing::Linear)]);
1364
1365 // committed after the edit — the target range.
1366 assert_eq!(code.inner.borrow().text.as_ref().unwrap().get_highlights().len(), 1);
1367
1368 // Before the start (during the pause) — morph at zero, "from" empty (no highlight).
1369 tl.seek(0.5);
1370 match &*stage.borrow() {
1371 text::HighlightStage::Morph { from, to, p } => {
1372 assert!(from.is_empty(), "before the start there should be no highlight");
1373 assert_eq!(to.len(), 1);
1374 assert!((*p - 0.0).abs() < 1e-3, "p={p}");
1375 }
1376 other => panic!("expected Morph, got {other:?}"),
1377 }
1378 // In the middle of the transition — progress 0.5.
1379 tl.seek(1.25);
1380 match &*stage.borrow() {
1381 text::HighlightStage::Morph { p, .. } => assert!((*p - 0.5).abs() < 1e-3, "p={p}"),
1382 other => panic!("{other:?}"),
1383 }
1384 // After — progress 1, the target range.
1385 tl.seek(1.5);
1386 match &*stage.borrow() {
1387 text::HighlightStage::Morph { to, p, .. } => {
1388 assert_eq!(to.len(), 1);
1389 assert!((*p - 1.0).abs() < 1e-3, "p={p}");
1390 }
1391 other => panic!("{other:?}"),
1392 };
1393 }
1394
1395 #[test]
1396 #[should_panic(expected = "text shape")]
1397 fn highlight_panics_on_non_text_shape() {
1398 // Highlighting is only available on a text (and code) shape.
1399 Shape::rect().highlight(0, 1);
1400 }
1401
1402 #[test]
1403 fn code_shares_text_defaults_and_kind() {
1404 let c = Shape::code("fn main() {}");
1405 assert_eq!(c.inner.borrow().kind, ShapeKind::Code);
1406 // Code is text in everything else: the same CSS defaults and shared layout.
1407 assert_eq!(c.background_signal().get(), Color::TRANSPARENT);
1408 assert_eq!(c.font_size_signal().get(), 32.0);
1409 assert!(c.inner.borrow().text.is_some());
1410 assert!(c.inner.borrow().code.is_some());
1411 }
1412
1413 #[test]
1414 fn code_uses_text_edit_methods_like_text() {
1415 // Content edits work the same as for text.
1416 let c = Shape::code("a").font_size(48.0).append("b").content("c\nd");
1417 assert_eq!(c.font_size_signal().get(), 48.0);
1418 assert_eq!(committed(&c), "c\nd");
1419 }
1420
1421 #[test]
1422 fn code_palette_and_language_setters_return_shape() {
1423 // Highlight setters are instant and continue the chain (return the shape).
1424 let c = Shape::code("let x = 1;")
1425 .language(Language::Rust)
1426 .palette(Palette::new(Color::WHITE).keyword(Color::from_rgba8(1, 2, 3, 255)))
1427 .font_size(20.0);
1428 assert_eq!(c.inner.borrow().kind, ShapeKind::Code);
1429 assert_eq!(c.font_size_signal().get(), 20.0);
1430 }
1431
1432 #[test]
1433 #[should_panic(expected = "palette")]
1434 fn code_color_panics() {
1435 // A code shape has no single color — color panics on it.
1436 Shape::code("x").color(Color::BLACK);
1437 }
1438
1439 #[test]
1440 #[should_panic(expected = "code shape")]
1441 fn palette_panics_on_non_code_shape() {
1442 // The palette is only available on a code shape.
1443 Shape::text("x").palette(Palette::default());
1444 }
1445
1446 /// The committed content of a text shape (for assertions).
1447 fn committed(s: &Shape) -> String {
1448 s.inner.borrow().text.as_ref().unwrap().get_text()
1449 }
1450
1451 #[test]
1452 fn edit_methods_update_committed_text() {
1453 let t = Shape::text("Hello");
1454 t.append(" world");
1455 assert_eq!(committed(&t), "Hello world");
1456 t.prepend(">> ");
1457 assert_eq!(committed(&t), ">> Hello world");
1458 t.content("abc\ndef");
1459 assert_eq!(committed(&t), "abc\ndef");
1460 t.insert(3, "X");
1461 assert_eq!(committed(&t), "abcX\ndef");
1462 // rewrite [0, line(1)) — the whole first line together with its '\n'.
1463 t.rewrite(0, line(1), "Z");
1464 assert_eq!(committed(&t), "Zdef");
1465 }
1466
1467 #[test]
1468 fn rewrite_supports_char_line_and_infinite_bounds() {
1469 let t = Shape::text("foo\nbar\nbaz");
1470 t.rewrite(line(1), infinite(), "X");
1471 assert_eq!(committed(&t), "foo\nX");
1472
1473 let u = Shape::text("abcdef");
1474 u.rewrite(1, 4, "_");
1475 assert_eq!(committed(&u), "a_ef");
1476 }
1477
1478 #[test]
1479 fn text_edit_handle_derefs_to_shape() {
1480 // TextEdit dereferences into Shape — the static chain can continue.
1481 let t = Shape::text("x").content("y").font_size(40.0).letter_spacing(1.0);
1482 assert_eq!(t.font_size_signal().get(), 40.0);
1483 assert_eq!(committed(&t), "y");
1484 }
1485
1486 #[test]
1487 fn typing_reveals_progressively() {
1488 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1489 let t = Shape::text("").on(&tl);
1490 let stage = t.inner.borrow().text.as_ref().unwrap().stage_handle();
1491 // old="", new="abcd": common prefix 0 → everything is typed (0..4 chars).
1492 tl.parallel(vec![t.content("abcd").typing(1.0, Easing::Linear)]);
1493
1494 tl.seek(0.0);
1495 match &stage.borrow().clone() {
1496 text::TextStage::Typing { text, visible } => {
1497 assert_eq!(text, "abcd");
1498 assert!((*visible - 0.0).abs() < 1e-3, "visible={visible}");
1499 }
1500 other => panic!("expected Typing, got {other:?}"),
1501 }
1502 tl.seek(0.5);
1503 match &stage.borrow().clone() {
1504 text::TextStage::Typing { visible, .. } => {
1505 assert!((*visible - 2.0).abs() < 1e-3, "visible={visible}")
1506 }
1507 other => panic!("{other:?}"),
1508 }
1509 tl.seek(1.0);
1510 match &stage.borrow().clone() {
1511 text::TextStage::Typing { visible, .. } => {
1512 assert!((*visible - 4.0).abs() < 1e-3, "visible={visible}")
1513 }
1514 other => panic!("{other:?}"),
1515 };
1516 }
1517
1518 #[test]
1519 fn typing_keeps_common_prefix() {
1520 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1521 let t = Shape::text("Hello").on(&tl);
1522 let stage = t.inner.borrow().text.as_ref().unwrap().stage_handle();
1523 // append types only the tail: common prefix "Hello" (5) → 11.
1524 tl.parallel(vec![t.append(" world").typing(1.0, Easing::Linear)]);
1525 tl.seek(0.0);
1526 match &stage.borrow().clone() {
1527 text::TextStage::Typing { text, visible } => {
1528 assert_eq!(text, "Hello world");
1529 assert!((*visible - 5.0).abs() < 1e-3, "visible={visible}");
1530 }
1531 other => panic!("{other:?}"),
1532 };
1533 }
1534
1535 #[test]
1536 fn smooth_drives_crossfade_progress() {
1537 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1538 let t = Shape::text("old").on(&tl);
1539 let stage = t.inner.borrow().text.as_ref().unwrap().stage_handle();
1540 // The stage progress (clamped to `0..=1`) is what smoothing crossfades by.
1541 let progress = |from_want: &str, to_want: &str| match &stage.borrow().clone() {
1542 text::TextStage::Crossfade { from, to, p } => {
1543 assert_eq!(from, from_want);
1544 assert_eq!(to, to_want);
1545 *p
1546 }
1547 other => panic!("expected Crossfade, got {other:?}"),
1548 };
1549 tl.parallel(vec![t.content("new").smooth(1.0, Easing::Linear)]);
1550
1551 // Progress goes linearly 0 → 1, and `from`/`to` stay equal to the old/new all the time.
1552 tl.seek(0.0);
1553 assert!((progress("old", "new") - 0.0).abs() < 1e-3);
1554 tl.seek(0.25);
1555 assert!((progress("old", "new") - 0.25).abs() < 1e-3);
1556 tl.seek(0.5);
1557 assert!((progress("old", "new") - 0.5).abs() < 1e-3);
1558 tl.seek(1.0);
1559 assert!((progress("old", "new") - 1.0).abs() < 1e-3);
1560 }
1561
1562 #[test]
1563 fn parallel_text_edits_merge_into_one_morph() {
1564 // prepend + append of one text in a single parallel must merge into one
1565 // morph from original → final without the appended edges "leaking" before the start.
1566 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1567 let code = Shape::text("X").on(&tl);
1568 let stage = code.inner.borrow().text.as_ref().unwrap().stage_handle();
1569 tl.pause(1.0);
1570 tl.parallel(vec![
1571 code.prepend("{").smooth(0.5, Easing::Linear),
1572 code.append("}").smooth(0.5, Easing::Linear),
1573 ]);
1574
1575 // The committed text after both edits — the final one.
1576 assert_eq!(committed(&code), "{X}");
1577
1578 // Before the start (during the pause) — the original text static, without
1579 // braces: resetting an edit that hasn't started yet gives Shown(base), and
1580 // the group base is "X".
1581 tl.seek(0.5);
1582 match &stage.borrow().clone() {
1583 text::TextStage::Shown(s) => assert_eq!(s, "X", "before the start there should be no braces"),
1584 other => panic!("expected Shown(\"X\"), got {other:?}"),
1585 }
1586
1587 // In the middle of the transition — the same consistent morph with progress 0.5.
1588 tl.seek(1.25);
1589 match &stage.borrow().clone() {
1590 text::TextStage::Crossfade { from, to, p } => {
1591 assert_eq!(from, "X");
1592 assert_eq!(to, "{X}");
1593 assert!((*p - 0.5).abs() < 1e-3, "p={p}");
1594 }
1595 other => panic!("{other:?}"),
1596 };
1597 }
1598
1599 #[test]
1600 fn parallel_text_merge_is_order_independent() {
1601 // The same morph X→{X}, even if the parallel's elements are not in edit
1602 // order (base/final are computed from the chain of endpoints, not from position).
1603 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1604 let code = Shape::text("X").on(&tl);
1605 let stage = code.inner.borrow().text.as_ref().unwrap().stage_handle();
1606 // First append, then prepend, but in the vec — in reverse order.
1607 let app = code.append("}").smooth(0.5, Easing::Linear);
1608 let pre = code.prepend("{").smooth(0.5, Easing::Linear);
1609 tl.parallel(vec![pre, app]);
1610
1611 tl.seek(0.0);
1612 match &stage.borrow().clone() {
1613 text::TextStage::Crossfade { from, to, .. } => {
1614 assert_eq!(from, "X");
1615 assert_eq!(to, "{X}");
1616 }
1617 other => panic!("{other:?}"),
1618 };
1619 }
1620
1621 #[test]
1622 fn spawn_swaps_instantly_at_its_moment() {
1623 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1624 let t = Shape::text("old").on(&tl);
1625 let stage = t.inner.borrow().text.as_ref().unwrap().stage_handle();
1626 // A spawn after one second of pause — an instant swap with no duration.
1627 tl.pause(1.0);
1628 tl.sequence(vec![t.content("new").spawn()]);
1629
1630 tl.seek(0.5); // before the moment — the old one
1631 match &stage.borrow().clone() {
1632 text::TextStage::Shown(s) => assert_eq!(s, "old"),
1633 other => panic!("{other:?}"),
1634 }
1635 tl.seek(1.0); // at the moment of the spawn — the new one
1636 match &stage.borrow().clone() {
1637 text::TextStage::Shown(s) => assert_eq!(s, "new"),
1638 other => panic!("{other:?}"),
1639 };
1640 }
1641
1642 #[test]
1643 fn later_text_block_does_not_leak_before_earlier_one() {
1644 // Regression: with several blocks of edits to the SAME text, the shared
1645 // stage cell is reset by all of their tweens. The reset must leave the
1646 // state from before the FIRST edit (the original text), not the "from" of
1647 // the latest block (the committed text with edits from previous blocks
1648 // already applied). Otherwise braces and inserts leak onto the screen
1649 // before their own animation starts.
1650 let tl = Timeline::new(64, 64, Color::BLACK, 30.0);
1651 let code = Shape::text("println!();").on(&tl);
1652 let stage = code.inner.borrow().text.as_ref().unwrap().stage_handle();
1653
1654 tl.pause(1.0);
1655 tl.parallel(vec![
1656 code.prepend("{\n ").smooth(0.5, Easing::Linear),
1657 code.append("\n}").smooth(0.5, Easing::Linear),
1658 ]);
1659 tl.pause(1.0);
1660 tl.parallel(vec![
1661 code.insert(2, " let x = 1;\n").smooth(0.5, Easing::Linear),
1662 code.rewrite(0, 1, "(").smooth(0.5, Easing::Linear),
1663 ]);
1664
1665 // During the first pause (before any edit starts) — the original text
1666 // static, without braces and without inserts from either block: resetting
1667 // the earliest edit gives Shown(its "from"), which after merging the first
1668 // block equals the original text.
1669 tl.seek(0.5);
1670 match &stage.borrow().clone() {
1671 text::TextStage::Shown(s) => {
1672 assert_eq!(s, "println!();", "before the start there should be no edits");
1673 }
1674 other => panic!("expected Shown of the original text, got {other:?}"),
1675 }
1676
1677 // Between the blocks (the first played out, the second hasn't started yet)
1678 // — only the result of the first block is shown: at p=1 the crossfade
1679 // draws its own "to" — the text wrapped in braces, but without the second
1680 // block's inserts.
1681 tl.seek(2.0);
1682 match &stage.borrow().clone() {
1683 text::TextStage::Crossfade { from, to, p } => {
1684 assert_eq!(from, "println!();");
1685 assert_eq!(to, "{\n println!();\n}");
1686 assert!((*p - 1.0).abs() < 1e-3, "p={p}");
1687 assert!(!to.contains("let x"), "the second block's insert leaked: {to:?}");
1688 }
1689 other => panic!("{other:?}"),
1690 };
1691 }
1692
1693 #[test]
1694 fn children_are_stored() {
1695 let s = Shape::rect()
1696 .child(Shape::rect())
1697 .children(vec![Shape::rect(), Shape::rect()]);
1698 assert_eq!(s.inner.borrow().children.len(), 3);
1699 }
1700
1701 #[test]
1702 fn children_accepts_single_shape() {
1703 // `children` also accepts a single shape — handy for nesting a ready-made
1704 // group into another shape without wrapping it in a collection.
1705 let group = Shape::rect().children(vec![Shape::circle(), Shape::circle()]);
1706 let outer = Shape::rect().children(group);
1707 assert_eq!(outer.inner.borrow().children.len(), 1);
1708 assert_eq!(outer.inner.borrow().children[0].inner.borrow().children.len(), 2);
1709 }
1710
1711 #[test]
1712 fn shapes_nest_arbitrarily_deep() {
1713 // Nest shapes into each other many times — each level holds the next as
1714 // its single child.
1715 let depth = 64;
1716 let mut node = Shape::rect().size(8.0, 8.0);
1717 for _ in 0..depth {
1718 node = Shape::rect().children(node);
1719 }
1720 // Descend the tree and count the depth.
1721 let mut levels = 0;
1722 let mut cur = node;
1723 loop {
1724 let next = {
1725 let d = cur.inner.borrow();
1726 d.children.first().cloned()
1727 };
1728 match next {
1729 Some(child) => {
1730 levels += 1;
1731 cur = child;
1732 }
1733 None => break,
1734 }
1735 }
1736 assert_eq!(levels, depth);
1737 }
1738}