1use 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
54pub 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 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 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 pub fn with_size(mut self, size: [f32; 2]) -> Self {
99 self.size = Some(size);
100 self
101 }
102
103 pub fn with_flags(mut self, flags: SubplotFlags) -> Self {
105 self.flags = flags;
106 self
107 }
108
109 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 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 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 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
205pub 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 pub fn end(self) {
218 }
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
230pub struct MultiAxisPlot<'a> {
232 title: &'a str,
233 size: Option<[f32; 2]>,
234 y_axes: Vec<YAxisConfig<'a>>,
235}
236
237pub 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 pub fn new(title: &'a str) -> Self {
247 Self {
248 title,
249 size: None,
250 y_axes: Vec::new(),
251 }
252 }
253
254 pub fn with_size(mut self, size: [f32; 2]) -> Self {
256 self.size = Some(size);
257 self
258 }
259
260 pub fn add_y_axis(mut self, config: YAxisConfig<'a>) -> Self {
262 self.y_axes.push(config);
263 self
264 }
265
266 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 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; 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
345pub 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 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, axis as i32,
364 );
365 }
366 })
367 }
368
369 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, axis,
382 );
383 }
384 })
385 }
386
387 pub fn end(self) {
389 }
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
401pub struct LegendManager;
403
404impl LegendManager {
405 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 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, )
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#[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 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
474pub 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 pub fn end(self) {
485 }
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}