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 = 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 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 pub fn with_size(mut self, size: [f32; 2]) -> Self {
97 self.size = Some(size);
98 self
99 }
100
101 pub fn with_flags(mut self, flags: SubplotFlags) -> Self {
103 self.flags = flags;
104 self
105 }
106
107 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 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 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 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
203pub 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 pub fn end(self) {
216 }
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
228pub struct MultiAxisPlot<'a> {
230 title: &'a str,
231 size: Option<[f32; 2]>,
232 y_axes: Vec<YAxisConfig<'a>>,
233}
234
235pub 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 pub fn new(title: &'a str) -> Self {
245 Self {
246 title,
247 size: None,
248 y_axes: Vec::new(),
249 }
250 }
251
252 pub fn with_size(mut self, size: [f32; 2]) -> Self {
254 self.size = Some(size);
255 self
256 }
257
258 pub fn add_y_axis(mut self, config: YAxisConfig<'a>) -> Self {
260 self.y_axes.push(config);
261 self
262 }
263
264 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 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; 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
343pub 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 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, axis as i32,
362 );
363 }
364 })
365 }
366
367 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, axis,
380 );
381 }
382 })
383 }
384
385 pub fn end(self) {
387 }
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
399pub struct LegendManager;
401
402impl LegendManager {
403 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 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, )
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#[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 _label: CString,
476 _lifetime: PhantomData<&'ui PlotUi<'ui>>,
477 _not_send_or_sync: PhantomData<Rc<()>>,
478}
479
480impl LegendToken<'_> {
481 pub fn end(self) {
483 }
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}