Skip to main content

egui_charts/widget/
anchors.rs

1//! Time anchors behind editable chart coordinates.
2
3use super::Chart;
4use crate::drawings::persistence::{
5    DrawingStorage, LoadError, SaveError, SkipReason, SkippedDrawing, StoredCoord, StoredDrawing,
6    drawing_to_stored_with, stored_to_drawing_with,
7};
8use crate::drawings::{ChartPoint, Drawing, DrawingManager};
9use crate::model::{index_at_with_timeframe, time_at_with_timeframe};
10
11impl Chart {
12    /// UTC time behind a drawing anchor, retaining fractional and repeated-time positions.
13    /// Uses the current rendered bars when its index was edited.
14    pub fn drawing_point_time(&self, point: &ChartPoint) -> Option<chrono::DateTime<chrono::Utc>> {
15        point
16            .time_anchor
17            .filter(|(_, index)| *index == point.bar_idx)
18            .map(|(time, _)| time)
19            .or_else(|| self.index_to_time(point.bar_idx as f64))
20    }
21
22    /// Map a UTC time and price into drawing coordinates, including times outside loaded bars.
23    /// Returns `None` without displayed bars or for a non-finite price or projected index.
24    pub fn drawing_point_at(
25        &self,
26        time: chrono::DateTime<chrono::Utc>,
27        price: f64,
28    ) -> Option<ChartPoint> {
29        let index = self.time_to_index(time)? as f32;
30        if !index.is_finite() || !price.is_finite() {
31            return None;
32        }
33        let mut point = ChartPoint::new(index, price);
34        point.time_anchor = Some((time, index));
35        Some(point)
36    }
37
38    /// Captures edited indices before the bars or the rendered element sequence change.
39    pub(super) fn anchor_drawing_points(&mut self) {
40        let bars = self.state.shown();
41        let timeframe = self.timeframe();
42        let timezone = self.calendar_timezone();
43        let mut anchor = |point: &mut ChartPoint| {
44            if point
45                .time_anchor
46                .is_none_or(|(_, projected)| projected != point.bar_idx)
47            {
48                point.time_anchor = time_at_with_timeframe(
49                    bars,
50                    point.bar_idx as f64,
51                    timeframe,
52                    timezone.as_ref(),
53                )
54                .map(|time| (time, point.bar_idx));
55            }
56        };
57        self.drawings.visit_chart_points(&mut anchor);
58        self.history.visit_chart_points(&mut anchor);
59    }
60
61    /// Projects retained timestamps against the current rendered bars, including whitespace.
62    pub(super) fn project_drawing_points(&mut self) {
63        let bars = self.state.shown();
64        let timeframe = self.timeframe();
65        let timezone = self.calendar_timezone();
66        let state = &self.state;
67        let chart_type = self.chart_type;
68        let params = &self.chart_type_params;
69        let older_bars_drawn = self.older_bars_drawn;
70        let mut project = |point: &mut ChartPoint| {
71            if let Some((time, _)) = point.time_anchor {
72                let index = crate::chart::rendering::candles::price_based::element_index_at(
73                    state,
74                    chart_type,
75                    params,
76                    older_bars_drawn,
77                    time,
78                )
79                .map(|index| index as f64)
80                .or_else(|| index_at_with_timeframe(bars, time, timeframe, timezone.as_ref()));
81                if let Some(index) = index {
82                    // Keep the rank of an anchor already on one of several equal-time elements.
83                    if time_at_with_timeframe(
84                        bars,
85                        point.bar_idx as f64,
86                        timeframe,
87                        timezone.as_ref(),
88                    ) != Some(time)
89                    {
90                        point.bar_idx = index as f32;
91                        point.time_anchor = Some((time, point.bar_idx));
92                    }
93                }
94            }
95        };
96        self.drawings.visit_chart_points(&mut project);
97        self.history.visit_chart_points(&mut project);
98    }
99
100    /// Exports the current symbol's drawings with timestamps, using the chart's own mapping.
101    /// Between bars time is interpolated; outside them the known timeframe duration or most
102    /// common positive spacing among the outermost 16 bars is extended (shorter spacing breaks
103    /// ties, one second if none), retaining repeated-edge ranks. Known month and year intervals
104    /// step by calendar months in the display time zone. Empty charts export retained
105    /// anchors; an index with no loaded mapping returns an error rather than clamping or
106    /// inventing time.
107    pub fn save_drawings(&self) -> Result<DrawingStorage, SaveError> {
108        self.store_drawings(
109            self.drawings
110                .drawings
111                .iter()
112                .filter(|drawing| drawing.saveable),
113        )
114    }
115
116    pub(super) fn store_drawings<'a>(
117        &self,
118        drawings: impl IntoIterator<Item = &'a Drawing>,
119    ) -> Result<DrawingStorage, SaveError> {
120        let mut storage = DrawingStorage::new(self.symbol());
121        storage.timeframe = self
122            .timeframe()
123            .map(|timeframe| timeframe.as_str().into_owned());
124        for drawing in drawings {
125            for (at, point) in drawing.chart_points.iter().enumerate() {
126                if point
127                    .time_anchor
128                    .is_none_or(|(_, index)| index != point.bar_idx)
129                    && self.index_to_time(point.bar_idx as f64).is_none()
130                {
131                    return Err(SaveError::UnmappedAnchor {
132                        id: drawing
133                            .stored_id
134                            .clone()
135                            .unwrap_or_else(|| drawing.id.to_string()),
136                        point: at,
137                    });
138                }
139            }
140            let mut stored = drawing_to_stored_with(drawing, |index, price| {
141                StoredCoord::from_time(
142                    self.index_to_time(index as f64).expect("validated anchor"),
143                    price,
144                )
145            });
146            stored.pane = self
147                .drawing_pane(drawing.id)
148                .and_then(|pane| self.stored_pane(pane));
149            storage.add_drawing(stored);
150        }
151        // Validate all settings too, before a host replaces its last good save.
152        storage.to_json()?;
153        Ok(storage)
154    }
155
156    /// Replaces the current symbol's drawings from a saved file, clears chart undo history
157    /// and reports skipped records.
158    /// Rejects a different symbol or an unreadable file without changing the current drawings,
159    /// with a [`LoadError`] that tells a file from a newer release
160    /// ([`LoadError::UnsupportedVersion`]) from damaged data; each skipped record carries its
161    /// [`SkipReason`].
162    /// Can run before history arrives: timestamp anchors are projected when bars load.
163    /// Restored drawings do not queue writes or enter undo history. Screen coordinates follow
164    /// on the next frame. Account, filesystem and cloud storage remain the host's responsibility.
165    pub fn load_drawings(&mut self, json: &str) -> Result<Vec<SkippedDrawing>, LoadError> {
166        self.load_drawings_remapped(json, false)
167    }
168
169    /// [`Self::load_drawings`]; with `remap`, a drawing whose study was replaced when a snapshot
170    /// was restored takes the replacement before its stored pane is matched, since a study's
171    /// input naming another study as its source holds that study's local id.
172    pub(super) fn load_drawings_remapped(
173        &mut self,
174        json: &str,
175        remap: bool,
176    ) -> Result<Vec<SkippedDrawing>, LoadError> {
177        let (storage, mut skipped) = DrawingStorage::from_json(json)?;
178        if storage.symbol != self.symbol() {
179            return Err(LoadError::OtherSymbol);
180        }
181        let mut manager = DrawingManager::new();
182        manager.set_sync_enabled(self.drawings.sync_enabled);
183        manager.registry = self.drawings.registry.clone();
184        manager.options = self.drawings.options.clone();
185        manager.tool_defaults = self.drawings.tool_defaults.clone();
186        manager.active_tool = self.drawings.active_tool;
187        if let Some(id) = self.drawings.active_custom_tool() {
188            let _ = manager.set_custom_tool(id);
189        }
190        manager.stay_in_drawing_mode = self.drawings.stay_in_drawing_mode;
191        manager.eraser_mode = self.drawings.eraser_mode;
192        manager.curr_timeframe = self.drawings.curr_timeframe.clone();
193        let mut refused = Vec::new();
194        // The n-th loaded drawing is the n-th entry of the file that was not skipped.
195        let read = (0..).filter(|index| skipped.iter().all(|s| s.index != *index));
196        for (index, stored) in read.zip(storage.drawings()) {
197            if let Err(error) = manager.add_synced_drawing(self.restored_drawing(stored, remap)) {
198                refused.push(SkippedDrawing {
199                    index,
200                    id: Some(stored.id.clone()),
201                    error: SkipReason::Invalid(serde::de::Error::custom(error)),
202                });
203            }
204        }
205        skipped.extend(refused);
206        skipped.sort_by_key(|skipped| skipped.index);
207        self.drawings = manager;
208        self.history.clear();
209        Ok(skipped)
210    }
211
212    /// `stored` as a drawing of this chart: anchors at the bars of their times, exact once bars
213    /// load, and in the pane of its study when the chart shows that study, else in the price pane.
214    /// With `remap`, see [`Self::load_drawings_remapped`].
215    pub(super) fn restored_drawing(&self, stored: &StoredDrawing, remap: bool) -> Drawing {
216        let mut drawing = stored_to_drawing_with(
217            stored,
218            |point| {
219                point
220                    .time()
221                    .and_then(|time| self.time_to_index(time))
222                    .unwrap_or(0.0) as f32
223            },
224            0,
225        );
226        if let Some(id) = drawing
227            .pane
228            .filter(|_| remap)
229            .and_then(|pane| self.restored_studies.get(&pane))
230        {
231            drawing.pane = Some(*id);
232        } else if let Some(pane) = &stored.pane {
233            drawing.pane = self.stored_pane_owner(pane);
234        }
235        drawing
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::drawings::{Drawing, DrawingToolType};
243    use crate::model::{Bar, BarData};
244    use crate::widget::SeriesId;
245    use chrono::{TimeZone, Utc};
246
247    #[test]
248    fn whitespace_anchors_keep_their_slots_and_axis_dates() {
249        use crate::model::Timeframe;
250        use chrono::Duration;
251
252        let date = |y, m, d| Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap();
253        let hourly = [
254            (4, 9, 30),
255            (4, 10, 0),
256            (4, 11, 0),
257            (4, 12, 0),
258            (4, 13, 0),
259            (4, 14, 0),
260            (4, 15, 0),
261            (5, 9, 30),
262            (5, 10, 0),
263            (5, 11, 0),
264        ]
265        .map(|(day, hour, minute)| Utc.with_ymd_and_hms(2024, 3, day, hour, minute, 0).unwrap())
266        .to_vec();
267        let bar = |time| Bar::new(time, 10.0, 12.0, 9.0, 11.0, 1.0);
268        for (times, timeframe, timezone, expected_left, expected_right, next) in [
269            // Thursday, Friday, Monday: the weekend cannot turn one slot into three days.
270            (
271                vec![date(2024, 2, 8), date(2024, 2, 9), date(2024, 2, 12)],
272                None,
273                "UTC",
274                date(2024, 2, 7),
275                date(2024, 2, 13),
276                date(2024, 2, 13),
277            ),
278            // Friday, Monday, Tuesday: the same rule applies to prepended history.
279            (
280                vec![date(2024, 2, 2), date(2024, 2, 5), date(2024, 2, 6)],
281                None,
282                "UTC",
283                date(2024, 2, 1),
284                date(2024, 2, 7),
285                date(2024, 2, 7),
286            ),
287            (
288                vec![
289                    date(2024, 1, 2) + Duration::minutes(1258),
290                    date(2024, 1, 2) + Duration::minutes(1259),
291                    date(2024, 1, 3) + Duration::minutes(870),
292                ],
293                Some(Timeframe::Min1),
294                "UTC",
295                date(2024, 1, 2) + Duration::minutes(1257),
296                date(2024, 1, 3) + Duration::minutes(871),
297                date(2024, 1, 3) + Duration::minutes(871),
298            ),
299            (
300                vec![date(2024, 2, 1), date(2024, 3, 1), date(2024, 4, 1)],
301                Some(Timeframe::Month1),
302                "UTC",
303                date(2024, 1, 1),
304                date(2024, 5, 1),
305                date(2024, 5, 1),
306            ),
307            (
308                vec![date(2024, 7, 1), date(2024, 10, 1), date(2025, 1, 1)],
309                Some("3month".parse().unwrap()),
310                "UTC",
311                date(2024, 4, 1),
312                date(2025, 4, 1),
313                date(2025, 4, 1),
314            ),
315            (
316                vec![date(2023, 12, 31), date(2024, 1, 31), date(2024, 2, 29)],
317                Some(Timeframe::Month1),
318                "UTC",
319                date(2023, 11, 30),
320                date(2024, 3, 31),
321                date(2024, 3, 31),
322            ),
323            (
324                vec![date(2023, 1, 1), date(2024, 1, 1), date(2025, 1, 1)],
325                Some("12month".parse().unwrap()),
326                "UTC",
327                date(2022, 1, 1),
328                date(2026, 1, 1),
329                date(2026, 1, 1),
330            ),
331            // Local month starts follow the clock change in November.
332            #[cfg(feature = "timezones")]
333            (
334                vec![
335                    date(2024, 9, 1) + Duration::hours(4),
336                    date(2024, 10, 1) + Duration::hours(4),
337                    date(2024, 11, 1) + Duration::hours(4),
338                ],
339                Some(Timeframe::Month1),
340                "America/New_York",
341                date(2024, 8, 1) + Duration::hours(4),
342                date(2024, 12, 1) + Duration::hours(5),
343                date(2024, 12, 1) + Duration::hours(5),
344            ),
345            // Months keyed at UTC midnight stay at UTC midnight across the clock change.
346            #[cfg(feature = "timezones")]
347            (
348                vec![date(2024, 1, 1), date(2024, 2, 1), date(2024, 3, 1)],
349                Some(Timeframe::Month1),
350                "America/New_York",
351                date(2023, 12, 1),
352                date(2024, 4, 1),
353                date(2024, 4, 1),
354            ),
355            // The half-hour opening bar as the last bar keeps the slots on the hourly grid.
356            (
357                hourly[..8].to_vec(),
358                Some(Timeframe::Hour1),
359                "UTC",
360                Utc.with_ymd_and_hms(2024, 3, 4, 9, 0, 0).unwrap(),
361                Utc.with_ymd_and_hms(2024, 3, 5, 10, 0, 0).unwrap(),
362                Utc.with_ymd_and_hms(2024, 3, 5, 10, 0, 0).unwrap(),
363            ),
364            (
365                hourly[..8].to_vec(),
366                None,
367                "UTC",
368                Utc.with_ymd_and_hms(2024, 3, 4, 9, 0, 0).unwrap(),
369                Utc.with_ymd_and_hms(2024, 3, 5, 10, 0, 0).unwrap(),
370                Utc.with_ymd_and_hms(2024, 3, 5, 10, 0, 0).unwrap(),
371            ),
372            // Clock-aligned hours keep the half-hour opening bar out of the step.
373            (
374                hourly.clone(),
375                Some(Timeframe::Hour1),
376                "UTC",
377                Utc.with_ymd_and_hms(2024, 3, 4, 9, 0, 0).unwrap(),
378                Utc.with_ymd_and_hms(2024, 3, 5, 12, 0, 0).unwrap(),
379                Utc.with_ymd_and_hms(2024, 3, 5, 12, 0, 0).unwrap(),
380            ),
381            (
382                hourly,
383                None,
384                "UTC",
385                Utc.with_ymd_and_hms(2024, 3, 4, 9, 0, 0).unwrap(),
386                Utc.with_ymd_and_hms(2024, 3, 5, 12, 0, 0).unwrap(),
387                Utc.with_ymd_and_hms(2024, 3, 5, 12, 0, 0).unwrap(),
388            ),
389            // A holiday shifts the weekly keys without changing the known duration.
390            (
391                vec![date(2024, 1, 8), date(2024, 1, 16), date(2024, 1, 22)],
392                Some(Timeframe::Week1),
393                "UTC",
394                date(2024, 1, 1),
395                date(2024, 1, 29),
396                date(2024, 1, 29),
397            ),
398            // Quarter-second bars keep their milliseconds on both sides.
399            (
400                [0, 250, 500]
401                    .map(|ms| date(2024, 3, 4) + Duration::milliseconds(ms))
402                    .to_vec(),
403                Some(Timeframe::Ms250),
404                "UTC",
405                date(2024, 3, 4) - Duration::milliseconds(250),
406                date(2024, 3, 4) + Duration::milliseconds(750),
407                date(2024, 3, 4) + Duration::milliseconds(750),
408            ),
409        ] {
410            let last = times.len() - 1;
411            let right_index = (last + 1) as f32;
412            let mut chart = Chart::new(BarData::from_bars(times.into_iter().map(bar).collect()));
413            if let Some(timeframe) = timeframe {
414                chart.set_timeframe(timeframe);
415            }
416            chart.chart_options.time_scale.timezone =
417                crate::scales::TimezoneMode::Timezone(timezone.parse().unwrap());
418            let mut drawing = Drawing::new(7, DrawingToolType::TrendLine);
419            drawing.chart_points = vec![
420                ChartPoint::new(-1.0, 10.0),
421                ChartPoint::new(right_index, 11.0),
422            ];
423            drawing.completed = true;
424            chart.drawings_mut().drawings.push(drawing);
425            for (index, expected) in [(right_index, expected_right), (-1.0, expected_left)] {
426                assert_eq!(
427                    chart.drawing_point_time(&ChartPoint::new(index, 10.0)),
428                    Some(expected),
429                    "{timeframe:?}"
430                );
431                assert_eq!(
432                    chart.drawing_point_at(expected, 10.0).unwrap().bar_idx,
433                    index
434                );
435                assert_eq!(chart.index_to_time(index as f64), Some(expected));
436            }
437            for (index, left, right) in [
438                (-0.5, expected_left, chart.state.shown()[0].time),
439                (
440                    last as f64 + 0.5,
441                    chart.state.shown()[last].time,
442                    expected_right,
443                ),
444            ] {
445                let midpoint = left + (right - left) / 2;
446                assert_eq!(chart.index_to_time(index), Some(midpoint));
447                assert_eq!(chart.time_to_index(midpoint), Some(index));
448            }
449            // The label keys must agree on both sides even if only the final bar is visible.
450            let ctx = egui::Context::default();
451            let painter =
452                egui::Painter::new(ctx, egui::LayerId::background(), egui::Rect::EVERYTHING);
453            // 20 slots of 100 points reach before the first bar of every series here.
454            let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(2000.0, 300.0));
455            let price = crate::scales::PriceMap::new(
456                crate::scales::PriceTransform::default(),
457                crate::scales::PriceRange::new(9.0, 12.0),
458                0.0,
459                300.0,
460                false,
461            );
462            for start in [0, last] {
463                let coords = crate::chart::rendering::ChartMapping::new(
464                    rect, 100.0, start, last, 5.0, price,
465                );
466                let (_, keys, axis) = crate::chart::rendering::time_axis_marks(
467                    &crate::chart::rendering::RenderContext::new(&painter, rect),
468                    &chart.state.shown()[start..],
469                    &coords,
470                    &chart.build_time_formatter(),
471                    &chart.chart_options.time_scale,
472                    chart.calendar_timezone().as_ref(),
473                    |index| chart.index_to_time(index),
474                );
475                // Key indices are shifted by the slots the keys reach before the first bar.
476                let shift = (axis.base_idx - coords.base_idx) as f32;
477                for (index, expected) in [(-1.0, expected_left), (right_index, expected_right)] {
478                    assert_eq!(
479                        keys.iter()
480                            .find(|(_, key)| *key as f32 - shift == index)
481                            .map(|(time, _)| *time),
482                        Some(expected),
483                        "{timeframe:?}, key {index}"
484                    );
485                }
486            }
487            let saved = chart.save_drawings().unwrap().to_json().unwrap();
488            chart.load_drawings(&saved).unwrap();
489            chart.update(SeriesId::MAIN, bar(next));
490            assert_eq!(
491                chart.drawings().drawings[0].chart_points[1].bar_idx,
492                right_index
493            );
494            let mut extended = vec![bar(expected_left)];
495            extended.extend_from_slice(&chart.state.data().bars);
496            chart.set_data(SeriesId::MAIN, extended);
497            assert_eq!(chart.drawings().drawings[0].chart_points[0].bar_idx, 0.0);
498            assert_eq!(
499                chart.drawings().drawings[0].chart_points[1].bar_idx,
500                right_index + 1.0
501            );
502        }
503    }
504
505    /// Millisecond JSON fields must not collapse execution bars one nanosecond apart.
506    #[test]
507    fn drawing_storage_preserves_submillisecond_anchors() {
508        for start in [
509            Utc.timestamp_opt(1_704_067_200, 0).unwrap(),
510            Utc.timestamp_opt(-1, 999_999_999).unwrap(),
511        ] {
512            let bars = crate::data::aggregate_trades(
513                &(0..3)
514                    .map(|id| crate::model::Tick::new(start, 10.0, 1.0, None).with_id(id))
515                    .collect::<Vec<_>>(),
516                "1t".parse::<crate::model::Timeframe>().unwrap(),
517            )
518            .unwrap();
519            let mut chart = Chart::new(BarData::from_bars(bars.clone()));
520            let mut drawing = Drawing::new(7, DrawingToolType::TrendLine);
521            drawing.chart_points = vec![ChartPoint::new(1.0, 10.0), ChartPoint::new(2.0, 11.0)];
522            drawing.completed = true;
523            chart.drawings_mut().drawings.push(drawing);
524            let saved = chart.save_drawings().unwrap().to_json().unwrap();
525            chart.load_drawings(&saved).unwrap();
526            assert_eq!(
527                chart.drawings().drawings[0]
528                    .chart_points
529                    .iter()
530                    .map(|p| p.bar_idx)
531                    .collect::<Vec<_>>(),
532                [1.0, 2.0]
533            );
534            assert_eq!(
535                chart.drawing_point_time(&chart.drawings().drawings[0].chart_points[1]),
536                Some(start + chrono::Duration::nanoseconds(2))
537            );
538            let id = chart.drawings().drawings[0].id;
539            chart.drawings_mut().select(id);
540            let copied = chart.copy_selected_drawings().unwrap().unwrap();
541            chart.paste_drawings(&copied).unwrap();
542            assert_eq!(
543                chart
544                    .drawings()
545                    .drawings
546                    .last()
547                    .unwrap()
548                    .chart_points
549                    .iter()
550                    .map(|point| point.bar_idx)
551                    .collect::<Vec<_>>(),
552                [4.0, 5.0]
553            );
554            assert!(chart.undo());
555            // Retained anchors also survive a save while history is absent.
556            chart.set_data(SeriesId::MAIN, Vec::new());
557            let saved = chart.save_drawings().unwrap().to_json().unwrap();
558            chart.load_drawings(&saved).unwrap();
559            chart.set_data(SeriesId::MAIN, bars);
560            assert_eq!(
561                chart.drawings().drawings[0]
562                    .chart_points
563                    .iter()
564                    .map(|p| p.bar_idx)
565                    .collect::<Vec<_>>(),
566                [1.0, 2.0]
567            );
568        }
569    }
570
571    #[test]
572    fn saved_drawings_restore_before_bars_and_report_skipped_records() {
573        let bars = || {
574            [0, 60, 180]
575                .map(|seconds| {
576                    Bar::new(
577                        Utc.timestamp_opt(seconds, 0).unwrap(),
578                        10.0,
579                        11.0,
580                        9.0,
581                        10.0,
582                        1.0,
583                    )
584                })
585                .to_vec()
586        };
587        let mut chart = Chart::new(BarData::from_bars(bars()));
588        chart.set_symbol("A");
589        let mut drawing = Drawing::new(7, DrawingToolType::TrendLine);
590        drawing.chart_points = [-0.5, 1.5, 3.5]
591            .map(|index| ChartPoint::new(index, 10.0))
592            .to_vec();
593        drawing.completed = true;
594        chart.drawings_mut().drawings.push(drawing);
595        let saved = chart.save_drawings().unwrap();
596        chart.drawings_mut().select(7);
597        let mut unrelated = chart.drawings().drawings[0].clone();
598        unrelated.id = 8;
599        unrelated.font_size = f32::NAN;
600        chart.drawings_mut().drawings.push(unrelated);
601        assert!(chart.save_drawings().is_err());
602        let copied = chart.copy_selected_drawings().unwrap().unwrap();
603        chart.drawings_mut().drawings.pop();
604        chart.paste_drawings(&copied).unwrap();
605        assert_eq!(chart.drawings().drawings.len(), 2);
606        assert!(chart.undo());
607        assert_eq!(chart.drawings().drawings.len(), 1);
608        // -0.5 * 60 = -30 s; 60 + 0.5 * 120 = 120 s; 180 + 1.5 * 60 = 270 s.
609        assert_eq!(
610            saved.drawings[0]
611                .points
612                .iter()
613                .map(|p| p.ts_ms)
614                .collect::<Vec<_>>(),
615            [-30_000, 120_000, 270_000]
616        );
617        let mut json = serde_json::to_value(&saved).unwrap();
618        let valid = json["drawings"][0].clone();
619        let mut bad_time = valid.clone();
620        bad_time["id"] = "bad-time".into();
621        bad_time["points"][0]["ts_ms"] = i64::MAX.into();
622        json["drawings"]
623            .as_array_mut()
624            .unwrap()
625            .extend([serde_json::json!({"id": "broken"}), bad_time]);
626        let mut restored = Chart::new(BarData::new());
627        restored.set_symbol("A");
628        restored.drawings_mut().stay_in_drawing_mode = true;
629        restored.drawings_mut().eraser_mode = true;
630        let skipped = restored.load_drawings(&json.to_string()).unwrap();
631        assert_eq!(skipped.iter().map(|s| s.index).collect::<Vec<_>>(), [1, 2]);
632        assert!(restored.drawings().pending_sync.lock().unwrap().is_empty());
633        assert!(!restored.can_undo());
634        assert!(restored.drawings().stay_in_drawing_mode);
635        assert!(restored.drawings().eraser_mode);
636        // Timestamp-only drawings can be saved while history is still pending.
637        assert_eq!(
638            serde_json::to_value(&restored.save_drawings().unwrap().drawings[0].points).unwrap(),
639            serde_json::to_value(&saved.drawings[0].points).unwrap()
640        );
641        restored.set_data(SeriesId::MAIN, bars());
642        assert_eq!(
643            restored.drawings().drawings[0].chart_points,
644            chart.drawings().drawings[0].chart_points
645        );
646        json["symbol"] = "B".into();
647        assert!(matches!(
648            restored.load_drawings(&json.to_string()),
649            Err(LoadError::OtherSymbol)
650        ));
651        assert_eq!(restored.drawings().drawings.len(), 1);
652        let mut empty = Chart::new(BarData::new());
653        empty.drawings_mut().drawings = chart.drawings().drawings.clone();
654        assert!(empty.save_drawings().is_err());
655    }
656
657    /// A paste refuses other text, drawings that could not all be read and a chart without
658    /// bars to place them, each with its own reason, before changing any drawing.
659    #[test]
660    fn a_refused_paste_reports_why() {
661        use crate::drawings::persistence::{LoadError, SkipReason};
662        use crate::widget::test_frames;
663        let mut source = test_frames::chart(&[100.0], &[110.0]);
664        source.set_symbol("A");
665        let mut line = Drawing::new(0, DrawingToolType::TrendLine);
666        line.chart_points = vec![ChartPoint::new(1.0, 105.0), ChartPoint::new(2.0, 106.0)];
667        let id = source.drawings_mut().add_drawing(line).unwrap();
668        source.drawings_mut().select(id);
669        let copied = source.copy_selected_drawings().unwrap().unwrap();
670        let newer = copied.replace("\"TrendLine\"", "\"NewerTool\"");
671        type Refused = fn(&LoadError) -> bool;
672        let cases: [(&str, &str, bool, Refused); 3] = [
673            ("hello", "A", true, |e| matches!(e, LoadError::NotClipboard)),
674            (&newer, "A", true, |e| {
675                matches!(e, LoadError::Skipped(s) if s.len() == 1
676                    && matches!(&s[0].error, SkipReason::UnknownTool(t) if t.input == "NewerTool"))
677            }),
678            (&copied, "A", false, |e| {
679                matches!(e, LoadError::NeedsHistory)
680            }),
681        ];
682        for (text, symbol, bars, refused) in cases {
683            let mut target = if bars {
684                test_frames::chart(&[100.0], &[110.0])
685            } else {
686                Chart::new(BarData::new())
687            };
688            target.set_symbol(symbol);
689            let error = target.paste_drawings(text).unwrap_err();
690            assert!(refused(&error), "{symbol}: {error:?}");
691            assert!(target.drawings().drawings.is_empty(), "{error}");
692        }
693    }
694}