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