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