Skip to main content

egui_charts/drawings/
registry.rs

1//! Host-defined tools and availability of built-in tools.
2
3use super::rendering::ChartView;
4use super::{Drawing, DrawingToolType};
5use egui::{Painter, Pos2, Rect, Ui};
6use std::collections::{BTreeMap, HashMap};
7use std::sync::Arc;
8
9/// Rendering, picking and settings for a host-defined drawing tool. Anchors are projected
10/// by the manager into `drawing.points`; tool-specific data lives in `drawing.custom_settings`.
11/// Callbacks must tolerate incomplete drawings while the pointer previews their construction.
12pub trait DrawingRenderer: Send + Sync {
13    /// Paints inside the supplied pane clip, using current projected anchors and market data.
14    fn render(&self, drawing: &Drawing, painter: &Painter, rect: Rect, view: &ChartView);
15    /// Tests the tool's geometry in screen points. This result is authoritative, including
16    /// after painting; returning false keeps custom decorative areas out of selection.
17    fn hit_test(&self, drawing: &Drawing, point: Pos2, tolerance: f32) -> bool;
18    /// Edits the tool's settings and returns whether they changed. The manager retains undo,
19    /// lock checks and persistence notifications. Tools without settings keep the default.
20    fn settings(&self, _ui: &mut Ui, _settings: &mut serde_json::Value) -> bool {
21        false
22    }
23}
24
25/// Whether a built-in tool appears in a picker and can create new drawings. Existing drawings
26/// remain visible and editable, so hiding a tool never makes a saved annotation disappear.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub struct ToolAvailability {
29    /// Include the tool in picker lists.
30    pub visible: bool,
31    /// Permit new drawings and interactive placement.
32    pub enabled: bool,
33}
34
35impl Default for ToolAvailability {
36    fn default() -> Self {
37        Self {
38            visible: true,
39            enabled: true,
40        }
41    }
42}
43
44/// A registered tool's host-facing name, anchor count and implementation.
45#[derive(Clone)]
46pub struct RegisteredDrawingTool {
47    /// Picker label supplied by the host.
48    pub name: String,
49    /// Exact number of clicked anchors required to complete the construction.
50    pub anchors: usize,
51    /// Shared implementation, separate from each drawing's saved settings.
52    pub implementation: Arc<dyn DrawingRenderer>,
53}
54
55/// Per-manager catalogue. Register stable host-owned string ids before loading drawings.
56/// Unknown custom ids stay in storage but are not rendered or hit until registered.
57/// Clone the catalogue into a host picker to share implementations and availability.
58#[derive(Clone, Default)]
59pub struct DrawingToolRegistry {
60    custom: BTreeMap<String, RegisteredDrawingTool>,
61    builtins: HashMap<DrawingToolType, ToolAvailability>,
62}
63
64impl DrawingToolRegistry {
65    /// Registers a tool. Empty ids, zero anchors and duplicate ids leave the catalogue intact.
66    pub fn register(
67        &mut self,
68        id: impl Into<String>,
69        tool: RegisteredDrawingTool,
70    ) -> Result<(), &'static str> {
71        let id = id.into();
72        if id.trim().is_empty() || tool.anchors == 0 {
73            return Err("A drawing tool needs an id and at least one anchor");
74        }
75        if self.custom.contains_key(&id) {
76            return Err("Drawing tool id is already registered");
77        }
78        self.custom.insert(id, tool);
79        Ok(())
80    }
81
82    /// Looks up a custom tool by its saved id.
83    pub fn get(&self, id: &str) -> Option<&RegisteredDrawingTool> {
84        self.custom.get(id)
85    }
86
87    /// Custom tool ids and metadata in stable id order for host pickers.
88    pub fn custom_tools(&self) -> impl Iterator<Item = (&str, &RegisteredDrawingTool)> {
89        self.custom.iter().map(|(id, tool)| (id.as_str(), tool))
90    }
91
92    /// Hides or disables a built-in tool. Hidden tools may still be created by the host when
93    /// enabled; disabling rejects new creation through the manager and chart APIs. Duplicates,
94    /// pastes and copies of its drawings are left out; restoring and syncing them is not affected.
95    pub fn set_availability(&mut self, tool: DrawingToolType, availability: ToolAvailability) {
96        self.builtins.insert(tool, availability);
97    }
98
99    /// Availability of a built-in tool; all tools are visible and enabled by default.
100    pub fn availability(&self, tool: DrawingToolType) -> ToolAvailability {
101        self.builtins.get(&tool).copied().unwrap_or_default()
102    }
103
104    /// Built-in picker entries, excluding hidden tools and including disabled entries.
105    pub fn builtin_tools(&self) -> impl Iterator<Item = (DrawingToolType, ToolAvailability)> + '_ {
106        super::categories::get_all_tools()
107            .into_iter()
108            .filter_map(|tool| {
109                let availability = self.availability(tool);
110                availability.visible.then_some((tool, availability))
111            })
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::drawings::{ChartPoint, DrawingManager};
119
120    struct Midpoint;
121    impl DrawingRenderer for Midpoint {
122        fn render(&self, drawing: &Drawing, painter: &Painter, _rect: Rect, _view: &ChartView) {
123            if let [a, b] = drawing.points.as_slice() {
124                painter.circle_filled(a.lerp(*b, 0.5), 10.0, egui::Color32::RED);
125            }
126        }
127        fn hit_test(&self, drawing: &Drawing, point: Pos2, tolerance: f32) -> bool {
128            match drawing.points.as_slice() {
129                [a, b] => a.lerp(*b, 0.5).distance(point) <= 10.0 + tolerance,
130                _ => false,
131            }
132        }
133        fn settings(&self, _ui: &mut Ui, settings: &mut serde_json::Value) -> bool {
134            *settings = serde_json::json!({"radius": 10});
135            true
136        }
137    }
138
139    #[test]
140    fn registered_tools_use_host_geometry_and_retain_settings_through_history_and_storage() {
141        let mut manager = DrawingManager::new();
142        manager
143            .registry
144            .register(
145                "midpoint",
146                RegisteredDrawingTool {
147                    name: "Midpoint".into(),
148                    anchors: 2,
149                    implementation: Arc::new(Midpoint),
150                },
151            )
152            .unwrap();
153        manager.set_custom_tool("midpoint").unwrap();
154        manager.start_drawing_with_coords(
155            DrawingToolType::Polyline,
156            egui::pos2(100., 100.),
157            |x| x,
158            f64::from,
159        );
160        manager.add_point_with_coords(egui::pos2(200., 100.), |x| x, f64::from);
161        let id = manager.drawings[0].id;
162        assert!(manager.curr_drawing.is_none());
163        assert_eq!(manager.drawings[0].custom_tool.as_deref(), Some("midpoint"));
164        assert_eq!(manager.hit_test(egui::pos2(150., 100.)), Some(id));
165        assert_eq!(manager.hit_test(egui::pos2(110., 100.)), None);
166        let ctx = egui::Context::default();
167        let output = ctx.run_ui(egui::RawInput::default(), |ui| {
168            manager.render_all(
169                ui.painter(),
170                ui.max_rect(),
171                &ChartView {
172                    price_formatter: None,
173                    time_formatter: None,
174                    bar_seconds: 0,
175                    instrument: None,
176                    bars: &[],
177                    revision: 0,
178                    time_axis: true,
179                    executions: None,
180                    timeframe: None,
181                    finer: &[],
182                    bar_to_x: &|x| x,
183                    price_to_y: &|y| y as f32,
184                    y_to_price: &f64::from,
185                },
186            );
187            assert!(manager.show_custom_settings(ui, id));
188        });
189        assert!(output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Circle(c) if c.center == egui::pos2(150.,100.) && c.radius == 10.)));
190        output.drop_without_applying_deltas();
191        assert_eq!(manager.hit_test(egui::pos2(110., 100.)), None);
192        assert!(manager.undo());
193        assert_eq!(manager.drawings[0].custom_settings, serde_json::Value::Null);
194        assert!(manager.redo());
195        let stored = crate::drawings::persistence::drawing_to_stored(&manager.drawings[0], |i| {
196            i as i64 * 1000
197        });
198        let restored =
199            crate::drawings::persistence::stored_to_drawing(&stored, |t| t as f32 / 1000., 7);
200        assert_eq!(restored.custom_tool.as_deref(), Some("midpoint"));
201        assert_eq!(restored.custom_settings, serde_json::json!({"radius": 10}));
202        let mut chart = crate::ChartBuilder::new()
203            .with_drawing_registry(manager.registry.clone())
204            .build();
205        chart.set_data(
206            crate::widget::SeriesId::MAIN,
207            crate::widget::test_frames::chart(&[90.0], &[110.0])
208                .bars(crate::widget::SeriesId::MAIN)
209                .to_vec(),
210        );
211        let mut exported = Drawing::custom("midpoint");
212        exported.chart_points = vec![ChartPoint::new(60.0, 100.0), ChartPoint::new(80.0, 100.0)];
213        chart.drawings_mut().add_drawing(exported).unwrap();
214        let mut image = chart.export_image(&ctx, &Default::default()).unwrap();
215        image.textures_delta.clear();
216        assert!(
217            image.primitives.iter().any(|primitive| matches!(
218                &primitive.primitive, egui::epaint::Primitive::Mesh(mesh)
219                    if mesh.vertices.iter().any(|vertex| vertex.color == egui::Color32::RED)
220            )),
221            "custom drawing missing from export"
222        );
223        chart.set_symbol("A");
224        chart.drawings_mut().set_custom_tool("midpoint").unwrap();
225        chart.set_symbol("B");
226        chart.drawings_mut().set_active_tool(None);
227        chart.set_symbol("A");
228        assert_eq!(chart.drawings().active_custom_tool(), None);
229        let mut invalid = Drawing::custom("midpoint");
230        invalid.chart_points = vec![ChartPoint::new(1., 1.)];
231        assert!(manager.add_drawing(invalid).is_err());
232        manager.registry.set_availability(
233            DrawingToolType::TrendLine,
234            ToolAvailability {
235                visible: false,
236                enabled: false,
237            },
238        );
239        assert!(
240            !manager
241                .registry
242                .builtin_tools()
243                .any(|(tool, _)| tool == DrawingToolType::TrendLine)
244        );
245        assert!(!manager.set_active_tool(Some(DrawingToolType::TrendLine)));
246        assert_eq!(manager.active_tool, None);
247        manager.start_drawing_with_coords(DrawingToolType::TrendLine, Pos2::ZERO, |x| x, f64::from);
248        assert!(manager.curr_drawing.is_none());
249        let mut disabled = Drawing::new(0, DrawingToolType::TrendLine);
250        disabled.chart_points = vec![ChartPoint::new(1., 1.), ChartPoint::new(2., 2.)];
251        assert!(manager.add_drawing(disabled).is_err());
252    }
253}