Skip to main content

plot_redactor/view/
app.rs

1//! Main editor window composition.
2//!
3//! Dataflow: render from controller state, collect user interactions, emit actions.
4
5use std::ops::Range;
6
7use crate::controller::action::Action;
8use crate::controller::{NotificationLevel, PlotController};
9use crate::model::{
10    AxisKind, LegendPosition, LineStyle, MarkerShape, RangePolicy, ScaleType, TickConfig,
11};
12use crate::view::FilesMenu;
13use eframe::egui::{self, Color32, RichText, SidePanel};
14use egui_plotter::{Chart, MouseConfig};
15use plotters::coord::types::RangedCoordf32;
16use plotters::prelude::*;
17use plotters::style::Color as PlottersColor;
18
19/// EN: Main editor view. Contains menu, control panels and plot area.
20/// RU: Osnovnoe predstavlenie. Soderzhit menyu, paneli upravleniya i oblast grafa.
21pub struct PlotEditorView;
22
23impl PlotEditorView {
24    /// Creates editor view state.
25    pub fn new() -> Self {
26        Self
27    }
28
29    /// Renders full UI and collects actions requested by user interactions.
30    pub fn draw(&mut self, ctx: &egui::Context, controller: &PlotController) -> Vec<Action> {
31        let mut actions = FilesMenu::draw(ctx);
32        self.draw_controls(ctx, controller, &mut actions);
33        self.draw_plot(ctx, controller);
34        actions
35    }
36
37    fn draw_controls(
38        &mut self,
39        ctx: &egui::Context,
40        controller: &PlotController,
41        actions: &mut Vec<Action>,
42    ) {
43        SidePanel::right("control_panel")
44            .resizable(true)
45            .default_width(360.0)
46            .show(ctx, |ui| {
47                ui.heading("Controls");
48                ui.separator();
49
50                if let Some(n) = controller.notification() {
51                    let color = match n.level {
52                        NotificationLevel::Info => Color32::DARK_GREEN,
53                        NotificationLevel::Error => Color32::RED,
54                    };
55                    ui.colored_label(color, &n.message);
56                    ui.separator();
57                }
58
59                ui.label(RichText::new("Axis").strong());
60                axis_editor(ui, "X Axis", AxisKind::X, controller, actions);
61                axis_editor(ui, "Y Axis", AxisKind::Y, controller, actions);
62
63                ui.separator();
64                ui.label(RichText::new("Legend").strong());
65                let mut legend_visible = controller.model.legend.visible;
66                if ui.checkbox(&mut legend_visible, "Visible").changed() {
67                    actions.push(Action::SetLegendVisible(legend_visible));
68                }
69                let mut legend_title = controller.model.legend.title.clone().unwrap_or_default();
70                if ui.text_edit_singleline(&mut legend_title).changed() {
71                    actions.push(Action::SetLegendTitle(if legend_title.trim().is_empty() {
72                        None
73                    } else {
74                        Some(legend_title)
75                    }));
76                }
77                let mut legend_pos = controller.model.legend.position;
78                egui::ComboBox::from_label("Position")
79                    .selected_text(legend_position_text(legend_pos))
80                    .show_ui(ui, |ui| {
81                        ui.selectable_value(&mut legend_pos, LegendPosition::TopLeft, "Top Left");
82                        ui.selectable_value(
83                            &mut legend_pos,
84                            LegendPosition::TopRight,
85                            "Top Right",
86                        );
87                        ui.selectable_value(
88                            &mut legend_pos,
89                            LegendPosition::BottomLeft,
90                            "Bottom Left",
91                        );
92                        ui.selectable_value(
93                            &mut legend_pos,
94                            LegendPosition::BottomRight,
95                            "Bottom Right",
96                        );
97                    });
98                if legend_pos != controller.model.legend.position {
99                    actions.push(Action::SetLegendPosition(legend_pos));
100                }
101                let mut legend_font_size = controller.model.legend.font_size;
102                if ui
103                    .add(
104                        egui::Slider::new(&mut legend_font_size, 8..=64)
105                            .text("Legend font size"),
106                    )
107                    .changed()
108                {
109                    actions.push(Action::SetLegendFontSize(legend_font_size));
110                }
111                let mut legend_color = Color32::from_rgba_premultiplied(
112                    controller.model.legend.font_color.r,
113                    controller.model.legend.font_color.g,
114                    controller.model.legend.font_color.b,
115                    controller.model.legend.font_color.a,
116                );
117                if ui.color_edit_button_srgba(&mut legend_color).changed() {
118                    actions.push(Action::SetLegendFontColor(crate::model::Color {
119                        r: legend_color.r(),
120                        g: legend_color.g(),
121                        b: legend_color.b(),
122                        a: legend_color.a(),
123                    }));
124                }
125
126                ui.separator();
127                ui.label(RichText::new("Label").strong());
128                let mut title = controller.model.layout.title.clone();
129                if ui.text_edit_singleline(&mut title).changed() {
130                    actions.push(Action::SetChartTitle(title));
131                }
132                let mut title_font_size = controller.model.layout.title_font_size;
133                if ui
134                    .add(egui::Slider::new(&mut title_font_size, 8..=72).text("Title font size"))
135                    .changed()
136                {
137                    actions.push(Action::SetLabelFontSize(title_font_size));
138                }
139                let mut title_color = Color32::from_rgba_premultiplied(
140                    controller.model.layout.title_font_color.r,
141                    controller.model.layout.title_font_color.g,
142                    controller.model.layout.title_font_color.b,
143                    controller.model.layout.title_font_color.a,
144                );
145                if ui.color_edit_button_srgba(&mut title_color).changed() {
146                    actions.push(Action::SetLabelFontColor(crate::model::Color {
147                        r: title_color.r(),
148                        g: title_color.g(),
149                        b: title_color.b(),
150                        a: title_color.a(),
151                    }));
152                }
153
154                ui.separator();
155                ui.label(RichText::new("Series").strong());
156                let columns = controller.available_columns();
157
158                for series in &controller.model.series {
159                    ui.push_id(series.id.0, |ui| {
160                        ui.group(|ui| {
161                        ui.horizontal(|ui| {
162                            ui.label(format!("ID {}", series.id.0));
163                            if ui.button("Remove").clicked() {
164                                actions.push(Action::RemoveSeries {
165                                    series_id: series.id,
166                                });
167                            }
168                        });
169
170                        let mut name = series.name.clone();
171                        if ui.text_edit_singleline(&mut name).changed() {
172                            actions.push(Action::RenameSeries {
173                                series_id: series.id,
174                                name,
175                            });
176                        }
177
178                        let mut visible = series.visible;
179                        if ui.checkbox(&mut visible, "Visible").changed() {
180                            actions.push(Action::SetSeriesVisibility {
181                                series_id: series.id,
182                                visible,
183                            });
184                        }
185
186                        if columns.is_empty() {
187                            ui.label("Load CSV/TXT to select X/Y columns");
188                        } else {
189                            let mut x_col = if series.x_column.is_empty() {
190                                columns[0].clone()
191                            } else {
192                                series.x_column.clone()
193                            };
194                            egui::ComboBox::from_label("X column")
195                                .selected_text(x_col.clone())
196                                .show_ui(ui, |ui| {
197                                    for col in &columns {
198                                        ui.selectable_value(&mut x_col, col.clone(), col);
199                                    }
200                                });
201                            if x_col != series.x_column {
202                                actions.push(Action::SetSeriesXColumn {
203                                    series_id: series.id,
204                                    x_column: x_col,
205                                });
206                            }
207
208                            let default_y =
209                                columns.get(1).cloned().unwrap_or_else(|| columns[0].clone());
210                            let mut y_col = if series.y_column.is_empty() {
211                                default_y
212                            } else {
213                                series.y_column.clone()
214                            };
215                            egui::ComboBox::from_label("Y column")
216                                .selected_text(y_col.clone())
217                                .show_ui(ui, |ui| {
218                                    for col in &columns {
219                                        ui.selectable_value(&mut y_col, col.clone(), col);
220                                    }
221                                });
222                            if y_col != series.y_column {
223                                actions.push(Action::SetSeriesYColumn {
224                                    series_id: series.id,
225                                    y_column: y_col,
226                                });
227                            }
228                        }
229
230                        let mut width = series.style.line_width;
231                        if ui
232                            .add(egui::Slider::new(&mut width, 1.0..=10.0).text("Width"))
233                            .changed()
234                        {
235                            actions.push(Action::SetSeriesLineWidth {
236                                series_id: series.id,
237                                width,
238                            });
239                        }
240
241                        let mut style = series.style.line_style;
242                        egui::ComboBox::from_label("Line style")
243                            .selected_text(line_style_text(style))
244                            .show_ui(ui, |ui| {
245                                ui.selectable_value(&mut style, LineStyle::Solid, "Solid");
246                                ui.selectable_value(&mut style, LineStyle::Dashed, "Dashed");
247                                ui.selectable_value(&mut style, LineStyle::Dotted, "Dotted");
248                            });
249                        if style != series.style.line_style {
250                            actions.push(Action::SetSeriesLineStyle {
251                                series_id: series.id,
252                                line_style: style,
253                            });
254                        }
255
256                        let mut color = Color32::from_rgba_premultiplied(
257                            series.style.color.r,
258                            series.style.color.g,
259                            series.style.color.b,
260                            series.style.color.a,
261                        );
262                        if ui.color_edit_button_srgba(&mut color).changed() {
263                            actions.push(Action::SetSeriesColor {
264                                series_id: series.id,
265                                color: crate::model::Color {
266                                    r: color.r(),
267                                    g: color.g(),
268                                    b: color.b(),
269                                    a: color.a(),
270                                },
271                            });
272                        }
273
274                        let mut marker_enabled = series.style.marker.is_some();
275                        if ui.checkbox(&mut marker_enabled, "Marker").changed() {
276                            actions.push(Action::SetSeriesMarker {
277                                series_id: series.id,
278                                marker: if marker_enabled {
279                                    Some(MarkerShape::Circle)
280                                } else {
281                                    None
282                                },
283                                size: series.style.marker.as_ref().map(|m| m.size).unwrap_or(3.0),
284                            });
285                        }
286                        });
287                    });
288                    ui.add_space(8.0);
289                }
290
291                if ui.button("Add series").clicked() {
292                    actions.push(Action::AddSeries {
293                        name: String::new(),
294                        x_column: String::new(),
295                        y_column: String::new(),
296                    });
297                }
298                ui.horizontal(|ui| {
299                    if ui.button("Undo").clicked() {
300                        actions.push(Action::Undo);
301                    }
302                    if ui.button("Redo").clicked() {
303                        actions.push(Action::Redo);
304                    }
305                });
306            });
307    }
308
309    fn draw_plot(&mut self, ctx: &egui::Context, controller: &PlotController) {
310        egui::CentralPanel::default().show(ctx, |ui| {
311            if !controller.has_data() {
312                ui.heading("No data loaded");
313                ui.label("Use Files > From CSV or Files > From TXT");
314                return;
315            }
316
317            let mut rendered = Vec::new();
318            for series in &controller.model.series {
319                if !series.visible {
320                    continue;
321                }
322                if let Ok(points) = controller.points_for_series(series.id) {
323                    let scaled = points
324                        .iter()
325                        .copied()
326                        .filter_map(|(x, y)| {
327                            apply_scale(
328                                x,
329                                y,
330                                controller.model.axes.x.scale,
331                                controller.model.axes.y.scale,
332                            )
333                        })
334                        .collect::<Vec<_>>();
335                    rendered.push((series.clone(), scaled));
336                }
337            }
338
339            let x_range = resolve_range(
340                &controller.model.axes.x.range,
341                &rendered,
342                true,
343                -1.0..1.0,
344            );
345            let y_range = resolve_range(
346                &controller.model.axes.y.range,
347                &rendered,
348                false,
349                -1.0..1.0,
350            );
351
352            let title = controller.model.layout.title.clone();
353            let x_label = format!(
354                "{}{}",
355                controller.model.axes.x.label,
356                scale_suffix(controller.model.axes.x.scale)
357            );
358            let y_label = format!(
359                "{}{}",
360                controller.model.axes.y.label,
361                scale_suffix(controller.model.axes.y.scale)
362            );
363            let x_ticks = controller.model.axes.x.ticks.clone();
364            let y_ticks = controller.model.axes.y.ticks.clone();
365            let x_label_font_size = controller.model.axes.x.label_font_size;
366            let y_label_font_size = controller.model.axes.y.label_font_size;
367            let legend_visible = controller.model.legend.visible;
368            let legend_pos = controller.model.legend.position;
369            let legend_font_size = controller.model.legend.font_size;
370            let legend_font_color = RGBColor(
371                controller.model.legend.font_color.r,
372                controller.model.legend.font_color.g,
373                controller.model.legend.font_color.b,
374            );
375            let margin = controller.model.layout.margin;
376            let x_label_area = controller.model.layout.x_label_area_size;
377            let y_label_area = controller.model.layout.y_label_area_size;
378            let title_font_size = controller.model.layout.title_font_size;
379            let title_font_color = RGBColor(
380                controller.model.layout.title_font_color.r,
381                controller.model.layout.title_font_color.g,
382                controller.model.layout.title_font_color.b,
383            );
384
385            let mut chart = Chart::new((x_range.clone(), y_range.clone()))
386                .mouse(MouseConfig::enabled())
387                .builder_cb(Box::new(move |area, _t, _ranges| {
388                    let mut chart = ChartBuilder::on(area)
389                        .caption(
390                            title.clone(),
391                            ("sans-serif", title_font_size)
392                                .into_font()
393                                .color(&title_font_color),
394                        )
395                        .margin(margin)
396                        .x_label_area_size(x_label_area)
397                        .y_label_area_size(y_label_area)
398                        .build_cartesian_2d(x_range.clone(), y_range.clone())
399                        .expect("build chart failed");
400
401                    configure_mesh(
402                        &mut chart,
403                        &x_label,
404                        &y_label,
405                        x_label_font_size,
406                        y_label_font_size,
407                        &x_ticks,
408                        &y_ticks,
409                        x_range.clone(),
410                        y_range.clone(),
411                    );
412
413                    for (series, points) in &rendered {
414                        if points.is_empty() {
415                            continue;
416                        }
417                        let color = RGBColor(
418                            series.style.color.r,
419                            series.style.color.g,
420                            series.style.color.b,
421                        );
422                        let style = ShapeStyle::from(&color)
423                            .stroke_width(series.style.line_width.max(1.0) as u32);
424
425                        if series.style.line_style == LineStyle::Dotted {
426                            let _ = chart.draw_series(
427                                points
428                                    .iter()
429                                    .map(|(x, y)| Circle::new((*x, *y), 2, style.filled())),
430                            );
431                        } else {
432                            let drawn = chart
433                                .draw_series(LineSeries::new(points.iter().copied(), style))
434                                .expect("draw series failed");
435                            if legend_visible {
436                                drawn.label(series.name.clone()).legend(move |(x, y)| {
437                                    PathElement::new(vec![(x, y), (x + 20, y)], color)
438                                });
439                            }
440                        }
441                    }
442
443                    if legend_visible {
444                        chart
445                            .configure_series_labels()
446                            .label_font(
447                                ("sans-serif", legend_font_size)
448                                    .into_font()
449                                    .color(&legend_font_color),
450                            )
451                            .position(series_label_position(legend_pos))
452                            .background_style(WHITE.mix(0.8))
453                            .border_style(BLACK)
454                            .draw()
455                            .expect("draw legend failed");
456                    }
457                }));
458
459            chart.draw(ui);
460        });
461    }
462}
463
464fn axis_editor(
465    ui: &mut egui::Ui,
466    title: &str,
467    axis: AxisKind,
468    controller: &PlotController,
469    actions: &mut Vec<Action>,
470) {
471    ui.push_id(title, |ui| {
472        ui.collapsing(title, |ui| {
473        let axis_ref = match axis {
474            AxisKind::X => &controller.model.axes.x,
475            AxisKind::Y => &controller.model.axes.y,
476        };
477
478        let mut label = axis_ref.label.clone();
479        if ui.text_edit_singleline(&mut label).changed() {
480            actions.push(Action::SetAxisLabel { axis, label });
481        }
482        ui.horizontal(|ui| {
483            let mut font_size = axis_ref.label_font_size;
484            let mut changed = false;
485            changed |= ui
486                .add(egui::Slider::new(&mut font_size, 8..=72).text("Label font"))
487                .changed();
488            changed |= ui
489                .add(egui::DragValue::new(&mut font_size).range(8..=200))
490                .changed();
491            if changed && font_size != axis_ref.label_font_size {
492                actions.push(Action::SetAxisLabelFontSize { axis, font_size });
493            }
494        });
495
496        let mut scale = axis_ref.scale;
497        egui::ComboBox::from_label("Scale")
498            .selected_text(scale_text(scale))
499            .show_ui(ui, |ui| {
500                ui.selectable_value(&mut scale, ScaleType::Linear, "Linear");
501                ui.selectable_value(&mut scale, ScaleType::Log10, "Log10");
502                ui.selectable_value(&mut scale, ScaleType::LogE, "LogE");
503            });
504        if scale != axis_ref.scale {
505            actions.push(Action::SetAxisScale { axis, scale });
506        }
507
508        let mut auto = matches!(axis_ref.range, RangePolicy::Auto);
509        ui.horizontal(|ui| {
510            if ui.radio_value(&mut auto, true, "Auto").clicked() {
511                actions.push(Action::SetAxisRange {
512                    axis,
513                    range: RangePolicy::Auto,
514                });
515            }
516            if ui.radio_value(&mut auto, false, "Manual").clicked()
517                && !matches!(axis_ref.range, RangePolicy::Manual { .. })
518            {
519                actions.push(Action::SetAxisRange {
520                    axis,
521                    range: RangePolicy::Manual {
522                        min: -1.0,
523                        max: 1.0,
524                    },
525                });
526            }
527        });
528
529        let (mut min, mut max) = match axis_ref.range {
530            RangePolicy::Auto => (-1.0, 1.0),
531            RangePolicy::Manual { min, max } => (min, max),
532        };
533        ui.horizontal(|ui| {
534            ui.label("Min");
535            let min_changed = ui.add(egui::DragValue::new(&mut min).speed(0.1)).changed();
536            ui.label("Max");
537            let max_changed = ui.add(egui::DragValue::new(&mut max).speed(0.1)).changed();
538            if (min_changed || max_changed) && !auto {
539                actions.push(Action::SetAxisRange {
540                    axis,
541                    range: RangePolicy::Manual { min, max },
542                });
543            }
544        });
545
546        ui.separator();
547        ui.label("Ticks");
548
549        let mut major_auto = axis_ref.ticks.major_step.is_none();
550        if ui.checkbox(&mut major_auto, "Auto major step").changed() {
551            actions.push(Action::SetAxisMajorTickStep {
552                axis,
553                step: if major_auto { None } else { Some(1.0) },
554            });
555        }
556        if !major_auto {
557            let mut step = axis_ref.ticks.major_step.unwrap_or(1.0);
558            if ui
559                .add(
560                    egui::DragValue::new(&mut step)
561                        .speed(0.1)
562                        .range(0.01..=1_000.0),
563                )
564                .changed()
565            {
566                actions.push(Action::SetAxisMajorTickStep {
567                    axis,
568                    step: Some(step),
569                });
570            }
571        }
572
573        let mut minor = axis_ref.ticks.minor_per_major;
574        ui.horizontal(|ui| {
575            ui.label("Minor per major");
576            if ui
577                .add(egui::DragValue::new(&mut minor).range(0..=20))
578                .changed()
579            {
580                actions.push(Action::SetAxisMinorTicks {
581                    axis,
582                    per_major: minor,
583                });
584            }
585        });
586        });
587    });
588}
589
590fn resolve_range(
591    policy: &RangePolicy,
592    data: &[(crate::model::SeriesModel, Vec<(f32, f32)>)],
593    is_x: bool,
594    fallback: Range<f32>,
595) -> Range<f32> {
596    match policy {
597        RangePolicy::Manual { min, max } => (*min as f32)..(*max as f32),
598        RangePolicy::Auto => {
599            let mut min_v = f32::INFINITY;
600            let mut max_v = f32::NEG_INFINITY;
601            for (_, points) in data {
602                for (x, y) in points {
603                    let v = if is_x { *x } else { *y };
604                    min_v = min_v.min(v);
605                    max_v = max_v.max(v);
606                }
607            }
608            if !min_v.is_finite() || !max_v.is_finite() || min_v >= max_v {
609                return fallback;
610            }
611            let pad = ((max_v - min_v) * 0.05).max(0.1);
612            (min_v - pad)..(max_v + pad)
613        }
614    }
615}
616
617fn apply_scale(x: f32, y: f32, x_scale: ScaleType, y_scale: ScaleType) -> Option<(f32, f32)> {
618    let sx = match x_scale {
619        ScaleType::Linear => Some(x),
620        ScaleType::Log10 => (x > 0.0).then(|| x.log10()),
621        ScaleType::LogE => (x > 0.0).then(|| x.ln()),
622    }?;
623    let sy = match y_scale {
624        ScaleType::Linear => Some(y),
625        ScaleType::Log10 => (y > 0.0).then(|| y.log10()),
626        ScaleType::LogE => (y > 0.0).then(|| y.ln()),
627    }?;
628    Some((sx, sy))
629}
630
631fn configure_mesh<DB: DrawingBackend>(
632    chart: &mut ChartContext<'_, DB, Cartesian2d<RangedCoordf32, RangedCoordf32>>,
633    x_label: &str,
634    y_label: &str,
635    x_label_font_size: u32,
636    y_label_font_size: u32,
637    x_ticks: &TickConfig,
638    y_ticks: &TickConfig,
639    x_range: Range<f32>,
640    y_range: Range<f32>,
641) {
642    let x_labels = labels_from_step(x_range, x_ticks.major_step).unwrap_or(10);
643    let y_labels = labels_from_step(y_range, y_ticks.major_step).unwrap_or(10);
644
645    chart
646        .configure_mesh()
647        .x_desc(x_label)
648        .y_desc(y_label)
649        .axis_desc_style(("sans-serif", x_label_font_size.max(y_label_font_size)))
650        .x_labels(x_labels)
651        .y_labels(y_labels)
652        .max_light_lines(x_ticks.minor_per_major.max(y_ticks.minor_per_major) as usize)
653        .draw()
654        .expect("draw mesh failed");
655}
656
657fn labels_from_step(range: Range<f32>, step: Option<f64>) -> Option<usize> {
658    let step = step?;
659    if step <= 0.0 {
660        return None;
661    }
662    let span = (range.end - range.start).abs() as f64;
663    if span <= 0.0 {
664        return None;
665    }
666    Some(((span / step).round() as usize + 1).clamp(2, 100))
667}
668
669fn line_style_text(value: LineStyle) -> &'static str {
670    match value {
671        LineStyle::Solid => "Solid",
672        LineStyle::Dashed => "Dashed",
673        LineStyle::Dotted => "Dotted",
674    }
675}
676
677fn scale_text(value: ScaleType) -> &'static str {
678    match value {
679        ScaleType::Linear => "Linear",
680        ScaleType::Log10 => "Log10",
681        ScaleType::LogE => "LogE",
682    }
683}
684
685fn scale_suffix(value: ScaleType) -> &'static str {
686    match value {
687        ScaleType::Linear => "",
688        ScaleType::Log10 => " [log10]",
689        ScaleType::LogE => " [ln]",
690    }
691}
692
693fn legend_position_text(value: LegendPosition) -> &'static str {
694    match value {
695        LegendPosition::TopLeft => "Top Left",
696        LegendPosition::TopRight => "Top Right",
697        LegendPosition::BottomLeft => "Bottom Left",
698        LegendPosition::BottomRight => "Bottom Right",
699    }
700}
701
702fn series_label_position(value: LegendPosition) -> SeriesLabelPosition {
703    match value {
704        LegendPosition::TopLeft => SeriesLabelPosition::UpperLeft,
705        LegendPosition::TopRight => SeriesLabelPosition::UpperRight,
706        LegendPosition::BottomLeft => SeriesLabelPosition::LowerLeft,
707        LegendPosition::BottomRight => SeriesLabelPosition::LowerRight,
708    }
709}
710
711