egui_map_view/layers/
text.rs

1//! A layer for placing text on the map.
2
3use crate::layers::Layer;
4use crate::projection::{GeoPos, MapProjection};
5use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Response};
6use serde::{Deserialize, Serialize};
7use std::any::Any;
8
9/// A helper module for serializing `egui::Color32`.
10mod ser_color {
11    use egui::Color32;
12    use serde::{self, Deserialize, Deserializer, Serializer};
13
14    pub fn serialize<S>(color: &Color32, serializer: S) -> Result<S::Ok, S::Error>
15    where
16        S: Serializer,
17    {
18        let s = color.to_hex();
19        serializer.serialize_str(&s)
20    }
21
22    pub fn deserialize<'de, D>(deserializer: D) -> Result<Color32, D::Error>
23    where
24        D: Deserializer<'de>,
25    {
26        let s = String::deserialize(deserializer)?;
27        if !s.starts_with('#') {
28            return Err(serde::de::Error::custom("hex color must start with '#'"));
29        }
30        let s = &s[1..];
31        let (r, g, b, a) = match s.len() {
32            6 => {
33                let r = u8::from_str_radix(&s[0..2], 16).map_err(serde::de::Error::custom)?;
34                let g = u8::from_str_radix(&s[2..4], 16).map_err(serde::de::Error::custom)?;
35                let b = u8::from_str_radix(&s[4..6], 16).map_err(serde::de::Error::custom)?;
36                (r, g, b, 255)
37            }
38            8 => {
39                let r = u8::from_str_radix(&s[0..2], 16).map_err(serde::de::Error::custom)?;
40                let g = u8::from_str_radix(&s[2..4], 16).map_err(serde::de::Error::custom)?;
41                let b = u8::from_str_radix(&s[4..6], 16).map_err(serde::de::Error::custom)?;
42                let a = u8::from_str_radix(&s[6..8], 16).map_err(serde::de::Error::custom)?;
43                (r, g, b, a)
44            }
45            _ => {
46                return Err(serde::de::Error::custom("invalid hex color length"));
47            }
48        };
49        Ok(Color32::from_rgba_unmultiplied(r, g, b, a))
50    }
51}
52
53/// The size of the text.
54#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
55pub enum TextSize {
56    /// Size is in screen points, and does not scale with zoom.
57    Static(f32),
58
59    /// Size is in meters at the equator, and scales with zoom.
60    Relative(f32),
61}
62
63impl Default for TextSize {
64    fn default() -> Self {
65        // A reasonable default.
66        Self::Static(12.0)
67    }
68}
69
70/// A piece of text on the map.
71#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
72pub struct Text {
73    /// The text to display.
74    pub text: String,
75
76    /// The geographical position of the text.
77    pub pos: GeoPos,
78
79    /// The size of the text.
80    pub size: TextSize,
81
82    /// The color of the text.
83    #[serde(with = "ser_color")]
84    pub color: Color32,
85
86    /// The color of the background.
87    #[serde(with = "ser_color")]
88    pub background: Color32,
89}
90
91impl Default for Text {
92    fn default() -> Self {
93        Self {
94            text: "New Text".to_string(),
95            pos: GeoPos { lon: 0.0, lat: 0.0 }, // This will be updated on click.
96            size: TextSize::default(),
97            color: Color32::BLACK,
98            background: Color32::from_rgba_unmultiplied(255, 255, 255, 180),
99        }
100    }
101}
102
103/// The state of the text currently being edited or added.
104#[derive(Clone, Debug)]
105pub struct EditingText {
106    /// The index of the text being edited, if it's an existing one.
107    pub index: Option<usize>,
108    /// The properties of the text being edited.
109    pub properties: Text,
110}
111
112/// The mode of the `TextLayer`.
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
114pub enum TextLayerMode {
115    /// The layer is not interactive.
116    #[default]
117    Disabled,
118    /// The user can add, remove, and modify text elements.
119    Modify,
120}
121
122/// Layer implementation that allows placing text on the map.
123#[derive(Clone, Serialize, Deserialize)]
124#[serde(default)]
125pub struct TextLayer {
126    texts: Vec<Text>,
127
128    /// The current mode.
129    #[serde(skip)]
130    pub mode: TextLayerMode,
131
132    /// The properties for the next text to be added.
133    #[serde(skip)]
134    pub new_text_properties: Text,
135
136    /// The state of the text currently being edited or added.
137    #[serde(skip)]
138    pub editing: Option<EditingText>,
139
140    #[serde(skip)]
141    dragged_text_index: Option<usize>,
142}
143
144impl Default for TextLayer {
145    fn default() -> Self {
146        Self {
147            texts: Vec::new(),
148            mode: TextLayerMode::default(),
149            new_text_properties: Text::default(),
150            editing: None,
151            dragged_text_index: None,
152        }
153    }
154}
155
156impl TextLayer {
157    /// Starts editing an existing text element.
158    pub fn start_editing(&mut self, index: usize) {
159        if let Some(text) = self.texts.get(index) {
160            self.editing = Some(EditingText {
161                index: Some(index),
162                properties: text.clone(),
163            });
164        }
165    }
166
167    /// Deletes a text element.
168    pub fn delete(&mut self, index: usize) {
169        if index < self.texts.len() {
170            self.texts.remove(index);
171        }
172    }
173
174    /// Saves the changes made in the editing dialog.
175    pub fn commit_edit(&mut self) {
176        if let Some(editing) = self.editing.take() {
177            if let Some(index) = editing.index {
178                // It's an existing text.
179                if let Some(text) = self.texts.get_mut(index) {
180                    *text = editing.properties;
181                }
182            } else {
183                // It's a new text.
184                self.texts.push(editing.properties);
185            }
186        }
187    }
188
189    /// Discards the changes made in the editing dialog.
190    pub fn cancel_edit(&mut self) {
191        self.editing = None;
192    }
193
194    fn handle_modify_input(&mut self, response: &Response, projection: &MapProjection) -> bool {
195        if self.editing.is_some() {
196            // While editing in a dialog, we don't want to interact with the map.
197            // We consume all hover events to prevent panning and zooming.
198            return response.hovered();
199        }
200
201        if response.drag_started() {
202            if let Some(pointer_pos) = response.interact_pointer_pos() {
203                self.dragged_text_index = self.find_text_at(pointer_pos, projection, &response.ctx);
204            }
205        }
206
207        if response.dragged() {
208            if let Some(text_index) = self.dragged_text_index {
209                if let Some(text) = self.texts.get_mut(text_index) {
210                    if let Some(pointer_pos) = response.interact_pointer_pos() {
211                        text.pos = projection.unproject(pointer_pos);
212                    }
213                }
214            }
215        }
216
217        if response.drag_stopped() {
218            self.dragged_text_index = None;
219        }
220
221        // Change cursor on hover
222        if self.dragged_text_index.is_some() {
223            response.ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
224        } else if let Some(hover_pos) = response.hover_pos() {
225            if self
226                .find_text_at(hover_pos, projection, &response.ctx)
227                .is_some()
228            {
229                response.ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
230            } else {
231                response.ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
232            }
233        }
234
235        if !response.dragged() && response.clicked() {
236            // Left-click to add or edit a text element
237            if let Some(pointer_pos) = response.interact_pointer_pos() {
238                if let Some(index) = self.find_text_at(pointer_pos, projection, &response.ctx) {
239                    // Clicked on an existing text, start editing it.
240                    self.start_editing(index);
241                } else {
242                    // Clicked on an empty spot, start adding a new text.
243                    let geo_pos = projection.unproject(pointer_pos);
244                    let mut properties = self.new_text_properties.clone();
245                    properties.pos = geo_pos;
246                    self.editing = Some(EditingText {
247                        index: None,
248                        properties,
249                    });
250                }
251            }
252        }
253
254        response.hovered()
255    }
256
257    /// A more robust check that considers the text's bounding box.
258    fn find_text_at(
259        &self,
260        screen_pos: Pos2,
261        projection: &MapProjection,
262        ctx: &egui::Context,
263    ) -> Option<usize> {
264        self.texts.iter().enumerate().rev().find_map(|(i, text)| {
265            let text_rect = self.get_text_rect(text, projection, ctx);
266            if text_rect.expand(5.0).contains(screen_pos) {
267                // Add some tolerance
268                Some(i)
269            } else {
270                None
271            }
272        })
273    }
274}
275
276impl Layer for TextLayer {
277    fn as_any(&self) -> &dyn Any {
278        self
279    }
280
281    fn as_any_mut(&mut self) -> &mut dyn Any {
282        self
283    }
284
285    fn handle_input(&mut self, response: &Response, projection: &MapProjection) -> bool {
286        match self.mode {
287            TextLayerMode::Disabled => false,
288            TextLayerMode::Modify => self.handle_modify_input(response, projection),
289        }
290    }
291
292    fn draw(&self, painter: &Painter, projection: &MapProjection) {
293        for text in &self.texts {
294            let screen_pos = projection.project(text.pos);
295
296            let galley = painter.layout_no_wrap(
297                // We use the painter's layout function here for drawing.
298                text.text.clone(),
299                FontId::proportional(self.get_font_size(text, projection)),
300                text.color,
301            );
302
303            let rect =
304                Align2::CENTER_CENTER.anchor_rect(Rect::from_min_size(screen_pos, galley.size()));
305
306            painter.rect_filled(rect.expand(2.0), 3.0, text.background);
307            painter.galley(rect.min, galley, Color32::TRANSPARENT);
308        }
309    }
310}
311
312impl TextLayer {
313    fn get_font_size(&self, text: &Text, projection: &MapProjection) -> f32 {
314        match text.size {
315            TextSize::Static(size) => size,
316            TextSize::Relative(size_in_meters) => {
317                let p2 = projection.project(GeoPos {
318                    lon: text.pos.lon
319                        + (size_in_meters as f64 / (111_320.0 * text.pos.lat.to_radians().cos())),
320                    lat: text.pos.lat,
321                });
322                (p2.x - projection.project(text.pos).x).abs()
323            }
324        }
325    }
326
327    fn get_text_rect(&self, text: &Text, projection: &MapProjection, ctx: &egui::Context) -> Rect {
328        let font_size = self.get_font_size(text, projection);
329        let galley = ctx.fonts(|f| {
330            f.layout_no_wrap(
331                text.text.clone(),
332                FontId::proportional(font_size),
333                text.color,
334            )
335        });
336        let screen_pos = projection.project(text.pos);
337        Align2::CENTER_CENTER.anchor_rect(Rect::from_min_size(screen_pos, galley.size()))
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn text_layer_serde() {
347        let mut layer = TextLayer::default();
348        layer.mode = TextLayerMode::Modify; // This should not be serialized.
349        layer.texts.push(Text {
350            text: "Hello".to_string(),
351            pos: GeoPos { lon: 1.0, lat: 2.0 },
352            size: TextSize::Static(14.0),
353            color: Color32::from_rgb(0, 0, 255),
354            background: Color32::from_rgba_unmultiplied(255, 0, 0, 128),
355        });
356
357        let json = serde_json::to_string(&layer).unwrap();
358
359        // The serialized string should only contain texts.
360        assert!(json.contains(r##""texts":[{"text":"Hello","pos":{"lon":1.0,"lat":2.0},"size":{"Static":14.0},"color":"#0000ffff","background":"#ff000080""##));
361
362        // it should not contain skipped fields
363        assert!(!json.contains("mode"));
364        assert!(!json.contains("new_text_properties"));
365        assert!(!json.contains("editing"));
366        assert!(!json.contains("dragged_text_index"));
367
368        let deserialized: TextLayer = serde_json::from_str(&json).unwrap();
369
370        // Check that texts are restored correctly.
371        assert_eq!(deserialized.texts.len(), 1);
372        assert_eq!(deserialized.texts[0].text, "Hello");
373        assert_eq!(deserialized.texts[0].pos, GeoPos { lon: 1.0, lat: 2.0 });
374        assert_eq!(deserialized.texts[0].size, TextSize::Static(14.0));
375        assert_eq!(deserialized.texts[0].color, Color32::from_rgb(0, 0, 255));
376        assert_eq!(
377            deserialized.texts[0].background,
378            Color32::from_rgba_unmultiplied(255, 0, 0, 128)
379        );
380
381        // Check that skipped fields have their values from the `default()` implementation.
382        let default_layer = TextLayer::default();
383        assert_eq!(deserialized.mode, default_layer.mode);
384        assert_eq!(
385            deserialized.new_text_properties,
386            default_layer.new_text_properties
387        );
388        assert!(deserialized.editing.is_none());
389        assert!(deserialized.dragged_text_index.is_none());
390    }
391}