Skip to main content

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}