Skip to main content

egui_charts/widget/
snapshot.rs

1//! A chart's state as a serializable snapshot, and named layouts of charts as versioned
2//! documents.
3
4use super::{Chart, ChartLayoutMode, PaneId, SeriesOptions};
5use crate::drawings::persistence::{DrawingStorage, SaveError, SkippedDrawing};
6use crate::model::{ChartType, ChartTypeParams, Marker, MarkerOptions, PriceLine, Timeframe};
7use crate::studies::{IndicatorFactory, StudyTemplate};
8use crate::widget::settings::{ChartSettingsState, WatermarkMode};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// One study of a [`ChartSnapshot`].
13#[derive(Clone, Debug, Serialize, Deserialize)]
14pub struct SavedStudy {
15    /// The study's id when saved, used by study sources and drawings in its pane.
16    pub id: usize,
17    /// Its name, inputs, styles and visibility.
18    pub study: StudyTemplate,
19    /// The caption after its title in the legend.
20    pub caption: String,
21    /// Whether it widens the automatic range of the price scale.
22    pub autoscale: bool,
23    /// The position of its pane in [`ChartSnapshot::panes`].
24    pub pane: usize,
25}
26
27/// The markers and price lines of one bar series of a [`ChartSnapshot`].
28#[derive(Clone, Debug, Default, Serialize, Deserialize)]
29pub struct SavedAnnotations {
30    /// The markers from [`Chart::set_markers`], with their colours.
31    pub markers: Vec<Marker>,
32    /// Their fit and layer.
33    pub options: MarkerOptions,
34    /// The price lines in drawing order. [`Chart::restore`] hands out new `PriceLineId`s.
35    pub price_lines: Vec<PriceLine>,
36}
37
38/// One bar series added with [`Chart::add_series`] to a [`ChartSnapshot`], without its bars.
39/// [`Chart::restore`] adds it again with a new id: supply its bars through
40/// [`Chart::set_data`] on [`Chart::restored_series_id`] of [`id`](Self::id), or set
41/// [`fed`](Self::fed) to reconnect its symbol or spread to the chart feed.
42#[derive(Clone, Debug, Serialize, Deserialize)]
43pub struct SavedSeries {
44    /// The series' id when saved, which [`SavedPane::members`] and drawings in its pane use.
45    pub id: usize,
46    /// Its name, chart type, colours and scale assignment. A style's autoscale function is not
47    /// saved.
48    pub options: SeriesOptions,
49    /// Whether it is shown.
50    pub visible: bool,
51    /// Reconnect the symbol or spread in `options.name` to the chart feed on restore.
52    /// Older layouts leave series host-fed.
53    #[serde(default)]
54    pub fed: bool,
55    /// Its price scale, including options and a manual range; absent in older layouts.
56    #[serde(default)]
57    pub price_scale: Option<crate::scales::PriceScale>,
58    /// The position of its pane in [`ChartSnapshot::panes`].
59    pub pane: usize,
60    /// Its markers and price lines.
61    pub annotations: SavedAnnotations,
62}
63
64/// One pane of a [`ChartSnapshot`].
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct SavedPane {
67    /// Whether it is the price pane.
68    pub main: bool,
69    /// Its share of the chart height; see [`Chart::pane_stretch_factor`].
70    pub stretch: f32,
71    /// Explicit collapse state, or automatic collapse in a narrow chart.
72    #[serde(default)]
73    pub collapsed: Option<bool>,
74    /// Keep the pane when its last study leaves.
75    #[serde(default)]
76    pub preserve: bool,
77    /// Saved study and series ids in legend order; empty in version 1 layouts.
78    #[serde(default)]
79    pub members: Vec<usize>,
80    /// The pane's price scale, including its manual range.
81    #[serde(default)]
82    pub scale: Option<crate::scales::PriceScale>,
83}
84
85/// What a chart shows and how: everything a user sets on it, without the bars, which come from
86/// the host's feed. Take one with [`Chart::snapshot`] and apply it with [`Chart::restore`].
87/// The trading and alert settings of the settings dialog belong to the host and are not part
88/// of it.
89#[derive(Clone, Debug, Serialize, Deserialize)]
90pub struct ChartSnapshot {
91    /// The symbol shown.
92    pub symbol: String,
93    /// The interval shown, `None` for a chart without one.
94    pub interval: Option<Timeframe>,
95    /// Whether extended hours are requested.
96    pub extended_hours: bool,
97    /// The chart type.
98    pub chart_type: ChartType,
99    /// The parameters of the price-based chart types and of the baseline.
100    pub chart_type_params: ChartTypeParams,
101    /// Whether the main series is drawn.
102    pub main_series_visible: bool,
103    /// Visibility and style of the main series' alert levels; absent in older layouts.
104    #[serde(default)]
105    pub main_series_alerts: crate::model::SeriesAlerts,
106    /// Raw numeric display settings of the main series; absent in older layouts.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub main_series_display_format: Option<crate::model::SeriesDisplayFormat>,
109    /// The display settings of the settings dialog, the price scale's mode and margins included.
110    pub settings: ChartSettingsState,
111    /// Complete appearance settings; absent in version 1 layouts.
112    #[serde(default)]
113    pub config: Option<crate::config::ChartConfig>,
114    /// The palette `config`'s colours followed, so that [`Chart::restore`] can tell the colours
115    /// the user edited from those that follow the active theme; absent in older layouts.
116    #[serde(default)]
117    pub palette: Option<crate::config::ChartConfig>,
118    /// Interaction and time axis settings; absent in version 1 layouts.
119    #[serde(default)]
120    pub options: Option<crate::config::ChartOptions>,
121    /// The main price scale, including its manual range and transform.
122    #[serde(default)]
123    pub price_scale: Option<crate::scales::PriceScale>,
124    /// Position of the maximized pane, or no maximized pane.
125    #[serde(default)]
126    pub maximized: Option<usize>,
127    /// The studies in the order they were added, including the volume study.
128    pub studies: Vec<SavedStudy>,
129    /// The bar series added to the chart in draw order, `None` in layouts saved before version
130    /// 3, which leave the chart's added series in place.
131    #[serde(default)]
132    pub series: Option<Vec<SavedSeries>>,
133    /// The markers and price lines of the main series, `None` in layouts saved before version 3.
134    #[serde(default)]
135    pub main_annotations: Option<SavedAnnotations>,
136    /// The panes from top to bottom, including preserved empty panes.
137    pub panes: Vec<SavedPane>,
138    /// The space between bars in points.
139    pub bar_spacing: f32,
140    /// The space after the last bar, in bars.
141    pub right_offset: f32,
142    /// The symbol's drawings as a drawings document (see [`DrawingStorage::to_json`]).
143    /// Version 1 layouts may omit drawings kept in per-symbol storage.
144    pub drawings: Option<serde_json::Value>,
145}
146
147/// Why [`Chart::restore`] did not show a snapshot.
148#[derive(Debug)]
149#[non_exhaustive]
150pub enum RestoreError {
151    /// A saved study cannot be rebuilt, or reads a study or plot the snapshot does not have.
152    Study {
153        /// The study's position in [`ChartSnapshot::studies`].
154        index: usize,
155        /// Why.
156        error: crate::studies::StudyError,
157    },
158    /// The saved chart settings are invalid.
159    Settings(super::settings::ChartSettingsError),
160    /// The saved panes, scales, series or view are out of range; the text says which.
161    Layout(&'static str),
162    /// The saved drawings cannot be read.
163    Drawings(crate::drawings::persistence::LoadError),
164}
165
166impl std::fmt::Display for RestoreError {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        match self {
169            Self::Study { index, error } => write!(f, "saved study {index}: {error}"),
170            Self::Settings(error) => write!(f, "{error}"),
171            Self::Layout(reason) => f.write_str(reason),
172            Self::Drawings(error) => write!(f, "saved drawings: {error}"),
173        }
174    }
175}
176impl std::error::Error for RestoreError {
177    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
178        match self {
179            Self::Study { error, .. } => Some(error),
180            Self::Settings(error) => Some(error),
181            Self::Drawings(error) => Some(error),
182            Self::Layout(_) => None,
183        }
184    }
185}
186
187impl Chart {
188    /// Captures the chart's state. Fails while stored drawings are loading, for an unregistered
189    /// study, when a drawing cannot be saved (see [`save_drawings`](Self::save_drawings)) or
190    /// when [`restore`](Self::restore) would refuse the result.
191    pub fn snapshot(&self) -> Result<ChartSnapshot, SaveError> {
192        self.snapshot_with(&IndicatorFactory::new())
193    }
194
195    /// Captures the chart's state like [`snapshot`](Self::snapshot), checking through `factory`
196    /// that each study can be built again, so that custom studies it registers are accepted.
197    pub fn snapshot_with(&self, factory: &IndicatorFactory) -> Result<ChartSnapshot, SaveError> {
198        if self
199            .drawing_store
200            .as_ref()
201            .is_some_and(|store| !store.ready(&self.symbol))
202        {
203            return Err(SaveError::Json(serde::ser::Error::custom(
204                "Drawings are still loading",
205            )));
206        }
207        let drawings =
208            Some(serde_json::to_value(self.save_drawings()?).expect("drawings were validated"));
209        let panes = self.panes();
210        let pane_of = |id| {
211            self.series_pane(id)
212                .and_then(|pane| panes.iter().position(|p| *p == pane))
213                .unwrap_or(0)
214        };
215        let studies: Vec<_> = self
216            .indicators()
217            .enumerate()
218            .map(|(index, (id, study))| SavedStudy {
219                id: id.0,
220                study: StudyTemplate::from_study(study),
221                caption: self.indicator_inputs(id).unwrap_or_default().to_owned(),
222                autoscale: self.study_autoscale[index],
223                pane: pane_of(id),
224            })
225            .collect();
226        let series = self
227            .price_series
228            .iter()
229            .map(|item| SavedSeries {
230                id: item.id.0,
231                options: item.options.clone(),
232                visible: item.visible,
233                fed: self.is_fed(item.id),
234                price_scale: self.series_price_scale(item.id).cloned(),
235                pane: pane_of(item.id),
236                annotations: SavedAnnotations {
237                    markers: item.markers.to_vec(),
238                    options: item.marker_options,
239                    price_lines: item.lines.iter().map(|(_, line)| line.clone()).collect(),
240                },
241            })
242            .collect();
243        let (bar_spacing, right_offset) = self.get_time_scale_state();
244        let snapshot = ChartSnapshot {
245            symbol: self.symbol.clone(),
246            interval: self.timeframe,
247            extended_hours: self.extended_hours,
248            chart_type: self.chart_type,
249            chart_type_params: self.chart_type_params.clone(),
250            main_series_visible: self.main_series_visible,
251            main_series_alerts: self.main_series_style.alerts,
252            main_series_display_format: self.main_series_style.display_format,
253            settings: {
254                let mut settings = ChartSettingsState::from_chart(self);
255                settings.adjust_for_dividends = self.dividend_adjusted();
256                settings
257            },
258            config: Some(self.config.clone()),
259            palette: Some(self.followed_palette()),
260            options: Some(self.chart_options.clone()),
261            price_scale: Some(self.state.price_scale().clone()),
262            maximized: self
263                .pane_layout
264                .maximized
265                .and_then(|id| panes.iter().position(|pane| *pane == id)),
266            studies,
267            series: Some(series),
268            main_annotations: Some(SavedAnnotations {
269                markers: self.markers(super::SeriesId::MAIN).to_vec(),
270                options: self.marker_options,
271                price_lines: self
272                    .price_lines
273                    .iter()
274                    .map(|(_, line)| line.clone())
275                    .collect(),
276            }),
277            panes: panes
278                .iter()
279                .map(|id| {
280                    let pane = self
281                        .pane_layout
282                        .panes
283                        .iter()
284                        .find(|pane| pane.id == *id)
285                        .expect("listed pane");
286                    SavedPane {
287                        main: *id == PaneId::MAIN,
288                        stretch: pane.stretch,
289                        collapsed: pane.collapsed,
290                        preserve: pane.preserve,
291                        members: pane.members.iter().map(|id| id.0).collect(),
292                        scale: (*id != PaneId::MAIN).then(|| pane.scale.clone()),
293                    }
294                })
295                .collect(),
296            bar_spacing,
297            right_offset,
298            drawings,
299        };
300        // A layout that restore would refuse is an error now rather than at the next load.
301        check(&snapshot, factory)
302            .map_err(|error| SaveError::Json(serde::ser::Error::custom(error)))?;
303        Ok(snapshot)
304    }
305
306    /// Shows what `snapshot` captured: its symbol, interval, chart type, settings, studies,
307    /// added series, panes and view, and its drawings. Studies are built through
308    /// [`StudyTemplate::create`]. Clears undo history and returns the drawings that could not
309    /// be read. Fails without changing the chart for an unknown study, a study source that
310    /// names no study, no plot of it or itself, a series or study id used twice, invalid
311    /// settings or options, a number that is not finite or an unreadable drawings document.
312    ///
313    /// The added series of the chart are replaced by those of the snapshot, which come back
314    /// without bars and under new ids: look each up with
315    /// [`restored_series_id`](Self::restored_series_id) and supply its bars with
316    /// [`set_data`](Self::set_data). Feed-backed series reconnect automatically.
317    ///
318    /// Errors distinguish a saved [`RestoreError::Study`], invalid [`RestoreError::Settings`],
319    /// [`RestoreError::Layout`] geometry and unreadable [`RestoreError::Drawings`].
320    pub fn restore(
321        &mut self,
322        snapshot: &ChartSnapshot,
323    ) -> Result<Vec<SkippedDrawing>, RestoreError> {
324        self.restore_with(snapshot, &IndicatorFactory::new())
325    }
326
327    /// Shows what `snapshot` captured like [`restore`](Self::restore), building the studies
328    /// through `factory`, so that studies it registers are restored with their inputs, styles
329    /// and source links.
330    pub fn restore_with(
331        &mut self,
332        snapshot: &ChartSnapshot,
333        factory: &IndicatorFactory,
334    ) -> Result<Vec<SkippedDrawing>, RestoreError> {
335        let (studies, drawings) = check(snapshot, factory)?;
336        self.set_symbol(snapshot.symbol.clone());
337        if let Some(interval) = snapshot.interval {
338            self.set_timeframe(interval);
339        } else {
340            self.timeframe = None;
341            self.drawings.curr_timeframe.clear();
342        }
343        self.set_extended_hours(snapshot.extended_hours);
344        self.set_dividend_adjusted(snapshot.settings.adjust_for_dividends);
345        self.set_chart_type(snapshot.chart_type);
346        self.set_chart_type_params(snapshot.chart_type_params.clone());
347        self.set_main_series_visible(snapshot.main_series_visible);
348        self.main_series_style.alerts = snapshot.main_series_alerts;
349        self.main_series_style.display_format = snapshot.main_series_display_format;
350        snapshot
351            .settings
352            .apply_to_chart(self)
353            .map_err(RestoreError::Settings)?;
354        if let Some(config) = &snapshot.config {
355            self.config = config.clone();
356            // Layouts saved before the description followed the symbol held it as fixed text.
357            if snapshot.settings.watermark.mode == WatermarkMode::SymbolAndDescription {
358                self.config.watermark_text = None;
359                self.config.watermark_description = true;
360            }
361            self.follow_restored_palette(snapshot.palette.as_ref());
362        }
363        if let Some(options) = &snapshot.options {
364            self.set_chart_options(options.clone());
365        }
366        if let Some(scale) = &snapshot.price_scale {
367            *self.state.price_scale_mut() = scale.clone();
368        }
369        let mut replaced: Vec<_> = self.indicators().map(|(id, _)| id).collect();
370        if snapshot.series.is_some() {
371            replaced.extend(self.series().map(|(id, _)| id));
372        }
373        let show_volume = self.config.show_volume;
374        for id in replaced {
375            self.remove_series(id);
376        }
377        self.config.show_volume = show_volume;
378        self.restored_studies.clear();
379        self.pane_layout
380            .panes
381            .retain(|pane| pane.id == PaneId::MAIN || !pane.members.is_empty());
382        let mut panes: HashMap<usize, PaneId> = HashMap::new();
383        for (at, pane) in snapshot.panes.iter().enumerate() {
384            panes.insert(
385                at,
386                if pane.main {
387                    PaneId::MAIN
388                } else {
389                    self.add_pane()
390                },
391            );
392        }
393        for (saved, study) in snapshot.studies.iter().zip(studies) {
394            let id = self.add_indicator(study);
395            self.set_indicator_inputs(id, saved.caption.clone());
396            self.set_indicator_autoscale(id, saved.autoscale);
397            self.restored_studies.insert(saved.id, id.0);
398            self.move_series_to_pane(id, panes[&saved.pane]);
399        }
400        for saved in snapshot.series.iter().flatten() {
401            let id = if saved.fed {
402                self.add_feed_series(saved.options.clone())
403            } else {
404                self.add_series(saved.options.clone())
405            };
406            self.set_series_visible(id, saved.visible);
407            self.restore_annotations(id, &saved.annotations);
408            self.restored_studies.insert(saved.id, id.0);
409            self.move_series_to_pane(id, panes[&saved.pane]);
410            if let Some(scale) = &saved.price_scale {
411                *self.series_price_scale_mut(id).expect("restored series") = scale.clone();
412            }
413        }
414        if let Some(main) = &snapshot.main_annotations {
415            self.restore_annotations(super::SeriesId::MAIN, main);
416        }
417        for saved in &snapshot.studies {
418            let id = super::SeriesId(self.restored_studies[&saved.id]);
419            for (input, value) in &saved.study.inputs {
420                if let crate::studies::InputValue::Source(crate::studies::StudySource::Study {
421                    id: parent,
422                    plot,
423                }) = value
424                {
425                    let source = crate::studies::StudySource::Study {
426                        id: self.restored_studies[parent],
427                        plot: *plot,
428                    };
429                    self.indicator_mut(id)
430                        .expect("restored study")
431                        .set_input(input, crate::studies::InputValue::Source(source))
432                        .expect("validated study source");
433                }
434            }
435        }
436        self.recalculate_studies();
437        for (at, saved) in snapshot.panes.iter().enumerate() {
438            let id = panes[&at];
439            self.move_pane(id, at);
440            self.set_pane_stretch_factor(id, saved.stretch);
441            let pane = self
442                .pane_layout
443                .panes
444                .iter_mut()
445                .find(|pane| pane.id == id)
446                .expect("restored pane");
447            pane.collapsed = saved.collapsed;
448            pane.preserve = saved.preserve;
449            if !saved.members.is_empty() {
450                pane.members.sort_by_key(|id| {
451                    saved
452                        .members
453                        .iter()
454                        .position(|old| self.restored_studies.get(old) == Some(&id.0))
455                        .unwrap_or(usize::MAX)
456                });
457            }
458            if let Some(scale) = &saved.scale {
459                pane.scale = scale.clone();
460            }
461        }
462        self.pane_layout.maximized = snapshot.maximized.map(|at| panes[&at]);
463        self.apply_synced_time_scale(snapshot.bar_spacing, snapshot.right_offset);
464        let skipped = match drawings {
465            Some(json) => {
466                let skipped = self
467                    .load_drawings_remapped(&json, true)
468                    .map_err(RestoreError::Drawings)?;
469                self.history.drawing_edits += 1;
470                self.save_restored_drawings();
471                skipped
472            }
473            None => Vec::new(),
474        };
475        self.history.clear();
476        Ok(skipped)
477    }
478}
479
480impl Chart {
481    fn restore_annotations(&mut self, series: super::SeriesId, saved: &SavedAnnotations) {
482        self.set_markers(series, saved.markers.clone());
483        self.set_marker_options(series, saved.options);
484        self.set_price_lines(series, saved.price_lines.clone());
485    }
486
487    /// The id a series or study of a restored snapshot has now, given its [`SavedSeries::id`] or
488    /// [`SavedStudy::id`], or `None` for an id the last [`restore`](Self::restore) did not add.
489    pub fn restored_series_id(&self, saved: usize) -> Option<super::SeriesId> {
490        self.restored_studies
491            .get(&saved)
492            .copied()
493            .map(super::SeriesId)
494    }
495}
496
497/// The studies of a checked snapshot, built in order, and its drawings document.
498type Checked = (Vec<Box<dyn crate::studies::Indicator>>, Option<String>);
499
500/// Checks `snapshot` as [`Chart::restore_with`] does before changing the chart.
501fn check(snapshot: &ChartSnapshot, factory: &IndicatorFactory) -> Result<Checked, RestoreError> {
502    let studies = snapshot
503        .studies
504        .iter()
505        .enumerate()
506        .map(|(index, saved)| {
507            saved
508                .study
509                .create_with(factory)
510                .map_err(|error| RestoreError::Study { index, error })
511        })
512        .collect::<Result<Vec<_>, _>>()?;
513    check_source_links(&snapshot.studies, &studies)?;
514    snapshot
515        .settings
516        .validate()
517        .map_err(RestoreError::Settings)?;
518    let valid_scale = |scale: &crate::scales::PriceScale| {
519        let range = scale.range();
520        let margins = scale.options().scale_margins;
521        range.min.is_finite()
522            && range.max.is_finite()
523            && range.min < range.max
524            && margins.top.is_finite()
525            && margins.bottom.is_finite()
526            && margins.top >= 0.0
527            && margins.bottom >= 0.0
528            && margins.top + margins.bottom < 1.0
529            && scale.options().tick_marks.max_marks
530                <= crate::scales::PriceMarkGeneratorConfig::MAX_MARKS
531    };
532    if snapshot
533        .price_scale
534        .as_ref()
535        .is_some_and(|scale| !valid_scale(scale))
536        || snapshot.panes.iter().filter(|pane| pane.main).count() != 1
537        || snapshot
538            .studies
539            .iter()
540            .map(|study| study.id)
541            .chain(snapshot.series.iter().flatten().map(|series| series.id))
542            .collect::<std::collections::HashSet<_>>()
543            .len()
544            != snapshot.studies.len() + snapshot.series.iter().flatten().count()
545        || snapshot
546            .series
547            .iter()
548            .flatten()
549            .any(|series| series.pane >= snapshot.panes.len())
550        || snapshot
551            .panes
552            .iter()
553            .filter_map(|pane| pane.scale.as_ref())
554            .chain(snapshot.series.iter().flatten().filter_map(|series| series.price_scale.as_ref()))
555            .any(|scale| !valid_scale(scale))
556        || snapshot
557            .studies
558            .iter()
559            .any(|study| study.pane >= snapshot.panes.len())
560        || snapshot
561            .panes
562            .iter()
563            .any(|pane| !pane.stretch.is_finite() || pane.stretch <= 0.0)
564        // Pane heights divide by the sum of the stretch factors.
565        || !snapshot
566            .panes
567            .iter()
568            .map(|pane| pane.stretch)
569            .sum::<f32>()
570            .is_finite()
571        || !snapshot.bar_spacing.is_finite()
572        || snapshot.bar_spacing <= 0.0
573        || !snapshot.right_offset.is_finite()
574        || snapshot
575            .maximized
576            .is_some_and(|pane| pane >= snapshot.panes.len())
577    {
578        return Err(RestoreError::Layout("Invalid layout geometry"));
579    }
580    // JSON has no NaN or infinity: serde writes them as null, which a number field then
581    // refuses, so the next save of such a value could not be loaded.
582    if serde_json::to_value(snapshot)
583        .and_then(serde_json::from_value::<ChartSnapshot>)
584        .is_err()
585    {
586        return Err(RestoreError::Layout("Layout values must be finite"));
587    }
588    if let Some(options) = &snapshot.options
589        && !(options.crosshair.vert_line_width > 0.0 && options.crosshair.horz_line_width > 0.0)
590    {
591        return Err(RestoreError::Layout("Crosshair width must be above zero"));
592    }
593    // A frame below this spacing shows a hundred bars per point; 0 sets no limit.
594    if let Some(options) = &snapshot.options
595        && [
596            options.time_scale.min_bar_spacing,
597            options.time_scale.max_bar_spacing,
598        ]
599        .into_iter()
600        .any(|spacing| spacing != 0.0 && !(0.01..).contains(&spacing))
601    {
602        return Err(RestoreError::Layout(
603            "Bar spacing limits must be 0 or at least 0.01",
604        ));
605    }
606    let font_sizes = &crate::styles::typography::FONT_SIZES;
607    if snapshot
608        .options
609        .iter()
610        .map(|options| options.tooltip.font_size)
611        .chain(snapshot.config.iter().flat_map(|config| {
612            std::iter::once(config.watermark_font_size).chain(config.axis_font_size)
613        }))
614        .any(|size| !font_sizes.contains(&size))
615    {
616        return Err(RestoreError::Layout("Font sizes must be between 1 and 256"));
617    }
618    let drawings = match &snapshot.drawings {
619        Some(drawings) => {
620            let json = drawings.to_string();
621            let (storage, _) = DrawingStorage::from_json(&json).map_err(RestoreError::Drawings)?;
622            if storage.symbol != snapshot.symbol {
623                return Err(RestoreError::Drawings(
624                    crate::drawings::persistence::LoadError::OtherSymbol,
625                ));
626            }
627            Some(json)
628        }
629        _ => None,
630    };
631    Ok((studies, drawings))
632}
633
634/// Checks the study outputs the saved `studies` read: each names a saved study and a plot it
635/// has, and no study reads its own output, directly or through others. `built` holds the
636/// studies reconstructed from `studies`, in the same order.
637fn check_source_links(
638    studies: &[SavedStudy],
639    built: &[Box<dyn crate::studies::Indicator>],
640) -> Result<(), RestoreError> {
641    let mut parents = vec![Vec::new(); studies.len()];
642    for (at, saved) in studies.iter().enumerate() {
643        for (_, value) in &saved.study.inputs {
644            if let crate::studies::InputValue::Source(crate::studies::StudySource::Study {
645                id,
646                plot,
647            }) = value
648            {
649                let parent = studies.iter().position(|other| other.id == *id).ok_or(
650                    RestoreError::Study {
651                        index: at,
652                        error: crate::studies::StudyError::UnknownSourceStudy(*id),
653                    },
654                )?;
655                if *plot >= built[parent].line_count() {
656                    return Err(RestoreError::Study {
657                        index: at,
658                        error: crate::studies::StudyError::UnknownSourcePlot {
659                            id: *id,
660                            plot: *plot,
661                        },
662                    });
663                }
664                parents[at].push(parent);
665            }
666        }
667    }
668    // Studies that read only resolved studies resolve in turn; any left over form a cycle.
669    let mut resolved = vec![false; studies.len()];
670    while let Some(at) = (0..studies.len())
671        .find(|at| !resolved[*at] && parents[*at].iter().all(|parent| resolved[*parent]))
672    {
673        resolved[at] = true;
674    }
675    if let Some(index) = resolved.iter().position(|done| !done) {
676        return Err(RestoreError::Study {
677            index,
678            error: crate::studies::StudyError::CyclicSource,
679        });
680    }
681    Ok(())
682}
683
684/// A named arrangement of charts: the [`ChartLayoutMode`] and a [`ChartSnapshot`] of each chart,
685/// as kept in [`StoreKind::Layouts`](crate::services::StoreKind::Layouts) documents.
686#[derive(Clone, Debug, Serialize, Deserialize)]
687pub struct SavedLayout {
688    /// How the charts are arranged.
689    pub mode: ChartLayoutMode,
690    /// The charts in the order of their slots.
691    pub charts: Vec<ChartSnapshot>,
692}
693
694#[derive(Serialize, Deserialize)]
695struct Document<T> {
696    version: u32,
697    #[serde(flatten)]
698    layout: T,
699}
700
701impl SavedLayout {
702    /// The version of the document [`to_json`](Self::to_json) writes.
703    pub const VERSION: u32 = 3;
704
705    /// The layout as a versioned JSON document.
706    pub fn to_json(&self) -> Result<String, serde_json::Error> {
707        serde_json::to_string(&Document {
708            version: Self::VERSION,
709            layout: self,
710        })
711    }
712
713    /// Reads a document [`to_json`](Self::to_json) wrote, migrating older versions. Fails on
714    /// invalid JSON and on a version this release does not know.
715    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
716        let document: Document<serde_json::Value> = serde_json::from_str(json)?;
717        match document.version {
718            1..=Self::VERSION => serde_json::from_value(document.layout),
719            version => Err(serde::de::Error::custom(format!(
720                "unsupported layout version {version}, expected {}",
721                Self::VERSION
722            ))),
723        }
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::drawings::{ChartPoint, Drawing, DrawingToolType};
731    use crate::model::{Bar, BarData, Marker, PriceLine};
732    use crate::studies::{MACD, RSI, SMA};
733    use chrono::{TimeZone, Utc};
734
735    /// A custom study, the study reading its output and an added series with its markers and
736    /// price lines come back through the public API; a document that cannot be rebuilt leaves
737    /// the current chart as it was.
738    #[test]
739    fn restore_rebuilds_custom_studies_source_links_and_added_series() {
740        use crate::studies::{CustomIndicator, IndicatorFactory, IndicatorValue, StudySource};
741        let bars: Vec<_> = (0..30)
742            .map(|i| {
743                Bar::new(
744                    Utc.timestamp_opt(i * 60, 0).unwrap(),
745                    10.0,
746                    11.0,
747                    9.0,
748                    10.0,
749                    1.0,
750                )
751            })
752            .collect();
753        let mut factory = IndicatorFactory::new();
754        factory.register("Bar range", || {
755            Box::new(CustomIndicator::new("Bar range", |bars| {
756                bars.iter()
757                    .map(|bar| IndicatorValue::Single(bar.high - bar.low))
758                    .collect()
759            }))
760        });
761        let mut chart = Chart::new(BarData::from_bars(bars.clone()));
762        chart.set_symbol("A");
763        let spread = chart.add_indicator(factory.create("Bar range").unwrap());
764        let rsi = chart.add_indicator(Box::new(RSI::new(5)));
765        chart
766            .set_indicator_source(
767                rsi,
768                StudySource::Study {
769                    id: spread.0,
770                    plot: 0,
771                },
772            )
773            .unwrap();
774        let extra = chart.add_series(SeriesOptions {
775            name: "Peer".into(),
776            color: egui::Color32::GOLD,
777            ..Default::default()
778        });
779        chart.set_markers(
780            extra,
781            vec![Marker::new(Utc.timestamp_opt(60, 0).unwrap()).with_color(egui::Color32::RED)],
782        );
783        chart.set_price_lines(extra, vec![PriceLine::new(12.5, egui::Color32::BLUE)]);
784        chart.move_series_to_pane(extra, chart.series_pane(rsi).unwrap());
785        assert!(
786            chart.snapshot().is_err(),
787            "the built-in factory cannot rebuild Bar range"
788        );
789        let json = SavedLayout {
790            mode: ChartLayoutMode::Single,
791            charts: vec![chart.snapshot_with(&factory).unwrap()],
792        }
793        .to_json()
794        .unwrap();
795        let snapshot = SavedLayout::from_json(&json).unwrap().charts.remove(0);
796
797        let mut restored = Chart::new(BarData::from_bars(bars));
798        restored.set_symbol("B");
799        assert!(restored.restore(&snapshot).is_err());
800        assert_eq!(restored.symbol(), "B");
801        let mut dangling = snapshot.clone();
802        dangling.studies[1]
803            .study
804            .inputs
805            .iter_mut()
806            .for_each(|(_, value)| {
807                if let crate::studies::InputValue::Source(StudySource::Study { id, .. }) = value {
808                    *id = 999;
809                }
810            });
811        assert!(restored.restore_with(&dangling, &factory).is_err());
812        assert_eq!(restored.symbol(), "B");
813
814        restored.restore_with(&snapshot, &factory).unwrap();
815        assert_eq!(restored.symbol(), "A");
816        let names: Vec<_> = restored
817            .indicators()
818            .map(|(_, study)| study.name().to_owned())
819            .collect();
820        assert_eq!(names, ["Bar range", "RSI"]);
821        let new_spread = restored.restored_series_id(spread.0).unwrap();
822        let new_rsi = restored.restored_series_id(rsi.0).unwrap();
823        assert_eq!(
824            restored.indicator_mut(new_rsi).unwrap().source(),
825            Some(StudySource::Study {
826                id: new_spread.0,
827                plot: 0
828            })
829        );
830        let new_extra = restored.restored_series_id(extra.0).unwrap();
831        let (_, options) = restored.series().next().unwrap();
832        assert_eq!(
833            (options.name.as_str(), options.color),
834            ("Peer", egui::Color32::GOLD)
835        );
836        assert_eq!(restored.markers(new_extra)[0].color, egui::Color32::RED);
837        assert_eq!(
838            restored.series_pane(new_extra),
839            restored.series_pane(new_rsi)
840        );
841        let lines = &restored.price_series[0].lines;
842        assert_eq!(lines.len(), 1);
843        assert_eq!(lines[0].1.price, 12.5);
844    }
845
846    #[test]
847    fn layouts_restore_interval_type_studies_panes_view_and_pane_drawings() {
848        let bars: Vec<_> = (0..60)
849            .map(|i| {
850                Bar::new(
851                    Utc.timestamp_opt(i * 60, 0).unwrap(),
852                    10.0,
853                    11.0,
854                    9.0,
855                    10.0,
856                    1.0,
857                )
858            })
859            .collect();
860        let mut chart = Chart::new(BarData::from_bars(bars.clone()));
861        chart.set_symbol("A");
862        chart.set_timeframe(Timeframe::Min5);
863        chart.set_chart_type(ChartType::Line);
864        let sma = chart.add_indicator(Box::new(SMA::new(7)));
865        chart.set_indicator_inputs(sma, "7 close");
866        chart.set_indicator_autoscale(sma, false);
867        let rsi = chart.add_indicator(Box::new(RSI::new(9)));
868        let macd = chart.add_indicator(Box::new(MACD::new(5, 13, 4)));
869        chart
870            .set_indicator_source(
871                rsi,
872                crate::studies::StudySource::Study { id: sma.0, plot: 0 },
873            )
874            .unwrap();
875        chart.recalculate_studies();
876        chart.move_indicator_to_pane(macd, rsi);
877        let pane = chart.series_pane(rsi).unwrap();
878        chart.move_pane(pane, 0);
879        chart.set_pane_stretch_factor(pane, 2.0);
880        let mut options = chart.chart_options().clone();
881        options.time_scale.min_bar_spacing = 0.1;
882        options.time_scale.max_bar_spacing = 12.0;
883        options.time_scale.right_offset = 7.0;
884        options.tooltip.show_volume_details = true;
885        chart.set_chart_options(options);
886        chart.apply_synced_time_scale(0.25, 4.0);
887        let empty = chart.add_pane();
888        chart.set_preserve_empty_pane(empty, true);
889        chart.pane_layout.panes[0].collapsed = Some(true);
890        chart.pane_layout.panes[0].scale.set_range(25.0, 75.0);
891        chart.pane_layout.maximized = Some(pane);
892        chart.price_scale_mut().set_range(8.0, 12.0);
893        chart.config_mut().candle_width = 0.42;
894        chart.config_mut().watermark_text = Some("Saved chart".into());
895        chart.config_mut().grid_line_style = crate::model::LineStyle::LargeDashed;
896        chart.config_mut().vertical_grid_line_style = Some(crate::model::LineStyle::SparseDotted);
897        chart.chart_options.crosshair.mode = crate::config::CrosshairMode::Magnet;
898        chart.chart_options.crosshair.vert_line_style = crate::model::LineStyle::Dashed;
899        chart.chart_options.crosshair.horz_line_style = crate::model::LineStyle::SparseDotted;
900        chart.chart_options.crosshair.vert_label_background_color = egui::Color32::RED;
901        chart.chart_options.crosshair.horz_label_background_color = egui::Color32::BLUE;
902        chart.chart_options.time_scale.timezone =
903            crate::scales::TimezoneMode::Timezone("+01:00".parse().unwrap());
904        let mut drawing = Drawing::new(1, DrawingToolType::HorizontalLine);
905        drawing.chart_points = vec![ChartPoint::new(3.0, 50.0)];
906        drawing.completed = true;
907        drawing.pane = Some(rsi.0);
908        chart.drawings_mut().drawings.push(drawing);
909        let json = SavedLayout {
910            mode: ChartLayoutMode::Single,
911            charts: vec![chart.snapshot().unwrap()],
912        }
913        .to_json()
914        .unwrap();
915
916        let mut restored = Chart::new(BarData::from_bars(bars));
917        restored.add_indicator(Box::new(SMA::new(3)));
918        let layout = SavedLayout::from_json(&json).unwrap();
919        assert!(restored.restore(&layout.charts[0]).unwrap().is_empty());
920        assert_eq!(restored.symbol(), "A");
921        assert_eq!(restored.timeframe(), Some(Timeframe::Min5));
922        assert_eq!(restored.chart_type(), ChartType::Line);
923        let studies: Vec<_> = restored
924            .indicators()
925            .map(|(id, study)| (id, study.name().to_owned(), study.inputs()[0].value.clone()))
926            .collect();
927        let names: Vec<_> = studies.iter().map(|(_, name, _)| name.as_str()).collect();
928        assert_eq!(names, ["SMA", "RSI", "MACD"]);
929        assert_eq!(
930            studies[0].2,
931            chart.indicators().next().unwrap().1.inputs()[0].value
932        );
933        let [(sma, ..), (rsi, ..), (macd, ..)] = studies.as_slice() else {
934            unreachable!()
935        };
936        assert_eq!(restored.indicator_inputs(*sma), Some("7 close"));
937        assert!(!restored.study_autoscale[0]);
938        assert_eq!(
939            restored.indicator_mut(*rsi).unwrap().source(),
940            Some(crate::studies::StudySource::Study { id: sma.0, plot: 0 })
941        );
942        let original = chart.indicators().nth(1).unwrap().1.values();
943        let output = restored.indicators().nth(1).unwrap().1.values();
944        assert!(!output.is_empty());
945        for at in 0..original.len() {
946            assert_eq!(output.value(0, at), original.value(0, at));
947        }
948        let panes = restored.panes();
949        assert_eq!(panes.len(), 3);
950        assert_eq!(panes[1], PaneId::MAIN);
951        assert_eq!(restored.pane_series(panes[0]), [*rsi, *macd]);
952        assert_eq!(restored.pane_stretch_factor(panes[0]), Some(2.0));
953        assert_eq!(restored.get_time_scale_state(), (0.25, 4.0));
954        restored.scroll_to_real_time(false);
955        assert_eq!(restored.get_time_scale_state(), (0.25, 7.0));
956        restored.apply_synced_time_scale(99.0, 4.0);
957        assert_eq!(restored.get_time_scale_state(), (12.0, 4.0));
958        restored.apply_synced_time_scale(0.25, 4.0);
959        assert_eq!(restored.config(), chart.config());
960        assert_eq!(restored.chart_options(), chart.chart_options());
961        assert_eq!(
962            restored.state().price_scale().range(),
963            chart.state().price_scale().range()
964        );
965        assert!(restored.state().price_scale().is_manual());
966        assert_eq!(restored.pane_layout.panes[0].collapsed, Some(true));
967        assert_eq!(
968            restored.pane_layout.panes[0].scale.range(),
969            chart.pane_layout.panes[0].scale.range()
970        );
971        assert_eq!(restored.pane_layout.maximized, Some(panes[0]));
972        assert!(restored.preserves_empty_pane(panes[2]));
973        let drawings = &restored.drawings().drawings;
974        assert_eq!(drawings.len(), 1);
975        assert_eq!(drawings[0].pane, Some(rsi.0));
976        assert_eq!(drawings[0].chart_points[0].bar_idx, 3.0);
977        assert!(!restored.can_undo());
978
979        // An absent interval clears an existing interval, and malformed geometry is atomic.
980        let mut snapshot = layout.charts[0].clone();
981        snapshot.interval = None;
982        restored.restore(&snapshot).unwrap();
983        assert_eq!(restored.timeframe(), None);
984        for bad in [0.0, f32::NAN, f32::INFINITY] {
985            snapshot.bar_spacing = bad;
986            snapshot.symbol = "Invalid".into();
987            assert!(restored.restore(&snapshot).is_err());
988            assert_eq!(restored.symbol(), "A");
989        }
990        for key in ["price_scale", "panes"] {
991            let mut invalid: serde_json::Value = serde_json::from_str(&json).unwrap();
992            invalid["charts"][0]["symbol"] = "Invalid".into();
993            invalid["charts"][0]["drawings"] = serde_json::Value::Null;
994            let scale = if key == "price_scale" {
995                &mut invalid["charts"][0]["price_scale"]
996            } else {
997                &mut invalid["charts"][0]["panes"][0]["scale"]
998            };
999            scale["options"]["tick_marks"]["max_marks"] = usize::MAX.into();
1000            let invalid = SavedLayout::from_json(&invalid.to_string()).unwrap();
1001            assert!(restored.restore(&invalid.charts[0]).is_err());
1002            assert_eq!(restored.symbol(), "A");
1003            assert_eq!(restored.timeframe(), None);
1004        }
1005        // Both theme palettes survive instead of being replaced by dialog defaults.
1006        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1007            chart.set_theme(&egui::Context::default(), &theme);
1008            let snapshot = chart.snapshot().unwrap();
1009            restored.restore(&snapshot).unwrap();
1010            assert_eq!(restored.config(), chart.config());
1011        }
1012        let mut legacy: serde_json::Value = serde_json::from_str(&json).unwrap();
1013        legacy["charts"][0]["options"]["tooltip"]
1014            .as_object_mut()
1015            .unwrap()
1016            .remove("show_volume_details");
1017        let config = &mut legacy["charts"][0]["config"];
1018        config["watermark_text"] = "A ยท Apple".into();
1019        config
1020            .as_object_mut()
1021            .unwrap()
1022            .remove("watermark_description");
1023        legacy["charts"][0]["settings"]["watermark"]["mode"] = "SymbolAndDescription".into();
1024        // Layouts saved before `thousands_sep` existed group thousands like a new chart.
1025        assert!(
1026            legacy["charts"][0]["config"]
1027                .as_object_mut()
1028                .unwrap()
1029                .remove("thousands_sep")
1030                .is_some()
1031        );
1032        for section in ["config", "settings"] {
1033            let value = if section == "settings" {
1034                &mut legacy["charts"][0][section]["status_line"]
1035            } else {
1036                &mut legacy["charts"][0][section]
1037            };
1038            assert!(
1039                value
1040                    .as_object_mut()
1041                    .unwrap()
1042                    .remove("show_market_status")
1043                    .is_some()
1044            );
1045        }
1046        restored.config_mut().show_market_status = false;
1047        restored.config_mut().thousands_sep = None;
1048        let migrated = SavedLayout::from_json(&legacy.to_string()).unwrap();
1049        restored.restore(&migrated.charts[0]).unwrap();
1050        assert!(!restored.chart_options().tooltip.show_volume_details);
1051        assert_eq!(restored.config().thousands_sep, Some(','));
1052        assert!(restored.config().show_market_status);
1053        assert_eq!(restored.config().watermark_text, None);
1054        assert!(restored.config().watermark_description);
1055        legacy["version"] = 1.into();
1056        for key in ["config", "options", "price_scale", "maximized"] {
1057            legacy["charts"][0].as_object_mut().unwrap().remove(key);
1058        }
1059        let migrated = SavedLayout::from_json(&legacy.to_string()).unwrap();
1060        restored.restore(&migrated.charts[0]).unwrap();
1061        assert_eq!(restored.chart_type(), ChartType::Line);
1062
1063        let newer = json.replacen("\"version\":3", "\"version\":99", 1);
1064        assert!(SavedLayout::from_json(&newer).is_err());
1065    }
1066
1067    /// A document that restore accepts saves a layout that loads again: values the next save
1068    /// cannot store or the next restore refuses are refused before the chart changes, and a
1069    /// study whose range overflows the price scale still saves.
1070    #[test]
1071    fn every_restored_layout_saves_a_layout_that_loads() {
1072        use crate::widget::test_frames::{chart, frame};
1073        let mut original = chart(&[10.0], &[10.0, 11.0]);
1074        original.set_symbol("A");
1075        original.add_indicator(Box::new(RSI::new(9)));
1076        let document = serde_json::to_value(original.snapshot().unwrap()).unwrap();
1077        for (pointer, value) in [
1078            ("/config/candle_width", 1e39),
1079            ("/options/tooltip/magnifier_size", -1e300),
1080            ("/options/crosshair/horz_line_width", 0.0),
1081            ("/options/crosshair/vert_line_width", -1.0),
1082            ("/options/time_scale/max_bar_spacing", 1e-9),
1083            ("/options/tooltip/font_size", 1e5),
1084            ("/config/watermark_font_size", 1e5),
1085            ("/config/axis_font_size", 0.0),
1086        ] {
1087            let mut invalid = document.clone();
1088            *invalid.pointer_mut(pointer).unwrap() = value.into();
1089            let snapshot: ChartSnapshot = serde_json::from_value(invalid).unwrap();
1090            let mut restored = chart(&[10.0], &[11.0]);
1091            restored.set_symbol("B");
1092            assert!(restored.restore(&snapshot).is_err(), "{pointer} {value}");
1093            assert_eq!(restored.symbol(), "B", "{pointer} {value}");
1094        }
1095
1096        // Bands half of f64::MAX from the closes overflow the autoscaled range with its margins.
1097        original.add_indicator(Box::new(crate::studies::BollingerBands::new(2, f64::MAX)));
1098        frame(&egui::Context::default(), &mut original, 0.0, Vec::new());
1099        let json = SavedLayout {
1100            mode: ChartLayoutMode::Single,
1101            charts: vec![original.snapshot().unwrap()],
1102        }
1103        .to_json()
1104        .unwrap();
1105        let layout = SavedLayout::from_json(&json).unwrap();
1106        chart(&[10.0], &[11.0]).restore(&layout.charts[0]).unwrap();
1107    }
1108}