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 dear_imgui_rs::ContextAliveToken;
10use std::ffi::CString;
11use std::marker::PhantomData;
12use std::rc::Rc;
13
14fn validate_size(caller: &str, size: [f32; 2]) -> Result<(), PlotError> {
15    if size[0].is_finite() && size[1].is_finite() {
16        Ok(())
17    } else {
18        Err(PlotError::InvalidData(format!(
19            "{caller} size must be finite"
20        )))
21    }
22}
23
24fn count_to_i32(caller: &str, name: &str, value: usize) -> Result<i32, PlotError> {
25    if value == 0 {
26        return Err(PlotError::InvalidData(format!(
27            "{caller} {name} must be positive"
28        )));
29    }
30
31    i32::try_from(value)
32        .map_err(|_| PlotError::InvalidData(format!("{caller} {name} exceeded ImPlot's i32 range")))
33}
34
35fn validate_ratios(caller: &str, name: &str, ratios: &[f32]) -> Result<(), PlotError> {
36    if ratios.iter().all(|value| value.is_finite() && *value > 0.0) {
37        Ok(())
38    } else {
39        Err(PlotError::InvalidData(format!(
40            "{caller} {name} must contain only positive finite values"
41        )))
42    }
43}
44
45fn validate_range(caller: &str, min: f64, max: f64) -> Result<(), PlotError> {
46    if min.is_finite() && max.is_finite() && min != max {
47        Ok(())
48    } else {
49        Err(PlotError::InvalidData(format!(
50            "{caller} range values must be finite and distinct"
51        )))
52    }
53}
54
55/// Multi-plot layout manager for creating subplot grids
56pub struct SubplotGrid<'a> {
57    title: &'a str,
58    rows: usize,
59    cols: usize,
60    size: Option<[f32; 2]>,
61    flags: SubplotFlags,
62    row_ratios: Option<Vec<f32>>,
63    col_ratios: Option<Vec<f32>>,
64}
65
66bitflags::bitflags! {
67    /// Flags for subplot configuration
68    pub struct SubplotFlags: u32 {
69        const NONE = 0;
70        const NO_TITLE = 1 << 0;
71        const NO_RESIZE = 1 << 1;
72        const NO_ALIGN = 1 << 2;
73        const SHARE_ITEMS = 1 << 3;
74        const LINK_ROWS = 1 << 4;
75        const LINK_COLS = 1 << 5;
76        const LINK_ALL_X = 1 << 6;
77        const LINK_ALL_Y = 1 << 7;
78        const COLUMN_MAJOR = 1 << 8;
79    }
80}
81
82impl<'a> SubplotGrid<'a> {
83    /// Create a new subplot grid
84    pub fn new(title: &'a str, rows: usize, cols: usize) -> Self {
85        Self {
86            title,
87            rows,
88            cols,
89            size: None,
90            flags: SubplotFlags::NONE,
91            row_ratios: None,
92            col_ratios: None,
93        }
94    }
95
96    /// Set the size of the subplot grid
97    pub fn with_size(mut self, size: [f32; 2]) -> Self {
98        self.size = Some(size);
99        self
100    }
101
102    /// Set subplot flags
103    pub fn with_flags(mut self, flags: SubplotFlags) -> Self {
104        self.flags = flags;
105        self
106    }
107
108    /// Set row height ratios
109    pub fn with_row_ratios(mut self, ratios: &[f32]) -> Self {
110        self.row_ratios = if ratios.is_empty() {
111            None
112        } else {
113            Some(ratios.to_vec())
114        };
115        self
116    }
117
118    /// Set column width ratios
119    pub fn with_col_ratios(mut self, ratios: &[f32]) -> Self {
120        self.col_ratios = if ratios.is_empty() {
121            None
122        } else {
123            Some(ratios.to_vec())
124        };
125        self
126    }
127
128    /// Begin the subplot grid on a bound ImPlot UI and return a token.
129    pub fn begin<'ui>(self, plot_ui: &'ui PlotUi<'ui>) -> Result<SubplotToken<'ui>, PlotError> {
130        let rows = count_to_i32("SubplotGrid::begin()", "rows", self.rows)?;
131        let cols = count_to_i32("SubplotGrid::begin()", "cols", self.cols)?;
132        let title_cstr =
133            CString::new(self.title).map_err(|e| PlotError::StringConversion(e.to_string()))?;
134
135        let size = self.size.unwrap_or([-1.0, -1.0]);
136        validate_size("SubplotGrid::begin()", size)?;
137        let size_vec = sys::ImVec2_c {
138            x: size[0],
139            y: size[1],
140        };
141
142        // The C API takes `float*` for ratios. Keep owned copies alive in the token to avoid
143        // casting away constness and to stay sound even if the backend ever writes to them.
144        let mut row_ratios = self.row_ratios;
145        let mut col_ratios = self.col_ratios;
146        if let Some(row_ratios) = &row_ratios {
147            if row_ratios.len() != self.rows {
148                return Err(PlotError::InvalidData(format!(
149                    "SubplotGrid::begin() row_ratios length must equal rows ({})",
150                    self.rows
151                )));
152            }
153            validate_ratios("SubplotGrid::begin()", "row_ratios", row_ratios)?;
154        }
155        if let Some(col_ratios) = &col_ratios {
156            if col_ratios.len() != self.cols {
157                return Err(PlotError::InvalidData(format!(
158                    "SubplotGrid::begin() col_ratios length must equal cols ({})",
159                    self.cols
160                )));
161            }
162            validate_ratios("SubplotGrid::begin()", "col_ratios", col_ratios)?;
163        }
164        let row_ratios_ptr = row_ratios
165            .as_mut()
166            .map(|r| r.as_mut_ptr())
167            .unwrap_or(std::ptr::null_mut());
168        let col_ratios_ptr = col_ratios
169            .as_mut()
170            .map(|c| c.as_mut_ptr())
171            .unwrap_or(std::ptr::null_mut());
172
173        let _guard = plot_ui.bind();
174        let success = unsafe {
175            sys::ImPlot_BeginSubplots(
176                title_cstr.as_ptr(),
177                rows,
178                cols,
179                size_vec,
180                self.flags.bits() as i32,
181                row_ratios_ptr,
182                col_ratios_ptr,
183            )
184        };
185
186        if success {
187            Ok(SubplotToken {
188                binding: plot_ui.context.binding(),
189                imgui_alive: plot_ui.context.imgui_alive_token(),
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/// Token representing an active subplot grid
205pub struct SubplotToken<'ui> {
206    binding: PlotContextBinding,
207    imgui_alive: Option<ContextAliveToken>,
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        assert_imgui_alive(&self.imgui_alive, "dear-implot: SubplotToken");
225        let _guard = self.binding.bind("dear-implot: SubplotToken");
226        unsafe {
227            sys::ImPlot_EndSubplots();
228        }
229    }
230}
231
232/// Multi-axis plot support
233pub struct MultiAxisPlot<'a> {
234    title: &'a str,
235    size: Option<[f32; 2]>,
236    y_axes: Vec<YAxisConfig<'a>>,
237}
238
239/// Configuration for a Y-axis
240pub struct YAxisConfig<'a> {
241    pub label: Option<&'a str>,
242    pub flags: AxisFlags,
243    pub range: Option<(f64, f64)>,
244}
245
246impl<'a> MultiAxisPlot<'a> {
247    /// Create a new multi-axis plot
248    pub fn new(title: &'a str) -> Self {
249        Self {
250            title,
251            size: None,
252            y_axes: Vec::new(),
253        }
254    }
255
256    /// Set the plot size
257    pub fn with_size(mut self, size: [f32; 2]) -> Self {
258        self.size = Some(size);
259        self
260    }
261
262    /// Add a Y-axis
263    pub fn add_y_axis(mut self, config: YAxisConfig<'a>) -> Self {
264        self.y_axes.push(config);
265        self
266    }
267
268    /// Begin the multi-axis plot on a bound ImPlot UI.
269    pub fn begin<'ui>(self, plot_ui: &'ui PlotUi<'ui>) -> Result<MultiAxisToken<'ui>, PlotError> {
270        let title_cstr =
271            CString::new(self.title).map_err(|e| PlotError::StringConversion(e.to_string()))?;
272
273        for axis in &self.y_axes {
274            if let Some(label) = axis.label
275                && label.contains('\0')
276            {
277                return Err(PlotError::StringConversion(
278                    "Axis label contained an interior NUL byte".to_string(),
279                ));
280            }
281            if let Some((min, max)) = axis.range {
282                validate_range("MultiAxisPlot::begin()", min, max)?;
283            }
284        }
285        if self.y_axes.len() > 3 {
286            return Err(PlotError::InvalidData(
287                "MultiAxisPlot::begin() supports at most 3 Y axes".to_string(),
288            ));
289        }
290
291        let size = self.size.unwrap_or([-1.0, -1.0]);
292        validate_size("MultiAxisPlot::begin()", size)?;
293        let size_vec = sys::ImVec2_c {
294            x: size[0],
295            y: size[1],
296        };
297
298        let _guard = plot_ui.bind();
299        let success = unsafe { sys::ImPlot_BeginPlot(title_cstr.as_ptr(), size_vec, 0) };
300
301        if success {
302            let mut axis_labels: Vec<CString> = Vec::new();
303
304            // Setup Y-axes (Y1..), matching `token.set_y_axis(YAxis::Y*)` convention.
305            for (i, axis_config) in self.y_axes.iter().enumerate() {
306                let label_ptr = if let Some(label) = axis_config.label {
307                    let cstr = CString::new(label)
308                        .map_err(|e| PlotError::StringConversion(e.to_string()))?;
309                    let ptr = cstr.as_ptr();
310                    axis_labels.push(cstr);
311                    ptr
312                } else {
313                    std::ptr::null()
314                };
315
316                unsafe {
317                    let axis_enum = (i as i32) + 3; // ImAxis_Y1 = 3
318                    sys::ImPlot_SetupAxis(axis_enum, label_ptr, axis_config.flags.bits() as i32);
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                imgui_alive: plot_ui.context.imgui_alive_token(),
329                _title: title_cstr,
330                _axis_labels: axis_labels,
331                _scope: PlotScopeGuard::new(),
332                _lifetime: PhantomData,
333                _not_send_or_sync: PhantomData,
334            })
335        } else {
336            Err(PlotError::PlotCreationFailed(
337                "Failed to begin multi-axis plot".to_string(),
338            ))
339        }
340    }
341}
342
343/// Token representing an active multi-axis plot
344pub struct MultiAxisToken<'ui> {
345    binding: PlotContextBinding,
346    imgui_alive: Option<ContextAliveToken>,
347    _title: CString,
348    _axis_labels: Vec<CString>,
349    _scope: PlotScopeGuard,
350    _lifetime: PhantomData<&'ui PlotUi<'ui>>,
351    _not_send_or_sync: PhantomData<Rc<()>>,
352}
353
354impl MultiAxisToken<'_> {
355    /// Set the current Y-axis for subsequent plots
356    pub fn set_y_axis(&self, axis: YAxis) {
357        let _guard = self.binding.bind("dear-implot: MultiAxisToken");
358        unsafe {
359            sys::ImPlot_SetAxes(
360                0, // ImAxis_X1
361                axis as i32,
362            );
363        }
364    }
365
366    /// Set the current raw Y-axis for subsequent plots.
367    ///
368    /// # Safety
369    ///
370    /// `axis` must be a valid ImPlot Y-axis value for the active plot. Passing an
371    /// out-of-range value lets ImPlot index internal axis arrays out of bounds.
372    pub unsafe fn set_y_axis_unchecked(&self, axis: sys::ImAxis) {
373        let _guard = self.binding.bind("dear-implot: MultiAxisToken");
374        unsafe {
375            sys::ImPlot_SetAxes(
376                0, // ImAxis_X1
377                axis,
378            );
379        }
380    }
381
382    /// End the multi-axis plot
383    pub fn end(self) {
384        // The actual ending happens in Drop.
385    }
386}
387
388impl Drop for MultiAxisToken<'_> {
389    fn drop(&mut self) {
390        assert_imgui_alive(&self.imgui_alive, "dear-implot: MultiAxisToken");
391        let _guard = self.binding.bind("dear-implot: MultiAxisToken");
392        unsafe {
393            sys::ImPlot_EndPlot();
394        }
395    }
396}
397
398/// Legend management utilities
399pub struct LegendManager;
400
401impl LegendManager {
402    /// Setup legend with custom position and flags
403    pub fn setup(plot_ui: &PlotUi<'_>, location: LegendLocation, flags: LegendFlags) {
404        let _guard = plot_ui.bind();
405        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        let _guard = plot_ui.bind();
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                imgui_alive: plot_ui.context.imgui_alive_token(),
431                _label: label_cstr,
432                _lifetime: PhantomData,
433                _not_send_or_sync: PhantomData,
434            })
435        } else {
436            Err(PlotError::PlotCreationFailed(
437                "Failed to begin legend".to_string(),
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    imgui_alive: Option<ContextAliveToken>,
476    _label: CString,
477    _lifetime: PhantomData<&'ui PlotUi<'ui>>,
478    _not_send_or_sync: PhantomData<Rc<()>>,
479}
480
481impl LegendToken<'_> {
482    /// End the legend
483    pub fn end(self) {
484        // The actual ending happens in Drop.
485    }
486}
487
488impl Drop for LegendToken<'_> {
489    fn drop(&mut self) {
490        assert_imgui_alive(&self.imgui_alive, "dear-implot: LegendToken");
491        let _guard = self.binding.bind("dear-implot: LegendToken");
492        unsafe {
493            sys::ImPlot_EndLegendPopup();
494        }
495    }
496}
497
498fn assert_imgui_alive(alive: &Option<ContextAliveToken>, caller: &str) {
499    if let Some(alive) = alive {
500        assert!(alive.is_alive(), "{caller}: ImGui context has been dropped");
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::{PlotError, SubplotGrid};
507    use crate::PlotContext;
508    use std::sync::{Mutex, OnceLock};
509
510    fn test_guard() -> std::sync::MutexGuard<'static, ()> {
511        static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
512        GUARD
513            .get_or_init(|| Mutex::new(()))
514            .lock()
515            .unwrap_or_else(|err| err.into_inner())
516    }
517
518    fn setup_context() -> (dear_imgui_rs::Context, PlotContext) {
519        let mut imgui = dear_imgui_rs::Context::create();
520        let _ = imgui.font_atlas_mut().build();
521        imgui.io_mut().set_display_size([256.0, 256.0]);
522        imgui.io_mut().set_delta_time(1.0 / 60.0);
523        let plot = PlotContext::create(&imgui);
524        (imgui, plot)
525    }
526
527    fn invalid_data_message(err: PlotError) -> String {
528        match err {
529            PlotError::InvalidData(message) => message,
530            other => panic!("expected invalid data error, got {other:?}"),
531        }
532    }
533
534    fn expect_invalid_data(result: Result<super::SubplotToken<'_>, PlotError>) -> String {
535        match result {
536            Err(err) => invalid_data_message(err),
537            Ok(_) => panic!("expected SubplotGrid::begin() to reject invalid input"),
538        }
539    }
540
541    #[test]
542    fn subplot_grid_rejects_invalid_counts_before_ffi() {
543        let _guard = test_guard();
544        let (mut imgui, _plot) = setup_context();
545        let ui = imgui.frame();
546        let plot_ui = _plot.get_plot_ui(&ui);
547
548        let rows = expect_invalid_data(SubplotGrid::new("bad_rows", 0, 1).begin(&plot_ui));
549        assert!(rows.contains("rows must be positive"));
550
551        let cols = expect_invalid_data(SubplotGrid::new("bad_cols", 1, 0).begin(&plot_ui));
552        assert!(cols.contains("cols must be positive"));
553
554        let overflow = expect_invalid_data(
555            SubplotGrid::new("too_many_rows", i32::MAX as usize + 1, 1).begin(&plot_ui),
556        );
557        assert!(overflow.contains("rows exceeded"));
558    }
559
560    #[test]
561    fn subplot_grid_ratio_lengths_follow_usize_counts() {
562        let _guard = test_guard();
563        let (mut imgui, _plot) = setup_context();
564        let ui = imgui.frame();
565        let plot_ui = _plot.get_plot_ui(&ui);
566
567        let err = expect_invalid_data(
568            SubplotGrid::new("bad_ratios", 2usize, 1usize)
569                .with_row_ratios(&[1.0])
570                .begin(&plot_ui),
571        );
572
573        assert!(err.contains("row_ratios length must equal rows (2)"));
574    }
575}