Skip to main content

libsurfer/
marker.rs

1use ecolor::Color32;
2use egui::{Context, RichText, WidgetText, Window};
3use egui_extras::{Column, TableBuilder};
4use emath::{Align2, Pos2, Rect};
5use epaint::{CornerRadius, FontId, Stroke};
6use itertools::Itertools;
7use num::BigInt;
8
9use crate::SystemState;
10use crate::drawing_canvas::draw_vertical_line;
11use crate::{
12    config::SurferTheme,
13    displayed_item::{DisplayedItem, DisplayedItemRef, DisplayedMarker},
14    message::Message,
15    time::TimeFormatter,
16    view::{DrawingContext, ItemDrawingInfo},
17    viewport::Viewport,
18    wave_data::WaveData,
19};
20
21pub const DEFAULT_MARKER_NAME: &str = "Marker";
22const MAX_MARKERS: usize = 255;
23const MAX_MARKER_INDEX: u8 = 254;
24const CURSOR_MARKER_IDX: u8 = 255;
25
26impl WaveData {
27    /// Get the color for a marker by its index, falling back to cursor color if not found
28    fn get_marker_color(&self, idx: u8, theme: &SurferTheme) -> Color32 {
29        self.items_tree
30            .iter()
31            .find_map(|node| {
32                if let Some(DisplayedItem::Marker(marker)) =
33                    self.displayed_items.get(&node.item_ref)
34                    && marker.idx == idx
35                {
36                    return marker
37                        .color
38                        .as_ref()
39                        .and_then(|color| theme.get_color(color));
40                }
41                None
42            })
43            .unwrap_or(theme.cursor.color)
44    }
45
46    pub fn draw_cursor(&self, theme: &SurferTheme, ctx: &mut DrawingContext, viewport: &Viewport) {
47        if let Some(marker) = &self.cursor {
48            let num_timestamps = self.safe_num_timestamps();
49            let x = viewport.pixel_from_time(marker, ctx.cfg.canvas_width, &num_timestamps);
50            draw_vertical_line(x, ctx, &theme.cursor);
51        }
52    }
53
54    pub fn draw_markers(&self, theme: &SurferTheme, ctx: &mut DrawingContext, viewport: &Viewport) {
55        let num_timestamps = self.safe_num_timestamps();
56        for (idx, marker) in &self.markers {
57            let color = self.get_marker_color(*idx, theme);
58            let stroke = Stroke {
59                color,
60                width: theme.cursor.width,
61            };
62            let x = viewport.pixel_from_time(marker, ctx.cfg.canvas_width, &num_timestamps);
63            draw_vertical_line(x, ctx, stroke);
64        }
65    }
66
67    #[must_use]
68    pub fn can_add_marker(&self) -> bool {
69        self.markers.len() < MAX_MARKERS
70    }
71
72    pub fn add_marker(
73        &mut self,
74        location: &BigInt,
75        name: Option<String>,
76        move_focus: bool,
77    ) -> Option<DisplayedItemRef> {
78        if !self.can_add_marker() {
79            return None;
80        }
81
82        let Some(idx) = (0..=MAX_MARKER_INDEX).find(|idx| !self.markers.contains_key(idx)) else {
83            // This shouldn't happen since can_add_marker() was already checked,
84            // but handle it gracefully
85            return None;
86        };
87
88        let item_ref = self.insert_item(
89            DisplayedItem::Marker(DisplayedMarker {
90                color: None,
91                background_color: None,
92                name,
93                idx,
94            }),
95            None,
96            move_focus,
97        );
98        self.markers.insert(idx, location.clone());
99
100        Some(item_ref)
101    }
102
103    pub fn remove_marker(&mut self, idx: u8) {
104        if let Some(&marker_item_ref) =
105            self.displayed_items
106                .iter()
107                .find_map(|(id, item)| match item {
108                    DisplayedItem::Marker(marker) if marker.idx == idx => Some(id),
109                    _ => None,
110                })
111        {
112            self.remove_displayed_item(marker_item_ref);
113        }
114    }
115
116    /// Set the marker with the specified id to the location. If the marker doesn't exist already,
117    /// it will be created
118    pub fn set_marker_position(&mut self, idx: u8, location: &BigInt) {
119        if !self.markers.contains_key(&idx) {
120            self.insert_item(
121                DisplayedItem::Marker(DisplayedMarker {
122                    color: None,
123                    background_color: None,
124                    name: None,
125                    idx,
126                }),
127                None,
128                true,
129            );
130        }
131        self.markers.insert(idx, location.clone());
132    }
133
134    pub fn move_marker_to_cursor(&mut self, idx: u8) {
135        if let Some(location) = self.cursor.clone() {
136            self.set_marker_position(idx, &location);
137        }
138    }
139
140    /// Draw text with background box at the specified position
141    /// Returns the text and its background rectangle info for reuse if needed
142    #[allow(clippy::too_many_arguments)]
143    fn draw_text_with_background(
144        ctx: &mut DrawingContext,
145        x: f32,
146        y: f32,
147        text: &str,
148        text_size: f32,
149        background_color: Color32,
150        foreground_color: Color32,
151        padding: f32,
152    ) {
153        // Measure text first
154        let rect = ctx.painter.text(
155            (ctx.to_screen)(x, y),
156            Align2::CENTER_CENTER,
157            text,
158            FontId::proportional(text_size),
159            foreground_color,
160        );
161
162        // Background rectangle with padding
163        let min = Pos2::new(rect.min.x - padding, rect.min.y - padding);
164        let max = Pos2::new(rect.max.x + padding, rect.max.y + padding);
165
166        ctx.painter
167            .rect_filled(Rect { min, max }, CornerRadius::ZERO, background_color);
168
169        // Draw text on top of background
170        ctx.painter.text(
171            (ctx.to_screen)(x, y),
172            Align2::CENTER_CENTER,
173            text,
174            FontId::proportional(text_size),
175            foreground_color,
176        );
177    }
178
179    pub fn draw_marker_number_boxes(
180        &self,
181        ctx: &mut DrawingContext,
182        theme: &SurferTheme,
183        viewport: &Viewport,
184    ) {
185        let text_size = ctx.cfg.text_size;
186
187        for displayed_item in self
188            .items_tree
189            .iter_visible()
190            .map(|node| self.displayed_items.get(&node.item_ref))
191            .filter_map(|item| match item {
192                Some(DisplayedItem::Marker(marker)) => Some(marker),
193                _ => None,
194            })
195        {
196            let item = DisplayedItem::Marker(displayed_item.clone());
197            let background_color = get_marker_background_color(&item, theme);
198
199            let x =
200                self.numbered_marker_location(displayed_item.idx, viewport, ctx.cfg.canvas_width);
201            let idx_string = displayed_item.idx.to_string();
202
203            Self::draw_text_with_background(
204                ctx,
205                x,
206                ctx.cfg.canvas_height * 0.5,
207                &idx_string,
208                text_size,
209                background_color,
210                theme.foreground,
211                2.0,
212            );
213        }
214    }
215}
216
217impl SystemState {
218    pub fn draw_marker_window(&self, waves: &WaveData, ctx: &Context, msgs: &mut Vec<Message>) {
219        let mut open = true;
220
221        // Construct markers list: cursor first (if present), then numbered markers
222        let markers: Vec<(u8, &BigInt, WidgetText)> = waves
223            .cursor
224            .as_ref()
225            .into_iter()
226            .map(|cursor| {
227                (
228                    CURSOR_MARKER_IDX,
229                    cursor,
230                    WidgetText::RichText(RichText::new("Primary").into()),
231                )
232            })
233            .chain(
234                waves
235                    .items_tree
236                    .iter()
237                    .filter_map(|node| waves.displayed_items.get(&node.item_ref))
238                    .filter_map(|displayed_item| match displayed_item {
239                        DisplayedItem::Marker(marker) => {
240                            let text_color = self.get_item_text_color(displayed_item);
241                            Some((
242                                marker.idx,
243                                waves.numbered_marker_time(marker.idx),
244                                marker.marker_text(text_color),
245                            ))
246                        }
247                        _ => None,
248                    })
249                    .sorted_by(|a, b| Ord::cmp(&a.0, &b.0)),
250            )
251            .collect();
252
253        Window::new("Markers")
254            .collapsible(true)
255            .resizable(true)
256            .open(&mut open)
257            .show(ctx, |ui| {
258                ui.vertical_centered(|ui| {
259                    // Table of markers: header row then rows of time differences.
260                    let row_height = ui.text_style_height(&egui::TextStyle::Body);
261                    TableBuilder::new(ui)
262                        .striped(true)
263                        .cell_layout(egui::Layout::right_to_left(emath::Align::TOP))
264                        .columns(Column::auto().resizable(true), markers.len() + 1)
265                        .auto_shrink(emath::Vec2b::new(false, true))
266                        .header(row_height, |mut header| {
267                            header.col(|ui| {
268                                ui.label("");
269                            });
270                            for (marker_idx, _, widget_text) in &markers {
271                                header.col(|ui| {
272                                    if ui.label(widget_text.clone()).clicked() {
273                                        msgs.push(marker_click_message(
274                                            *marker_idx,
275                                            waves.cursor.as_ref(),
276                                        ));
277                                    }
278                                });
279                            }
280                        })
281                        .body(|mut body| {
282                            let time_formatter = TimeFormatter::new(
283                                &waves.inner.metadata().timescale,
284                                &self.user.wanted_timeunit,
285                                &self.get_time_format(),
286                            );
287                            for (marker_idx, row_marker_time, row_widget_text) in &markers {
288                                body.row(row_height, |mut row| {
289                                    row.col(|ui| {
290                                        if ui.label(row_widget_text.clone()).clicked() {
291                                            msgs.push(marker_click_message(
292                                                *marker_idx,
293                                                waves.cursor.as_ref(),
294                                            ));
295                                        }
296                                    });
297                                    for (_, col_marker_time, _) in &markers {
298                                        let diff = time_formatter
299                                            .format(&(*row_marker_time - *col_marker_time));
300                                        row.col(|ui| {
301                                            ui.label(diff);
302                                        });
303                                    }
304                                });
305                            }
306                        });
307                    ui.add_space(15.);
308                    if ui.button("Close").clicked() {
309                        msgs.push(Message::SetCursorWindowVisible(false));
310                    }
311                });
312            });
313        if !open {
314            msgs.push(Message::SetCursorWindowVisible(false));
315        }
316    }
317
318    pub fn draw_marker_boxes(
319        &self,
320        waves: &WaveData,
321        ctx: &mut DrawingContext,
322        gap: f32,
323        viewport: &Viewport,
324        y_zero: f32,
325    ) {
326        let text_size = ctx.cfg.text_size;
327
328        let time_formatter = TimeFormatter::new(
329            &waves.inner.metadata().timescale,
330            &self.user.wanted_timeunit,
331            &self.get_time_format(),
332        );
333        for drawing_info in waves.drawing_infos.iter().filter_map(|item| match item {
334            ItemDrawingInfo::Marker(marker) => Some(marker),
335            _ => None,
336        }) {
337            let Some(item) = waves
338                .items_tree
339                .get_visible(drawing_info.vidx)
340                .and_then(|node| waves.displayed_items.get(&node.item_ref))
341            else {
342                return;
343            };
344
345            // We draw in absolute coords, but the variable offset in the y
346            // direction is also in absolute coordinates, so we need to
347            // compensate for that
348            let y_offset = drawing_info.top - y_zero;
349            let y_bottom = drawing_info.bottom - y_zero;
350
351            let background_color = get_marker_background_color(item, &self.user.config.theme);
352
353            let x =
354                waves.numbered_marker_location(drawing_info.idx, viewport, ctx.cfg.canvas_width);
355
356            // Time string
357            let time = time_formatter.format(
358                waves
359                    .markers
360                    .get(&drawing_info.idx)
361                    .unwrap_or(&BigInt::from(0)),
362            );
363
364            let text_color = self.user.config.theme.get_best_text_color(background_color);
365
366            // Create galley
367            let galley =
368                ctx.painter
369                    .layout_no_wrap(time, FontId::proportional(text_size), text_color);
370            let offset_width = galley.rect.width() * 0.5 + 2. * gap;
371
372            // Background rectangle
373            let min = (ctx.to_screen)(x - offset_width, y_offset - gap);
374            let max = (ctx.to_screen)(x + offset_width, y_bottom + gap);
375
376            ctx.painter
377                .rect_filled(Rect { min, max }, CornerRadius::ZERO, background_color);
378
379            // Draw actual text on top of rectangle
380            ctx.painter.galley(
381                (ctx.to_screen)(
382                    x - galley.rect.width() * 0.5,
383                    (y_offset + y_bottom - galley.rect.height()) * 0.5,
384                ),
385                galley,
386                text_color,
387            );
388        }
389    }
390}
391
392/// Get the background color for a marker or cursor, with fallback to theme cursor color
393fn get_marker_background_color(item: &DisplayedItem, theme: &SurferTheme) -> Color32 {
394    item.color()
395        .and_then(|color| theme.get_color(color))
396        .unwrap_or(theme.cursor.color)
397}
398
399/// Generate the message for a marker click based on its index
400fn marker_click_message(marker_idx: u8, cursor: Option<&BigInt>) -> Message {
401    if marker_idx < CURSOR_MARKER_IDX {
402        Message::GoToMarkerPosition(marker_idx, 0)
403    } else {
404        Message::GoToTime(cursor.cloned(), 0)
405    }
406}