1use crate::context::PlotScopeGuard;
7use crate::{AxisFlags, YAxis, plots::PlotError, sys};
8use std::ffi::CString;
9use std::marker::PhantomData;
10use std::rc::Rc;
11
12fn validate_size(caller: &str, size: [f32; 2]) -> Result<(), PlotError> {
13 if size[0].is_finite() && size[1].is_finite() {
14 Ok(())
15 } else {
16 Err(PlotError::InvalidData(format!(
17 "{caller} size must be finite"
18 )))
19 }
20}
21
22fn count_to_i32(caller: &str, name: &str, value: usize) -> Result<i32, PlotError> {
23 if value == 0 {
24 return Err(PlotError::InvalidData(format!(
25 "{caller} {name} must be positive"
26 )));
27 }
28
29 i32::try_from(value)
30 .map_err(|_| PlotError::InvalidData(format!("{caller} {name} exceeded ImPlot's i32 range")))
31}
32
33fn validate_ratios(caller: &str, name: &str, ratios: &[f32]) -> Result<(), PlotError> {
34 if ratios.iter().all(|value| value.is_finite() && *value > 0.0) {
35 Ok(())
36 } else {
37 Err(PlotError::InvalidData(format!(
38 "{caller} {name} must contain only positive finite values"
39 )))
40 }
41}
42
43fn validate_range(caller: &str, min: f64, max: f64) -> Result<(), PlotError> {
44 if min.is_finite() && max.is_finite() && min != max {
45 Ok(())
46 } else {
47 Err(PlotError::InvalidData(format!(
48 "{caller} range values must be finite and distinct"
49 )))
50 }
51}
52
53pub struct SubplotGrid<'a> {
55 title: &'a str,
56 rows: usize,
57 cols: usize,
58 size: Option<[f32; 2]>,
59 flags: SubplotFlags,
60 row_ratios: Option<Vec<f32>>,
61 col_ratios: Option<Vec<f32>>,
62}
63
64bitflags::bitflags! {
65 pub struct SubplotFlags: u32 {
67 const NONE = 0;
68 const NO_TITLE = 1 << 0;
69 const NO_RESIZE = 1 << 1;
70 const NO_ALIGN = 1 << 2;
71 const SHARE_ITEMS = 1 << 3;
72 const LINK_ROWS = 1 << 4;
73 const LINK_COLS = 1 << 5;
74 const LINK_ALL_X = 1 << 6;
75 const LINK_ALL_Y = 1 << 7;
76 const COLUMN_MAJOR = 1 << 8;
77 }
78}
79
80impl<'a> SubplotGrid<'a> {
81 pub fn new(title: &'a str, rows: usize, cols: usize) -> Self {
83 Self {
84 title,
85 rows,
86 cols,
87 size: None,
88 flags: SubplotFlags::NONE,
89 row_ratios: None,
90 col_ratios: None,
91 }
92 }
93
94 pub fn with_size(mut self, size: [f32; 2]) -> Self {
96 self.size = Some(size);
97 self
98 }
99
100 pub fn with_flags(mut self, flags: SubplotFlags) -> Self {
102 self.flags = flags;
103 self
104 }
105
106 pub fn with_row_ratios(mut self, ratios: &[f32]) -> Self {
108 self.row_ratios = if ratios.is_empty() {
109 None
110 } else {
111 Some(ratios.to_vec())
112 };
113 self
114 }
115
116 pub fn with_col_ratios(mut self, ratios: &[f32]) -> Self {
118 self.col_ratios = if ratios.is_empty() {
119 None
120 } else {
121 Some(ratios.to_vec())
122 };
123 self
124 }
125
126 pub fn begin(self) -> Result<SubplotToken<'a>, PlotError> {
128 let rows = count_to_i32("SubplotGrid::begin()", "rows", self.rows)?;
129 let cols = count_to_i32("SubplotGrid::begin()", "cols", self.cols)?;
130 let title_cstr =
131 CString::new(self.title).map_err(|e| PlotError::StringConversion(e.to_string()))?;
132
133 let size = self.size.unwrap_or([-1.0, -1.0]);
134 validate_size("SubplotGrid::begin()", size)?;
135 let size_vec = sys::ImVec2_c {
136 x: size[0],
137 y: size[1],
138 };
139
140 let mut row_ratios = self.row_ratios;
143 let mut col_ratios = self.col_ratios;
144 if let Some(row_ratios) = &row_ratios {
145 if row_ratios.len() != self.rows {
146 return Err(PlotError::InvalidData(format!(
147 "SubplotGrid::begin() row_ratios length must equal rows ({})",
148 self.rows
149 )));
150 }
151 validate_ratios("SubplotGrid::begin()", "row_ratios", row_ratios)?;
152 }
153 if let Some(col_ratios) = &col_ratios {
154 if col_ratios.len() != self.cols {
155 return Err(PlotError::InvalidData(format!(
156 "SubplotGrid::begin() col_ratios length must equal cols ({})",
157 self.cols
158 )));
159 }
160 validate_ratios("SubplotGrid::begin()", "col_ratios", col_ratios)?;
161 }
162 let row_ratios_ptr = row_ratios
163 .as_mut()
164 .map(|r| r.as_mut_ptr())
165 .unwrap_or(std::ptr::null_mut());
166 let col_ratios_ptr = col_ratios
167 .as_mut()
168 .map(|c| c.as_mut_ptr())
169 .unwrap_or(std::ptr::null_mut());
170
171 let success = unsafe {
172 sys::ImPlot_BeginSubplots(
173 title_cstr.as_ptr(),
174 rows,
175 cols,
176 size_vec,
177 self.flags.bits() as i32,
178 row_ratios_ptr,
179 col_ratios_ptr,
180 )
181 };
182
183 if success {
184 Ok(SubplotToken {
185 _title: title_cstr,
186 _row_ratios: row_ratios,
187 _col_ratios: col_ratios,
188 _phantom: PhantomData,
189 _not_send_or_sync: PhantomData,
190 })
191 } else {
192 Err(PlotError::PlotCreationFailed(
193 "Failed to begin subplots".to_string(),
194 ))
195 }
196 }
197}
198
199pub struct SubplotToken<'a> {
201 _title: CString,
202 _row_ratios: Option<Vec<f32>>,
203 _col_ratios: Option<Vec<f32>>,
204 _phantom: PhantomData<&'a ()>,
205 _not_send_or_sync: PhantomData<Rc<()>>,
206}
207
208impl<'a> SubplotToken<'a> {
209 pub fn end(self) {
211 }
213}
214
215impl<'a> Drop for SubplotToken<'a> {
216 fn drop(&mut self) {
217 unsafe {
218 sys::ImPlot_EndSubplots();
219 }
220 }
221}
222
223pub struct MultiAxisPlot<'a> {
225 title: &'a str,
226 size: Option<[f32; 2]>,
227 y_axes: Vec<YAxisConfig<'a>>,
228}
229
230pub struct YAxisConfig<'a> {
232 pub label: Option<&'a str>,
233 pub flags: AxisFlags,
234 pub range: Option<(f64, f64)>,
235}
236
237impl<'a> MultiAxisPlot<'a> {
238 pub fn new(title: &'a str) -> Self {
240 Self {
241 title,
242 size: None,
243 y_axes: Vec::new(),
244 }
245 }
246
247 pub fn with_size(mut self, size: [f32; 2]) -> Self {
249 self.size = Some(size);
250 self
251 }
252
253 pub fn add_y_axis(mut self, config: YAxisConfig<'a>) -> Self {
255 self.y_axes.push(config);
256 self
257 }
258
259 pub fn begin(self) -> Result<MultiAxisToken<'a>, PlotError> {
261 let title_cstr =
262 CString::new(self.title).map_err(|e| PlotError::StringConversion(e.to_string()))?;
263
264 for axis in &self.y_axes {
265 if let Some(label) = axis.label
266 && label.contains('\0')
267 {
268 return Err(PlotError::StringConversion(
269 "Axis label contained an interior NUL byte".to_string(),
270 ));
271 }
272 if let Some((min, max)) = axis.range {
273 validate_range("MultiAxisPlot::begin()", min, max)?;
274 }
275 }
276 if self.y_axes.len() > 3 {
277 return Err(PlotError::InvalidData(
278 "MultiAxisPlot::begin() supports at most 3 Y axes".to_string(),
279 ));
280 }
281
282 let size = self.size.unwrap_or([-1.0, -1.0]);
283 validate_size("MultiAxisPlot::begin()", size)?;
284 let size_vec = sys::ImVec2_c {
285 x: size[0],
286 y: size[1],
287 };
288
289 let success = unsafe { sys::ImPlot_BeginPlot(title_cstr.as_ptr(), size_vec, 0) };
290
291 if success {
292 let mut axis_labels: Vec<CString> = Vec::new();
293
294 for (i, axis_config) in self.y_axes.iter().enumerate() {
296 let label_ptr = if let Some(label) = axis_config.label {
297 let cstr = CString::new(label)
298 .map_err(|e| PlotError::StringConversion(e.to_string()))?;
299 let ptr = cstr.as_ptr();
300 axis_labels.push(cstr);
301 ptr
302 } else {
303 std::ptr::null()
304 };
305
306 unsafe {
307 let axis_enum = (i as i32) + 3; sys::ImPlot_SetupAxis(axis_enum, label_ptr, axis_config.flags.bits() as i32);
309
310 if let Some((min, max)) = axis_config.range {
311 sys::ImPlot_SetupAxisLimits(axis_enum, min, max, 0);
312 }
313 }
314 }
315
316 Ok(MultiAxisToken {
317 _title: title_cstr,
318 _axis_labels: axis_labels,
319 _scope: PlotScopeGuard::new(),
320 _phantom: PhantomData,
321 })
322 } else {
323 Err(PlotError::PlotCreationFailed(
324 "Failed to begin multi-axis plot".to_string(),
325 ))
326 }
327 }
328}
329
330pub struct MultiAxisToken<'a> {
332 _title: CString,
333 _axis_labels: Vec<CString>,
334 _scope: PlotScopeGuard,
335 _phantom: PhantomData<&'a ()>,
336}
337
338impl<'a> MultiAxisToken<'a> {
339 pub fn set_y_axis(&self, axis: YAxis) {
341 unsafe {
342 sys::ImPlot_SetAxes(
343 0, axis as i32,
345 );
346 }
347 }
348
349 pub unsafe fn set_y_axis_unchecked(&self, axis: sys::ImAxis) {
356 unsafe {
357 sys::ImPlot_SetAxes(
358 0, axis,
360 );
361 }
362 }
363
364 pub fn end(self) {
366 }
368}
369
370impl<'a> Drop for MultiAxisToken<'a> {
371 fn drop(&mut self) {
372 unsafe {
373 sys::ImPlot_EndPlot();
374 }
375 }
376}
377
378pub struct LegendManager;
380
381impl LegendManager {
382 pub fn setup(location: LegendLocation, flags: LegendFlags) {
384 unsafe {
385 sys::ImPlot_SetupLegend(location as i32, flags.bits() as i32);
386 }
387 }
388
389 pub fn begin_custom(label: &str, _size: [f32; 2]) -> Result<LegendToken, PlotError> {
391 let label_cstr =
392 CString::new(label).map_err(|e| PlotError::StringConversion(e.to_string()))?;
393
394 let success = unsafe {
395 sys::ImPlot_BeginLegendPopup(
396 label_cstr.as_ptr(),
397 1, )
399 };
400
401 if success {
402 Ok(LegendToken {
403 _label: label_cstr,
404 _not_send_or_sync: PhantomData,
405 })
406 } else {
407 Err(PlotError::PlotCreationFailed(
408 "Failed to begin legend".to_string(),
409 ))
410 }
411 }
412}
413
414#[repr(i32)]
416pub enum LegendLocation {
417 Center = sys::ImPlotLocation_Center as i32,
418 North = sys::ImPlotLocation_North as i32,
419 South = sys::ImPlotLocation_South as i32,
420 West = sys::ImPlotLocation_West as i32,
421 East = sys::ImPlotLocation_East as i32,
422 NorthWest = sys::ImPlotLocation_NorthWest as i32,
423 NorthEast = sys::ImPlotLocation_NorthEast as i32,
424 SouthWest = sys::ImPlotLocation_SouthWest as i32,
425 SouthEast = sys::ImPlotLocation_SouthEast as i32,
426}
427
428bitflags::bitflags! {
429 pub struct LegendFlags: u32 {
431 const NONE = sys::ImPlotLegendFlags_None as u32;
432 const NO_BUTTONS = sys::ImPlotLegendFlags_NoButtons as u32;
433 const NO_HIGHLIGHT_ITEM = sys::ImPlotLegendFlags_NoHighlightItem as u32;
434 const NO_HIGHLIGHT_AXIS = sys::ImPlotLegendFlags_NoHighlightAxis as u32;
435 const NO_MENUS = sys::ImPlotLegendFlags_NoMenus as u32;
436 const OUTSIDE = sys::ImPlotLegendFlags_Outside as u32;
437 const HORIZONTAL = sys::ImPlotLegendFlags_Horizontal as u32;
438 const SORT = sys::ImPlotLegendFlags_Sort as u32;
439 }
441}
442
443pub struct LegendToken {
445 _label: CString,
446 _not_send_or_sync: PhantomData<Rc<()>>,
447}
448
449impl LegendToken {
450 pub fn end(self) {
452 }
454}
455
456impl Drop for LegendToken {
457 fn drop(&mut self) {
458 unsafe {
459 sys::ImPlot_EndLegendPopup();
460 }
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::{PlotError, SubplotGrid};
467 use crate::PlotContext;
468 use std::sync::{Mutex, OnceLock};
469
470 fn test_guard() -> std::sync::MutexGuard<'static, ()> {
471 static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
472 GUARD.get_or_init(|| Mutex::new(())).lock().unwrap()
473 }
474
475 fn setup_context() -> (dear_imgui_rs::Context, PlotContext) {
476 let mut imgui = dear_imgui_rs::Context::create();
477 let _ = imgui.font_atlas_mut().build();
478 imgui.io_mut().set_display_size([256.0, 256.0]);
479 imgui.io_mut().set_delta_time(1.0 / 60.0);
480 let plot = PlotContext::create(&imgui);
481 (imgui, plot)
482 }
483
484 fn invalid_data_message(err: PlotError) -> String {
485 match err {
486 PlotError::InvalidData(message) => message,
487 other => panic!("expected invalid data error, got {other:?}"),
488 }
489 }
490
491 fn expect_invalid_data(result: Result<super::SubplotToken<'_>, PlotError>) -> String {
492 match result {
493 Err(err) => invalid_data_message(err),
494 Ok(_) => panic!("expected SubplotGrid::begin() to reject invalid input"),
495 }
496 }
497
498 #[test]
499 fn subplot_grid_rejects_invalid_counts_before_ffi() {
500 let _guard = test_guard();
501 let (mut imgui, _plot) = setup_context();
502 let _ui = imgui.frame();
503
504 let rows = expect_invalid_data(SubplotGrid::new("bad_rows", 0, 1).begin());
505 assert!(rows.contains("rows must be positive"));
506
507 let cols = expect_invalid_data(SubplotGrid::new("bad_cols", 1, 0).begin());
508 assert!(cols.contains("cols must be positive"));
509
510 let overflow = expect_invalid_data(
511 SubplotGrid::new("too_many_rows", i32::MAX as usize + 1, 1).begin(),
512 );
513 assert!(overflow.contains("rows exceeded"));
514 }
515
516 #[test]
517 fn subplot_grid_ratio_lengths_follow_usize_counts() {
518 let _guard = test_guard();
519 let (mut imgui, _plot) = setup_context();
520 let _ui = imgui.frame();
521
522 let err = expect_invalid_data(
523 SubplotGrid::new("bad_ratios", 2usize, 1usize)
524 .with_row_ratios(&[1.0])
525 .begin(),
526 );
527
528 assert!(err.contains("row_ratios length must equal rows (2)"));
529 }
530}