1use 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#[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 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 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 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 #[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 #[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 #[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 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 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 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 #[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}