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