Skip to main content

egui_charts/widget/
clipboard.rs

1//! Drawing clipboard uses the existing validated, timestamp-based storage format; the study
2//! clipboard holds a study's name, inputs and presentation.
3
4use crate::drawings::persistence::{DrawingStorage, LoadError, SaveError, stored_to_drawing_with};
5use crate::studies::{InputValue, StudyScale, StudySettings};
6use crate::widget::{Chart, SeriesId};
7
8const PREFIX: &str = "egui-charts-drawings\n";
9const STUDY_PREFIX: &str = "egui-charts-study\n";
10
11/// A copied study, recreated by its catalogue name.
12#[derive(serde::Serialize, serde::Deserialize)]
13struct CopiedStudy {
14    name: String,
15    inputs: Vec<(String, InputValue)>,
16    settings: StudySettings,
17    overlay: bool,
18    scale: StudyScale,
19}
20
21impl Chart {
22    /// Copy the selected drawings as versioned text for the host's system clipboard.
23    /// Returns `None` for an empty selection. Anchors retain timestamps across intervals.
24    pub fn copy_selected_drawings(&self) -> Result<Option<String>, SaveError> {
25        let ids: Vec<_> = self.drawings.selected_ids().collect();
26        if ids.is_empty() {
27            return Ok(None);
28        }
29        let storage = self.store_drawings(
30            self.drawings
31                .drawings
32                .iter()
33                .filter(|d| ids.contains(&d.id)),
34        )?;
35        Ok(Some(format!("{PREFIX}{}", storage.to_json()?)))
36    }
37
38    /// Paste drawing clipboard text as one undoable addition, with new identities and a
39    /// three-bar offset. Copies belong to this chart's symbol; timestamps and study panes are
40    /// mapped against this chart. Selects the copies. Before changing any drawing, rejects text
41    /// that is not drawing clipboard text ([`LoadError::NotClipboard`]), invalid data or a
42    /// version from a newer release, drawings that could not all be read ([`LoadError::Skipped`])
43    /// and a chart without the history to place them ([`LoadError::NeedsHistory`]). The host
44    /// handles the system clipboard and forwards its paste event here. Copies of a disabled
45    /// tool, and copies that saving would refuse, are left out.
46    pub fn paste_drawings(&mut self, text: &str) -> Result<(), LoadError> {
47        let json = text.strip_prefix(PREFIX).ok_or(LoadError::NotClipboard)?;
48        let (storage, skipped) = DrawingStorage::from_json(json)?;
49        if !skipped.is_empty() {
50            return Err(LoadError::Skipped(skipped));
51        }
52        let mut copies = Vec::new();
53        for stored in storage.drawings() {
54            for point in &stored.points {
55                if point
56                    .time()
57                    .and_then(|time| self.time_to_index(time))
58                    .is_none()
59                {
60                    return Err(LoadError::NeedsHistory);
61                }
62            }
63            let mut copy = stored_to_drawing_with(
64                stored,
65                |point| {
66                    self.time_to_index(point.time().expect("validated timestamp"))
67                        .expect("validated time mapping") as f32
68                },
69                0,
70            );
71            if let Some(pane) = &stored.pane {
72                copy.pane = self.stored_pane_owner(pane);
73            }
74            copies.push(copy);
75        }
76        self.user_action(|chart| chart.drawings.paste_drawings(&copies));
77        Ok(())
78    }
79
80    /// Copy study `id` with its inputs, styles, interval visibility, placement and scale as
81    /// versioned text for the host's system clipboard. Returns `None` for an unknown id.
82    pub fn copy_indicator(&self, id: SeriesId) -> Option<String> {
83        let (_, study) = self.indicators().find(|(study, _)| *study == id)?;
84        let copied = CopiedStudy {
85            name: study.name().to_owned(),
86            inputs: study
87                .inputs()
88                .into_iter()
89                .map(|input| (input.id, input.value))
90                .collect(),
91            settings: study.settings(),
92            overlay: study.is_overlay(),
93            scale: study.scale(),
94        };
95        serde_json::to_string(&copied)
96            .ok()
97            .map(|json| format!("{STUDY_PREFIX}{json}"))
98    }
99
100    /// Paste study clipboard text from [`Self::copy_indicator`], on this chart or another, as
101    /// one undoable addition, and return the new study's id. `factory` creates the study by its
102    /// name. Hands a study pasted beyond the [study cap](Self::set_study_limit) back in
103    /// [`crate::studies::StudyError::StudyLimitReached`]. Rejects other text, a name
104    /// `factory` does not know, and inputs or styles the study does not accept, before changing
105    /// the chart. Study output references need host remapping and are rejected; hosts restore
106    /// those inputs through [`Self::add_indicator`].
107    #[cfg(feature = "studies")]
108    #[cfg_attr(docsrs, doc(cfg(feature = "studies")))]
109    pub fn paste_indicator(
110        &mut self,
111        text: &str,
112        factory: &crate::studies::IndicatorFactory,
113    ) -> Result<SeriesId, crate::studies::StudyError> {
114        use crate::studies::{Indicator, RefusedStudy, StudyError};
115        let json = text
116            .strip_prefix(STUDY_PREFIX)
117            .ok_or(StudyError::NotClipboard)?;
118        let copied: CopiedStudy = serde_json::from_str(json).map_err(StudyError::Clipboard)?;
119        if let Some((id, _)) = copied.inputs.iter().find(|(_, value)| {
120            matches!(
121                value,
122                InputValue::Source(crate::studies::StudySource::Study { .. })
123            )
124        }) {
125            return Err(StudyError::InvalidInput {
126                id: id.clone(),
127                reason: "reads another study's output, which needs remapping before paste".into(),
128            });
129        }
130        let mut study = crate::studies::ConfiguredStudy::new(
131            factory
132                .create(&copied.name)
133                .ok_or_else(|| StudyError::UnknownStudy(copied.name.clone()))?,
134        );
135        study.apply_settings(&copied.inputs, copied.settings)?;
136        study.set_overlay(copied.overlay);
137        study.set_scale(copied.scale);
138        let study: Box<dyn Indicator> = Box::new(study);
139        if self.study_limit_reached() {
140            return Err(StudyError::StudyLimitReached(RefusedStudy::new(study)));
141        }
142        Ok(self.user_action(|chart| chart.add_indicator(study)))
143    }
144}
145
146#[cfg(all(test, feature = "studies"))]
147mod tests {
148    use crate::studies::{IndicatorFactory, StudyScale};
149    use crate::widget::test_frames;
150
151    /// A copied study pastes onto another chart with its inputs, styles, interval visibility,
152    /// placement and scale, as one undoable addition.
153    #[test]
154    fn copied_studies_paste_with_their_settings() {
155        let mut source = test_frames::chart(&[100.0], &[110.0]);
156        let id = source.add_indicator(Box::new(crate::studies::SMA::new(7)));
157        let study = source.indicator_mut(id).unwrap();
158        let mut settings = study.settings();
159        settings.plots[0].width = 3.0;
160        settings
161            .hidden_intervals
162            .push(crate::model::Timeframe::Min1);
163        study.apply_settings(&[], settings).unwrap();
164        source.move_indicator_to_pane(id, id);
165        source.set_indicator_scale(id, StudyScale::None);
166        let text = source.copy_indicator(id).unwrap();
167
168        let mut target = test_frames::chart(&[50.0], &[60.0]);
169        let factory = IndicatorFactory::new();
170        assert!(target.paste_indicator("SMA", &factory).is_err());
171        let pasted = target.paste_indicator(&text, &factory).unwrap();
172        let find = |chart: &crate::widget::Chart, id| {
173            chart
174                .indicators()
175                .find(|(study, _)| *study == id)
176                .unwrap()
177                .1
178                .clone_box()
179        };
180        let (copied, pasted) = (find(&source, id), find(&target, pasted));
181        let inputs = |study: &dyn crate::studies::Indicator| {
182            study
183                .inputs()
184                .into_iter()
185                .map(|input| (input.id, input.value))
186                .collect::<Vec<_>>()
187        };
188        assert!(!inputs(copied.as_ref()).is_empty());
189        assert_eq!(inputs(pasted.as_ref()), inputs(copied.as_ref()));
190        assert_eq!(pasted.settings(), copied.settings());
191        assert!(!pasted.is_overlay());
192        assert_eq!(pasted.scale(), StudyScale::None);
193        assert!(target.can_undo());
194        let parent = source.add_indicator(Box::new(crate::studies::RSI::new(2)));
195        source
196            .set_indicator_source(
197                id,
198                crate::studies::StudySource::Study {
199                    id: parent.0,
200                    plot: 0,
201                },
202            )
203            .unwrap();
204        let text = source.copy_indicator(id).unwrap();
205        let unrelated = target.add_indicator(Box::new(crate::studies::EMA::new(2)));
206        assert_eq!(unrelated.0, parent.0);
207        let before: Vec<_> = target.indicators().map(|(id, _)| id).collect();
208        assert!(target.paste_indicator(&text, &factory).is_err());
209        assert_eq!(
210            target.indicators().map(|(id, _)| id).collect::<Vec<_>>(),
211            before
212        );
213    }
214
215    /// Clipboard paste maps times into the destination before offsetting, keeps properties,
216    /// and adds the whole selection to that symbol as one undo step.
217    #[test]
218    fn drawings_paste_across_symbols_with_destination_anchors_and_history() {
219        use crate::drawings::persistence::LoadError;
220        use crate::drawings::{ChartPoint, Drawing, DrawingToolType, ToolAvailability};
221        use crate::model::{BarData, LineStyle};
222        use crate::widget::{Chart, SeriesId};
223        use chrono::{Duration, TimeZone, Utc};
224
225        let mut source = test_frames::chart(&[100.0], &[110.0]);
226        source.set_symbol("SOURCE");
227        for (start, price) in [(20.5, 105.0), (40.0, 107.0)] {
228            let mut line = Drawing::new(0, DrawingToolType::TrendLine);
229            line.chart_points = vec![
230                ChartPoint::new(start, price),
231                ChartPoint::new(start + 10.0, price + 1.0),
232            ];
233            line.color = [20, 80, 160, 200];
234            line.stroke_width = 4.0;
235            line.line_style = LineStyle::Dashed;
236            line.label = Some("Copied line".into());
237            line.notes = Some("Keep these notes".into());
238            source.drawings_mut().add_drawing(line).unwrap();
239        }
240        let ids: Vec<_> = source.drawings().drawings.iter().map(|d| d.id).collect();
241        source.drawings_mut().select_drawings(ids);
242        let copied = source.copy_selected_drawings().unwrap().unwrap();
243        let saved = source.save_drawings().unwrap().to_json().unwrap();
244        let mut target = Chart::new(BarData::new());
245        target.set_symbol("DESTINATION");
246        assert!(matches!(
247            target.paste_drawings(&copied),
248            Err(LoadError::NeedsHistory)
249        ));
250        assert!(target.drawings().drawings.is_empty());
251        assert!(!target.can_undo());
252        // Destination starts ten days later: source index 20.5 maps to 10.5, then 13.5.
253        target.set_data(SeriesId::MAIN, source.state().data().bars[10..].to_vec());
254        assert!(matches!(
255            target.load_drawings(&saved),
256            Err(LoadError::OtherSymbol)
257        ));
258        target.paste_drawings(&copied).unwrap();
259        assert_eq!(target.drawings().drawings.len(), 2);
260        let epoch = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
261        for (original, pasted) in source
262            .drawings()
263            .drawings
264            .iter()
265            .zip(&target.drawings().drawings)
266        {
267            assert!(target.drawings().is_selected(pasted.id));
268            assert!(pasted.stored_id.is_none());
269            assert!(pasted.created_at.is_none());
270            assert_eq!(pasted.notes, original.notes);
271            let mut expected = original.clone();
272            expected.z_order = pasted.z_order;
273            assert_eq!(
274                serde_json::to_value(pasted).unwrap(),
275                serde_json::to_value(expected).unwrap()
276            );
277            for (from, to) in original.chart_points.iter().zip(&pasted.chart_points) {
278                assert_eq!(to.bar_idx, from.bar_idx - 10.0 + 3.0);
279                assert_eq!(to.price, from.price);
280                // Daily source bars start at epoch; the ordinary offset adds three days.
281                assert_eq!(
282                    target.drawing_point_time(to),
283                    Some(
284                        epoch + Duration::seconds(((from.bar_idx as f64 + 3.0) * 86_400.0) as i64)
285                    )
286                );
287            }
288        }
289        let pasted_ids: Vec<_> = target.drawings().drawings.iter().map(|d| d.id).collect();
290        assert_ne!(pasted_ids[0], pasted_ids[1]);
291        let pasted = serde_json::to_value(target.save_drawings().unwrap()).unwrap();
292        assert_eq!(pasted["symbol"], "DESTINATION");
293        assert!(target.undo());
294        assert!(target.drawings().drawings.is_empty());
295        assert!(!target.can_undo());
296        assert!(target.redo());
297        assert_eq!(
298            target
299                .drawings()
300                .drawings
301                .iter()
302                .map(|d| d.id)
303                .collect::<Vec<_>>(),
304            pasted_ids
305        );
306        assert!(target.undo());
307        target.drawings_mut().registry.set_availability(
308            DrawingToolType::TrendLine,
309            ToolAvailability {
310                visible: true,
311                enabled: false,
312            },
313        );
314        target.paste_drawings(&copied).unwrap();
315        assert!(target.drawings().drawings.is_empty());
316        assert!(!target.can_undo());
317        // The copies stay with the destination when navigating between symbol drawing sets.
318        target
319            .drawings_mut()
320            .registry
321            .set_availability(DrawingToolType::TrendLine, ToolAvailability::default());
322        target.paste_drawings(&copied).unwrap();
323        target.set_symbol("SOURCE");
324        assert!(target.drawings().drawings.is_empty());
325        target.set_symbol("DESTINATION");
326        assert_eq!(target.drawings().drawings.len(), 2);
327        assert_eq!(source.drawings().drawings.len(), 2);
328    }
329
330    /// A drawing copied or saved in a study pane lands in the pane of the same study on a chart
331    /// whose studies were added in another order, and in the price pane on a chart without it,
332    /// although study ids are local to each chart.
333    #[test]
334    fn drawings_in_study_panes_follow_their_study_across_charts() {
335        use crate::drawings::{ChartPoint, Drawing, DrawingToolType};
336        use crate::model::Bar;
337        use crate::studies::{CustomIndicator, IndicatorValue};
338        use crate::widget::PaneId;
339        let study = |name: &'static str| {
340            let value = if name == "Gamma" { 40.0 } else { 3.0 };
341            Box::new(
342                CustomIndicator::new(name, move |bars: &[Bar]| {
343                    vec![IndicatorValue::Single(value); bars.len()]
344                })
345                .with_overlay(false),
346            )
347        };
348        let mut source = test_frames::chart(&[10.0], &[20.0]);
349        source.set_symbol("SOURCE");
350        let gamma = source.add_indicator(study("Gamma"));
351        source.add_indicator(study("Delta"));
352        let mut line = Drawing::new(0, DrawingToolType::HorizontalLine);
353        line.chart_points = vec![ChartPoint::new(50.0, 40.0)];
354        line.pane = Some(gamma.0);
355        let id = source.drawings_mut().add_drawing(line).unwrap();
356        source.drawings_mut().select(id);
357        let copied = source.copy_selected_drawings().unwrap().unwrap();
358        let saved = source.save_drawings().unwrap().to_json().unwrap();
359        let ctx = egui::Context::default();
360        for (studies, paste) in [
361            (&["Delta", "Gamma"][..], true),
362            (&["Delta", "Gamma"], false),
363            (&["Delta"], true),
364            (&["Delta"], false),
365        ] {
366            let mut target = test_frames::chart(&[10.0], &[20.0]);
367            target.set_symbol(if paste { "DESTINATION" } else { "SOURCE" });
368            let ids: Vec<_> = studies
369                .iter()
370                .map(|name| target.add_indicator(study(name)))
371                .collect();
372            if paste {
373                target.paste_drawings(&copied).unwrap();
374            } else {
375                target.load_drawings(&saved).unwrap();
376            }
377            test_frames::frame(&ctx, &mut target, 0.0, Vec::new());
378            let drawing = &target.drawings().drawings[0];
379            let case = format!("{studies:?}, paste {paste}");
380            match studies.iter().position(|name| *name == "Gamma") {
381                Some(at) => {
382                    assert_eq!(
383                        target.drawing_pane(drawing.id),
384                        target.series_pane(ids[at]),
385                        "{case}"
386                    );
387                    let x = target.get_rendered_price_rect().center().x;
388                    let on_line = egui::pos2(x, drawing.points[0].y);
389                    assert_eq!(
390                        target.drawings().hit_test(on_line),
391                        Some(drawing.id),
392                        "{case}"
393                    );
394                }
395                None => {
396                    assert_eq!(drawing.pane, None, "{case}");
397                    assert_eq!(
398                        target.drawing_pane(drawing.id),
399                        Some(PaneId::MAIN),
400                        "{case}"
401                    );
402                }
403            }
404        }
405    }
406}