Skip to main content

plotkit_core/
figure.rs

1//! The top-level Figure container.
2//!
3//! A [`Figure`] owns one or more [`Axes`] (subplots) and orchestrates the
4//! full rendering pipeline: filling the figure background, computing the
5//! subplot grid layout, drawing the optional super-title, and delegating
6//! per-axes rendering.
7
8use crate::axes::{Axes, TwinSide};
9use crate::error::Result;
10use crate::layout;
11use crate::legend;
12use crate::primitives::{Affine, HAlign, Paint, Path, Point, Rect, TextStyle, VAlign};
13use crate::renderer::Renderer;
14use crate::theme::Theme;
15
16// ---------------------------------------------------------------------------
17// Constants
18// ---------------------------------------------------------------------------
19
20/// Default figure width in pixels.
21const DEFAULT_WIDTH: u32 = 800;
22
23/// Default figure height in pixels.
24const DEFAULT_HEIGHT: u32 = 600;
25
26/// Vertical space (in pixels) reserved for the suptitle when present.
27const SUPTITLE_RESERVED_HEIGHT: f64 = 30.0;
28
29/// Default outer margin (in pixels) around the subplot grid.
30const DEFAULT_MARGIN: f64 = 20.0;
31
32/// Default gap (in pixels) between subplot cells.
33const DEFAULT_GAP: f64 = 15.0;
34
35// ---------------------------------------------------------------------------
36// Figure
37// ---------------------------------------------------------------------------
38
39/// A figure is the top-level container for one or more axes (subplots).
40///
41/// The figure owns all axes, holds the overall dimensions, an optional
42/// super-title, and the visual theme. It implements the rendering pipeline
43/// described in `ARCHITECTURE.md`:
44///
45/// 1. Fill figure background.
46/// 2. Draw suptitle if set.
47/// 3. Compute subplot layout rectangles.
48/// 4. For each axes: delegate rendering with its allocated rectangle and theme.
49///
50/// # Ownership model (ADR-003)
51///
52/// `Figure` owns `Vec<Axes>` directly -- no `Rc`, no `RefCell`.
53/// [`add_subplot`](Figure::add_subplot) returns `&mut Axes`, and the borrow
54/// checker enforces single-axes mutation at compile time.
55///
56/// # Examples
57///
58/// ```no_run
59/// use plotkit_core::figure::Figure;
60///
61/// let mut fig = Figure::new();
62/// let ax = fig.add_subplot(1, 1, 1);
63/// // ax.plot(...)?;
64/// ```
65#[derive(Debug)]
66pub struct Figure {
67    /// The subplot axes owned by this figure, stored in insertion order.
68    axes: Vec<Axes>,
69    /// Width of the output image in pixels.
70    width: u32,
71    /// Height of the output image in pixels.
72    height: u32,
73    /// Optional overall title displayed above all subplots.
74    suptitle: Option<String>,
75    /// The visual theme applied to the figure and inherited by axes.
76    theme: Theme,
77    /// Subplot grid dimensions `(nrows, ncols)`, set by `add_subplot`.
78    subplot_grid: Option<(usize, usize)>,
79    /// Maps each primary axes index to its twin axes index (if any).
80    ///
81    /// `twin_map[i] = Some(j)` means axes `j` is a twin of axes `i`.
82    /// Twin axes are stored in the same `axes` vec but are rendered
83    /// overlaid on their parent's plot area rather than in their own
84    /// grid cell.
85    twin_map: Vec<Option<usize>>,
86}
87
88impl Figure {
89    /// Creates a new figure with default dimensions (800 x 600 pixels).
90    pub fn new() -> Self {
91        Self {
92            axes: Vec::new(),
93            width: DEFAULT_WIDTH,
94            height: DEFAULT_HEIGHT,
95            suptitle: None,
96            theme: Theme::default(),
97            subplot_grid: None,
98            twin_map: Vec::new(),
99        }
100    }
101
102    /// Creates a new figure with the specified dimensions in pixels.
103    pub fn with_size(width: u32, height: u32) -> Self {
104        Self {
105            axes: Vec::new(),
106            width,
107            height,
108            suptitle: None,
109            theme: Theme::default(),
110            subplot_grid: None,
111            twin_map: Vec::new(),
112        }
113    }
114
115    /// Returns the figure width in pixels.
116    pub fn width(&self) -> u32 {
117        self.width
118    }
119
120    /// Returns the figure height in pixels.
121    pub fn height(&self) -> u32 {
122        self.height
123    }
124
125    /// Adds a subplot and returns a mutable reference to it.
126    ///
127    /// Uses 1-based indexing in matplotlib style: `(nrows, ncols, index)`.
128    /// The index counts across rows first (row-major), starting at 1.
129    ///
130    /// If a subplot already exists at the given `index`, the existing axes
131    /// is returned without creating a duplicate. Otherwise a new [`Axes`]
132    /// is created, appended to the figure's axes list, and returned.
133    ///
134    /// # Panics
135    ///
136    /// Panics if `nrows`, `ncols`, or `index` is zero, or if `index` exceeds
137    /// `nrows * ncols`.
138    ///
139    /// # Examples
140    ///
141    /// ```no_run
142    /// use plotkit_core::figure::Figure;
143    ///
144    /// let mut fig = Figure::new();
145    /// let ax = fig.add_subplot(2, 2, 1); // top-left of a 2x2 grid
146    /// ```
147    pub fn add_subplot(&mut self, nrows: usize, ncols: usize, index: usize) -> &mut Axes {
148        assert!(nrows > 0, "nrows must be at least 1");
149        assert!(ncols > 0, "ncols must be at least 1");
150        assert!(index >= 1, "subplot index is 1-based; got 0");
151        assert!(
152            index <= nrows * ncols,
153            "subplot index {index} exceeds grid size {nrows}x{ncols} = {}",
154            nrows * ncols
155        );
156
157        // Store (or validate) the grid dimensions. If the grid was already set
158        // with different dimensions, update to the latest request -- this
159        // mirrors matplotlib's behaviour where later add_subplot calls can
160        // redefine the grid.
161        self.subplot_grid = Some((nrows, ncols));
162
163        // Convert 1-based index to 0-based for internal storage.
164        let zero_index = index - 1;
165
166        // Ensure the internal axes vec is large enough. If the user skips
167        // indices (e.g. add_subplot(2,2,3) without adding 1 and 2 first) we
168        // pad with default Axes so that positional indexing is consistent.
169        while self.axes.len() <= zero_index {
170            self.axes.push(Axes::new());
171        }
172
173        &mut self.axes[zero_index]
174    }
175
176    /// Sets the overall figure title (super-title), displayed above all
177    /// subplots.
178    ///
179    /// Returns `&mut Self` for builder-style chaining.
180    pub fn suptitle(&mut self, title: &str) -> &mut Self {
181        self.suptitle = Some(crate::text::format_markup(title));
182        self
183    }
184
185    /// Adjusts subplot spacing so axis labels, tick labels, and titles do not
186    /// overlap or clip — the matplotlib `tight_layout()` equivalent.
187    ///
188    /// plotkit computes margins automatically on every render (each axes
189    /// reserves exactly the space its labels and ticks need, via
190    /// [`crate::layout`]), so the layout is already "tight" by default. This
191    /// method exists for matplotlib API parity and to make the intent explicit;
192    /// it is safe to call and returns `&mut Self` for builder-style chaining.
193    pub fn tight_layout(&mut self) -> &mut Self {
194        // Layout is recomputed from scratch at render time, so there is no
195        // cached state to invalidate here. Provided for API compatibility.
196        self
197    }
198
199    /// Sets the visual theme for the entire figure.
200    ///
201    /// The theme is inherited by all axes during rendering unless an
202    /// individual axes has its own override.
203    ///
204    /// Returns `&mut Self` for builder-style chaining.
205    pub fn set_theme(&mut self, theme: Theme) -> &mut Self {
206        self.theme = theme;
207        self
208    }
209
210    /// Returns a reference to the figure's theme.
211    pub fn theme(&self) -> &Theme {
212        &self.theme
213    }
214
215    /// Returns mutable access to the axes at `index` (0-based).
216    ///
217    /// Returns `None` if `index` is out of bounds.
218    pub fn axes_mut(&mut self, index: usize) -> Option<&mut Axes> {
219        self.axes.get_mut(index)
220    }
221
222    /// Returns a shared reference to the axes at `index` (0-based).
223    ///
224    /// Returns `None` if `index` is out of bounds.
225    pub fn axes(&self, index: usize) -> Option<&Axes> {
226        self.axes.get(index)
227    }
228
229    /// Returns the number of axes (subplots) in this figure.
230    pub fn num_axes(&self) -> usize {
231        self.axes.len()
232    }
233
234    /// Creates a new figure with an `nrows × ncols` subplot grid.
235    ///
236    /// Axes are added in row-major order (index 0 = top-left). Access them
237    /// via `fig.axes_mut(index)` where index goes from 0 to `nrows*ncols - 1`.
238    ///
239    /// # Panics
240    ///
241    /// Panics if `nrows` or `ncols` is zero.
242    pub fn subplots(nrows: usize, ncols: usize) -> Self {
243        assert!(
244            nrows > 0 && ncols > 0,
245            "subplots: nrows and ncols must be > 0"
246        );
247        let mut fig = Self::new();
248        for i in 1..=(nrows * ncols) {
249            fig.add_subplot(nrows, ncols, i);
250        }
251        fig
252    }
253
254    /// Creates a new figure with an `nrows × ncols` subplot grid and custom size.
255    ///
256    /// # Panics
257    ///
258    /// Panics if `nrows` or `ncols` is zero.
259    pub fn subplots_with_size(nrows: usize, ncols: usize, width: u32, height: u32) -> Self {
260        assert!(
261            nrows > 0 && ncols > 0,
262            "subplots: nrows and ncols must be > 0"
263        );
264        let mut fig = Self::with_size(width, height);
265        for i in 1..=(nrows * ncols) {
266            fig.add_subplot(nrows, ncols, i);
267        }
268        fig
269    }
270
271    /// 2D indexing into the subplot grid.
272    ///
273    /// Returns `axes_mut(row * ncols + col)`, or `None` if out of bounds.
274    pub fn axes_grid(&mut self, row: usize, col: usize, ncols: usize) -> Option<&mut Axes> {
275        self.axes_mut(row * ncols + col)
276    }
277
278    // -----------------------------------------------------------------------
279    // Twin axes
280    // -----------------------------------------------------------------------
281
282    /// Creates a twin axes that shares the x-axis of the axes at `parent_index`
283    /// but has an independent y-axis drawn on the right side.
284    ///
285    /// # Panics
286    ///
287    /// Panics if `parent_index` is out of bounds or if the parent already
288    /// has a twin.
289    pub fn twinx(&mut self, parent_index: usize) -> &mut Axes {
290        self.add_twin(parent_index, TwinSide::Right)
291    }
292
293    /// Creates a twin axes that shares the y-axis of the axes at `parent_index`
294    /// but has an independent x-axis drawn on the top side.
295    ///
296    /// # Panics
297    ///
298    /// Panics if `parent_index` is out of bounds or if the parent already
299    /// has a twin.
300    pub fn twiny(&mut self, parent_index: usize) -> &mut Axes {
301        self.add_twin(parent_index, TwinSide::Top)
302    }
303
304    /// Internal helper that creates a twin axes of the given side.
305    fn add_twin(&mut self, parent_index: usize, side: TwinSide) -> &mut Axes {
306        assert!(
307            parent_index < self.axes.len(),
308            "twinx/twiny: parent_index {parent_index} is out of bounds (have {} axes)",
309            self.axes.len()
310        );
311
312        while self.twin_map.len() <= parent_index {
313            self.twin_map.push(None);
314        }
315        assert!(
316            self.twin_map[parent_index].is_none(),
317            "axes at index {parent_index} already has a twin"
318        );
319
320        let parent_color_index = self.axes[parent_index].color_index;
321        let twin = Axes::new_twin(side, parent_color_index);
322        let twin_index = self.axes.len();
323        self.axes.push(twin);
324        self.twin_map[parent_index] = Some(twin_index);
325
326        &mut self.axes[twin_index]
327    }
328
329    /// Returns the twin axes index for a given parent axes index, if one exists.
330    pub fn twin_of(&self, parent_index: usize) -> Option<usize> {
331        self.twin_map.get(parent_index).copied().flatten()
332    }
333
334    // -----------------------------------------------------------------------
335    // Rendering
336    // -----------------------------------------------------------------------
337
338    /// Renders the figure using the given renderer.
339    ///
340    /// This is the core rendering pipeline (per `ARCHITECTURE.md`):
341    ///
342    /// 1. Fill figure background with the theme's `figure_background` color.
343    /// 2. Draw suptitle if one has been set.
344    /// 3. Compute the subplot grid layout, producing one [`Rect`] per axes.
345    /// 4. For each axes, delegate to [`Axes::render`] with its assigned
346    ///    rectangle and the figure theme.
347    pub fn render(&self, renderer: &mut impl Renderer) {
348        let (w, h) = renderer.size();
349        let fw = w as f64;
350        let fh = h as f64;
351        let theme = &self.theme;
352
353        // ----- 1. Fill figure background -----------------------------------
354        let bg_path = Path::rect(Rect::new(0.0, 0.0, fw, fh));
355        renderer.fill_path(
356            &bg_path,
357            &Paint::new(theme.figure_background),
358            Affine::IDENTITY,
359        );
360
361        // ----- 2. Draw suptitle if set -------------------------------------
362        let top_offset = if let Some(ref title) = self.suptitle {
363            let style = TextStyle {
364                size: theme.title_size + 2.0, // suptitle slightly larger than axes title
365                color: theme.text_color,
366                weight: theme.title_weight,
367                family: theme.font_family.clone(),
368                halign: HAlign::Center,
369                valign: VAlign::Top,
370            };
371
372            let text_pos = Point::new(fw / 2.0, DEFAULT_MARGIN * 0.5);
373            renderer.draw_text(title, text_pos, &style, Affine::IDENTITY);
374
375            SUPTITLE_RESERVED_HEIGHT
376        } else {
377            0.0
378        };
379
380        // ----- 3. Compute subplot layout -----------------------------------
381        // Reduce the available height by the suptitle offset so that subplots
382        // are laid out below the suptitle instead of overlapping it.
383        let grid = self.subplot_grid.unwrap_or((1, 1));
384        let rects = layout::compute_subplot_rects(
385            fw,
386            fh - top_offset,
387            grid.0,
388            grid.1,
389            DEFAULT_MARGIN,
390            DEFAULT_GAP,
391        )
392        .into_iter()
393        .map(|mut r| {
394            r.y += top_offset;
395            r
396        })
397        .collect::<Vec<_>>();
398
399        // ----- 4. Render each axes -----------------------------------------
400        // Build a set of twin indices so we skip them in the primary loop.
401        let twin_indices: std::collections::HashSet<usize> =
402            self.twin_map.iter().filter_map(|opt| *opt).collect();
403
404        for (i, axes) in self.axes.iter().enumerate() {
405            // Skip twin axes -- they are rendered after their parent.
406            if twin_indices.contains(&i) {
407                continue;
408            }
409
410            if let Some(rect) = rects.get(i) {
411                let has_twin = self.twin_map.get(i).copied().flatten();
412
413                if let Some(twin_idx) = has_twin {
414                    // Render the primary axes with legend suppressed so the
415                    // Figure can draw a combined legend from both axes.
416                    axes.render_primary(renderer, *rect, theme, true);
417
418                    if let Some(twin_axes) = self.axes.get(twin_idx) {
419                        let plot_area = axes.compute_plot_area(rect);
420                        twin_axes.render_twin(renderer, plot_area, *rect, theme);
421
422                        // Draw a combined legend that includes entries from
423                        // both the primary and twin axes.
424                        if axes.show_legend || twin_axes.show_legend {
425                            let mut entries = axes.collect_legend_entries();
426                            entries.extend(twin_axes.collect_legend_entries());
427                            let loc = if axes.show_legend {
428                                axes.legend_loc
429                            } else {
430                                twin_axes.legend_loc
431                            };
432                            legend::draw_legend(renderer, &entries, &plot_area, loc, theme);
433                        }
434                    }
435                } else {
436                    // No twin -- render normally.
437                    axes.render(renderer, *rect, theme);
438                }
439            }
440        }
441    }
442
443    /// Renders the figure using the given renderer and returns the encoded
444    /// output bytes (PNG, SVG, PDF, etc., depending on the renderer).
445    ///
446    /// This convenience method calls [`render`](Figure::render) and then
447    /// [`Renderer::finalize`] to produce the final byte output.
448    pub fn render_to<R: Renderer>(&self, mut renderer: R) -> Vec<u8> {
449        self.render(&mut renderer);
450        renderer.finalize()
451    }
452
453    /// Saves the figure to a file using the provided renderer.
454    ///
455    /// The renderer determines the output format. The encoded bytes are
456    /// written to `path` atomically via [`std::fs::write`].
457    pub fn save_with<R: Renderer>(
458        &self,
459        renderer: R,
460        path: impl AsRef<std::path::Path>,
461    ) -> Result<()> {
462        let bytes = self.render_to(renderer);
463        std::fs::write(path, bytes)?;
464        Ok(())
465    }
466}
467
468impl Default for Figure {
469    fn default() -> Self {
470        Self::new()
471    }
472}
473
474// ---------------------------------------------------------------------------
475// Tests
476// ---------------------------------------------------------------------------
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::primitives::Color;
482
483    #[test]
484    fn new_figure_has_default_dimensions() {
485        let fig = Figure::new();
486        assert_eq!(fig.width(), DEFAULT_WIDTH);
487        assert_eq!(fig.height(), DEFAULT_HEIGHT);
488    }
489
490    #[test]
491    fn with_size_sets_dimensions() {
492        let fig = Figure::with_size(1024, 768);
493        assert_eq!(fig.width(), 1024);
494        assert_eq!(fig.height(), 768);
495    }
496
497    #[test]
498    fn default_figure_has_no_axes() {
499        let fig = Figure::new();
500        assert_eq!(fig.num_axes(), 0);
501    }
502
503    #[test]
504    fn add_subplot_creates_axes() {
505        let mut fig = Figure::new();
506        let _ax = fig.add_subplot(1, 1, 1);
507        assert_eq!(fig.num_axes(), 1);
508    }
509
510    #[test]
511    fn add_subplot_returns_same_axes_on_repeat() {
512        let mut fig = Figure::new();
513        fig.add_subplot(2, 2, 1);
514        fig.add_subplot(2, 2, 1); // same index, should not duplicate
515        assert_eq!(fig.num_axes(), 1);
516    }
517
518    #[test]
519    fn add_subplot_pads_for_skipped_indices() {
520        let mut fig = Figure::new();
521        fig.add_subplot(2, 2, 3); // skip indices 1 and 2
522        assert_eq!(fig.num_axes(), 3); // indices 0, 1, 2 all exist
523    }
524
525    #[test]
526    #[should_panic(expected = "nrows must be at least 1")]
527    fn add_subplot_panics_on_zero_rows() {
528        let mut fig = Figure::new();
529        fig.add_subplot(0, 1, 1);
530    }
531
532    #[test]
533    #[should_panic(expected = "ncols must be at least 1")]
534    fn add_subplot_panics_on_zero_cols() {
535        let mut fig = Figure::new();
536        fig.add_subplot(1, 0, 1);
537    }
538
539    #[test]
540    #[should_panic(expected = "subplot index is 1-based")]
541    fn add_subplot_panics_on_zero_index() {
542        let mut fig = Figure::new();
543        fig.add_subplot(1, 1, 0);
544    }
545
546    #[test]
547    #[should_panic(expected = "subplot index 5 exceeds grid size")]
548    fn add_subplot_panics_on_index_out_of_range() {
549        let mut fig = Figure::new();
550        fig.add_subplot(2, 2, 5);
551    }
552
553    #[test]
554    fn suptitle_sets_title() {
555        let mut fig = Figure::new();
556        fig.suptitle("My Figure");
557        assert_eq!(fig.suptitle, Some("My Figure".to_string()));
558    }
559
560    #[test]
561    fn suptitle_returns_self_for_chaining() {
562        let mut fig = Figure::new();
563        fig.suptitle("Title 1").suptitle("Title 2");
564        assert_eq!(fig.suptitle, Some("Title 2".to_string()));
565    }
566
567    #[test]
568    fn set_theme_updates_theme() {
569        let mut fig = Figure::new();
570        let dark = Theme::dark();
571        fig.set_theme(dark);
572        assert_eq!(fig.theme().figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
573    }
574
575    #[test]
576    fn theme_returns_reference() {
577        let fig = Figure::new();
578        assert_eq!(fig.theme().figure_background, Color::WHITE);
579    }
580
581    #[test]
582    fn axes_mut_returns_none_for_out_of_bounds() {
583        let mut fig = Figure::new();
584        assert!(fig.axes_mut(0).is_none());
585    }
586
587    #[test]
588    fn axes_mut_returns_some_for_valid_index() {
589        let mut fig = Figure::new();
590        fig.add_subplot(1, 1, 1);
591        assert!(fig.axes_mut(0).is_some());
592    }
593
594    #[test]
595    fn axes_returns_shared_reference() {
596        let mut fig = Figure::new();
597        fig.add_subplot(1, 1, 1);
598        assert!(fig.axes(0).is_some());
599        assert!(fig.axes(1).is_none());
600    }
601
602    #[test]
603    fn default_impl_matches_new() {
604        let from_new = Figure::new();
605        let from_default = Figure::default();
606        assert_eq!(from_new.width(), from_default.width());
607        assert_eq!(from_new.height(), from_default.height());
608        assert_eq!(from_new.num_axes(), from_default.num_axes());
609    }
610
611    #[test]
612    fn multiple_subplots_in_grid() {
613        let mut fig = Figure::new();
614        fig.add_subplot(2, 3, 1);
615        fig.add_subplot(2, 3, 4);
616        fig.add_subplot(2, 3, 6);
617        assert_eq!(fig.num_axes(), 6); // padded to fill up to index 6
618        assert_eq!(fig.subplot_grid, Some((2, 3)));
619    }
620
621    // -----------------------------------------------------------------------
622    // Twin axes tests
623    // -----------------------------------------------------------------------
624
625    #[test]
626    fn twinx_creates_new_axes() {
627        let mut fig = Figure::new();
628        fig.add_subplot(1, 1, 1);
629        let ax2 = fig.twinx(0);
630        assert!(ax2.is_twin());
631        assert_eq!(ax2.twin_side(), Some(TwinSide::Right));
632        assert_eq!(fig.num_axes(), 2);
633    }
634
635    #[test]
636    fn twiny_creates_new_axes() {
637        let mut fig = Figure::new();
638        fig.add_subplot(1, 1, 1);
639        let ax2 = fig.twiny(0);
640        assert!(ax2.is_twin());
641        assert_eq!(ax2.twin_side(), Some(TwinSide::Top));
642        assert_eq!(fig.num_axes(), 2);
643    }
644
645    #[test]
646    fn twinx_links_to_parent() {
647        let mut fig = Figure::new();
648        fig.add_subplot(1, 1, 1);
649        fig.twinx(0);
650        assert_eq!(fig.twin_of(0), Some(1));
651    }
652
653    #[test]
654    fn twin_has_independent_ylimits() {
655        let mut fig = Figure::new();
656        fig.add_subplot(1, 1, 1);
657        fig.axes_mut(0).unwrap().set_ylim(0.0, 100.0);
658        fig.twinx(0);
659        fig.axes_mut(1).unwrap().set_ylim(900.0, 1100.0);
660        assert_eq!(fig.axes(0).unwrap().ylim, Some((0.0, 100.0)));
661        assert_eq!(fig.axes(1).unwrap().ylim, Some((900.0, 1100.0)));
662    }
663
664    #[test]
665    fn twinx_inherits_color_cycle() {
666        let mut fig = Figure::new();
667        let ax = fig.add_subplot(1, 1, 1);
668        ax.plot(vec![1.0, 2.0], vec![3.0, 4.0]).unwrap();
669        let ax2 = fig.twinx(0);
670        ax2.plot(vec![1.0, 2.0], vec![5.0, 6.0]).unwrap();
671        let twin = fig.axes(1).unwrap();
672        match &twin.artists[0] {
673            crate::artist::Artist::Line(a) => {
674                assert_eq!(a.color, Color::TABLEAU_10[1]);
675            }
676            _ => panic!("expected Line artist"),
677        }
678    }
679
680    #[test]
681    fn primary_axes_is_not_twin() {
682        let mut fig = Figure::new();
683        fig.add_subplot(1, 1, 1);
684        assert!(!fig.axes(0).unwrap().is_twin());
685        assert_eq!(fig.axes(0).unwrap().twin_side(), None);
686    }
687
688    #[test]
689    fn twin_of_returns_none_when_no_twin() {
690        let mut fig = Figure::new();
691        fig.add_subplot(1, 1, 1);
692        assert_eq!(fig.twin_of(0), None);
693    }
694
695    #[test]
696    #[should_panic(expected = "parent_index 5 is out of bounds")]
697    fn twinx_panics_on_out_of_bounds() {
698        let mut fig = Figure::new();
699        fig.add_subplot(1, 1, 1);
700        fig.twinx(5);
701    }
702
703    #[test]
704    #[should_panic(expected = "already has a twin")]
705    fn twinx_panics_on_duplicate_twin() {
706        let mut fig = Figure::new();
707        fig.add_subplot(1, 1, 1);
708        fig.twinx(0);
709        fig.twinx(0);
710    }
711
712    #[test]
713    fn multiple_subplots_with_different_twins() {
714        let mut fig = Figure::new();
715        fig.add_subplot(1, 2, 1);
716        fig.add_subplot(1, 2, 2);
717        fig.twinx(0);
718        fig.twiny(1);
719        assert_eq!(fig.twin_of(0), Some(2));
720        assert_eq!(fig.twin_of(1), Some(3));
721        assert_eq!(fig.num_axes(), 4);
722    }
723}