Skip to main content

dear_implot/
advanced.rs

1//! Advanced plotting features for complex visualizations
2//!
3//! This module provides high-level functionality for creating complex plots
4//! with multiple subplots, legends, and advanced layout management.
5
6use crate::context::PlotScopeGuard;
7use crate::{AxisFlags, YAxis, plots::PlotError, sys};
8use crate::{PlotContextBinding, PlotUi};
9use std::ffi::CString;
10use std::marker::PhantomData;
11use std::rc::Rc;
12
13fn validate_size(caller: &str, size: [f32; 2]) -> Result<(), PlotError> {
14    if size[0].is_finite() && size[1].is_finite() {
15        Ok(())
16    } else {
17        Err(PlotError::InvalidData(format!(
18            "{caller} size must be finite"
19        )))
20    }
21}
22
23fn count_to_i32(caller: &str, name: &str, value: usize) -> Result<i32, PlotError> {
24    if value == 0 {
25        return Err(PlotError::InvalidData(format!(
26            "{caller} {name} must be positive"
27        )));
28    }
29
30    i32::try_from(value)
31        .map_err(|_| PlotError::InvalidData(format!("{caller} {name} exceeded ImPlot's i32 range")))
32}
33
34fn validate_ratios(caller: &str, name: &str, ratios: &[f32]) -> Result<(), PlotError> {
35    if ratios.iter().all(|value| value.is_finite() && *value > 0.0) {
36        Ok(())
37    } else {
38        Err(PlotError::InvalidData(format!(
39            "{caller} {name} must contain only positive finite values"
40        )))
41    }
42}
43
44fn validate_range(caller: &str, min: f64, max: f64) -> Result<(), PlotError> {
45    if min.is_finite() && max.is_finite() && min != max {
46        Ok(())
47    } else {
48        Err(PlotError::InvalidData(format!(
49            "{caller} range values must be finite and distinct"
50        )))
51    }
52}
53
54/// Multi-plot layout manager for creating subplot grids
55pub struct SubplotGrid<'a> {
56    title: &'a str,
57    rows: usize,
58    cols: usize,
59    size: Option<[f32; 2]>,
60    flags: SubplotFlags,
61    row_ratios: Option<Vec<f32>>,
62    col_ratios: Option<Vec<f32>>,
63}
64
65bitflags::bitflags! {
66    /// Flags for subplot configuration
67    pub struct SubplotFlags: u32 {
68        const NONE = sys::ImPlotSubplotFlags_None as u32;
69        const NO_TITLE = sys::ImPlotSubplotFlags_NoTitle as u32;
70        const NO_LEGEND = sys::ImPlotSubplotFlags_NoLegend as u32;
71        const NO_MENUS = sys::ImPlotSubplotFlags_NoMenus as u32;
72        const NO_RESIZE = sys::ImPlotSubplotFlags_NoResize as u32;
73        const NO_ALIGN = sys::ImPlotSubplotFlags_NoAlign as u32;
74        const SHARE_ITEMS = sys::ImPlotSubplotFlags_ShareItems as u32;
75        const LINK_ROWS = sys::ImPlotSubplotFlags_LinkRows as u32;
76        const LINK_COLS = sys::ImPlotSubplotFlags_LinkCols as u32;
77        const LINK_ALL_X = sys::ImPlotSubplotFlags_LinkAllX as u32;
78        const LINK_ALL_Y = sys::ImPlotSubplotFlags_LinkAllY as u32;
79        const COLUMN_MAJOR = sys::ImPlotSubplotFlags_ColMajor as u32;
80    }
81}
82
83impl<'a> SubplotGrid<'a> {
84    /// Create a new subplot grid
85    pub fn new(title: &'a str, rows: usize, cols: usize) -> Self {
86        Self {
87            title,
88            rows,
89            cols,
90            size: None,
91            flags: SubplotFlags::NONE,
92            row_ratios: None,
93            col_ratios: None,
94        }
95    }
96
97    /// Set the size of the subplot grid
98    pub fn with_size(mut self, size: [f32; 2]) -> Self {
99        self.size = Some(size);
100        self
101    }
102
103    /// Set subplot flags
104    pub fn with_flags(mut self, flags: SubplotFlags) -> Self {
105        self.flags = flags;
106        self
107    }
108
109    /// Set row height ratios
110    pub fn with_row_ratios(mut self, ratios: &[f32]) -> Self {
111        self.row_ratios = if ratios.is_empty() {
112            None
113        } else {
114            Some(ratios.to_vec())
115        };
116        self
117    }
118
119    /// Set column width ratios
120    pub fn with_col_ratios(mut self, ratios: &[f32]) -> Self {
121        self.col_ratios = if ratios.is_empty() {
122            None
123        } else {
124            Some(ratios.to_vec())
125        };
126        self
127    }
128
129    /// Begin the subplot grid on a bound ImPlot UI and return a token.
130    pub fn begin<'ui>(self, plot_ui: &'ui PlotUi<'ui>) -> Result<SubplotToken<'ui>, PlotError> {
131        let rows = count_to_i32("SubplotGrid::begin()", "rows", self.rows)?;
132        let cols = count_to_i32("SubplotGrid::begin()", "cols", self.cols)?;
133        let title_cstr =
134            CString::new(self.title).map_err(|e| PlotError::StringConversion(e.to_string()))?;
135
136        let size = self.size.unwrap_or([-1.0, -1.0]);
137        validate_size("SubplotGrid::begin()", size)?;
138        let size_vec = sys::ImVec2_c {
139            x: size[0],
140            y: size[1],
141        };
142
143        // The C API takes `float*` for ratios. Keep owned copies alive in the token to avoid
144        // casting away constness and to stay sound even if the backend ever writes to them.
145        let mut row_ratios = self.row_ratios;
146        let mut col_ratios = self.col_ratios;
147        if let Some(row_ratios) = &row_ratios {
148            if row_ratios.len() != self.rows {
149                return Err(PlotError::InvalidData(format!(
150                    "SubplotGrid::begin() row_ratios length must equal rows ({})",
151                    self.rows
152                )));
153            }
154            validate_ratios("SubplotGrid::begin()", "row_ratios", row_ratios)?;
155        }
156        if let Some(col_ratios) = &col_ratios {
157            if col_ratios.len() != self.cols {
158                return Err(PlotError::InvalidData(format!(
159                    "SubplotGrid::begin() col_ratios length must equal cols ({})",
160                    self.cols
161                )));
162            }
163            validate_ratios("SubplotGrid::begin()", "col_ratios", col_ratios)?;
164        }
165        let row_ratios_ptr = row_ratios
166            .as_mut()
167            .map(|r| r.as_mut_ptr())
168            .unwrap_or(std::ptr::null_mut());
169        let col_ratios_ptr = col_ratios
170            .as_mut()
171            .map(|c| c.as_mut_ptr())
172            .unwrap_or(std::ptr::null_mut());
173
174        plot_ui.with_bound_context(|| {
175            let success = unsafe {
176                sys::ImPlot_BeginSubplots(
177                    title_cstr.as_ptr(),
178                    rows,
179                    cols,
180                    size_vec,
181                    self.flags.bits() as i32,
182                    row_ratios_ptr,
183                    col_ratios_ptr,
184                )
185            };
186
187            if success {
188                Ok(SubplotToken {
189                    binding: plot_ui.context.binding(),
190                    _title: title_cstr,
191                    _row_ratios: row_ratios,
192                    _col_ratios: col_ratios,
193                    _lifetime: PhantomData,
194                    _not_send_or_sync: PhantomData,
195                })
196            } else {
197                Err(PlotError::PlotCreationFailed(
198                    "Failed to begin subplots".to_string(),
199                ))
200            }
201        })
202    }
203}
204
205/// Token representing an active subplot grid
206pub struct SubplotToken<'ui> {
207    binding: PlotContextBinding,
208    _title: CString,
209    _row_ratios: Option<Vec<f32>>,
210    _col_ratios: Option<Vec<f32>>,
211    _lifetime: PhantomData<&'ui PlotUi<'ui>>,
212    _not_send_or_sync: PhantomData<Rc<()>>,
213}
214
215impl SubplotToken<'_> {
216    /// End the subplot grid
217    pub fn end(self) {
218        // The actual ending happens in Drop.
219    }
220}
221
222impl Drop for SubplotToken<'_> {
223    fn drop(&mut self) {
224        let _ = self
225            .binding
226            .try_with_bound_context(|| unsafe { sys::ImPlot_EndSubplots() });
227    }
228}
229
230/// Multi-axis plot support
231pub struct MultiAxisPlot<'a> {
232    title: &'a str,
233    size: Option<[f32; 2]>,
234    y_axes: Vec<YAxisConfig<'a>>,
235}
236
237/// Configuration for a Y-axis
238pub struct YAxisConfig<'a> {
239    pub label: Option<&'a str>,
240    pub flags: AxisFlags,
241    pub range: Option<(f64, f64)>,
242}
243
244impl<'a> MultiAxisPlot<'a> {
245    /// Create a new multi-axis plot
246    pub fn new(title: &'a str) -> Self {
247        Self {
248            title,
249            size: None,
250            y_axes: Vec::new(),
251        }
252    }
253
254    /// Set the plot size
255    pub fn with_size(mut self, size: [f32; 2]) -> Self {
256        self.size = Some(size);
257        self
258    }
259
260    /// Add a Y-axis
261    pub fn add_y_axis(mut self, config: YAxisConfig<'a>) -> Self {
262        self.y_axes.push(config);
263        self
264    }
265
266    /// Begin the multi-axis plot on a bound ImPlot UI.
267    pub fn begin<'ui>(self, plot_ui: &'ui PlotUi<'ui>) -> Result<MultiAxisToken<'ui>, PlotError> {
268        let title_cstr =
269            CString::new(self.title).map_err(|e| PlotError::StringConversion(e.to_string()))?;
270
271        for axis in &self.y_axes {
272            if let Some(label) = axis.label
273                && label.contains('\0')
274            {
275                return Err(PlotError::StringConversion(
276                    "Axis label contained an interior NUL byte".to_string(),
277                ));
278            }
279            if let Some((min, max)) = axis.range {
280                validate_range("MultiAxisPlot::begin()", min, max)?;
281            }
282        }
283        if self.y_axes.len() > 3 {
284            return Err(PlotError::InvalidData(
285                "MultiAxisPlot::begin() supports at most 3 Y axes".to_string(),
286            ));
287        }
288
289        let size = self.size.unwrap_or([-1.0, -1.0]);
290        validate_size("MultiAxisPlot::begin()", size)?;
291        let size_vec = sys::ImVec2_c {
292            x: size[0],
293            y: size[1],
294        };
295
296        plot_ui.with_bound_context(|| {
297            let success = unsafe { sys::ImPlot_BeginPlot(title_cstr.as_ptr(), size_vec, 0) };
298
299            if success {
300                let mut axis_labels: Vec<CString> = Vec::new();
301
302                // Setup Y-axes (Y1..), matching `token.set_y_axis(YAxis::Y*)` convention.
303                for (i, axis_config) in self.y_axes.iter().enumerate() {
304                    let label_ptr = if let Some(label) = axis_config.label {
305                        let cstr = CString::new(label)
306                            .map_err(|e| PlotError::StringConversion(e.to_string()))?;
307                        let ptr = cstr.as_ptr();
308                        axis_labels.push(cstr);
309                        ptr
310                    } else {
311                        std::ptr::null()
312                    };
313
314                    unsafe {
315                        let axis_enum = (i as i32) + 3; // ImAxis_Y1 = 3
316                        sys::ImPlot_SetupAxis(
317                            axis_enum,
318                            label_ptr,
319                            axis_config.flags.bits() as i32,
320                        );
321
322                        if let Some((min, max)) = axis_config.range {
323                            sys::ImPlot_SetupAxisLimits(axis_enum, min, max, 0);
324                        }
325                    }
326                }
327
328                Ok(MultiAxisToken {
329                    binding: plot_ui.context.binding(),
330                    _title: title_cstr,
331                    _axis_labels: axis_labels,
332                    _scope: PlotScopeGuard::new(),
333                    _lifetime: PhantomData,
334                    _not_send_or_sync: PhantomData,
335                })
336            } else {
337                Err(PlotError::PlotCreationFailed(
338                    "Failed to begin multi-axis plot".to_string(),
339                ))
340            }
341        })
342    }
343}
344
345/// Token representing an active multi-axis plot
346pub struct MultiAxisToken<'ui> {
347    binding: PlotContextBinding,
348    _title: CString,
349    _axis_labels: Vec<CString>,
350    _scope: PlotScopeGuard,
351    _lifetime: PhantomData<&'ui PlotUi<'ui>>,
352    _not_send_or_sync: PhantomData<Rc<()>>,
353}
354
355impl MultiAxisToken<'_> {
356    /// Set the current Y-axis for subsequent plots
357    pub fn set_y_axis(&self, axis: YAxis) {
358        self.binding
359            .with_bound_context("dear-implot: MultiAxisToken", || {
360                unsafe {
361                    sys::ImPlot_SetAxes(
362                        0, // ImAxis_X1
363                        axis as i32,
364                    );
365                }
366            })
367    }
368
369    /// Set the current raw Y-axis for subsequent plots.
370    ///
371    /// # Safety
372    ///
373    /// `axis` must be a valid ImPlot Y-axis value for the active plot. Passing an
374    /// out-of-range value lets ImPlot index internal axis arrays out of bounds.
375    pub unsafe fn set_y_axis_unchecked(&self, axis: sys::ImAxis) {
376        self.binding
377            .with_bound_context("dear-implot: MultiAxisToken", || {
378                unsafe {
379                    sys::ImPlot_SetAxes(
380                        0, // ImAxis_X1
381                        axis,
382                    );
383                }
384            })
385    }
386
387    /// End the multi-axis plot
388    pub fn end(self) {
389        // The actual ending happens in Drop.
390    }
391}
392
393impl Drop for MultiAxisToken<'_> {
394    fn drop(&mut self) {
395        let _ = self
396            .binding
397            .try_with_bound_context(|| unsafe { sys::ImPlot_EndPlot() });
398    }
399}
400
401/// Legend management utilities
402pub struct LegendManager;
403
404impl LegendManager {
405    /// Setup legend with custom position and flags
406    pub fn setup(plot_ui: &PlotUi<'_>, location: LegendLocation, flags: LegendFlags) {
407        plot_ui.with_bound_context(|| unsafe {
408            sys::ImPlot_SetupLegend(location as i32, flags.bits() as i32);
409        })
410    }
411
412    /// Begin a custom legend popup on a bound ImPlot UI.
413    pub fn begin_custom<'ui>(
414        plot_ui: &'ui PlotUi<'ui>,
415        label: &str,
416        _size: [f32; 2],
417    ) -> Result<LegendToken<'ui>, PlotError> {
418        let label_cstr =
419            CString::new(label).map_err(|e| PlotError::StringConversion(e.to_string()))?;
420
421        plot_ui.with_bound_context(|| {
422            let success = unsafe {
423                sys::ImPlot_BeginLegendPopup(
424                    label_cstr.as_ptr(),
425                    1, // mouse button
426                )
427            };
428
429            if success {
430                Ok(LegendToken {
431                    binding: plot_ui.context.binding(),
432                    _label: label_cstr,
433                    _lifetime: PhantomData,
434                    _not_send_or_sync: PhantomData,
435                })
436            } else {
437                Err(PlotError::PlotCreationFailed(
438                    "Failed to begin legend".to_string(),
439                ))
440            }
441        })
442    }
443}
444
445/// Legend location options (ImPlotLocation)
446#[repr(i32)]
447pub enum LegendLocation {
448    Center = sys::ImPlotLocation_Center as i32,
449    North = sys::ImPlotLocation_North as i32,
450    South = sys::ImPlotLocation_South as i32,
451    West = sys::ImPlotLocation_West as i32,
452    East = sys::ImPlotLocation_East as i32,
453    NorthWest = sys::ImPlotLocation_NorthWest as i32,
454    NorthEast = sys::ImPlotLocation_NorthEast as i32,
455    SouthWest = sys::ImPlotLocation_SouthWest as i32,
456    SouthEast = sys::ImPlotLocation_SouthEast as i32,
457}
458
459bitflags::bitflags! {
460    /// Flags for legend configuration (ImPlotLegendFlags)
461    pub struct LegendFlags: u32 {
462        const NONE              = sys::ImPlotLegendFlags_None as u32;
463        const NO_BUTTONS        = sys::ImPlotLegendFlags_NoButtons as u32;
464        const NO_HIGHLIGHT_ITEM = sys::ImPlotLegendFlags_NoHighlightItem as u32;
465        const NO_HIGHLIGHT_AXIS = sys::ImPlotLegendFlags_NoHighlightAxis as u32;
466        const NO_MENUS          = sys::ImPlotLegendFlags_NoMenus as u32;
467        const OUTSIDE           = sys::ImPlotLegendFlags_Outside as u32;
468        const HORIZONTAL        = sys::ImPlotLegendFlags_Horizontal as u32;
469        const SORT              = sys::ImPlotLegendFlags_Sort as u32;
470        const REVERSE           = sys::ImPlotLegendFlags_Reverse as u32;
471    }
472}
473
474/// Token representing an active legend
475pub struct LegendToken<'ui> {
476    binding: PlotContextBinding,
477    _label: CString,
478    _lifetime: PhantomData<&'ui PlotUi<'ui>>,
479    _not_send_or_sync: PhantomData<Rc<()>>,
480}
481
482impl LegendToken<'_> {
483    /// End the legend
484    pub fn end(self) {
485        // The actual ending happens in Drop.
486    }
487}
488
489impl Drop for LegendToken<'_> {
490    fn drop(&mut self) {
491        let _ = self
492            .binding
493            .try_with_bound_context(|| unsafe { sys::ImPlot_EndLegendPopup() });
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::{PlotError, SubplotFlags, SubplotGrid};
500    use crate::{PlotContext, sys};
501    use std::sync::{Mutex, OnceLock};
502
503    fn test_guard() -> std::sync::MutexGuard<'static, ()> {
504        static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
505        GUARD
506            .get_or_init(|| Mutex::new(()))
507            .lock()
508            .unwrap_or_else(|err| err.into_inner())
509    }
510
511    fn setup_context() -> (dear_imgui_rs::Context, PlotContext) {
512        let mut imgui = dear_imgui_rs::Context::create();
513        imgui
514            .font_atlas()
515            .try_claim_legacy_renderer()
516            .expect("headless test requires the legacy font-atlas capability")
517            .build();
518        imgui.io_mut().set_display_size([256.0, 256.0]);
519        imgui.io_mut().set_delta_time(1.0 / 60.0);
520        let plot = PlotContext::create(&imgui);
521        (imgui, plot)
522    }
523
524    fn invalid_data_message(err: PlotError) -> String {
525        match err {
526            PlotError::InvalidData(message) => message,
527            other => panic!("expected invalid data error, got {other:?}"),
528        }
529    }
530
531    fn expect_invalid_data(result: Result<super::SubplotToken<'_>, PlotError>) -> String {
532        match result {
533            Err(err) => invalid_data_message(err),
534            Ok(_) => panic!("expected SubplotGrid::begin() to reject invalid input"),
535        }
536    }
537
538    #[test]
539    fn subplot_flags_match_the_upstream_contract() {
540        let cases = [
541            (SubplotFlags::NONE, sys::ImPlotSubplotFlags_None),
542            (SubplotFlags::NO_TITLE, sys::ImPlotSubplotFlags_NoTitle),
543            (SubplotFlags::NO_LEGEND, sys::ImPlotSubplotFlags_NoLegend),
544            (SubplotFlags::NO_MENUS, sys::ImPlotSubplotFlags_NoMenus),
545            (SubplotFlags::NO_RESIZE, sys::ImPlotSubplotFlags_NoResize),
546            (SubplotFlags::NO_ALIGN, sys::ImPlotSubplotFlags_NoAlign),
547            (
548                SubplotFlags::SHARE_ITEMS,
549                sys::ImPlotSubplotFlags_ShareItems,
550            ),
551            (SubplotFlags::LINK_ROWS, sys::ImPlotSubplotFlags_LinkRows),
552            (SubplotFlags::LINK_COLS, sys::ImPlotSubplotFlags_LinkCols),
553            (SubplotFlags::LINK_ALL_X, sys::ImPlotSubplotFlags_LinkAllX),
554            (SubplotFlags::LINK_ALL_Y, sys::ImPlotSubplotFlags_LinkAllY),
555            (SubplotFlags::COLUMN_MAJOR, sys::ImPlotSubplotFlags_ColMajor),
556        ];
557        for (flags, expected) in cases {
558            assert_eq!(flags.bits(), expected as u32);
559        }
560    }
561
562    #[test]
563    fn subplot_grid_forwards_resize_link_and_layout_flags() {
564        let _guard = test_guard();
565        let (mut imgui, plot) = setup_context();
566        let frame = imgui.begin_frame();
567        let plot_ui = plot.get_plot_ui(frame.ui());
568        let flags = SubplotFlags::NO_RESIZE | SubplotFlags::LINK_ROWS | SubplotFlags::COLUMN_MAJOR;
569        let _subplot = SubplotGrid::new("flag forwarding", 1, 1)
570            .with_size([200.0, 180.0])
571            .with_flags(flags)
572            .begin(&plot_ui)
573            .expect("subplot should be visible");
574        plot_ui.with_bound_context(|| unsafe {
575            let subplot = (*sys::ImPlot_GetCurrentContext()).CurrentSubplot;
576            assert!(!subplot.is_null());
577            assert_eq!(
578                (*subplot).Flags,
579                (sys::ImPlotSubplotFlags_NoResize
580                    | sys::ImPlotSubplotFlags_LinkRows
581                    | sys::ImPlotSubplotFlags_ColMajor) as sys::ImPlotSubplotFlags
582            );
583        });
584    }
585
586    #[test]
587    fn subplot_grid_rejects_invalid_counts_before_ffi() {
588        let _guard = test_guard();
589        let (mut imgui, _plot) = setup_context();
590        let frame = imgui.begin_frame();
591        let plot_ui = _plot.get_plot_ui(frame.ui());
592
593        let rows = expect_invalid_data(SubplotGrid::new("bad_rows", 0, 1).begin(&plot_ui));
594        assert!(rows.contains("rows must be positive"));
595
596        let cols = expect_invalid_data(SubplotGrid::new("bad_cols", 1, 0).begin(&plot_ui));
597        assert!(cols.contains("cols must be positive"));
598
599        let overflow = expect_invalid_data(
600            SubplotGrid::new("too_many_rows", i32::MAX as usize + 1, 1).begin(&plot_ui),
601        );
602        assert!(overflow.contains("rows exceeded"));
603    }
604
605    #[test]
606    fn subplot_grid_ratio_lengths_follow_usize_counts() {
607        let _guard = test_guard();
608        let (mut imgui, _plot) = setup_context();
609        let frame = imgui.begin_frame();
610        let plot_ui = _plot.get_plot_ui(frame.ui());
611
612        let err = expect_invalid_data(
613            SubplotGrid::new("bad_ratios", 2usize, 1usize)
614                .with_row_ratios(&[1.0])
615                .begin(&plot_ui),
616        );
617
618        assert!(err.contains("row_ratios length must equal rows (2)"));
619    }
620}