Skip to main content

runmat_plot/gui/
plot_overlay.rs

1//! GUI overlay system for interactive plot controls and annotations
2//!
3//! This module handles the egui-based UI that sits on top of the WGPU-rendered
4//! plot content, providing controls, axis labels, grid lines, and titles.
5
6use crate::core::{plot_utils, PlotRenderer};
7use crate::plots::TextStyle;
8use crate::styling::{ModernDarkTheme, PlotThemeConfig, ThemeVariant};
9use egui::{Align2, Color32, Context, FontId, Pos2, Rect, Stroke};
10use glam::{Vec3, Vec4};
11
12/// GUI overlay manager for plot annotations and controls
13pub struct PlotOverlay {
14    /// Current theme
15    theme: PlotThemeConfig,
16
17    /// Cached plot area from last frame
18    plot_area: Option<Rect>,
19    /// Cached per-axes content rectangles from last frame (egui points, snapped)
20    axes_plot_rects: Vec<Rect>,
21    /// Cached toolbar rectangle (egui points)
22    toolbar_rect: Option<Rect>,
23    /// Cached sidebar rectangle (egui points)
24    sidebar_rect: Option<Rect>,
25
26    /// Show debug information
27    show_debug: bool,
28
29    /// Show Dystr information modal
30    show_dystr_modal: bool,
31
32    // Toolbar state
33    want_save_png: bool,
34    want_save_svg: bool,
35    want_reset_view: bool,
36    want_toggle_grid: Option<bool>,
37    want_toggle_legend: Option<bool>,
38}
39
40/// Configuration for the plot overlay
41#[derive(Debug, Clone)]
42pub struct OverlayConfig {
43    /// Whether to show the sidebar with controls
44    pub show_sidebar: bool,
45
46    /// Whether to show the top toolbar (legend/grid/reset/save).
47    pub show_toolbar: bool,
48
49    /// Scale factor applied to overlay font sizes (1.0 = default).
50    pub font_scale: f32,
51
52    /// Whether to show grid lines
53    pub show_grid: bool,
54
55    /// Whether to show axis labels
56    pub show_axes: bool,
57
58    /// Whether to show plot title
59    pub show_title: bool,
60
61    /// Custom plot title (if any)
62    pub title: Option<String>,
63
64    /// Custom axis labels
65    pub x_label: Option<String>,
66    pub y_label: Option<String>,
67
68    /// Sidebar width
69    pub sidebar_width: f32,
70
71    /// Margins around plot area
72    pub plot_margins: PlotMargins,
73}
74
75#[derive(Debug, Clone)]
76pub struct PlotMargins {
77    pub left: f32,
78    pub right: f32,
79    pub top: f32,
80    pub bottom: f32,
81}
82
83#[derive(Debug, Clone, Copy)]
84struct PanelLayout {
85    plot_rect: Rect,
86    frame_rect: Rect,
87    title_rect: Rect,
88    x_label_rect: Rect,
89    y_label_rect: Rect,
90}
91
92impl Default for OverlayConfig {
93    fn default() -> Self {
94        Self {
95            show_sidebar: true,
96            show_toolbar: true,
97            font_scale: 1.0,
98            show_grid: true,
99            show_axes: true,
100            show_title: true,
101            title: Some("Plot".to_string()),
102            x_label: Some("X".to_string()),
103            y_label: Some("Y".to_string()),
104            sidebar_width: 280.0,
105            plot_margins: PlotMargins {
106                left: 60.0,
107                right: 20.0,
108                top: 40.0,
109                bottom: 60.0,
110            },
111        }
112    }
113}
114
115/// Information about the current frame's UI state
116#[derive(Debug)]
117pub struct FrameInfo {
118    /// The plot area where WGPU should render
119    pub plot_area: Option<Rect>,
120
121    /// Whether the UI consumed any input events
122    pub consumed_input: bool,
123
124    /// Performance metrics to display
125    pub metrics: OverlayMetrics,
126}
127
128#[derive(Debug, Default)]
129pub struct OverlayMetrics {
130    pub vertex_count: usize,
131    pub triangle_count: usize,
132    pub render_time_ms: f64,
133    pub fps: f32,
134}
135
136impl Default for PlotOverlay {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl PlotOverlay {
143    const SUBPLOT_GAP_POINTS: f32 = 6.0;
144
145    fn style_color(style: &TextStyle, fallback: Color32) -> Color32 {
146        style
147            .color
148            .map(|c| {
149                Color32::from_rgb(
150                    (c.x.clamp(0.0, 1.0) * 255.0) as u8,
151                    (c.y.clamp(0.0, 1.0) * 255.0) as u8,
152                    (c.z.clamp(0.0, 1.0) * 255.0) as u8,
153                )
154            })
155            .unwrap_or(fallback)
156    }
157
158    fn style_font_size(style: &TextStyle, default_size: f32, scale: f32) -> f32 {
159        style.font_size.unwrap_or(default_size) * scale.max(0.75)
160    }
161
162    fn style_is_bold(style: &TextStyle) -> bool {
163        style
164            .font_weight
165            .as_deref()
166            .map(|weight| weight.eq_ignore_ascii_case("bold"))
167            .unwrap_or(false)
168    }
169
170    #[allow(clippy::too_many_arguments)]
171    fn paint_styled_text(
172        painter: &egui::Painter,
173        pos: Pos2,
174        align: Align2,
175        text: &str,
176        font_size: f32,
177        color: Color32,
178        bold: bool,
179        shadow_alpha: u8,
180    ) {
181        let font = FontId::proportional(font_size);
182        painter.text(
183            pos + egui::vec2(1.0, 1.0),
184            align,
185            text,
186            font.clone(),
187            Color32::from_rgba_premultiplied(0, 0, 0, shadow_alpha),
188        );
189        painter.text(pos, align, text, font.clone(), color);
190        if bold {
191            painter.text(pos + egui::vec2(0.6, 0.0), align, text, font.clone(), color);
192            painter.text(pos + egui::vec2(0.0, 0.6), align, text, font.clone(), color);
193            painter.text(pos + egui::vec2(0.6, 0.6), align, text, font, color);
194        }
195    }
196
197    fn label_stride(labels: &[String], axis_span_px: f32, font_size_px: f32) -> usize {
198        if labels.len() <= 1 || axis_span_px <= 1.0 {
199            return 1;
200        }
201        let max_chars = labels
202            .iter()
203            .map(|label| truncate_label(label, 14).chars().count())
204            .max()
205            .unwrap_or(0) as f32;
206        let estimated_label_width = (max_chars * font_size_px * 0.55).max(font_size_px * 2.0);
207        let slot_width = (axis_span_px / labels.len() as f32).max(1.0);
208        ((estimated_label_width / slot_width).ceil().max(1.0)) as usize
209    }
210
211    #[allow(clippy::too_many_arguments)]
212    fn draw_histogram_axis_ticks(
213        &self,
214        ui: &mut egui::Ui,
215        plot_rect: Rect,
216        ppp: f32,
217        axis_color: Color32,
218        label_color: Color32,
219        tick_length: f32,
220        label_offset: f32,
221        tick_font: FontId,
222        border_bottom: f32,
223        x_min: f64,
224        x_max: f64,
225        edges: &[f64],
226    ) {
227        if edges.len() < 2 || (x_max - x_min).abs() <= f64::EPSILON {
228            return;
229        }
230        let labels: Vec<String> = edges
231            .iter()
232            .map(|value| plot_utils::format_tick_label(*value))
233            .collect();
234        let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
235        let denom = (edges.len() - 1) as f64;
236        for (idx, label) in labels.iter().enumerate() {
237            if idx != 0 && idx != labels.len() - 1 && idx % stride != 0 {
238                continue;
239            }
240            let frac = idx as f64 / denom;
241            let x_val = x_min + frac * (x_max - x_min);
242            let x_screen =
243                plot_rect.min.x + ((x_val - x_min) / (x_max - x_min)) as f32 * plot_rect.width();
244            let x_screen = Self::snap_coord(x_screen, ppp);
245            ui.painter().line_segment(
246                [
247                    Pos2::new(x_screen, border_bottom),
248                    Pos2::new(x_screen, border_bottom + tick_length),
249                ],
250                Stroke::new(1.0, axis_color),
251            );
252            ui.painter().text(
253                Pos2::new(x_screen, border_bottom + label_offset),
254                Align2::CENTER_CENTER,
255                label,
256                tick_font.clone(),
257                label_color,
258            );
259        }
260    }
261
262    /// Create a new plot overlay
263    pub fn new() -> Self {
264        Self {
265            theme: PlotThemeConfig::default(),
266            plot_area: None,
267            axes_plot_rects: Vec::new(),
268            toolbar_rect: None,
269            sidebar_rect: None,
270            show_debug: false,
271            show_dystr_modal: false,
272            want_save_png: false,
273            want_save_svg: false,
274            want_reset_view: false,
275            want_toggle_grid: None,
276            want_toggle_legend: None,
277        }
278    }
279
280    pub fn set_theme_config(&mut self, theme: PlotThemeConfig) {
281        self.theme = theme;
282    }
283
284    fn has_visible_text(text: Option<&str>) -> bool {
285        text.map(|s| !s.trim().is_empty()).unwrap_or(false)
286    }
287
288    fn approx_text_width_points(text: &str, font_size: f32) -> f32 {
289        (text.chars().count() as f32) * font_size * 0.56
290    }
291
292    fn estimate_y_axis_band_width(
293        &self,
294        plot_renderer: &PlotRenderer,
295        axes_index: usize,
296        has_y_label: bool,
297        scale: f32,
298    ) -> f32 {
299        let tick_font_size = 10.0 * scale;
300        let label_offset = 15.0 * scale;
301
302        let y_log = plot_renderer.overlay_y_log_for_axes(axes_index);
303        let categorical = plot_renderer
304            .overlay_categorical_labels_for_axes(axes_index)
305            .filter(|(is_x, _)| !*is_x)
306            .map(|(_, labels)| labels)
307            .or_else(|| {
308                plot_renderer
309                    .overlay_categorical_labels()
310                    .and_then(|(is_x, labels)| if !is_x { Some(labels.clone()) } else { None })
311            });
312
313        let max_label_width = if let Some(labels) = categorical {
314            labels
315                .iter()
316                .map(|label| {
317                    Self::approx_text_width_points(&truncate_label(label, 14), tick_font_size)
318                })
319                .fold(0.0_f32, f32::max)
320        } else if let Some((_x_min, _x_max, y_min, y_max)) =
321            plot_renderer.overlay_display_bounds_for_axes(axes_index)
322        {
323            if y_log && y_min > 0.0 && y_max > 0.0 {
324                let start_decade = y_min.log10().floor() as i32;
325                let end_decade = y_max.log10().ceil() as i32;
326                (start_decade..=end_decade)
327                    .map(|d| Self::approx_text_width_points(&format!("10^{d}"), tick_font_size))
328                    .fold(0.0_f32, f32::max)
329            } else {
330                plot_utils::generate_major_ticks(y_min, y_max)
331                    .into_iter()
332                    .map(plot_utils::format_tick_label)
333                    .map(|label| Self::approx_text_width_points(&label, tick_font_size))
334                    .fold(0.0_f32, f32::max)
335            }
336        } else {
337            Self::approx_text_width_points("-1.00", tick_font_size)
338        };
339
340        let y_tick_zone = label_offset + max_label_width * 0.5 + 4.0 * scale;
341        let y_label_zone = if has_y_label {
342            11.0 * scale
343        } else {
344            4.0 * scale
345        };
346        (y_tick_zone + y_label_zone).max(24.0 * scale)
347    }
348
349    fn estimate_x_axis_edge_padding(
350        &self,
351        plot_renderer: &PlotRenderer,
352        axes_index: usize,
353        scale: f32,
354    ) -> f32 {
355        let tick_font_size = 10.0 * scale;
356        let x_log = plot_renderer.overlay_x_log_for_axes(axes_index);
357
358        let explicit_tick_labels = plot_renderer
359            .overlay_x_tick_labels_for_axes(axes_index)
360            .filter(|labels| !labels.is_empty());
361        let categorical_tick_labels = plot_renderer
362            .overlay_categorical_labels_for_axes(axes_index)
363            .and_then(|(is_x, labels)| if is_x { Some(labels) } else { None })
364            .or_else(|| {
365                plot_renderer
366                    .overlay_categorical_labels()
367                    .and_then(|(is_x, labels)| if is_x { Some(labels.clone()) } else { None })
368            });
369
370        let labels: Vec<String> = if let Some(labels) = explicit_tick_labels {
371            labels
372                .iter()
373                .map(|label| truncate_label(label, 14))
374                .collect()
375        } else if let Some(labels) = categorical_tick_labels {
376            labels
377                .iter()
378                .map(|label| truncate_label(label, 14))
379                .collect()
380        } else if let Some((x_min, x_max, _y_min, _y_max)) =
381            plot_renderer.overlay_display_bounds_for_axes(axes_index)
382        {
383            if x_log && x_min > 0.0 && x_max > 0.0 {
384                let start_decade = x_min.log10().floor() as i32;
385                let end_decade = x_max.log10().ceil() as i32;
386                (start_decade..=end_decade)
387                    .map(|d| format!("10^{d}"))
388                    .collect()
389            } else {
390                plot_utils::generate_major_ticks(x_min, x_max)
391                    .into_iter()
392                    .map(plot_utils::format_tick_label)
393                    .collect()
394            }
395        } else {
396            vec!["-1.00".to_string(), "1.00".to_string()]
397        };
398
399        let max_edge_label_width = if labels.is_empty() {
400            Self::approx_text_width_points("-1.00", tick_font_size)
401        } else if labels.len() == 1 {
402            Self::approx_text_width_points(&labels[0], tick_font_size)
403        } else {
404            let left = Self::approx_text_width_points(&labels[0], tick_font_size);
405            let right = Self::approx_text_width_points(&labels[labels.len() - 1], tick_font_size);
406            left.max(right)
407        };
408
409        (max_edge_label_width * 0.5 + 3.0 * scale).max(6.0 * scale)
410    }
411
412    #[allow(clippy::too_many_arguments)]
413    fn layout_2d_panel(
414        &self,
415        outer: Rect,
416        plot_renderer: &PlotRenderer,
417        axes_index: usize,
418        title: Option<&str>,
419        x_label: Option<&str>,
420        y_label: Option<&str>,
421        scale: f32,
422    ) -> PanelLayout {
423        let scale = scale.max(0.75);
424        let has_title = Self::has_visible_text(title);
425        let has_x_label = Self::has_visible_text(x_label);
426        let has_y_label = Self::has_visible_text(y_label);
427        let outer_w = outer.width().max(1.0);
428        let outer_h = outer.height().max(1.0);
429        let title_gap = if has_title { 4.0 * scale } else { 1.5 * scale };
430        let x_gap = 4.0 * scale;
431        let x_edge_pad = self.estimate_x_axis_edge_padding(plot_renderer, axes_index, scale);
432        let mut right_pad = (3.0 * scale).max(x_edge_pad + 1.0 * scale);
433
434        let mut title_h = if has_title {
435            (28.0 * scale).min(outer_h * 0.16)
436        } else {
437            0.0
438        };
439        let mut x_h = ((24.0 + if has_x_label { 14.0 } else { 0.0 }) * scale).min(outer_h * 0.28);
440        let y_band_estimate =
441            self.estimate_y_axis_band_width(plot_renderer, axes_index, has_y_label, scale);
442        let mut y_w = y_band_estimate.min(outer_w * 0.30);
443
444        let min_plot_w = (outer_w * 0.56).max(44.0 * scale).min(outer_w);
445        let min_plot_h = (outer_h * 0.54).max(44.0 * scale).min(outer_h);
446
447        if outer_w - y_w - right_pad < min_plot_w {
448            y_w = (outer_w - right_pad - min_plot_w).max(0.0);
449        }
450        if outer_w - y_w - right_pad < min_plot_w {
451            right_pad = (outer_w - y_w - min_plot_w).max(0.0);
452        }
453
454        let available_h = outer_h - title_h - title_gap - x_h - x_gap;
455        if available_h < min_plot_h {
456            let deficit = min_plot_h - available_h;
457            let reducible = title_h + x_h;
458            if reducible > 0.0 {
459                let keep = ((reducible - deficit).max(0.0)) / reducible;
460                title_h *= keep;
461                x_h *= keep;
462            }
463        }
464
465        let plot_rect = Rect::from_min_max(
466            egui::pos2(outer.min.x + y_w, outer.min.y + title_h + title_gap),
467            egui::pos2(
468                (outer.max.x - right_pad).max(outer.min.x + y_w + 1.0),
469                outer.max.y - x_h - x_gap,
470            ),
471        );
472        let frame_rect = plot_rect;
473        let title_rect = Rect::from_min_max(
474            egui::pos2(outer.min.x, outer.min.y),
475            egui::pos2(outer.max.x, plot_rect.min.y),
476        );
477        let x_label_rect = Rect::from_min_max(
478            egui::pos2(outer.min.x, plot_rect.max.y),
479            egui::pos2(outer.max.x, outer.max.y),
480        );
481        let y_label_rect = Rect::from_min_max(
482            egui::pos2(outer.min.x, plot_rect.min.y),
483            egui::pos2(plot_rect.min.x, plot_rect.max.y),
484        );
485        PanelLayout {
486            plot_rect,
487            frame_rect,
488            title_rect,
489            x_label_rect,
490            y_label_rect,
491        }
492    }
493
494    fn layout_3d_panel(&self, outer: Rect, title: Option<&str>, scale: f32) -> PanelLayout {
495        let scale = scale.max(0.75);
496        let title_h = if Self::has_visible_text(title) {
497            (28.0 * scale).min(outer.height().max(1.0) * 0.16)
498        } else {
499            0.0
500        };
501        let plot_rect =
502            Rect::from_min_max(egui::pos2(outer.min.x, outer.min.y + title_h), outer.max);
503        let title_rect = Rect::from_min_max(outer.min, egui::pos2(outer.max.x, plot_rect.min.y));
504        PanelLayout {
505            plot_rect,
506            frame_rect: plot_rect,
507            title_rect,
508            x_label_rect: plot_rect,
509            y_label_rect: plot_rect,
510        }
511    }
512
513    fn axes_is_3d(plot_renderer: &PlotRenderer, axes_index: usize) -> bool {
514        let cam = plot_renderer
515            .axes_camera(axes_index)
516            .unwrap_or_else(|| plot_renderer.camera());
517        matches!(
518            cam.projection,
519            crate::core::camera::ProjectionType::Perspective { .. }
520        )
521    }
522
523    fn panel_layout_for_axes(
524        &self,
525        outer: Rect,
526        plot_renderer: &PlotRenderer,
527        axes_index: usize,
528        scale: f32,
529    ) -> PanelLayout {
530        if Self::axes_is_3d(plot_renderer, axes_index) {
531            self.layout_3d_panel(
532                outer,
533                plot_renderer
534                    .overlay_title_for_axes(axes_index)
535                    .map(|s| s.as_str()),
536                scale,
537            )
538        } else {
539            self.layout_2d_panel(
540                outer,
541                plot_renderer,
542                axes_index,
543                plot_renderer
544                    .overlay_title_for_axes(axes_index)
545                    .map(|s| s.as_str()),
546                plot_renderer
547                    .overlay_x_label_for_axes(axes_index)
548                    .map(|s| s.as_str()),
549                plot_renderer
550                    .overlay_y_label_for_axes(axes_index)
551                    .map(|s| s.as_str()),
552                scale,
553            )
554        }
555    }
556
557    fn reserve_sg_title_band(
558        &self,
559        outer: Rect,
560        plot_renderer: &PlotRenderer,
561        scale: f32,
562    ) -> (Rect, Option<Rect>) {
563        let Some(title) = plot_renderer.overlay_sg_title().map(String::as_str) else {
564            return (outer, None);
565        };
566        if !Self::has_visible_text(Some(title)) {
567            return (outer, None);
568        }
569
570        let scale = scale.max(0.75);
571        let band_h = (30.0 * scale).min(outer.height().max(1.0) * 0.14);
572        let gap = 4.0 * scale;
573        let title_max_y = (outer.min.y + band_h).min(outer.max.y);
574        let content_min_y = (title_max_y + gap).min(outer.max.y);
575        (
576            Rect::from_min_max(egui::pos2(outer.min.x, content_min_y), outer.max),
577            Some(Rect::from_min_max(
578                outer.min,
579                egui::pos2(outer.max.x, title_max_y),
580            )),
581        )
582    }
583
584    fn compute_subplot_plot_rects_impl(
585        &self,
586        outer: Rect,
587        plot_renderer: &PlotRenderer,
588        font_scale: f32,
589        show_title: bool,
590    ) -> Vec<Rect> {
591        let plot_area = Self::outer_plot_area_for_axes(outer, plot_renderer);
592        let (plot_area, _) = if show_title {
593            self.reserve_sg_title_band(plot_area, plot_renderer, font_scale)
594        } else {
595            (plot_area, None)
596        };
597        let (rows, cols) = plot_renderer.figure_axes_grid();
598        if rows * cols <= 1 {
599            vec![
600                self.panel_layout_for_axes(plot_area, plot_renderer, 0, font_scale)
601                    .plot_rect,
602            ]
603        } else {
604            let rects = self.compute_subplot_rects(
605                plot_area,
606                rows,
607                cols,
608                Self::SUBPLOT_GAP_POINTS,
609                Self::SUBPLOT_GAP_POINTS,
610            );
611            rects
612                .into_iter()
613                .enumerate()
614                .map(|(axes_index, rect)| {
615                    self.panel_layout_for_axes(rect, plot_renderer, axes_index, font_scale)
616                        .plot_rect
617                })
618                .collect()
619        }
620    }
621
622    pub fn compute_subplot_plot_rects(
623        &self,
624        outer: Rect,
625        plot_renderer: &PlotRenderer,
626        font_scale: f32,
627    ) -> Vec<Rect> {
628        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, true)
629    }
630
631    /// Like [`compute_subplot_plot_rects`] but lets the caller control whether the
632    /// super-title band is reserved. Pass `show_title: false` when the overlay is
633    /// rendered without titles so that GPU viewports and hit-rects stay aligned.
634    pub fn compute_subplot_plot_rects_explicit(
635        &self,
636        outer: Rect,
637        plot_renderer: &PlotRenderer,
638        font_scale: f32,
639        show_title: bool,
640    ) -> Vec<Rect> {
641        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, show_title)
642    }
643
644    pub fn snap_rect_to_pixels(rect: Rect, pixels_per_point: f32) -> Rect {
645        let ppp = pixels_per_point.max(0.5);
646        let min_x = (rect.min.x * ppp).round() / ppp;
647        let min_y = (rect.min.y * ppp).round() / ppp;
648        let width = (rect.width() * ppp).round().max(1.0) / ppp;
649        let height = (rect.height() * ppp).round().max(1.0) / ppp;
650        Rect::from_min_size(egui::pos2(min_x, min_y), egui::vec2(width, height))
651    }
652
653    fn snap_coord(value: f32, pixels_per_point: f32) -> f32 {
654        let ppp = pixels_per_point.max(0.5);
655        (value * ppp).round() / ppp
656    }
657
658    fn border_centerline_edges(
659        plot_rect: Rect,
660        pixels_per_point: f32,
661        stroke_width: f32,
662    ) -> (f32, f32, f32, f32) {
663        let offset = stroke_width * 0.5;
664        let left = Self::snap_coord(plot_rect.min.x - offset, pixels_per_point);
665        let right = Self::snap_coord(plot_rect.max.x + offset, pixels_per_point);
666        let top = Self::snap_coord(plot_rect.min.y - offset, pixels_per_point);
667        let bottom = Self::snap_coord(plot_rect.max.y + offset, pixels_per_point);
668        (left, right, top, bottom)
669    }
670
671    fn draw_2d_border(&self, ui: &mut egui::Ui, plot_rect: Rect) {
672        let stroke = Stroke::new(1.5, self.theme_axis_color());
673        let ppp = ui.ctx().pixels_per_point();
674        let (left, right, top, bottom) =
675            Self::border_centerline_edges(plot_rect, ppp, stroke.width);
676        ui.painter()
677            .line_segment([Pos2::new(left, top), Pos2::new(right, top)], stroke);
678        ui.painter()
679            .line_segment([Pos2::new(left, bottom), Pos2::new(right, bottom)], stroke);
680        ui.painter()
681            .line_segment([Pos2::new(left, top), Pos2::new(left, bottom)], stroke);
682        ui.painter()
683            .line_segment([Pos2::new(right, top), Pos2::new(right, bottom)], stroke);
684    }
685
686    fn draw_plot_box_mask(&self, ui: &mut egui::Ui, plot_rect: Rect) {
687        let mask = 2.0;
688        let bg = self.theme_background_color();
689        let top = Rect::from_min_max(
690            Pos2::new(plot_rect.min.x - mask, plot_rect.min.y - mask),
691            Pos2::new(plot_rect.max.x + mask, plot_rect.min.y),
692        );
693        let bottom = Rect::from_min_max(
694            Pos2::new(plot_rect.min.x - mask, plot_rect.max.y),
695            Pos2::new(plot_rect.max.x + mask, plot_rect.max.y + mask),
696        );
697        let left = Rect::from_min_max(
698            Pos2::new(plot_rect.min.x - mask, plot_rect.min.y - mask),
699            Pos2::new(plot_rect.min.x, plot_rect.max.y + mask),
700        );
701        let right = Rect::from_min_max(
702            Pos2::new(plot_rect.max.x, plot_rect.min.y - mask),
703            Pos2::new(plot_rect.max.x + mask, plot_rect.max.y + mask),
704        );
705        ui.painter().rect_filled(top, 0.0, bg);
706        ui.painter().rect_filled(bottom, 0.0, bg);
707        ui.painter().rect_filled(left, 0.0, bg);
708        ui.painter().rect_filled(right, 0.0, bg);
709    }
710
711    pub fn compute_subplot_plot_rects_snapped(
712        &self,
713        outer: Rect,
714        plot_renderer: &PlotRenderer,
715        font_scale: f32,
716        pixels_per_point: f32,
717    ) -> Vec<Rect> {
718        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, true)
719            .into_iter()
720            .map(|rect| Self::snap_rect_to_pixels(rect, pixels_per_point))
721            .collect()
722    }
723
724    /// Like [`compute_subplot_plot_rects_snapped`] but lets the caller control whether
725    /// the super-title band is reserved. Pass `show_title: false` when the overlay is
726    /// rendered without titles so that GPU viewports stay aligned with the drawn area.
727    pub fn compute_subplot_plot_rects_snapped_explicit(
728        &self,
729        outer: Rect,
730        plot_renderer: &PlotRenderer,
731        font_scale: f32,
732        pixels_per_point: f32,
733        show_title: bool,
734    ) -> Vec<Rect> {
735        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, show_title)
736            .into_iter()
737            .map(|rect| Self::snap_rect_to_pixels(rect, pixels_per_point))
738            .collect()
739    }
740
741    pub fn outer_plot_area_for_axes(available_rect: Rect, plot_renderer: &PlotRenderer) -> Rect {
742        let (rows, cols) = plot_renderer.figure_axes_grid();
743        let single_axes_is_3d = rows * cols <= 1 && Self::axes_is_3d(plot_renderer, 0);
744        if single_axes_is_3d || rows * cols > 1 {
745            available_rect
746        } else {
747            available_rect.shrink2(egui::vec2(8.0, 8.0))
748        }
749    }
750
751    fn theme_text_color(&self) -> Color32 {
752        let text = self.theme.build_theme().get_text_color();
753        Color32::from_rgba_premultiplied(
754            (text.x.clamp(0.0, 1.0) * 255.0) as u8,
755            (text.y.clamp(0.0, 1.0) * 255.0) as u8,
756            (text.z.clamp(0.0, 1.0) * 255.0) as u8,
757            (text.w.clamp(0.0, 1.0) * 255.0) as u8,
758        )
759    }
760
761    fn theme_axis_color(&self) -> Color32 {
762        let axis = self.theme.build_theme().get_axis_color();
763        Color32::from_rgba_premultiplied(
764            (axis.x.clamp(0.0, 1.0) * 255.0) as u8,
765            (axis.y.clamp(0.0, 1.0) * 255.0) as u8,
766            (axis.z.clamp(0.0, 1.0) * 255.0) as u8,
767            (axis.w.clamp(0.0, 1.0) * 255.0) as u8,
768        )
769    }
770
771    fn theme_background_color(&self) -> Color32 {
772        let bg = self.theme.build_theme().get_background_color();
773        Color32::from_rgba_premultiplied(
774            (bg.x.clamp(0.0, 1.0) * 255.0) as u8,
775            (bg.y.clamp(0.0, 1.0) * 255.0) as u8,
776            (bg.z.clamp(0.0, 1.0) * 255.0) as u8,
777            (bg.w.clamp(0.0, 1.0) * 255.0) as u8,
778        )
779    }
780
781    fn themed_grid_colors(&self) -> (Color32, Color32) {
782        let grid = self.theme.build_theme().get_grid_color();
783        let major = Color32::from_rgba_premultiplied(
784            (grid.x.clamp(0.0, 1.0) * 255.0) as u8,
785            (grid.y.clamp(0.0, 1.0) * 255.0) as u8,
786            (grid.z.clamp(0.0, 1.0) * 255.0) as u8,
787            ((grid.w.clamp(0.15, 0.55)) * 255.0) as u8,
788        );
789        let minor = Color32::from_rgba_premultiplied(
790            (grid.x.clamp(0.0, 1.0) * 255.0) as u8,
791            (grid.y.clamp(0.0, 1.0) * 255.0) as u8,
792            (grid.z.clamp(0.0, 1.0) * 255.0) as u8,
793            ((grid.w * 0.6).clamp(0.10, 0.34) * 255.0) as u8,
794        );
795        (major, minor)
796    }
797
798    /// Apply theme to egui context
799    pub fn apply_theme(&self, ctx: &Context) {
800        match self.theme.variant {
801            ThemeVariant::ModernDark => {
802                ModernDarkTheme::default().apply_to_egui(ctx);
803            }
804            ThemeVariant::ClassicLight => {
805                ctx.set_visuals(egui::Visuals::light());
806            }
807            ThemeVariant::HighContrast => {
808                let mut visuals = egui::Visuals::dark();
809                visuals.extreme_bg_color = egui::Color32::BLACK;
810                visuals.widgets.noninteractive.bg_fill = egui::Color32::BLACK;
811                visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::WHITE;
812                ctx.set_visuals(visuals);
813            }
814            ThemeVariant::Custom => {
815                let mut visuals = egui::Visuals::light();
816                let bg = self.theme.build_theme().get_background_color();
817                if bg.x + bg.y + bg.z < 1.5 {
818                    visuals = egui::Visuals::dark();
819                }
820                ctx.set_visuals(visuals);
821            }
822        }
823
824        // Make context transparent so WGPU content shows through
825        let mut visuals = ctx.style().visuals.clone();
826        visuals.window_fill = Color32::TRANSPARENT;
827        visuals.panel_fill = Color32::TRANSPARENT;
828        visuals.extreme_bg_color = Color32::TRANSPARENT;
829        visuals.faint_bg_color = Color32::TRANSPARENT;
830        visuals.widgets.noninteractive.bg_fill = Color32::TRANSPARENT;
831        visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
832        visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
833        visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
834        visuals.widgets.open.bg_fill = Color32::TRANSPARENT;
835        ctx.set_visuals(visuals);
836    }
837
838    /// Render the complete overlay UI
839    pub fn render(
840        &mut self,
841        ctx: &Context,
842        plot_renderer: &PlotRenderer,
843        config: &OverlayConfig,
844        metrics: OverlayMetrics,
845    ) -> FrameInfo {
846        let mut consumed_input = false;
847        let mut plot_area = None;
848
849        // Render sidebar if enabled
850        if config.show_sidebar {
851            consumed_input |= self.render_sidebar(ctx, plot_renderer, config, &metrics);
852        }
853
854        // Render main plot area
855        let central_response = egui::CentralPanel::default()
856            .frame(egui::Frame::none()) // Transparent frame
857            .show(ctx, |ui| {
858                // Toolbar (top-right)
859                if config.show_toolbar {
860                    egui::TopBottomPanel::top("plot_toolbar")
861                        .frame(egui::Frame::none())
862                        .show_inside(ui, |ui| {
863                            let padded = ui.max_rect().shrink2(egui::vec2(12.0, 6.0));
864                            self.toolbar_rect = Some(padded);
865                            ui.allocate_ui_at_rect(padded, |ui| {
866                                ui.with_layout(
867                                    egui::Layout::right_to_left(egui::Align::Center),
868                                    |ui| {
869                                        ui.spacing_mut().item_spacing = egui::vec2(8.0, 4.0);
870                                        ui.spacing_mut().button_padding = egui::vec2(8.0, 6.0);
871                                        if ui.button("Save PNG").clicked() {
872                                            self.want_save_png = true;
873                                        }
874                                        if ui.button("Save SVG").clicked() {
875                                            self.want_save_svg = true;
876                                        }
877                                        if ui.button("Reset View").clicked() {
878                                            self.want_reset_view = true;
879                                        }
880                                        let mut grid = plot_renderer.overlay_show_grid();
881                                        if ui.toggle_value(&mut grid, "Grid").changed() {
882                                            self.want_toggle_grid = Some(grid);
883                                        }
884                                        let mut legend = plot_renderer.overlay_show_legend();
885                                        if ui.toggle_value(&mut legend, "Legend").changed() {
886                                            self.want_toggle_legend = Some(legend);
887                                        }
888                                    },
889                                );
890                            });
891                        });
892                } else {
893                    self.toolbar_rect = None;
894                }
895                plot_area = Some(self.render_plot_area(ui, plot_renderer, config));
896            });
897
898        consumed_input |= central_response.response.hovered();
899
900        // Render Dystr modal if needed
901        if self.show_dystr_modal {
902            consumed_input |= self.render_dystr_modal(ctx);
903        }
904
905        // Store plot area for next frame
906        self.plot_area = plot_area;
907
908        FrameInfo {
909            plot_area,
910            consumed_input,
911            metrics,
912        }
913    }
914
915    /// Render the sidebar with controls and information
916    fn render_sidebar(
917        &mut self,
918        ctx: &Context,
919        plot_renderer: &PlotRenderer,
920        config: &OverlayConfig,
921        metrics: &OverlayMetrics,
922    ) -> bool {
923        let mut consumed_input = false;
924
925        let sidebar_response = egui::SidePanel::left("plot_controls")
926            .resizable(true)
927            .default_width(config.sidebar_width)
928            .min_width(200.0)
929            .show(ctx, |ui| {
930                ui.style_mut().visuals.widgets.noninteractive.bg_fill = Color32::from_gray(25);
931                ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::from_gray(35);
932                ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::from_gray(45);
933
934                // Header with Dystr branding
935                ui.horizontal(|ui| {
936                    // Placeholder for Dystr logo (32x32 square)
937                    let logo_size = egui::Vec2::splat(32.0);
938                    let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::click()).0;
939
940                    // Draw placeholder logo background
941                    ui.painter().rect_filled(
942                        logo_rect,
943                        4.0, // rounded corners
944                        Color32::from_rgb(100, 100, 100),
945                    );
946
947                    // Draw "D" placeholder text in the logo area
948                    ui.painter().text(
949                        logo_rect.center(),
950                        Align2::CENTER_CENTER,
951                        "D",
952                        FontId::proportional(20.0),
953                        Color32::WHITE,
954                    );
955
956                    ui.vertical(|ui| {
957                        ui.heading("RunMat");
958                        ui.horizontal(|ui| {
959                            ui.small("a community project by ");
960                            if ui.small_button("dystr.com").clicked() {
961                                self.show_dystr_modal = true;
962                            }
963                        });
964                    });
965                });
966                ui.separator();
967                ui.label("GC Stats: [not available]");
968
969                // Camera information
970                ui.collapsing("📷 Camera", |ui| {
971                    let camera = plot_renderer.camera();
972                    ui.label(format!(
973                        "Position: {:.2}, {:.2}, {:.2}",
974                        camera.position.x, camera.position.y, camera.position.z
975                    ));
976                    ui.label(format!(
977                        "Target: {:.2}, {:.2}, {:.2}",
978                        camera.target.x, camera.target.y, camera.target.z
979                    ));
980
981                    if let Some(vb) = plot_renderer.view_bounds() {
982                        ui.label(format!("View X: {:.2} to {:.2}", vb.0, vb.1));
983                        ui.label(format!("View Y: {:.2} to {:.2}", vb.2, vb.3));
984                    }
985                    if let Some(db) = plot_renderer.data_bounds() {
986                        ui.label(format!("Data X: {:.2} to {:.2}", db.0, db.1));
987                        ui.label(format!("Data Y: {:.2} to {:.2}", db.2, db.3));
988                    }
989                });
990
991                // Scene information
992                ui.collapsing("🎬 Scene", |ui| {
993                    let stats = plot_renderer.scene_statistics();
994                    ui.label(format!("Nodes: {}", stats.total_nodes));
995                    ui.label(format!("Visible: {}", stats.visible_nodes));
996                    ui.label(format!("Vertices: {}", stats.total_vertices));
997                    ui.label(format!("Triangles: {}", stats.total_triangles));
998                });
999
1000                // Performance metrics
1001                ui.collapsing("⚡ Performance", |ui| {
1002                    ui.label(format!("FPS: {:.1}", metrics.fps));
1003                    ui.label(format!("Render: {:.2}ms", metrics.render_time_ms));
1004                    ui.label(format!("Vertices: {}", metrics.vertex_count));
1005                    ui.label(format!("Triangles: {}", metrics.triangle_count));
1006                });
1007
1008                // Theme selection
1009                ui.collapsing("🎨 Theme", |ui| {
1010                    let label = match self.theme.variant {
1011                        ThemeVariant::ModernDark => "Modern Dark",
1012                        ThemeVariant::ClassicLight => "Classic Light",
1013                        ThemeVariant::HighContrast => "High Contrast",
1014                        ThemeVariant::Custom => "Custom",
1015                    };
1016                    ui.label(format!("{label} (Active)"));
1017                    ui.checkbox(&mut self.show_debug, "Show Debug Info");
1018                });
1019
1020                ui.separator();
1021
1022                // Plot controls
1023                ui.collapsing("🔧 Controls", |ui| {
1024                    ui.label("🖱️ Orbit: MMB drag (or RMB drag)");
1025                    ui.label("🖱️ Pan: Shift + MMB drag (or Shift + RMB drag)");
1026                    ui.label("🖱️ Zoom: Scroll wheel (zooms to cursor)");
1027                    ui.label("🖱️ Alt + LMB/MMB/RMB: Orbit/Pan/Zoom");
1028                    ui.label("📱 Touch: Pinch to zoom");
1029                });
1030            });
1031
1032        consumed_input |= sidebar_response.response.hovered();
1033        self.sidebar_rect = Some(sidebar_response.response.rect);
1034        consumed_input
1035    }
1036
1037    /// Render the main plot area with grid, axes, and annotations
1038    fn render_plot_area(
1039        &mut self,
1040        ui: &mut egui::Ui,
1041        plot_renderer: &PlotRenderer,
1042        config: &OverlayConfig,
1043    ) -> Rect {
1044        let available_rect = ui.available_rect_before_wrap();
1045        let mut rendered_axes_rects: Vec<Rect> = Vec::new();
1046
1047        let (rows, cols) = plot_renderer.figure_axes_grid();
1048        let plot_rect = Self::outer_plot_area_for_axes(available_rect, plot_renderer);
1049        let (plot_rect, sg_title_rect) = if config.show_title {
1050            self.reserve_sg_title_band(plot_rect, plot_renderer, config.font_scale)
1051        } else {
1052            (plot_rect, None)
1053        };
1054
1055        // Use full available rectangular plot area (do not force square);
1056        // camera fitting and axis_equal settings will control aspect.
1057        let plot_area_rect = plot_rect;
1058
1059        if let Some(title_rect) = sg_title_rect {
1060            if let Some(title) = plot_renderer.overlay_sg_title() {
1061                self.draw_title_in_rect(
1062                    ui,
1063                    title_rect,
1064                    title,
1065                    Some(plot_renderer.overlay_sg_title_style()),
1066                    config.font_scale,
1067                );
1068            }
1069        }
1070
1071        if rows * cols > 1 {
1072            let rects = self.compute_subplot_rects(
1073                plot_area_rect,
1074                rows,
1075                cols,
1076                Self::SUBPLOT_GAP_POINTS,
1077                Self::SUBPLOT_GAP_POINTS,
1078            );
1079            for (i, cell_rect) in rects.iter().enumerate() {
1080                let cam = plot_renderer
1081                    .axes_camera(i)
1082                    .unwrap_or_else(|| plot_renderer.camera());
1083                let panel_layout =
1084                    self.panel_layout_for_axes(*cell_rect, plot_renderer, i, config.font_scale);
1085                let r =
1086                    Self::snap_rect_to_pixels(panel_layout.plot_rect, ui.ctx().pixels_per_point());
1087                let frame_rect =
1088                    Self::snap_rect_to_pixels(panel_layout.frame_rect, ui.ctx().pixels_per_point());
1089                rendered_axes_rects.push(r);
1090                log::debug!(
1091                    target: "runmat_plot.axes_layout",
1092                    "computed axes panel layout axes_index={} rows={} cols={} is_3d={} cell=({}, {})..({}, {}) frame=({}, {})..({}, {}) content=({}, {})..({}, {})",
1093                    i,
1094                    rows,
1095                    cols,
1096                    Self::axes_is_3d(plot_renderer, i),
1097                    cell_rect.min.x,
1098                    cell_rect.min.y,
1099                    cell_rect.max.x,
1100                    cell_rect.max.y,
1101                    frame_rect.min.x,
1102                    frame_rect.min.y,
1103                    frame_rect.max.x,
1104                    frame_rect.max.y,
1105                    r.min.x,
1106                    r.min.y,
1107                    r.max.x,
1108                    r.max.y
1109                );
1110                if matches!(
1111                    cam.projection,
1112                    crate::core::camera::ProjectionType::Perspective { .. }
1113                ) {
1114                    if config.show_title {
1115                        if let Some(title) = plot_renderer.overlay_title_for_axes(i) {
1116                            self.draw_title_in_rect(
1117                                ui,
1118                                panel_layout.title_rect,
1119                                title,
1120                                None,
1121                                config.font_scale,
1122                            );
1123                        }
1124                    }
1125                    self.draw_3d_orientation_gizmo(ui, r, plot_renderer, i, config.font_scale);
1126                    self.draw_3d_origin_axis_ticks(ui, r, plot_renderer, i, config.font_scale);
1127                    self.draw_projected_world_texts(ui, r, plot_renderer, i, config.font_scale);
1128                    for (label, pos) in plot_renderer.pie_labels_for_axes(i) {
1129                        self.draw_pie_label(ui, r, &label, pos, config.font_scale);
1130                    }
1131                    if plot_renderer.overlay_show_legend_for_axes(i) {
1132                        let entries = plot_renderer.overlay_legend_entries_for_axes(i);
1133                        self.draw_legend(ui, r, &entries, config.font_scale);
1134                    }
1135                    continue;
1136                }
1137                // Frame (2D only; 3D uses the axes cube instead)
1138                if plot_renderer.overlay_show_box_for_axes(i) {
1139                    self.draw_plot_box_mask(ui, r);
1140                    self.draw_2d_border(ui, frame_rect);
1141                }
1142
1143                // Grid (2D)
1144                if config.show_grid && plot_renderer.overlay_show_grid_for_axes(i) {
1145                    let b = plot_renderer.view_bounds_for_axes(i);
1146                    self.draw_grid(ui, r, plot_renderer, b, Some(i));
1147                }
1148                // Axes (2D)
1149                if config.show_axes {
1150                    let b = plot_renderer.view_bounds_for_axes(i);
1151                    self.draw_axes(ui, r, plot_renderer, config, b, Some(i));
1152                }
1153
1154                if config.show_title {
1155                    if let Some(title) = plot_renderer.overlay_title_for_axes(i) {
1156                        self.draw_title_in_rect(
1157                            ui,
1158                            panel_layout.title_rect,
1159                            title,
1160                            None,
1161                            config.font_scale,
1162                        );
1163                    }
1164                }
1165                if !matches!(
1166                    cam.projection,
1167                    crate::core::camera::ProjectionType::Perspective { .. }
1168                ) {
1169                    if let Some(x_label) = plot_renderer.overlay_x_label_for_axes(i) {
1170                        self.draw_x_label_in_rect(
1171                            ui,
1172                            panel_layout.x_label_rect,
1173                            x_label,
1174                            config.font_scale,
1175                        );
1176                    }
1177                }
1178                if !matches!(
1179                    cam.projection,
1180                    crate::core::camera::ProjectionType::Perspective { .. }
1181                ) {
1182                    if let Some(y_label) = plot_renderer.overlay_y_label_for_axes(i) {
1183                        self.draw_y_label_in_rect(
1184                            ui,
1185                            panel_layout.y_label_rect,
1186                            y_label,
1187                            config.font_scale,
1188                        );
1189                    }
1190                }
1191                self.draw_projected_world_texts(ui, r, plot_renderer, i, config.font_scale);
1192                for (label, pos) in plot_renderer.pie_labels_for_axes(i) {
1193                    self.draw_pie_label(ui, r, &label, pos, config.font_scale);
1194                }
1195                if plot_renderer.overlay_show_legend_for_axes(i) {
1196                    let entries = plot_renderer.overlay_legend_entries_for_axes(i);
1197                    self.draw_legend(ui, r, &entries, config.font_scale);
1198                }
1199            }
1200        } else {
1201            let cam = plot_renderer.camera();
1202            let panel_layout =
1203                self.panel_layout_for_axes(plot_area_rect, plot_renderer, 0, config.font_scale);
1204            let centered_plot_rect =
1205                Self::snap_rect_to_pixels(panel_layout.plot_rect, ui.ctx().pixels_per_point());
1206            let centered_frame_rect =
1207                Self::snap_rect_to_pixels(panel_layout.frame_rect, ui.ctx().pixels_per_point());
1208            rendered_axes_rects.push(centered_plot_rect);
1209            log::debug!(
1210                target: "runmat_plot.axes_layout",
1211                "computed axes panel layout axes_index=0 rows={} cols={} is_3d={} cell=({}, {})..({}, {}) frame=({}, {})..({}, {}) content=({}, {})..({}, {})",
1212                rows,
1213                cols,
1214                Self::axes_is_3d(plot_renderer, 0),
1215                plot_area_rect.min.x,
1216                plot_area_rect.min.y,
1217                plot_area_rect.max.x,
1218                plot_area_rect.max.y,
1219                centered_frame_rect.min.x,
1220                centered_frame_rect.min.y,
1221                centered_frame_rect.max.x,
1222                centered_frame_rect.max.y,
1223                centered_plot_rect.min.x,
1224                centered_plot_rect.min.y,
1225                centered_plot_rect.max.x,
1226                centered_plot_rect.max.y
1227            );
1228            if config.show_title {
1229                if let Some(title) = plot_renderer
1230                    .overlay_title_for_axes(0)
1231                    .or(config.title.as_ref())
1232                {
1233                    self.draw_title_in_rect(
1234                        ui,
1235                        panel_layout.title_rect,
1236                        title,
1237                        None,
1238                        config.font_scale,
1239                    );
1240                }
1241            }
1242            if matches!(
1243                cam.projection,
1244                crate::core::camera::ProjectionType::Perspective { .. }
1245            ) {
1246                self.draw_3d_orientation_gizmo(
1247                    ui,
1248                    centered_plot_rect,
1249                    plot_renderer,
1250                    0,
1251                    config.font_scale,
1252                );
1253                self.draw_3d_origin_axis_ticks(
1254                    ui,
1255                    centered_plot_rect,
1256                    plot_renderer,
1257                    0,
1258                    config.font_scale,
1259                );
1260                self.draw_projected_world_texts(
1261                    ui,
1262                    centered_plot_rect,
1263                    plot_renderer,
1264                    0,
1265                    config.font_scale,
1266                );
1267            } else {
1268                // Draw plot frame (2D only; 3D uses the axes cube instead)
1269                if plot_renderer.overlay_show_box() {
1270                    self.draw_plot_box_mask(ui, centered_plot_rect);
1271                    self.draw_2d_border(ui, centered_frame_rect);
1272                }
1273                // Draw grid if enabled
1274                if config.show_grid {
1275                    self.draw_grid(ui, centered_plot_rect, plot_renderer, None, None);
1276                }
1277
1278                // Draw axes if enabled
1279                if config.show_axes {
1280                    self.draw_axes(ui, centered_plot_rect, plot_renderer, config, None, None);
1281                    // Emphasize zero baseline if within data range
1282                    if let Some((x_min, x_max, y_min, y_max)) = plot_renderer
1283                        .view_bounds()
1284                        .or_else(|| plot_renderer.data_bounds())
1285                    {
1286                        let axis_color = self.theme_axis_color();
1287                        let zero_stroke = Stroke::new(1.5, axis_color);
1288                        if y_min < 0.0 && y_max > 0.0 {
1289                            let y_screen = centered_plot_rect.max.y
1290                                - ((0.0 - y_min) / (y_max - y_min)) as f32
1291                                    * centered_plot_rect.height();
1292                            ui.painter().line_segment(
1293                                [
1294                                    Pos2::new(centered_plot_rect.min.x, y_screen),
1295                                    Pos2::new(centered_plot_rect.max.x, y_screen),
1296                                ],
1297                                zero_stroke,
1298                            );
1299                        }
1300                        if x_min < 0.0 && x_max > 0.0 {
1301                            let x_screen = centered_plot_rect.min.x
1302                                + ((0.0 - x_min) / (x_max - x_min)) as f32
1303                                    * centered_plot_rect.width();
1304                            ui.painter().line_segment(
1305                                [
1306                                    Pos2::new(x_screen, centered_plot_rect.min.y),
1307                                    Pos2::new(x_screen, centered_plot_rect.max.y),
1308                                ],
1309                                zero_stroke,
1310                            );
1311                        }
1312                    }
1313                }
1314                if let Some(x_label) = plot_renderer
1315                    .overlay_x_label_for_axes(0)
1316                    .or(config.x_label.as_ref())
1317                {
1318                    self.draw_x_label_in_rect(
1319                        ui,
1320                        panel_layout.x_label_rect,
1321                        x_label,
1322                        config.font_scale,
1323                    );
1324                }
1325                if let Some(y_label) = plot_renderer
1326                    .overlay_y_label_for_axes(0)
1327                    .or(config.y_label.as_ref())
1328                {
1329                    self.draw_y_label_in_rect(
1330                        ui,
1331                        panel_layout.y_label_rect,
1332                        y_label,
1333                        config.font_scale,
1334                    );
1335                }
1336                self.draw_projected_world_texts(
1337                    ui,
1338                    centered_plot_rect,
1339                    plot_renderer,
1340                    0,
1341                    config.font_scale,
1342                );
1343            }
1344        }
1345        let centered_plot_rect = if rows * cols <= 1 {
1346            self.panel_layout_for_axes(plot_area_rect, plot_renderer, 0, config.font_scale)
1347                .plot_rect
1348        } else {
1349            plot_area_rect
1350        };
1351        for (label, pos) in if rows * cols <= 1 {
1352            plot_renderer.active_axes_pie_labels()
1353        } else {
1354            Vec::new()
1355        } {
1356            self.draw_pie_label(ui, centered_plot_rect, &label, pos, config.font_scale);
1357        }
1358
1359        // Draw legend if enabled and entries available
1360        if rows * cols <= 1 && plot_renderer.overlay_show_legend() {
1361            self.draw_legend(
1362                ui,
1363                centered_plot_rect,
1364                plot_renderer.overlay_legend_entries(),
1365                config.font_scale,
1366            );
1367        }
1368
1369        // Draw colorbar if enabled
1370        if plot_renderer.overlay_colorbar_enabled() {
1371            // Simple vertical colorbar on the right side inside plot
1372            let bar_width = 12.0;
1373            let pad = 8.0;
1374            let bar_rect = Rect::from_min_max(
1375                egui::pos2(
1376                    centered_plot_rect.max.x - bar_width - pad,
1377                    centered_plot_rect.min.y + pad,
1378                ),
1379                egui::pos2(
1380                    centered_plot_rect.max.x - pad,
1381                    centered_plot_rect.max.y - pad,
1382                ),
1383            );
1384            // Fill with gradient according to colormap
1385            let steps = 64;
1386            for i in 0..steps {
1387                let t0 = i as f32 / steps as f32;
1388                let t1 = (i + 1) as f32 / steps as f32;
1389                let y0 = bar_rect.min.y + (1.0 - t0) * bar_rect.height();
1390                let y1 = bar_rect.min.y + (1.0 - t1) * bar_rect.height();
1391                let cmap = plot_renderer.overlay_colormap();
1392                let c = cmap.map_value(t0);
1393                let col = Color32::from_rgb(
1394                    (c.x * 255.0) as u8,
1395                    (c.y * 255.0) as u8,
1396                    (c.z * 255.0) as u8,
1397                );
1398                ui.painter().rect_filled(
1399                    Rect::from_min_max(
1400                        egui::pos2(bar_rect.min.x, y1),
1401                        egui::pos2(bar_rect.max.x, y0),
1402                    ),
1403                    0.0,
1404                    col,
1405                );
1406            }
1407            let bg = plot_renderer.theme.build_theme().get_background_color();
1408            let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
1409            let border = if bg_luma > 0.62 {
1410                Color32::from_gray(60)
1411            } else {
1412                Color32::WHITE
1413            };
1414            ui.painter()
1415                .rect_stroke(bar_rect, 0.0, Stroke::new(1.0, border));
1416        }
1417
1418        self.axes_plot_rects = rendered_axes_rects;
1419        centered_plot_rect
1420    }
1421
1422    /// Compute subplot rectangles within a given plot area for a rows x cols grid (row-major)
1423    pub fn compute_subplot_rects(
1424        &self,
1425        outer: Rect,
1426        rows: usize,
1427        cols: usize,
1428        hgap: f32,
1429        vgap: f32,
1430    ) -> Vec<Rect> {
1431        let rows = rows.max(1) as f32;
1432        let cols = cols.max(1) as f32;
1433        let total_hgap = hgap * (cols - 1.0);
1434        let total_vgap = vgap * (rows - 1.0);
1435        let cell_w = ((outer.width()).max(1.0) - total_hgap).max(1.0) / cols;
1436        let cell_h = ((outer.height()).max(1.0) - total_vgap).max(1.0) / rows;
1437        let mut rects = Vec::new();
1438        for r in 0..rows as i32 {
1439            for c in 0..cols as i32 {
1440                let x = outer.min.x + c as f32 * (cell_w + hgap);
1441                let y = outer.min.y + r as f32 * (cell_h + vgap);
1442                rects.push(Rect::from_min_size(
1443                    egui::pos2(x, y),
1444                    egui::vec2(cell_w, cell_h),
1445                ));
1446            }
1447        }
1448        rects
1449    }
1450
1451    /// Draw grid lines based on data bounds
1452    fn draw_grid(
1453        &self,
1454        ui: &mut egui::Ui,
1455        plot_rect: Rect,
1456        plot_renderer: &PlotRenderer,
1457        view_bounds_override: Option<(f64, f64, f64, f64)>,
1458        axes_index: Option<usize>,
1459    ) {
1460        let ppp = ui.ctx().pixels_per_point();
1461        let edge_eps = 0.51 / ppp.max(0.5);
1462        if let Some(data_bounds) = view_bounds_override
1463            .or_else(|| plot_renderer.view_bounds())
1464            .or_else(|| plot_renderer.data_bounds())
1465        {
1466            let (grid_color_major, _grid_color_minor) = self.themed_grid_colors();
1467
1468            let (x_min, x_max, y_min, y_max) = data_bounds;
1469            let x_range = x_max - x_min;
1470            let y_range = y_max - y_min;
1471
1472            // Calculate tick intervals
1473            let x_log = axes_index
1474                .map(|idx| plot_renderer.overlay_x_log_for_axes(idx))
1475                .unwrap_or_else(|| plot_renderer.overlay_x_log());
1476            let y_log = axes_index
1477                .map(|idx| plot_renderer.overlay_y_log_for_axes(idx))
1478                .unwrap_or_else(|| plot_renderer.overlay_y_log());
1479
1480            let x_ticks = if x_log {
1481                Vec::new()
1482            } else {
1483                plot_utils::generate_major_ticks(x_min, x_max)
1484            };
1485            let y_ticks = if y_log {
1486                Vec::new()
1487            } else {
1488                plot_utils::generate_major_ticks(y_min, y_max)
1489            };
1490
1491            // Draw vertical grid lines (linear vs log)
1492            if x_log {
1493                // Decades within [x_min, x_max]
1494                let start_decade = x_min.log10().floor() as i32;
1495                let end_decade = x_max.log10().ceil() as i32;
1496                for d in start_decade..=end_decade {
1497                    let decade = 10f64.powi(d);
1498                    for m in [1.0, 2.0, 5.0].iter() {
1499                        let x_val = decade * m;
1500                        if x_val < x_min || x_val > x_max {
1501                            continue;
1502                        }
1503                        let x_screen = plot_rect.min.x
1504                            + ((x_val.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
1505                                as f32
1506                                * plot_rect.width();
1507                        let x_screen = Self::snap_coord(x_screen, ppp);
1508                        if (x_screen - plot_rect.min.x).abs() <= edge_eps
1509                            || (x_screen - plot_rect.max.x).abs() <= edge_eps
1510                        {
1511                            continue;
1512                        }
1513                        ui.painter().line_segment(
1514                            [
1515                                Pos2::new(x_screen, plot_rect.min.y),
1516                                Pos2::new(x_screen, plot_rect.max.y),
1517                            ],
1518                            Stroke::new(0.8, grid_color_major),
1519                        );
1520                    }
1521                }
1522            } else {
1523                for x_val in x_ticks {
1524                    let x_screen =
1525                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1526                    let x_screen = Self::snap_coord(x_screen, ppp);
1527                    if (x_screen - plot_rect.min.x).abs() <= edge_eps
1528                        || (x_screen - plot_rect.max.x).abs() <= edge_eps
1529                    {
1530                        continue;
1531                    }
1532                    ui.painter().line_segment(
1533                        [
1534                            Pos2::new(x_screen, plot_rect.min.y),
1535                            Pos2::new(x_screen, plot_rect.max.y),
1536                        ],
1537                        Stroke::new(0.8, grid_color_major),
1538                    );
1539                }
1540            }
1541
1542            // Draw horizontal grid lines (linear vs log)
1543            if y_log {
1544                let start_decade = y_min.log10().floor() as i32;
1545                let end_decade = y_max.log10().ceil() as i32;
1546                for d in start_decade..=end_decade {
1547                    let decade = 10f64.powi(d);
1548                    for m in [1.0, 2.0, 5.0].iter() {
1549                        let y_val = decade * m;
1550                        if y_val < y_min || y_val > y_max {
1551                            continue;
1552                        }
1553                        let y_screen = plot_rect.max.y
1554                            - ((y_val.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
1555                                as f32
1556                                * plot_rect.height();
1557                        let y_screen = Self::snap_coord(y_screen, ppp);
1558                        if (y_screen - plot_rect.min.y).abs() <= edge_eps
1559                            || (y_screen - plot_rect.max.y).abs() <= edge_eps
1560                        {
1561                            continue;
1562                        }
1563                        ui.painter().line_segment(
1564                            [
1565                                Pos2::new(plot_rect.min.x, y_screen),
1566                                Pos2::new(plot_rect.max.x, y_screen),
1567                            ],
1568                            Stroke::new(0.8, grid_color_major),
1569                        );
1570                    }
1571                }
1572            } else {
1573                for y_val in y_ticks {
1574                    let y_screen =
1575                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1576                    let y_screen = Self::snap_coord(y_screen, ppp);
1577                    if (y_screen - plot_rect.min.y).abs() <= edge_eps
1578                        || (y_screen - plot_rect.max.y).abs() <= edge_eps
1579                    {
1580                        continue;
1581                    }
1582                    ui.painter().line_segment(
1583                        [
1584                            Pos2::new(plot_rect.min.x, y_screen),
1585                            Pos2::new(plot_rect.max.x, y_screen),
1586                        ],
1587                        Stroke::new(0.8, grid_color_major),
1588                    );
1589                }
1590            }
1591        }
1592    }
1593
1594    /// Draw axis ticks and numeric labels
1595    fn draw_axes(
1596        &self,
1597        ui: &mut egui::Ui,
1598        plot_rect: Rect,
1599        plot_renderer: &PlotRenderer,
1600        config: &OverlayConfig,
1601        view_bounds_override: Option<(f64, f64, f64, f64)>,
1602        axes_index: Option<usize>,
1603    ) {
1604        let ppp = ui.ctx().pixels_per_point();
1605        if let Some(data_bounds) = view_bounds_override
1606            .or_else(|| plot_renderer.view_bounds())
1607            .or_else(|| plot_renderer.data_bounds())
1608        {
1609            let (x_min, x_max, y_min, y_max) = data_bounds;
1610            let x_range = x_max - x_min;
1611            let y_range = y_max - y_min;
1612            let scale = config.font_scale.max(0.75);
1613            let tick_length = 6.0 * scale;
1614            let label_offset = 15.0 * scale;
1615            let tick_font = FontId::proportional(10.0 * scale);
1616            let axis_color = self.theme_axis_color();
1617            let label_color = self.theme_text_color();
1618            let border_left = plot_rect.min.x;
1619            let border_bottom = plot_rect.max.y;
1620
1621            let x_log = axes_index
1622                .map(|idx| plot_renderer.overlay_x_log_for_axes(idx))
1623                .unwrap_or_else(|| plot_renderer.overlay_x_log());
1624            let y_log = axes_index
1625                .map(|idx| plot_renderer.overlay_y_log_for_axes(idx))
1626                .unwrap_or_else(|| plot_renderer.overlay_y_log());
1627
1628            // Histogram numeric tick support and categorical axis support
1629            let (mut cat_x, mut cat_y) = (false, false);
1630            let mut custom_hist_x = false;
1631            if let Some((true, edges)) =
1632                axes_index.and_then(|idx| plot_renderer.overlay_histogram_edges_for_axes(idx))
1633            {
1634                custom_hist_x = true;
1635                self.draw_histogram_axis_ticks(
1636                    ui,
1637                    plot_rect,
1638                    ppp,
1639                    axis_color,
1640                    label_color,
1641                    tick_length,
1642                    label_offset,
1643                    tick_font.clone(),
1644                    border_bottom,
1645                    x_min,
1646                    x_max,
1647                    &edges,
1648                );
1649            }
1650            if let Some(labels) = axes_index.and_then(|idx| {
1651                plot_renderer
1652                    .overlay_x_tick_labels_for_axes(idx)
1653                    .filter(|labels| !labels.is_empty())
1654            }) {
1655                cat_x = true;
1656                let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
1657                for (label_idx, label) in labels.iter().enumerate() {
1658                    if label_idx != 0 && label_idx != labels.len() - 1 && label_idx % stride != 0 {
1659                        continue;
1660                    }
1661                    let x_val = (label_idx + 1) as f64;
1662                    if x_val < x_min || x_val > x_max {
1663                        continue;
1664                    }
1665                    let x_screen =
1666                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1667                    let x_screen = Self::snap_coord(x_screen, ppp);
1668                    ui.painter().line_segment(
1669                        [
1670                            Pos2::new(x_screen, border_bottom),
1671                            Pos2::new(x_screen, border_bottom + tick_length),
1672                        ],
1673                        Stroke::new(1.0, axis_color),
1674                    );
1675                    let text = truncate_label(label, 14);
1676                    ui.painter().text(
1677                        Pos2::new(x_screen, border_bottom + label_offset),
1678                        Align2::CENTER_CENTER,
1679                        text,
1680                        tick_font.clone(),
1681                        label_color,
1682                    );
1683                }
1684            }
1685            if let Some(labels) = axes_index.and_then(|idx| {
1686                plot_renderer
1687                    .overlay_y_tick_labels_for_axes(idx)
1688                    .filter(|labels| !labels.is_empty())
1689            }) {
1690                cat_y = true;
1691                let stride = Self::label_stride(&labels, plot_rect.height(), tick_font.size);
1692                for (label_idx, label) in labels.iter().enumerate() {
1693                    if label_idx != 0 && label_idx != labels.len() - 1 && label_idx % stride != 0 {
1694                        continue;
1695                    }
1696                    let y_val = (label_idx + 1) as f64;
1697                    if y_val < y_min || y_val > y_max {
1698                        continue;
1699                    }
1700                    let y_screen =
1701                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1702                    let y_screen = Self::snap_coord(y_screen, ppp);
1703                    ui.painter().line_segment(
1704                        [
1705                            Pos2::new(border_left - tick_length, y_screen),
1706                            Pos2::new(border_left, y_screen),
1707                        ],
1708                        Stroke::new(1.0, axis_color),
1709                    );
1710                    let text = truncate_label(label, 14);
1711                    ui.painter().text(
1712                        Pos2::new(border_left - label_offset, y_screen),
1713                        Align2::CENTER_CENTER,
1714                        text,
1715                        tick_font.clone(),
1716                        label_color,
1717                    );
1718                }
1719            }
1720            if let Some((is_x, labels)) = axes_index
1721                .and_then(|idx| plot_renderer.overlay_categorical_labels_for_axes(idx))
1722                .or_else(|| {
1723                    plot_renderer
1724                        .overlay_categorical_labels()
1725                        .map(|(is_x, labels)| (is_x, labels.clone()))
1726                })
1727            {
1728                if (is_x && cat_x) || (!is_x && cat_y) {
1729                    // Explicit axes tick labels take precedence over inferred bar labels.
1730                } else if is_x {
1731                    cat_x = true;
1732                    let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
1733                    // Draw X categorical labels at integer positions (1..n)
1734                    for (label_idx, label) in labels.iter().enumerate() {
1735                        if label_idx != 0
1736                            && label_idx != labels.len() - 1
1737                            && label_idx % stride != 0
1738                        {
1739                            continue;
1740                        }
1741                        let x_val = (label_idx + 1) as f64;
1742                        if x_val < x_min || x_val > x_max {
1743                            continue;
1744                        }
1745                        let x_screen = plot_rect.min.x
1746                            + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1747                        let x_screen = Self::snap_coord(x_screen, ppp);
1748                        // Tick
1749                        ui.painter().line_segment(
1750                            [
1751                                Pos2::new(x_screen, border_bottom),
1752                                Pos2::new(x_screen, border_bottom + tick_length),
1753                            ],
1754                            Stroke::new(1.0, axis_color),
1755                        );
1756                        // Label
1757                        let text = truncate_label(label, 14);
1758                        ui.painter().text(
1759                            Pos2::new(x_screen, border_bottom + label_offset),
1760                            Align2::CENTER_CENTER,
1761                            text,
1762                            tick_font.clone(),
1763                            label_color,
1764                        );
1765                    }
1766                } else {
1767                    cat_y = true;
1768                    let stride = Self::label_stride(&labels, plot_rect.height(), tick_font.size);
1769                    // Draw Y categorical labels at integer positions (1..n)
1770                    for (label_idx, label) in labels.iter().enumerate() {
1771                        if label_idx != 0
1772                            && label_idx != labels.len() - 1
1773                            && label_idx % stride != 0
1774                        {
1775                            continue;
1776                        }
1777                        let y_val = (label_idx + 1) as f64;
1778                        if y_val < y_min || y_val > y_max {
1779                            continue;
1780                        }
1781                        let y_screen = plot_rect.max.y
1782                            - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1783                        let y_screen = Self::snap_coord(y_screen, ppp);
1784                        // Tick
1785                        ui.painter().line_segment(
1786                            [
1787                                Pos2::new(border_left - tick_length, y_screen),
1788                                Pos2::new(border_left, y_screen),
1789                            ],
1790                            Stroke::new(1.0, axis_color),
1791                        );
1792                        // Label
1793                        let text = truncate_label(label, 14);
1794                        ui.painter().text(
1795                            Pos2::new(border_left - label_offset, y_screen),
1796                            Align2::CENTER_CENTER,
1797                            text,
1798                            tick_font.clone(),
1799                            label_color,
1800                        );
1801                    }
1802                }
1803            }
1804
1805            // Draw X-axis ticks and labels (categorical handled above)
1806            if x_log {
1807                let start_decade = x_min.log10().floor() as i32;
1808                let end_decade = x_max.log10().ceil() as i32;
1809                for d in start_decade..=end_decade {
1810                    let decade = 10f64.powi(d);
1811                    let x_screen = plot_rect.min.x
1812                        + ((decade.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
1813                            as f32
1814                            * plot_rect.width();
1815                    let x_screen = Self::snap_coord(x_screen, ppp);
1816                    // Tick mark
1817                    ui.painter().line_segment(
1818                        [
1819                            Pos2::new(x_screen, border_bottom),
1820                            Pos2::new(x_screen, border_bottom + tick_length),
1821                        ],
1822                        Stroke::new(1.0, axis_color),
1823                    );
1824                    // Label like 10^d
1825                    ui.painter().text(
1826                        Pos2::new(x_screen, border_bottom + label_offset),
1827                        Align2::CENTER_CENTER,
1828                        format!("10^{}", d),
1829                        tick_font.clone(),
1830                        label_color,
1831                    );
1832                }
1833            } else if !cat_x && !custom_hist_x {
1834                for x_val in plot_utils::generate_major_ticks(x_min, x_max) {
1835                    let x_screen =
1836                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1837                    let x_screen = Self::snap_coord(x_screen, ppp);
1838                    ui.painter().line_segment(
1839                        [
1840                            Pos2::new(x_screen, border_bottom),
1841                            Pos2::new(x_screen, border_bottom + tick_length),
1842                        ],
1843                        Stroke::new(1.0, axis_color),
1844                    );
1845                    ui.painter().text(
1846                        Pos2::new(x_screen, border_bottom + label_offset),
1847                        Align2::CENTER_CENTER,
1848                        plot_utils::format_tick_label(x_val),
1849                        tick_font.clone(),
1850                        label_color,
1851                    );
1852                }
1853            }
1854
1855            // Draw Y-axis ticks and labels (categorical handled above)
1856            if y_log {
1857                let start_decade = y_min.log10().floor() as i32;
1858                let end_decade = y_max.log10().ceil() as i32;
1859                for d in start_decade..=end_decade {
1860                    let decade = 10f64.powi(d);
1861                    let y_screen = plot_rect.max.y
1862                        - ((decade.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
1863                            as f32
1864                            * plot_rect.height();
1865                    let y_screen = Self::snap_coord(y_screen, ppp);
1866                    ui.painter().line_segment(
1867                        [
1868                            Pos2::new(border_left - tick_length, y_screen),
1869                            Pos2::new(border_left, y_screen),
1870                        ],
1871                        Stroke::new(1.0, axis_color),
1872                    );
1873                    ui.painter().text(
1874                        Pos2::new(border_left - label_offset, y_screen),
1875                        Align2::CENTER_CENTER,
1876                        format!("10^{}", d),
1877                        tick_font.clone(),
1878                        label_color,
1879                    );
1880                }
1881            } else if !cat_y {
1882                for y_val in plot_utils::generate_major_ticks(y_min, y_max) {
1883                    let y_screen =
1884                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1885                    let y_screen = Self::snap_coord(y_screen, ppp);
1886                    ui.painter().line_segment(
1887                        [
1888                            Pos2::new(border_left - tick_length, y_screen),
1889                            Pos2::new(border_left, y_screen),
1890                        ],
1891                        Stroke::new(1.0, axis_color),
1892                    );
1893                    ui.painter().text(
1894                        Pos2::new(border_left - label_offset, y_screen),
1895                        Align2::CENTER_CENTER,
1896                        plot_utils::format_tick_label(y_val),
1897                        tick_font.clone(),
1898                        label_color,
1899                    );
1900                }
1901            }
1902        }
1903    }
1904
1905    /// Draw a CAD-style XYZ orientation gizmo in the bottom-left corner of the plot rect.
1906    /// This is drawn in screen-space (overlay) but rotates with the current 3D camera.
1907    fn draw_3d_orientation_gizmo(
1908        &self,
1909        ui: &mut egui::Ui,
1910        plot_rect: Rect,
1911        plot_renderer: &PlotRenderer,
1912        axes_index: usize,
1913        font_scale: f32,
1914    ) {
1915        let cam_ref = plot_renderer
1916            .axes_camera(axes_index)
1917            .unwrap_or_else(|| plot_renderer.camera());
1918        let cam = cam_ref.clone();
1919
1920        let forward = (cam.target - cam.position).normalize_or_zero();
1921        if forward.length_squared() < 1e-9 {
1922            return;
1923        }
1924        let world_up = cam.up.normalize_or_zero();
1925        let right = forward.cross(world_up).normalize_or_zero();
1926        if right.length_squared() < 1e-9 {
1927            return;
1928        }
1929        let up = right.cross(forward).normalize_or_zero();
1930        if up.length_squared() < 1e-9 {
1931            return;
1932        }
1933
1934        let scale = font_scale.max(0.75);
1935        let base = plot_rect.width().min(plot_rect.height()).max(1.0);
1936        let gizmo_size = (base * 0.16).clamp(44.0, 110.0) * scale;
1937        let pad = (30.0 * scale).round();
1938        let origin = Pos2::new(plot_rect.min.x + pad, plot_rect.max.y - pad);
1939
1940        struct AxisItem {
1941            label: &'static str,
1942            dir_world: Vec3,
1943            color: Color32,
1944            z_sort: f32,
1945        }
1946
1947        let mut axes = [
1948            AxisItem {
1949                label: "X",
1950                dir_world: Vec3::X,
1951                color: Color32::from_rgb(235, 80, 80),
1952                z_sort: 0.0,
1953            },
1954            AxisItem {
1955                label: "Y",
1956                dir_world: Vec3::Y,
1957                color: Color32::from_rgb(90, 220, 120),
1958                z_sort: 0.0,
1959            },
1960            AxisItem {
1961                label: "Z",
1962                dir_world: Vec3::Z,
1963                color: Color32::from_rgb(90, 160, 255),
1964                z_sort: 0.0,
1965            },
1966        ];
1967
1968        // Transform world axis directions into camera space and use the camera-space z as a
1969        // painter's-order hint (draw farther axes first so nearer ones sit on top).
1970        for a in axes.iter_mut() {
1971            let x = a.dir_world.dot(right);
1972            let y = a.dir_world.dot(up);
1973            let z = a.dir_world.dot(-forward);
1974            a.z_sort = z;
1975            a.dir_world = Vec3::new(x, y, z);
1976        }
1977        axes.sort_by(|a, b| {
1978            a.z_sort
1979                .partial_cmp(&b.z_sort)
1980                .unwrap_or(std::cmp::Ordering::Equal)
1981        });
1982
1983        let painter = ui.painter();
1984
1985        painter.circle_filled(origin, 2.0 * scale, Color32::from_gray(210));
1986
1987        let axis_len = gizmo_size * 0.65;
1988        let head_len = (8.0 * scale).min(axis_len * 0.35);
1989        let head_w = 5.0 * scale;
1990        let font = FontId::proportional(11.0 * scale);
1991
1992        for a in axes.iter() {
1993            let dir2 = egui::Vec2::new(a.dir_world.x, -a.dir_world.y);
1994            let mag = dir2.length();
1995            if !mag.is_finite() || mag < 1e-4 {
1996                continue;
1997            }
1998            let d = dir2 / mag;
1999
2000            let end = origin + d * axis_len;
2001            let stroke = Stroke::new(2.0 * scale, a.color);
2002            painter.line_segment([origin, end], stroke);
2003
2004            // Arrow head
2005            let base = end - d * head_len;
2006            let perp = egui::Vec2::new(-d.y, d.x);
2007            painter.line_segment([end, base + perp * head_w], stroke);
2008            painter.line_segment([end, base - perp * head_w], stroke);
2009
2010            // Label near arrow tip
2011            let label_pos = end + d * (10.0 * scale);
2012            painter.text(
2013                label_pos,
2014                Align2::CENTER_CENTER,
2015                a.label,
2016                font.clone(),
2017                a.color,
2018            );
2019        }
2020    }
2021
2022    /// Draw dynamic tick labels for the 3D origin triad axes (X/Y/Z).
2023    /// These labels are screen-space (egui) but are positioned by projecting 3D points.
2024    fn draw_3d_origin_axis_ticks(
2025        &self,
2026        ui: &mut egui::Ui,
2027        plot_rect: Rect,
2028        plot_renderer: &PlotRenderer,
2029        axes_index: usize,
2030        font_scale: f32,
2031    ) {
2032        let cam_ref = plot_renderer
2033            .axes_camera(axes_index)
2034            .unwrap_or_else(|| plot_renderer.camera());
2035        let mut cam = cam_ref.clone();
2036        let w = plot_rect.width().max(1.0);
2037        let h = plot_rect.height().max(1.0);
2038        cam.update_aspect_ratio(w / h);
2039        let view_proj = cam.view_proj_matrix();
2040
2041        let project = |p: Vec3| -> Option<Pos2> {
2042            let clip: Vec4 = view_proj * Vec4::new(p.x, p.y, p.z, 1.0);
2043            if !clip.w.is_finite() || clip.w.abs() < 1e-6 {
2044                return None;
2045            }
2046            let ndc = clip.truncate() / clip.w;
2047            if !(ndc.x.is_finite() && ndc.y.is_finite()) {
2048                return None;
2049            }
2050            let sx = plot_rect.min.x + ((ndc.x + 1.0) * 0.5) * plot_rect.width();
2051            let sy = plot_rect.min.y + ((1.0 - ndc.y) * 0.5) * plot_rect.height();
2052            Some(Pos2::new(sx, sy))
2053        };
2054
2055        let nice_step = |raw: f64| -> f64 {
2056            if !raw.is_finite() || raw <= 0.0 {
2057                return 1.0;
2058            }
2059            let pow10 = 10.0_f64.powf(raw.log10().floor());
2060            let norm = raw / pow10;
2061            let mult = if norm <= 1.0 {
2062                1.0
2063            } else if norm <= 2.0 {
2064                2.0
2065            } else if norm <= 5.0 {
2066                5.0
2067            } else {
2068                10.0
2069            };
2070            mult * pow10
2071        };
2072
2073        // Use the same basic heuristic as the renderer: choose a major tick spacing based on
2074        // projected pixels per world unit near the origin.
2075        let origin = Vec3::ZERO;
2076        let px_per_world = match (project(origin), project(origin + Vec3::X)) {
2077            (Some(a), Some(b)) => ((b.x - a.x).hypot(b.y - a.y) as f64).max(1e-3),
2078            _ => 1.0,
2079        };
2080        let desired_major_px = 120.0_f64;
2081        let major_step = nice_step((desired_major_px / px_per_world).max(1e-6));
2082        if !(major_step.is_finite() && major_step > 0.0) {
2083            return;
2084        }
2085        let axis_len = (major_step as f32 * 5.0).max(0.5);
2086
2087        let scale = font_scale.max(0.75);
2088        let font = FontId::proportional(10.0 * scale);
2089        let painter = ui.painter();
2090        let col_x = Color32::from_rgb(235, 80, 80);
2091        let col_y = Color32::from_rgb(90, 220, 120);
2092        let col_z = Color32::from_rgb(90, 160, 255);
2093        let panel_center = plot_rect.center();
2094
2095        let outward_offset = |pos: Pos2, base: f32| {
2096            let dir = pos - panel_center;
2097            let len = dir.length().max(1.0);
2098            (dir / len) * base
2099        };
2100
2101        if let Some(pos) = project(Vec3::X * axis_len * 1.10) {
2102            if let Some(label) = plot_renderer.overlay_x_label_for_axes(axes_index) {
2103                let style = plot_renderer
2104                    .overlay_x_label_style_for_axes(axes_index)
2105                    .cloned()
2106                    .unwrap_or_default();
2107                let offset = outward_offset(pos, 12.0 * scale) + egui::vec2(4.0 * scale, 0.0);
2108                Self::paint_styled_text(
2109                    painter,
2110                    pos + offset,
2111                    Align2::LEFT_CENTER,
2112                    label,
2113                    Self::style_font_size(&style, 12.0, scale),
2114                    Self::style_color(&style, col_x),
2115                    Self::style_is_bold(&style),
2116                    110,
2117                );
2118            }
2119        }
2120        if let Some(pos) = project(Vec3::Y * axis_len * 1.10) {
2121            if let Some(label) = plot_renderer.overlay_y_label_for_axes(axes_index) {
2122                let style = plot_renderer
2123                    .overlay_y_label_style_for_axes(axes_index)
2124                    .cloned()
2125                    .unwrap_or_default();
2126                let offset =
2127                    outward_offset(pos, 12.0 * scale) + egui::vec2(2.0 * scale, -2.0 * scale);
2128                Self::paint_styled_text(
2129                    painter,
2130                    pos + offset,
2131                    Align2::LEFT_CENTER,
2132                    label,
2133                    Self::style_font_size(&style, 12.0, scale),
2134                    Self::style_color(&style, col_y),
2135                    Self::style_is_bold(&style),
2136                    110,
2137                );
2138            }
2139        }
2140        if let Some(pos) = project(Vec3::Z * axis_len * 1.10) {
2141            if let Some(label) = plot_renderer.overlay_z_label_for_axes(axes_index) {
2142                let style = plot_renderer
2143                    .overlay_z_label_style_for_axes(axes_index)
2144                    .cloned()
2145                    .unwrap_or_default();
2146                let offset = outward_offset(pos, 12.0 * scale) + egui::vec2(0.0, -4.0 * scale);
2147                Self::paint_styled_text(
2148                    painter,
2149                    pos + offset,
2150                    Align2::LEFT_BOTTOM,
2151                    label,
2152                    Self::style_font_size(&style, 12.0, scale),
2153                    Self::style_color(&style, col_z),
2154                    Self::style_is_bold(&style),
2155                    110,
2156                );
2157            }
2158        }
2159
2160        let draw_axis = |axis: Vec3, color: Color32| {
2161            for i in 1..=6 {
2162                let t = (i as f32) * (major_step as f32);
2163                if t >= axis_len * 0.999 {
2164                    break;
2165                }
2166                let p = origin + axis * t;
2167                let Some(pos) = project(p) else { continue };
2168                // Offset labels slightly away from the axis in screen-space based on camera right/up.
2169                let offset =
2170                    outward_offset(pos, 7.0 * scale) + egui::Vec2::new(3.0 * scale, -3.0 * scale);
2171                painter.text(
2172                    pos + offset + egui::vec2(1.0, 1.0),
2173                    Align2::LEFT_CENTER,
2174                    plot_utils::format_tick_label((i as f64) * major_step),
2175                    font.clone(),
2176                    Color32::from_rgba_premultiplied(0, 0, 0, 90),
2177                );
2178                painter.text(
2179                    pos + offset,
2180                    Align2::LEFT_CENTER,
2181                    plot_utils::format_tick_label((i as f64) * major_step),
2182                    font.clone(),
2183                    color,
2184                );
2185            }
2186        };
2187        draw_axis(Vec3::X, col_x);
2188        draw_axis(Vec3::Y, col_y);
2189        draw_axis(Vec3::Z, col_z);
2190    }
2191
2192    fn draw_title_in_rect(
2193        &self,
2194        ui: &mut egui::Ui,
2195        rect: Rect,
2196        title: &str,
2197        style: Option<&TextStyle>,
2198        scale: f32,
2199    ) {
2200        let scale = scale.max(0.75);
2201        let style = style.cloned().unwrap_or_default();
2202        Self::paint_styled_text(
2203            ui.painter(),
2204            rect.center(),
2205            Align2::CENTER_CENTER,
2206            title,
2207            Self::style_font_size(&style, 16.0, scale),
2208            Self::style_color(&style, self.theme_text_color()),
2209            Self::style_is_bold(&style),
2210            110,
2211        );
2212    }
2213
2214    fn draw_legend(
2215        &self,
2216        ui: &mut egui::Ui,
2217        plot_rect: Rect,
2218        entries: &[crate::plots::figure::LegendEntry],
2219        scale: f32,
2220    ) {
2221        if entries.is_empty() {
2222            return;
2223        }
2224        let scale = scale.max(0.75);
2225        let theme = self.theme.build_theme();
2226        let bg = theme.get_background_color();
2227        let text = theme.get_text_color();
2228        let legend_text = Color32::from_rgb(
2229            (text.x.clamp(0.0, 1.0) * 255.0) as u8,
2230            (text.y.clamp(0.0, 1.0) * 255.0) as u8,
2231            (text.z.clamp(0.0, 1.0) * 255.0) as u8,
2232        );
2233        let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
2234        let legend_bg = if bg_luma > 0.62 {
2235            Color32::from_rgba_premultiplied(255, 255, 255, 170)
2236        } else {
2237            Color32::from_rgba_premultiplied(0, 0, 0, 128)
2238        };
2239        let legend_stroke = if bg_luma > 0.62 {
2240            Color32::from_rgb(55, 55, 55)
2241        } else {
2242            Color32::BLACK
2243        };
2244        let pad = 8.0 * scale;
2245        let row_h = (16.0 * scale).clamp(13.0, 18.0);
2246        let swatch_w = 14.0 * scale;
2247        let text_x_gap = 18.0 * scale;
2248        let legend_w = (plot_rect.width() * 0.30).clamp(92.0, 132.0);
2249        let x = plot_rect.max.x - legend_w - pad;
2250        let mut y = plot_rect.min.y + pad + 4.0 * scale;
2251        let legend_rect = Rect::from_min_max(
2252            egui::pos2(x - pad, plot_rect.min.y + pad),
2253            egui::pos2(x + legend_w, y + entries.len() as f32 * row_h + pad),
2254        );
2255        ui.painter().rect_filled(legend_rect, 4.0, legend_bg);
2256        y += 10.0 * scale;
2257        for e in entries {
2258            let c = Color32::from_rgb(
2259                (e.color.x * 255.0) as u8,
2260                (e.color.y * 255.0) as u8,
2261                (e.color.z * 255.0) as u8,
2262            );
2263            let swatch_rect = Rect::from_min_size(
2264                egui::pos2(x, y - 5.0 * scale),
2265                egui::vec2(swatch_w, 7.0 * scale),
2266            );
2267            match e.plot_type {
2268                crate::plots::figure::PlotType::Line
2269                | crate::plots::figure::PlotType::Line3
2270                | crate::plots::figure::PlotType::Contour
2271                | crate::plots::figure::PlotType::ReferenceLine => {
2272                    let ymid = swatch_rect.center().y;
2273                    ui.painter().line_segment(
2274                        [
2275                            Pos2::new(swatch_rect.min.x, ymid),
2276                            Pos2::new(swatch_rect.max.x, ymid),
2277                        ],
2278                        Stroke::new(2.0, c),
2279                    );
2280                }
2281                crate::plots::figure::PlotType::Scatter
2282                | crate::plots::figure::PlotType::Scatter3 => {
2283                    let center = swatch_rect.center();
2284                    ui.painter().circle_filled(center, 3.5, c);
2285                    ui.painter()
2286                        .circle_stroke(center, 3.5, Stroke::new(1.0, legend_stroke));
2287                }
2288                crate::plots::figure::PlotType::Bar
2289                | crate::plots::figure::PlotType::Area
2290                | crate::plots::figure::PlotType::Surface
2291                | crate::plots::figure::PlotType::Patch
2292                | crate::plots::figure::PlotType::Pie
2293                | crate::plots::figure::PlotType::ContourFill => {
2294                    ui.painter().rect_filled(swatch_rect, 2.0, c);
2295                    ui.painter()
2296                        .rect_stroke(swatch_rect, 2.0, Stroke::new(1.0, legend_stroke));
2297                }
2298                crate::plots::figure::PlotType::ErrorBar
2299                | crate::plots::figure::PlotType::Stairs
2300                | crate::plots::figure::PlotType::Stem
2301                | crate::plots::figure::PlotType::Quiver => {
2302                    let ymid = swatch_rect.center().y;
2303                    ui.painter().line_segment(
2304                        [
2305                            Pos2::new(swatch_rect.min.x, ymid),
2306                            Pos2::new(swatch_rect.max.x - 4.0, ymid),
2307                        ],
2308                        Stroke::new(1.5, c),
2309                    );
2310                    ui.painter().line_segment(
2311                        [
2312                            Pos2::new(swatch_rect.max.x - 4.0, ymid - 3.0),
2313                            Pos2::new(swatch_rect.max.x, ymid),
2314                        ],
2315                        Stroke::new(1.0, c),
2316                    );
2317                }
2318            }
2319            ui.painter().text(
2320                egui::pos2(x + text_x_gap, y),
2321                Align2::LEFT_CENTER,
2322                &e.label,
2323                FontId::proportional(11.0 * scale),
2324                legend_text,
2325            );
2326            y += row_h;
2327        }
2328    }
2329
2330    fn draw_x_label_in_rect(&self, ui: &mut egui::Ui, rect: Rect, label: &str, scale: f32) {
2331        let scale = scale.max(0.75);
2332        let text_color = self.theme_text_color();
2333        ui.painter().text(
2334            Pos2::new(rect.center().x, rect.max.y - rect.height() * 0.24),
2335            Align2::CENTER_CENTER,
2336            label,
2337            FontId::proportional(14.0 * scale),
2338            text_color,
2339        );
2340    }
2341
2342    fn draw_y_label_in_rect(&self, ui: &mut egui::Ui, rect: Rect, label: &str, scale: f32) {
2343        let scale = scale.max(0.75);
2344        let text_color = self.theme_text_color();
2345        let galley = ui.fonts(|fonts| {
2346            fonts.layout_no_wrap(
2347                label.to_owned(),
2348                FontId::proportional(13.0 * scale),
2349                text_color,
2350            )
2351        });
2352        let size = galley.size();
2353        let center = Pos2::new(rect.min.x + rect.width() * 0.32, rect.center().y);
2354        let pos = Pos2::new(center.x - size.y * 0.5, center.y + size.x * 0.5);
2355        let mut shape = egui::epaint::TextShape::new(pos, galley, text_color);
2356        shape.angle = -std::f32::consts::FRAC_PI_2;
2357        shape.override_text_color = Some(text_color);
2358        ui.painter().add(shape);
2359    }
2360
2361    fn project_world_to_screen(
2362        &self,
2363        plot_rect: Rect,
2364        camera: &crate::core::Camera,
2365        point: glam::Vec3,
2366    ) -> Option<Pos2> {
2367        let mut cam = camera.clone();
2368        let clip = cam.view_proj_matrix() * point.extend(1.0);
2369        if !clip.x.is_finite() || !clip.y.is_finite() || !clip.z.is_finite() || !clip.w.is_finite()
2370        {
2371            return None;
2372        }
2373        if clip.w.abs() < 1.0e-6 {
2374            return None;
2375        }
2376        let ndc = clip.truncate() / clip.w;
2377        if ndc.z < -1.1 || ndc.z > 1.1 {
2378            return None;
2379        }
2380        if clip.w <= 0.0
2381            && matches!(
2382                camera.projection,
2383                crate::core::camera::ProjectionType::Perspective { .. }
2384            )
2385        {
2386            return None;
2387        }
2388        let x = plot_rect.min.x + (ndc.x + 1.0) * 0.5 * plot_rect.width();
2389        let y = plot_rect.min.y + (1.0 - (ndc.y + 1.0) * 0.5) * plot_rect.height();
2390        Some(Pos2::new(x, y))
2391    }
2392
2393    fn draw_projected_world_texts(
2394        &self,
2395        ui: &mut egui::Ui,
2396        plot_rect: Rect,
2397        plot_renderer: &PlotRenderer,
2398        axes_index: usize,
2399        scale: f32,
2400    ) {
2401        let Some(camera) = plot_renderer
2402            .axes_camera(axes_index)
2403            .or_else(|| Some(plot_renderer.camera()))
2404        else {
2405            return;
2406        };
2407        let annotations = plot_renderer.world_text_annotations_for_axes(axes_index);
2408        let is_3d = Self::axes_is_3d(plot_renderer, axes_index);
2409        for (position, text, style) in annotations {
2410            if !style.visible || text.trim().is_empty() {
2411                continue;
2412            }
2413            let Some(screen) = self.project_world_to_screen(plot_rect, camera, position) else {
2414                continue;
2415            };
2416            let color = Self::style_color(&style, self.theme_text_color());
2417            let font_size = Self::style_font_size(&style, 14.0, scale);
2418            let offset = if is_3d {
2419                egui::vec2(0.0, -8.0 * scale.max(0.75))
2420            } else {
2421                egui::vec2(0.0, 6.0 * scale.max(0.75))
2422            };
2423            Self::paint_styled_text(
2424                ui.painter(),
2425                screen + offset,
2426                Align2::CENTER_CENTER,
2427                &text,
2428                font_size,
2429                color,
2430                Self::style_is_bold(&style),
2431                if is_3d { 120 } else { 90 },
2432            );
2433        }
2434    }
2435
2436    fn draw_pie_label(
2437        &self,
2438        ui: &mut egui::Ui,
2439        plot_rect: Rect,
2440        label: &str,
2441        pos: glam::Vec2,
2442        scale: f32,
2443    ) {
2444        let center = plot_rect.center();
2445        let radius = plot_rect.width().min(plot_rect.height()) * 0.4;
2446        let screen = Pos2::new(center.x + pos.x * radius, center.y - pos.y * radius);
2447        ui.painter().text(
2448            screen,
2449            Align2::CENTER_CENTER,
2450            label,
2451            FontId::proportional(12.0 * scale.max(0.75)),
2452            self.theme_text_color(),
2453        );
2454    }
2455
2456    /// Get the plot area from the last frame
2457    pub fn plot_area(&self) -> Option<Rect> {
2458        self.plot_area
2459    }
2460
2461    /// Get per-axes snapped content rectangles from the last frame.
2462    pub fn axes_plot_rects(&self) -> &[Rect] {
2463        &self.axes_plot_rects
2464    }
2465
2466    /// Get toolbar rectangle from last frame
2467    pub fn toolbar_rect(&self) -> Option<Rect> {
2468        self.toolbar_rect
2469    }
2470
2471    /// Get sidebar rectangle from last frame
2472    pub fn sidebar_rect(&self) -> Option<Rect> {
2473        self.sidebar_rect
2474    }
2475
2476    pub fn take_toolbar_actions(&mut self) -> (bool, bool, bool, Option<bool>, Option<bool>) {
2477        let out = (
2478            self.want_save_png,
2479            self.want_save_svg,
2480            self.want_reset_view,
2481            self.want_toggle_grid.take(),
2482            self.want_toggle_legend.take(),
2483        );
2484        self.want_save_png = false;
2485        self.want_save_svg = false;
2486        self.want_reset_view = false;
2487        out
2488    }
2489
2490    /// Render the Dystr information modal
2491    fn render_dystr_modal(&mut self, ctx: &Context) -> bool {
2492        let mut consumed_input = false;
2493
2494        egui::Window::new("About Dystr")
2495            .anchor(Align2::CENTER_CENTER, egui::Vec2::ZERO)
2496            .collapsible(false)
2497            .resizable(false)
2498            .default_width(400.0)
2499            .show(ctx, |ui| {
2500                consumed_input = true;
2501
2502                ui.vertical_centered(|ui| {
2503                    ui.add_space(10.0);
2504
2505                    // Dystr logo placeholder (larger for modal)
2506                    let logo_size = egui::Vec2::splat(64.0);
2507                    let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::hover()).0;
2508
2509                    ui.painter().rect_filled(
2510                        logo_rect,
2511                        8.0,                             // rounded corners
2512                        Color32::from_rgb(60, 130, 200), // Dystr brand color placeholder
2513                    );
2514
2515                    ui.painter().text(
2516                        logo_rect.center(),
2517                        Align2::CENTER_CENTER,
2518                        "D",
2519                        FontId::proportional(40.0),
2520                        Color32::WHITE,
2521                    );
2522
2523                    ui.add_space(15.0);
2524
2525                    ui.heading("Welcome to RunMat");
2526                    ui.add_space(10.0);
2527
2528                    ui.label("RunMat is a high-performance MATLAB-compatible");
2529                    ui.label("numerical computing platform, built as part of");
2530                    ui.label("the Dystr computation ecosystem.");
2531
2532                    ui.add_space(15.0);
2533
2534                    ui.label("🚀 V8-inspired JIT compilation");
2535                    ui.label("⚡ BLAS/LAPACK acceleration");
2536                    ui.label("🎯 Full MATLAB compatibility");
2537                    ui.label("🔬 Advanced plotting & visualization");
2538
2539                    ui.add_space(20.0);
2540
2541                    ui.horizontal(|ui| {
2542                        if ui.button("Visit dystr.com").clicked() {
2543                            // Open dystr.com in browser
2544                            if let Err(e) = webbrowser::open("https://dystr.com") {
2545                                eprintln!("Failed to open browser: {e}");
2546                            }
2547                        }
2548
2549                        if ui.button("Close").clicked() {
2550                            self.show_dystr_modal = false;
2551                        }
2552                    });
2553
2554                    ui.add_space(10.0);
2555                });
2556            });
2557
2558        consumed_input
2559    }
2560}
2561
2562fn truncate_label(label: &str, max_len: usize) -> String {
2563    if label.chars().count() <= max_len {
2564        return label.to_string();
2565    }
2566    let mut out = String::new();
2567    for (i, ch) in label.chars().enumerate() {
2568        if i >= max_len - 1 {
2569            break;
2570        }
2571        out.push(ch);
2572    }
2573    out.push('…');
2574    out
2575}