1use 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
55pub 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 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 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 pub fn with_size(mut self, size: [f32; 2]) -> Self {
98 self.size = Some(size);
99 self
100 }
101
102 pub fn with_flags(mut self, flags: SubplotFlags) -> Self {
104 self.flags = flags;
105 self
106 }
107
108 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 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 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 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
204pub 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 pub fn end(self) {
218 }
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
232pub struct MultiAxisPlot<'a> {
234 title: &'a str,
235 size: Option<[f32; 2]>,
236 y_axes: Vec<YAxisConfig<'a>>,
237}
238
239pub 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 pub fn new(title: &'a str) -> Self {
249 Self {
250 title,
251 size: None,
252 y_axes: Vec::new(),
253 }
254 }
255
256 pub fn with_size(mut self, size: [f32; 2]) -> Self {
258 self.size = Some(size);
259 self
260 }
261
262 pub fn add_y_axis(mut self, config: YAxisConfig<'a>) -> Self {
264 self.y_axes.push(config);
265 self
266 }
267
268 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 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; 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
343pub 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 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, axis as i32,
362 );
363 }
364 }
365
366 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, axis,
378 );
379 }
380 }
381
382 pub fn end(self) {
384 }
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
398pub struct LegendManager;
400
401impl LegendManager {
402 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 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, )
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#[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 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 }
470}
471
472pub 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 pub fn end(self) {
484 }
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}