Skip to main content

egui_charts/ui/widget_bar/panels/object_tree/
chart_actions.rs

1//! Apply object tree operations to the attached chart.
2
3use crate::Chart;
4use crate::widget::SeriesId;
5
6use super::{ObjectTreeAction, ObjectTreePanel, SourceItem, SourceType};
7
8impl ObjectTreePanel {
9    /// Apply a tree action to a chart. Drawing rows use drawing ids; study rows bind their
10    /// chart series through [`SourceItem::with_series_id`]. Call this after [`Self::show`].
11    ///
12    /// Bind the main data source with `with_series_id(SeriesId::MAIN)` to control its visibility.
13    /// Returns `false` for host-owned property dialogs, colour gestures, templates, main-series
14    /// removal and renaming non-drawing rows. Drawing names are saved and undone with the drawing.
15    /// Bulk actions apply to every selected drawing and study, and visibility includes the main series.
16    pub fn apply_to_chart(
17        &mut self,
18        chart: &mut Chart,
19        action: &ObjectTreeAction,
20        sources: &[SourceItem],
21    ) -> bool {
22        let selected: Vec<_> = sources
23            .iter()
24            .filter(|s| self.state().is_selected(s.id))
25            .collect();
26        match action {
27            ObjectTreeAction::None => {}
28            ObjectTreeAction::CreateFolder(parent, name) => {
29                let name = name.trim();
30                if name.is_empty() || (!parent.is_empty() && !self.state().folders.contains(parent))
31                {
32                    return false;
33                }
34                let mut path = parent.clone();
35                path.push(name.to_owned());
36                if !self.state_mut().folders.insert(path.clone()) {
37                    return false;
38                }
39                self.state_mut().expanded_groups.insert("Drawings".into());
40                for depth in 1..=path.len() {
41                    self.state_mut().collapsed_folders.remove(&path[..depth]);
42                }
43                let ids: Vec<_> = selected
44                    .iter()
45                    .filter(|s| s.is_drawing())
46                    .map(|s| s.id)
47                    .collect();
48                chart
49                    .drawings_mut()
50                    .edit_drawings(&ids, |d| d.folder = path.clone());
51            }
52            ObjectTreeAction::MoveToFolder(ids, path) => {
53                if !path.is_empty() && !self.state().folders.contains(path) {
54                    return false;
55                }
56                let ids: Vec<_> = chart
57                    .drawings()
58                    .drawings
59                    .iter()
60                    .filter(|d| {
61                        ids.contains(&d.id)
62                            && d.folder != *path
63                            && sources.iter().any(|s| s.id == d.id && s.is_drawing())
64                    })
65                    .map(|d| d.id)
66                    .collect();
67                chart
68                    .drawings_mut()
69                    .edit_drawings(&ids, |d| d.folder = path.clone());
70            }
71            ObjectTreeAction::SetFolderVisible(path, value)
72            | ObjectTreeAction::SetFolderLocked(path, value) => {
73                if path.is_empty() || !self.state().folders.contains(path) {
74                    return false;
75                }
76                let ids: Vec<_> = chart
77                    .drawings()
78                    .drawings
79                    .iter()
80                    .filter(|d| {
81                        d.folder.starts_with(path)
82                            && match action {
83                                ObjectTreeAction::SetFolderVisible(_, _) => d.visible != *value,
84                                _ => d.locked != *value,
85                            }
86                    })
87                    .map(|d| d.id)
88                    .collect();
89                chart.drawings_mut().edit_drawings(&ids, |d| match action {
90                    ObjectTreeAction::SetFolderVisible(_, _) => d.visible = *value,
91                    _ => d.locked = *value,
92                });
93            }
94            ObjectTreeAction::Rename(id, name) => {
95                if !sources.iter().any(|s| s.id == *id && s.is_drawing()) {
96                    return false;
97                }
98                if !name.trim().is_empty() {
99                    chart
100                        .drawings_mut()
101                        .edit_drawing(*id, |d| d.label = Some(name.clone()));
102                }
103            }
104            ObjectTreeAction::Select(id) => {
105                chart.clear_selection();
106                if sources.iter().any(|s| s.id == *id && s.is_drawing()) {
107                    chart.drawings_mut().select(*id);
108                    chart.reveal_drawing(*id);
109                } else {
110                    chart.drawings_mut().deselect();
111                }
112            }
113            ObjectTreeAction::ClearSelection => {
114                chart.drawings_mut().deselect();
115                chart.clear_selection();
116            }
117            ObjectTreeAction::AddToSelection(_)
118            | ObjectTreeAction::RangeSelect(_)
119            | ObjectTreeAction::SelectAll => {
120                chart.clear_selection();
121                chart
122                    .drawings_mut()
123                    .select_drawings(selected.iter().filter(|s| s.is_drawing()).map(|s| s.id));
124            }
125            ObjectTreeAction::ShowAll | ObjectTreeAction::HideAll => {
126                let visible = matches!(action, ObjectTreeAction::ShowAll);
127                for source in sources {
128                    set_visible(chart, source, visible);
129                }
130            }
131            ObjectTreeAction::HideSelected => {
132                for source in selected {
133                    set_visible(chart, source, false);
134                }
135            }
136            ObjectTreeAction::ToggleVisibility(id) => {
137                let Some(source) = sources.iter().find(|s| s.id == *id) else {
138                    return false;
139                };
140                if !set_visible(chart, source, !source.visible) {
141                    return false;
142                }
143            }
144            ObjectTreeAction::ToggleLock(id) => {
145                chart
146                    .drawings_mut()
147                    .edit_drawing(*id, |d| d.locked = !d.locked);
148            }
149            ObjectTreeAction::LockAll | ObjectTreeAction::UnlockAll => {
150                let locked = matches!(action, ObjectTreeAction::LockAll);
151                let ids: Vec<_> = chart
152                    .drawings()
153                    .drawings
154                    .iter()
155                    .filter(|d| d.locked != locked)
156                    .map(|d| d.id)
157                    .collect();
158                for id in ids {
159                    chart.drawings_mut().edit_drawing(id, |d| d.locked = locked);
160                }
161            }
162            ObjectTreeAction::Delete(id) | ObjectTreeAction::RemoveIndicator(id) => {
163                let Some(source) = sources.iter().find(|s| s.id == *id) else {
164                    return false;
165                };
166                if !source.is_drawing() && !source.is_indicator() {
167                    return false;
168                }
169                delete(chart, source);
170            }
171            ObjectTreeAction::DeleteSelected => {
172                for source in selected {
173                    delete(chart, source);
174                }
175                self.state_mut().clear_selection();
176                chart.drawings_mut().deselect();
177            }
178            ObjectTreeAction::Duplicate(id) => {
179                let Some(source) = sources.iter().find(|s| s.id == *id) else {
180                    return false;
181                };
182                if !source.is_drawing() && !source.is_indicator() {
183                    return false;
184                }
185                duplicate(chart, source);
186            }
187            ObjectTreeAction::DuplicateSelected => {
188                for source in selected {
189                    duplicate(chart, source);
190                }
191            }
192            ObjectTreeAction::MoveToPane(id, pane) => {
193                if !sources.iter().any(|s| s.id == *id && s.is_drawing())
194                    || !chart.move_drawing_to_pane(*id, *pane)
195                {
196                    return false;
197                }
198            }
199            ObjectTreeAction::BringToFront(id)
200            | ObjectTreeAction::SendToBack(id)
201            | ObjectTreeAction::MoveUp(id)
202            | ObjectTreeAction::MoveDown(id) => {
203                let mut order: Vec<_> = chart
204                    .drawings()
205                    .drawings
206                    .iter()
207                    .map(|d| (d.id, d.z_order))
208                    .collect();
209                order.sort_by_key(|(_, z)| *z);
210                let Some(index) = order.iter().position(|(candidate, _)| candidate == id) else {
211                    return false;
212                };
213                let other = match action {
214                    ObjectTreeAction::BringToFront(_) => Some(order.len() - 1),
215                    ObjectTreeAction::SendToBack(_) => Some(0),
216                    ObjectTreeAction::MoveUp(_) => {
217                        index.checked_add(1).filter(|next| *next < order.len())
218                    }
219                    _ => index.checked_sub(1),
220                };
221                if let Some(other) = other.filter(|other| *other != index) {
222                    let target = order.remove(index);
223                    order.insert(other, target);
224                    // Normalise tied layers so exactly one neighbour is crossed.
225                    let layers: Vec<_> = order
226                        .into_iter()
227                        .enumerate()
228                        .filter_map(|(z, (id, _))| i32::try_from(z).ok().map(|z| (id, z)))
229                        .collect();
230                    chart.drawings_mut().reorder_drawings(*id, &layers);
231                }
232            }
233            ObjectTreeAction::ZoomTo(id) => {
234                if !sources.iter().any(|s| s.id == *id && s.is_drawing())
235                    || !chart.drawings().drawings.iter().any(|d| d.id == *id)
236                {
237                    return false;
238                }
239                chart.clear_selection();
240                chart.drawings_mut().select(*id);
241                chart.reveal_drawing(*id);
242            }
243            ObjectTreeAction::RemoveAllDrawings => chart.drawings_mut().clear_all(),
244            ObjectTreeAction::RemoveAllIndicators => {
245                let ids: Vec<SeriesId> = chart.indicators().map(|(id, _)| id).collect();
246                for id in ids {
247                    chart.remove_series(id);
248                }
249            }
250            _ => return false,
251        }
252        true
253    }
254}
255
256fn set_visible(chart: &mut Chart, source: &SourceItem, visible: bool) -> bool {
257    if source.source_type == SourceType::DataSource && source.series_id == Some(SeriesId::MAIN) {
258        chart.set_main_series_visible(visible);
259        true
260    } else if source.is_indicator() {
261        source
262            .series_id
263            .is_some_and(|id| chart.set_indicator_visible(id, visible))
264    } else if source.is_drawing() {
265        chart
266            .drawings_mut()
267            .edit_drawing(source.id, |d| d.visible = visible);
268        true
269    } else {
270        false
271    }
272}
273
274fn delete(chart: &mut Chart, source: &SourceItem) {
275    if source.is_indicator() {
276        if let Some(id) = source.series_id {
277            chart.remove_series(id);
278        }
279    } else if source.is_drawing() {
280        chart.drawings_mut().delete_drawing(source.id);
281    }
282}
283
284fn duplicate(chart: &mut Chart, source: &SourceItem) {
285    if source.source_type == SourceType::Indicator {
286        let copy = chart
287            .indicators()
288            .find(|(id, _)| Some(*id) == source.series_id)
289            .map(|(id, i)| {
290                (
291                    i.clone_box(),
292                    chart.indicator_inputs(id).unwrap_or_default().to_owned(),
293                )
294            });
295        if let Some((copy, inputs)) = copy {
296            let id = chart.add_indicator(copy);
297            chart.set_indicator_inputs(id, inputs);
298        }
299    } else if source.is_drawing() {
300        chart.drawings_mut().duplicate_drawing(source.id);
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::drawings::{ChartPoint, Drawing, DrawingToolType};
308    use crate::model::LogicalRange;
309    use crate::widget::test_frames::{chart, frame};
310
311    #[test]
312    fn tree_selection_reveals_drawings_without_refitting_scales() {
313        for tool in [
314            DrawingToolType::AnchoredVWAP,
315            DrawingToolType::AnchoredVolumeProfile,
316        ] {
317            for zoom in [false, true] {
318                for manual in [false, true] {
319                    // A 100-point manual span pans by 25 to reveal 80 or 220 with 5 points of margin.
320                    for (index, price, low, high) in [
321                        (10.0, 150.0, 100.0, 200.0),
322                        (70.0, 150.0, 100.0, 200.0),
323                        (110.0, 150.0, 100.0, 200.0),
324                        (70.0, 80.0, 75.0, 175.0),
325                        (70.0, 220.0, 125.0, 225.0),
326                    ] {
327                        let ctx = egui::Context::default();
328                        let mut chart = chart(&[100.0], &[200.0]);
329                        frame(&ctx, &mut chart, 0.0, vec![]);
330                        chart.set_visible_logical_range(LogicalRange::new(50.0, 89.0));
331                        if manual {
332                            chart.price_scale_mut().set_range(100.0, 200.0);
333                        }
334                        let mut drawing = Drawing::new(1, tool);
335                        drawing.chart_points = vec![ChartPoint::new(index, price)];
336                        drawing.completed = true;
337                        chart.drawings_mut().add_synced_drawing(drawing).unwrap();
338                        let id = chart.drawings().drawings[0].id;
339                        let sources = [SourceItem::drawing(id, tool, egui::Color32::RED)];
340                        let mut tree = ObjectTreePanel::new();
341                        let action = if zoom {
342                            ObjectTreeAction::ZoomTo(id)
343                        } else {
344                            ObjectTreeAction::Select(id)
345                        };
346                        let spacing = chart.get_time_scale_state().0;
347                        assert!(tree.apply_to_chart(&mut chart, &action, &sources));
348                        assert_eq!(chart.drawings().selected_ids().collect::<Vec<_>>(), [id]);
349                        assert_eq!(chart.get_time_scale_state().0, spacing);
350                        assert_eq!(chart.state().price_scale().is_manual(), manual);
351                        let range = chart.visible_logical_range().unwrap();
352                        assert!(
353                            range.left <= index && index <= range.right,
354                            "{action:?}: {range:?}, index {index}"
355                        );
356                        if index == 70.0 {
357                            assert_eq!(range, LogicalRange::new(50.0, 89.0));
358                        }
359                        if manual {
360                            assert_eq!(
361                                chart.state().price_scale().range(),
362                                crate::scales::PriceRange::new(low, high)
363                            );
364                        }
365                    }
366                }
367            }
368        }
369    }
370
371    /// The rows a host builds from the chart: the main series, the studies and the drawings.
372    fn rows(chart: &Chart) -> Vec<SourceItem> {
373        const STUDIES: usize = usize::MAX / 2;
374        let mut main = SourceItem::data_source(usize::MAX, "AAPL").with_series_id(SeriesId::MAIN);
375        main.visible = chart.is_main_series_visible();
376        let mut rows = vec![main];
377        for (id, study) in chart.indicators() {
378            let item = SourceItem::indicator(STUDIES + id.0, study.name(), egui::Color32::GRAY)
379                .with_series_id(id);
380            rows.push(item);
381        }
382        for drawing in chart.drawings().listed_drawings() {
383            let mut item = SourceItem::drawing(drawing.id, drawing.tool_type, drawing.color32());
384            item.visible = drawing.visible;
385            item.z_index = drawing.z_order;
386            rows.push(item);
387        }
388        rows
389    }
390
391    /// Applies `action` as the host's handler does: one undoable step over the current rows.
392    fn apply(tree: &mut ObjectTreePanel, chart: &mut Chart, action: ObjectTreeAction) {
393        let rows = rows(chart);
394        chart.user_action(|chart| tree.apply_to_chart(chart, &action, &rows));
395    }
396
397    #[test]
398    fn layers_names_copies_and_visibility_apply_as_undoable_steps() {
399        let mut chart = chart(&[100.0], &[200.0]);
400        let mut tree = ObjectTreePanel::new();
401        for id in 1..=3 {
402            let mut drawing = Drawing::new(id, DrawingToolType::TrendLine);
403            drawing.chart_points = vec![ChartPoint::new(10.0, 100.0), ChartPoint::new(20.0, 200.0)];
404            drawing.completed = true;
405            chart.drawings_mut().add_synced_drawing(drawing).unwrap();
406        }
407        let ids: Vec<_> = chart.drawings().drawings.iter().map(|d| d.id).collect();
408        let z_order = |chart: &Chart| {
409            chart
410                .drawings()
411                .drawings
412                .iter()
413                .map(|d| d.z_order)
414                .collect::<Vec<_>>()
415        };
416        let painted = |chart: &Chart| {
417            let mut order: Vec<_> = chart
418                .drawings()
419                .drawings
420                .iter()
421                .map(|d| (d.z_order, d.id))
422                .collect();
423            order.sort_by_key(|(z, _)| *z);
424            order.into_iter().map(|(_, id)| id).collect::<Vec<_>>()
425        };
426        // The main series hides and shows again through its row.
427        apply(
428            &mut tree,
429            &mut chart,
430            ObjectTreeAction::ToggleVisibility(usize::MAX),
431        );
432        assert!(!chart.is_main_series_visible());
433        assert!(chart.undo());
434        assert!(chart.is_main_series_visible());
435        apply(
436            &mut tree,
437            &mut chart,
438            ObjectTreeAction::ToggleVisibility(ids[0]),
439        );
440        assert!(!chart.drawings().drawings[0].visible);
441        assert!(chart.undo());
442        assert!(chart.drawings().drawings[0].visible);
443        // Tied layers still have a stable paint order. Each move crosses exactly one neighbour.
444        for drawing in &mut chart.drawings_mut().drawings {
445            drawing.z_order = 0;
446        }
447        for (action, expected) in [
448            (
449                ObjectTreeAction::MoveUp(ids[0]),
450                vec![ids[1], ids[0], ids[2]],
451            ),
452            (ObjectTreeAction::MoveDown(ids[0]), ids.clone()),
453            (
454                ObjectTreeAction::BringToFront(ids[0]),
455                vec![ids[1], ids[2], ids[0]],
456            ),
457            (ObjectTreeAction::SendToBack(ids[0]), ids.clone()),
458        ] {
459            let before = z_order(&chart);
460            apply(&mut tree, &mut chart, action);
461            let after = z_order(&chart);
462            assert!(chart.undo());
463            assert_eq!(z_order(&chart), before);
464            assert!(chart.redo());
465            assert_eq!(z_order(&chart), after);
466            assert_eq!(painted(&chart), expected);
467        }
468        apply(
469            &mut tree,
470            &mut chart,
471            ObjectTreeAction::Rename(ids[0], "Support".into()),
472        );
473        assert_eq!(
474            chart.drawings().drawings[0].label.as_deref(),
475            Some("Support")
476        );
477        assert!(chart.undo());
478        assert_eq!(chart.drawings().drawings[0].label, None);
479        apply(&mut tree, &mut chart, ObjectTreeAction::Duplicate(ids[0]));
480        assert_eq!(chart.drawings().drawings.len(), 4);
481        assert!(chart.undo());
482        assert_eq!(chart.drawings().drawings.len(), 3);
483        // A copied study keeps its inputs.
484        let study = chart.add_indicator(Box::new(crate::studies::SMA::new(2)));
485        chart.set_indicator_inputs(study, "2 close");
486        let before: Vec<_> = chart.indicators().map(|(id, _)| id).collect();
487        apply(
488            &mut tree,
489            &mut chart,
490            ObjectTreeAction::Duplicate(usize::MAX / 2 + study.0),
491        );
492        let copy = chart
493            .indicators()
494            .map(|(id, _)| id)
495            .find(|id| !before.contains(id))
496            .unwrap();
497        assert_eq!(chart.indicator_inputs(copy), Some("2 close"));
498    }
499}