Skip to main content

egui_charts/widget/
drawing_api.rs

1//! Validated host creation and editing of timestamp-based drawings.
2
3use super::PaneId;
4use crate::Chart;
5use crate::drawings::{ChartPoint, Drawing};
6use chrono::{DateTime, Utc};
7
8/// Why [`Chart::create_drawing`] or [`Chart::set_drawing_points`] changed nothing.
9#[derive(Clone, Debug, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum DrawingError {
12    /// No drawing has this id.
13    UnknownDrawing(usize),
14    /// The drawing with this id is locked.
15    Locked(usize),
16    /// The price of the anchor at this index, counted from 0, is NaN or infinite.
17    NonFinitePrice {
18        /// The index of the anchor.
19        point: usize,
20    },
21    /// The drawing manager refused the drawing; the text says why, such as a disabled or
22    /// unknown tool or anchors the tool cannot use.
23    Invalid(&'static str),
24}
25
26impl std::fmt::Display for DrawingError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::UnknownDrawing(id) => write!(f, "Unknown drawing {id}"),
30            Self::Locked(id) => write!(f, "Drawing {id} is locked"),
31            Self::NonFinitePrice { point } => {
32                write!(f, "Drawing price of point {point} must be finite")
33            }
34            Self::Invalid(reason) => f.write_str(reason),
35        }
36    }
37}
38impl std::error::Error for DrawingError {}
39
40impl Chart {
41    /// Creates a completed drawing from UTC times and prices, with the supplied drawing's
42    /// properties, pane, group and layer. Its id and storage identity are replaced.
43    /// Returns an error without changing history or drawings for invalid anchors or settings.
44    /// Times can precede loaded history; their projection is updated when bars arrive.
45    /// Wrap this call in [`Self::user_action`] to include it in chart undo history.
46    ///
47    /// ```
48    /// use egui_charts::{Chart, drawings::{Drawing, DrawingToolType}};
49    /// let mut chart = Chart::new(egui_charts::model::BarData::default());
50    /// let time = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
51    /// let mut line = Drawing::new(0, DrawingToolType::HorizontalLine);
52    /// line.locked = true;
53    /// let id = chart.create_drawing(line, &[(time, 100.0)]).unwrap();
54    /// assert_eq!(chart.drawing_points(id).unwrap(), vec![(time, 100.0)]);
55    /// ```
56    pub fn create_drawing(
57        &mut self,
58        mut drawing: Drawing,
59        points: &[(DateTime<Utc>, f64)],
60    ) -> Result<usize, DrawingError> {
61        drawing.chart_points = self.map_drawing_points(points)?;
62        self.drawings_mut()
63            .add_drawing(drawing)
64            .map_err(DrawingError::Invalid)
65    }
66
67    /// Returns a drawing's UTC anchors and prices, or `None` for an unknown id or an index
68    /// without a time mapping. Retains exact supplied times between bars and in whitespace.
69    pub fn drawing_points(&self, id: usize) -> Option<Vec<(DateTime<Utc>, f64)>> {
70        self.drawings()
71            .drawings
72            .iter()
73            .find(|d| d.id == id)?
74            .chart_points
75            .iter()
76            .map(|p| Some((self.drawing_point_time(p)?, p.price)))
77            .collect()
78    }
79
80    /// Replaces a drawing's anchors as one undoable edit. Invalid points and locked drawings
81    /// leave the original untouched. Screen positions are rebuilt on the next frame.
82    pub fn set_drawing_points(
83        &mut self,
84        id: usize,
85        points: &[(DateTime<Utc>, f64)],
86    ) -> Result<(), DrawingError> {
87        let mut drawing = self
88            .drawings()
89            .drawings
90            .iter()
91            .find(|d| d.id == id)
92            .ok_or(DrawingError::UnknownDrawing(id))?
93            .clone();
94        if drawing.locked {
95            return Err(DrawingError::Locked(id));
96        }
97        drawing.chart_points = self.map_drawing_points(points)?;
98        self.drawings()
99            .validate_drawing(&drawing)
100            .map_err(DrawingError::Invalid)?;
101        self.drawings_mut().edit_drawing(id, |target| {
102            target.chart_points = drawing.chart_points;
103        });
104        Ok(())
105    }
106
107    /// Sets an image drawing's picture as one undoable edit. `uri` is the address the host's
108    /// image loaders resolve, and `size` is its pixel size, which gives the frame its
109    /// proportions, scaled down to fit 240 x 180 points. Closes this drawing's text editor.
110    /// Unknown, locked and non-image drawings, an empty address and a size that is not
111    /// positive and finite change nothing. Each frame side is at least one point.
112    pub fn set_drawing_image(
113        &mut self,
114        id: usize,
115        uri: impl Into<String>,
116        size: egui::Vec2,
117    ) -> Result<(), &'static str> {
118        let drawing = self
119            .drawings()
120            .drawings
121            .iter()
122            .find(|d| d.id == id)
123            .ok_or("Unknown drawing")?;
124        if drawing.locked {
125            return Err("Drawing is locked");
126        }
127        if drawing.tool_type != crate::drawings::DrawingToolType::Image {
128            return Err("Drawing is not an image");
129        }
130        let uri = uri.into();
131        if uri.trim().is_empty() {
132            return Err("Image address is empty");
133        }
134        if !(size.x > 0.0 && size.y > 0.0 && size.is_finite()) {
135            return Err("Image size must be positive and finite");
136        }
137        let sizing = &crate::tokens::DESIGN_TOKENS.sizing.media;
138        let limit = egui::vec2(
139            2.0 * sizing.image_default_width,
140            2.0 * sizing.image_default_height,
141        );
142        let scale = 1.0_f32.min(limit.x / size.x).min(limit.y / size.y);
143        let frame = (size * scale).max(egui::Vec2::splat(1.0)).min(limit);
144        self.user_action(|chart| {
145            let manager = chart.drawings_mut();
146            if manager
147                .text_editor
148                .as_ref()
149                .is_some_and(|editor| editor.id == id)
150            {
151                manager.text_editor = None;
152            }
153            manager.edit_drawing(id, |drawing| {
154                drawing.text = Some(uri);
155                drawing.image.get_or_insert_with(Default::default).size = Some(frame.into());
156            });
157        });
158        Ok(())
159    }
160
161    /// The pane that draws drawing `id`, or `None` for an unknown drawing.
162    pub fn drawing_pane(&self, id: usize) -> Option<PaneId> {
163        let drawing = self.drawings().drawings.iter().find(|d| d.id == id)?;
164        Some(
165            self.pane_layout
166                .panes
167                .iter()
168                .find(|pane| {
169                    drawing.pane == Some(pane.id.0)
170                        || pane.members.iter().any(|id| drawing.pane == Some(id.0))
171                })
172                .map_or(PaneId::MAIN, |pane| pane.id),
173        )
174    }
175
176    /// Moves drawing `id` into `pane` as one undoable edit, keeping its anchors and style; the
177    /// anchors' prices are read on the new pane's scale. Returns `false` for an unknown drawing
178    /// or pane and for a locked drawing.
179    pub fn move_drawing_to_pane(&mut self, id: usize, pane: PaneId) -> bool {
180        let owner = if pane == PaneId::MAIN {
181            None
182        } else if let Some(pane) = self
183            .pane_layout
184            .panes
185            .iter()
186            .find(|item| item.id == pane)
187            .filter(|_| self.panes().contains(&pane))
188        {
189            // The pane's drawings belong to its first member, or to an empty pane itself.
190            Some(pane.members.first().map_or(pane.id.0, |id| id.0))
191        } else {
192            return false;
193        };
194        let drawings = self.drawings_mut();
195        if !drawings.drawings.iter().any(|d| d.id == id && !d.locked) {
196            return false;
197        }
198        drawings.edit_drawing(id, |d| d.pane = owner);
199        true
200    }
201
202    /// Adds a horizontal line at `price` in the pane under `pos`, the price pane when no other
203    /// pane is there, with the tool's default style, and selects it. Returns its id, or `None`
204    /// for a price that is not finite or a disabled tool.
205    #[cfg(feature = "ui")]
206    pub(crate) fn draw_horizontal_line_at(&mut self, pos: egui::Pos2, price: f64) -> Option<usize> {
207        if !price.is_finite() {
208            return None;
209        }
210        let tool = crate::drawings::DrawingToolType::HorizontalLine;
211        let mut drawing = self.drawings.new_drawing(0, tool);
212        // A study pane's drawings belong to its first member, as in `move_drawing_to_pane`.
213        drawing.pane = self
214            .pane_layout
215            .panes
216            .iter()
217            .find(|pane| pane.id != PaneId::MAIN && pane.rect.contains(pos))
218            .map(|pane| pane.members.first().map_or(pane.id.0, |id| id.0));
219        drawing.z_order = self.drawings.top_z_order();
220        let bar = self.get_chart_mapping().x_to_idx_f64(pos.x).round() as f32;
221        drawing.chart_points = vec![ChartPoint::new(bar, price)];
222        let id = self.drawings.add_drawing(drawing).ok()?;
223        self.drawings.select(id);
224        Some(id)
225    }
226
227    /// Reveals an offscreen drawing by panning, retaining zoom and the auto/manual scale state.
228    #[cfg(feature = "ui")]
229    pub(crate) fn reveal_drawing(&mut self, id: usize) {
230        let Some(drawing) = self.drawings.drawings.iter().find(|d| d.id == id) else {
231            return;
232        };
233        if !drawing.selectable || drawing.screen_anchor.is_some() || self.state.shown().is_empty() {
234            return;
235        }
236        let pane = self.pane_layout.panes.iter().position(|pane| {
237            drawing.pane == Some(pane.id.0)
238                || pane.members.iter().any(|id| drawing.pane == Some(id.0))
239        });
240        let price = match pane {
241            Some(at) => &self.pane_layout.panes[at].scale,
242            None => self.state.price_scale(),
243        };
244        let transform = price.transform();
245        let bounds = drawing
246            .chart_points
247            .iter()
248            .filter_map(|point| {
249                let price = transform.to_scale(point.price);
250                (point.bar_idx.is_finite() && price.is_finite()).then_some((point.bar_idx, price))
251            })
252            .fold(None, |bounds, (bar, price)| {
253                Some(match bounds {
254                    None => (bar, bar, price, price),
255                    Some((left, right, low, high)) => (
256                        bar.min(left),
257                        bar.max(right),
258                        price.min(low),
259                        price.max(high),
260                    ),
261                })
262            });
263        let Some((left, right, low, high)) = bounds else {
264            return;
265        };
266        let range = self.state.time_scale().visible_logical_range();
267        let padding = ((range.right - range.left) * 0.05).clamp(0.0, 2.0);
268        let shift = if right < range.left {
269            right - range.left - padding
270        } else if left > range.right {
271            left - range.right + padding
272        } else {
273            0.0
274        };
275        if shift != 0.0 {
276            self.cancel_view_motion();
277            self.tracking_mode_active = false;
278            let scale = self.state.time_scale_mut();
279            scale.set_right_offset(scale.right_offset_precise() + f64::from(shift));
280        }
281        let scale = match pane {
282            Some(at) => &mut self.pane_layout.panes[at].scale,
283            None => self.state.price_scale_mut(),
284        };
285        if scale.is_manual() {
286            let range = scale.range();
287            let padding = (range.max - range.min) * 0.05;
288            let shift = if high < range.min {
289                high - range.min - padding
290            } else if low > range.max {
291                low - range.max + padding
292            } else {
293                0.0
294            };
295            if shift != 0.0 && (range.min + shift).is_finite() && (range.max + shift).is_finite() {
296                scale.set_range(range.min + shift, range.max + shift);
297            }
298        }
299    }
300
301    fn map_drawing_points(
302        &self,
303        points: &[(DateTime<Utc>, f64)],
304    ) -> Result<Vec<ChartPoint>, DrawingError> {
305        points
306            .iter()
307            .enumerate()
308            .map(|(point, &(time, price))| {
309                if !price.is_finite() {
310                    return Err(DrawingError::NonFinitePrice { point });
311                }
312                Ok(self.drawing_point_at(time, price).unwrap_or_else(|| {
313                    let mut point = ChartPoint::new(0.0, price);
314                    point.time_anchor = Some((time, 0.0));
315                    point
316                }))
317            })
318            .collect()
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::drawings::DrawingToolType;
326    use crate::drawings::manager::DrawingSyncAction;
327    use crate::model::{Bar, BarData};
328    use crate::studies::{CustomIndicator, IndicatorValue};
329
330    /// Guards #977: a drawing moves between the price pane and a study pane as one undoable
331    /// edit with its anchors and style, and joins the pane the chart draws it in.
332    #[test]
333    fn drawings_move_between_panes_with_their_anchors() {
334        let bars = (0..20)
335            .map(|i| {
336                let time = DateTime::from_timestamp(i * 60, 0).unwrap();
337                Bar::new(time, 10., 12., 8., 10. + i as f64 % 3., 1.)
338            })
339            .collect();
340        let mut chart = Chart::new(BarData::from_bars(bars));
341        let study = chart.add_indicator(Box::new(
342            CustomIndicator::new("Pane", |bars| {
343                vec![IndicatorValue::Single(50.0); bars.len()]
344            })
345            .with_overlay(false),
346        ));
347        let pane = chart.series_pane(study).unwrap();
348        let mut line = Drawing::new(0, DrawingToolType::TrendLine);
349        line.color = [1, 2, 3, 255];
350        line.chart_points = vec![ChartPoint::new(2.0, 30.0), ChartPoint::new(9.0, 70.0)];
351        let id = chart.drawings_mut().add_drawing(line).unwrap();
352        let before = chart.drawings().drawings[0].clone();
353        assert_eq!(chart.drawing_pane(id), Some(PaneId::MAIN));
354
355        assert!(chart.user_action(|chart| chart.move_drawing_to_pane(id, pane)));
356        let moved = &chart.drawings().drawings[0];
357        assert_eq!(chart.drawing_pane(id), Some(pane));
358        assert_eq!(moved.pane, Some(study.0));
359        assert_eq!(
360            (&moved.chart_points, moved.color),
361            (&before.chart_points, before.color)
362        );
363        assert!(chart.undo());
364        assert_eq!(chart.drawing_pane(id), Some(PaneId::MAIN));
365        assert!(chart.redo());
366        assert!(chart.move_drawing_to_pane(id, PaneId::MAIN));
367        assert_eq!(chart.drawings().drawings[0].pane, None);
368
369        assert!(!chart.move_drawing_to_pane(id, PaneId(999)));
370        assert!(!chart.move_drawing_to_pane(id + 1, pane));
371        chart.drawings_mut().edit_drawing(id, |d| d.locked = true);
372        assert!(!chart.move_drawing_to_pane(id, pane));
373        assert_eq!(chart.drawing_pane(id), Some(PaneId::MAIN));
374    }
375
376    #[test]
377    fn timestamp_creation_validates_before_mutation_and_round_trips_edits() {
378        let time = |seconds| DateTime::from_timestamp(seconds, 0).unwrap();
379        let bars = [0, 60, 180]
380            .map(|seconds| Bar::new(time(seconds), 10., 12., 8., 10., 1.))
381            .to_vec();
382        let mut chart = Chart::new(BarData::from_bars(bars));
383        let points = [(time(-30), 9.), (time(120), 11.)];
384        let id = chart
385            .user_action(|chart| {
386                chart.create_drawing(Drawing::new(99, DrawingToolType::TrendLine), &points)
387            })
388            .unwrap();
389        let drawing = &chart.drawings().drawings[0];
390        // -30/60 = -0.5; 1 + (120-60)/(180-60) = 1.5.
391        assert_eq!(
392            drawing
393                .chart_points
394                .iter()
395                .map(|p| p.bar_idx)
396                .collect::<Vec<_>>(),
397            [-0.5, 1.5]
398        );
399        assert_eq!(chart.drawing_points(id).unwrap(), points);
400        assert!(chart.undo());
401        assert!(chart.drawings().drawings.is_empty());
402        assert!(chart.redo());
403        chart.drawings().pending_sync.lock().unwrap().clear();
404        for invalid in [
405            vec![],
406            vec![points[0]],
407            vec![points[0]; 3],
408            vec![(time(0), f64::NAN); 2],
409        ] {
410            assert!(
411                chart
412                    .create_drawing(Drawing::new(0, DrawingToolType::TrendLine), &invalid)
413                    .is_err()
414            );
415            assert!(chart.set_drawing_points(id, &invalid).is_err());
416            assert_eq!(chart.drawing_points(id).unwrap(), points);
417        }
418        assert!(chart.drawings().pending_sync.lock().unwrap().is_empty());
419        let edited = [(time(30), 10.), (time(360), 12.)];
420        chart.set_drawing_points(id, &edited).unwrap();
421        let json = chart.save_drawings().unwrap().to_json().unwrap();
422        let mut empty = Chart::new(crate::model::BarData::default());
423        empty.load_drawings(&json).unwrap();
424        let restored_id = empty.drawings().drawings[0].id;
425        assert_eq!(empty.drawing_points(restored_id).unwrap(), edited);
426        chart.drawings_mut().edit_drawing(id, |d| {
427            d.saveable = false;
428            d.listed = false;
429        });
430        assert!(chart.save_drawings().unwrap().drawings().is_empty());
431        assert!(chart.drawings().listed_drawings().next().is_none());
432        assert!(matches!(
433            chart.drawings().pending_sync.lock().unwrap().last(),
434            Some(DrawingSyncAction::Delete(_))
435        ));
436        assert!(chart.drawings_mut().undo());
437        assert_eq!(chart.save_drawings().unwrap().drawings().len(), 1);
438        assert!(matches!(
439            chart.drawings().pending_sync.lock().unwrap().last(),
440            Some(DrawingSyncAction::SaveDrawing(_))
441        ));
442        let mut volatile = Drawing::new(0, DrawingToolType::HorizontalLine);
443        volatile.saveable = false;
444        chart.drawings().pending_sync.lock().unwrap().clear();
445        let volatile = chart.create_drawing(volatile, &points[..1]).unwrap();
446        chart.drawings_mut().delete_drawing(volatile);
447        assert!(chart.drawings().pending_sync.lock().unwrap().is_empty());
448    }
449}