Skip to main content

libsurfer/
arrow.rs

1use crate::annotation::{Annotatable, AnnotationData};
2use crate::annotation_list::DEFAULT_GROUP_NAME;
3use crate::comment::Comment;
4use crate::config::SurferTheme;
5use crate::displayed_item::DisplayedItemRef;
6use crate::graphics::GraphicsY;
7use crate::message::Message;
8use crate::time::TimeFormatter;
9use crate::{Viewport, view::DrawingContext, wave_data::WaveData};
10
11use chrono::{DateTime, Local};
12use egui::{Id, Pos2, Response, Stroke, Ui, Vec2, Widget};
13use emath::RectTransform;
14use num::BigInt;
15use serde::{Deserialize, Serialize};
16
17const DEFAULT_TYPE: &str = "Arrow";
18const SELECTED_GAMMA_FACTOR: f32 = 1.1;
19const SELECTED_WIDTH_FACTOR: f32 = 1.2;
20const HITBOX_SIZE: f32 = 4.0;
21const HEAD_LEN_FACTOR: f32 = 5.0;
22const HEAD_WIDTH_FACTOR: f32 = 3.0;
23
24#[derive(Clone, Serialize, Deserialize, Debug)]
25pub enum ArrowHeadMode {
26    /// One-headed arrow, with the head at the target/end point.
27    End,
28    /// Double-headed arrow, with heads at both the start and end points.
29    Double,
30}
31
32#[derive(Clone, Serialize, Deserialize, Debug)]
33pub struct WavePoint {
34    pub time: BigInt,
35    pub attached_item: Option<DisplayedItemRef>,
36    pub screen_pos: Pos2,
37}
38
39#[derive(Clone, Copy, Debug)]
40struct ArrowSegments {
41    shaft_start: Pos2,
42    shaft_end: Pos2,
43    end_tip: Pos2,
44    end_left: Pos2,
45    end_right: Pos2,
46    start_tip: Option<Pos2>,
47    start_left: Option<Pos2>,
48    start_right: Option<Pos2>,
49}
50
51/// Returns the shortest distance between point `p` and the line segment `a -> b`.
52fn distance_to_segment(p: Pos2, a: Pos2, b: Pos2) -> f32 {
53    let ab = b - a;
54    let ap = p - a;
55
56    let ab_len_sq = ab.length_sq();
57    if ab_len_sq <= 0.0001 {
58        return ap.length();
59    }
60
61    let t = (ap.dot(ab) / ab_len_sq).clamp(0.0, 1.0);
62    let closest = a + ab * t;
63    (p - closest).length()
64}
65
66/// Calculates the base, left, and right points of an arrow head ending at `to`.
67fn arrow_geometry(from: Pos2, to: Pos2, width: f32) -> Option<(Pos2, Pos2, Pos2)> {
68    let v = to - from;
69    let len = v.length();
70
71    if len <= 0.1 {
72        return None;
73    }
74
75    let dir = v / len;
76    let perp = Vec2::new(-dir.y, dir.x);
77
78    let head_len = width * HEAD_LEN_FACTOR;
79    let head_half_width = width * HEAD_WIDTH_FACTOR;
80
81    let base = to - dir * head_len;
82    let left = base + perp * head_half_width;
83    let right = base - perp * head_half_width;
84
85    Some((base, left, right))
86}
87/// Returns the vertical center of a displayed waveform item with the drawing offset applied.
88fn item_center_y(waves: &WaveData, item_ref: &DisplayedItemRef, offset: f32) -> Option<f32> {
89    match waves.get_displayed_item_index(item_ref) {
90        Some(vidx) => {
91            let info = waves.drawing_infos.get(vidx.0)?;
92            Some(info.center() + offset)
93        }
94        None => None,
95    }
96}
97
98#[derive(Clone, Serialize, Deserialize)]
99pub struct ArrowAnnotation {
100    pub from: WavePoint,
101    pub to: WavePoint,
102    pub created_at: DateTime<Local>,
103    pub length: f32,
104    pub head_mode: ArrowHeadMode,
105    pub annotation_data: AnnotationData,
106}
107
108impl Annotatable for ArrowAnnotation {
109    fn get_id(&self) -> Id {
110        self.annotation_data.id
111    }
112    fn get_type(&self) -> &str {
113        DEFAULT_TYPE
114    }
115    fn set_name(&mut self, name: &str) {
116        self.annotation_data.name = name.to_string();
117    }
118
119    fn get_name(&self) -> String {
120        self.annotation_data.name.clone()
121    }
122
123    fn is_selected(&mut self) {
124        self.annotation_data.stroke.width *= SELECTED_WIDTH_FACTOR;
125        self.annotation_data
126            .stroke
127            .color
128            .gamma_multiply(SELECTED_GAMMA_FACTOR);
129    }
130
131    fn set_visibility(&mut self, visible: bool) {
132        self.annotation_data.visible = visible;
133    }
134
135    fn show_comments(&self) -> bool {
136        self.annotation_data.show_comments
137    }
138
139    fn set_show_comments(&mut self, show: bool) {
140        self.annotation_data.show_comments = show;
141    }
142
143    fn show_comment_box(&self) -> bool {
144        self.annotation_data.comment_box.visible
145    }
146
147    fn is_visible(&self) -> bool {
148        self.annotation_data.visible
149    }
150
151    fn get_center_time(&self) -> BigInt {
152        (&self.from.time + &self.to.time) / 2
153    }
154
155    fn get_start_time(&self) -> BigInt {
156        self.from.time.clone()
157    }
158
159    fn get_end_time(&self) -> BigInt {
160        self.to.time.clone()
161    }
162
163    fn is_attached(&self, removed_ref: &DisplayedItemRef) -> bool {
164        self.to.attached_item.as_ref() == Some(removed_ref)
165    }
166
167    fn get_from_wave(&self) -> Option<GraphicsY> {
168        //this REALLY should be changed, arrow should likely just use a GraphicsY instead of WavePoint
169        if let Some(item) = self.from.attached_item {
170            let temp_graphics = GraphicsY {
171                item,
172                anchor: crate::graphics::Anchor::Center,
173            };
174
175            return Some(temp_graphics);
176        }
177
178        None
179    }
180
181    fn get_to_wave(&self) -> Option<GraphicsY> {
182        if let Some(item) = self.to.attached_item {
183            let temp_graphics = GraphicsY {
184                item,
185                anchor: crate::graphics::Anchor::Center,
186            };
187
188            return Some(temp_graphics);
189        }
190
191        None
192    }
193
194    fn draw(
195        &self,
196        ui: &mut Ui,
197        waves: &WaveData,
198        viewport_idx: usize,
199        ctx: &mut DrawingContext,
200        theme: &SurferTheme,
201        msgs: &mut Vec<Message>,
202        y_offset: f32,
203        to_screen: RectTransform,
204        time_formatter: &TimeFormatter,
205    ) {
206        let mut arrow_annotation = self.clone();
207        arrow_annotation.annotation_data.stroke =
208            Stroke::new(theme.annotation_arrow.width, theme.annotation_arrow.color);
209
210        if waves.selected_annotation == Some(self.annotation_data.id) {
211            arrow_annotation.is_selected();
212        }
213
214        let range = waves.time_range();
215        let viewport = waves.viewports[viewport_idx];
216        let frame_width = ctx.cfg.canvas_size.x;
217
218        arrow_annotation.annotation_data.id =
219            egui::Id::new(("arrow", self.annotation_data.id, viewport_idx));
220
221        // `item_center_y` returns a canvas-local y-coordinate, so it must be converted
222        // through `ctx.to_screen` before use as a final screen position.
223        let to_y = match self.to.attached_item.as_ref() {
224            Some(item_ref) => match item_center_y(waves, item_ref, y_offset) {
225                Some(y) => y,
226                None => return,
227            },
228            None => return,
229        };
230
231        // A one-headed arrow keeps its original vertical length. A double-headed arrow
232        // follows the vertical centers of both attached items.
233        let from_y = match self.head_mode {
234            ArrowHeadMode::End => to_y - self.length,
235            ArrowHeadMode::Double => match self.from.attached_item.as_ref() {
236                Some(item_ref) => match item_center_y(waves, item_ref, y_offset) {
237                    Some(y) => y,
238                    None => return,
239                },
240                None => return,
241            },
242        };
243
244        // Convert annotation times into viewport-local x pixel positions.
245        let new_to_x = viewport.pixel_from_time(&arrow_annotation.to.time, frame_width, range);
246
247        let new_from_x = viewport.pixel_from_time(&arrow_annotation.from.time, frame_width, range);
248
249        let new_to: Pos2 = (ctx.to_screen)(new_to_x, to_y);
250        let new_from = (ctx.to_screen)(new_from_x, from_y);
251
252        arrow_annotation.to.screen_pos = new_to;
253        arrow_annotation.from.screen_pos = new_from;
254
255        // Get hover/click position for hit detection
256        let pointer_hover_pos = ui.input(|i| i.pointer.hover_pos());
257        let pointer_click_pos = ui.input(|i| i.pointer.interact_pos());
258        let primary_clicked = ui.input(|i| i.pointer.primary_clicked());
259
260        let exact_hovered = pointer_hover_pos
261            .and_then(|p| arrow_annotation.hit_distance_screen(p))
262            .is_some();
263
264        let exact_clicked = primary_clicked
265            && pointer_click_pos
266                .and_then(|p| arrow_annotation.hit_distance_screen(p))
267                .is_some();
268
269        ui.add(arrow_annotation);
270
271        if exact_clicked {
272            // Notify the application that this annotation was clicked and that the
273            // current viewport should become active
274
275            msgs.push(Message::SetActiveViewport(viewport_idx));
276            msgs.push(Message::AnnotationClicked(
277                Some(self.annotation_data.id),
278                pointer_click_pos,
279                Some(viewport_idx),
280                Some(to_screen),
281                Some(ctx.cfg.canvas_size.x),
282            ));
283            msgs.push(Message::ClickHandled());
284        }
285
286        if exact_hovered && let Some(pointer_pos) = pointer_hover_pos {
287            // Use a tiny hover rectangle at the pointer position to attach egui's
288            // tooltip UI to the actual arrow hit location.
289            let hover_rect = egui::Rect::from_center_size(pointer_pos, egui::vec2(1.0, 1.0));
290
291            let hover_response = ui.interact(
292                hover_rect,
293                egui::Id::new(("arrow_hover_info", self.annotation_data.id, viewport_idx)),
294                egui::Sense::hover(),
295            );
296
297            let hover_start_time = time_formatter.format(&self.from.time.clone());
298            let hover_end_time = time_formatter.format(&self.to.time.clone());
299
300            let group_name = waves
301                .annotation_groups
302                .iter()
303                .find(|group| group.annotations.contains(&self.get_id()))
304                .map_or(DEFAULT_GROUP_NAME, |group| &group.name);
305            hover_response.on_hover_ui(|ui| {
306                self.draw_hover_info(group_name, ui, (&hover_start_time, &hover_end_time));
307            });
308        }
309    }
310
311    fn get_comment_position(
312        &self,
313        viewport: &Viewport,
314        ctx: &DrawingContext,
315        waves: &WaveData,
316        offset: f32,
317    ) -> Option<Pos2> {
318        let range = waves.time_range();
319        let to_y = self
320            .to
321            .attached_item
322            .as_ref()
323            .and_then(|item_ref| item_center_y(waves, item_ref, offset))?;
324        let (x, y) = match self.head_mode {
325            ArrowHeadMode::End => (
326                viewport.pixel_from_time(&self.to.time, ctx.cfg.canvas_size.x, range),
327                to_y,
328            ),
329            ArrowHeadMode::Double => {
330                // For double-headed arrows, place comments near the visual midpoint.
331                let from_y = self
332                    .from
333                    .attached_item
334                    .as_ref()
335                    .and_then(|item_ref| item_center_y(waves, item_ref, offset))?;
336                let from_x =
337                    viewport.pixel_from_time(&self.from.time, ctx.cfg.canvas_size.x, range);
338                let to_x = viewport.pixel_from_time(&self.to.time, ctx.cfg.canvas_size.x, range);
339                (f32::midpoint(from_x, to_x), f32::midpoint(to_y, from_y))
340            }
341        };
342        Some((ctx.to_screen)(x, y))
343    }
344
345    fn get_time_info(&self, time_formatter: &TimeFormatter) -> String {
346        match self.head_mode {
347            ArrowHeadMode::End => format!("Pointing at {}", time_formatter.format(&self.to.time)),
348            ArrowHeadMode::Double => format!(
349                "from: {}, to: {}",
350                time_formatter.format(&self.from.time),
351                time_formatter.format(&self.to.time)
352            ),
353        }
354    }
355
356    fn get_comment_box(&self) -> Comment {
357        self.annotation_data.comment_box.clone()
358    }
359
360    fn get_comment_box_mut(&mut self) -> &mut Comment {
361        &mut self.annotation_data.comment_box
362    }
363
364    fn get_messages(&self) -> Vec<crate::comment::CommentMessage> {
365        self.annotation_data.comment_box.message_chain.clone()
366    }
367}
368
369impl ArrowAnnotation {
370    pub(crate) fn new(
371        id: Id,
372        from: WavePoint,
373        to: WavePoint,
374        head_mode: ArrowHeadMode,
375        num: i32,
376    ) -> Self {
377        let name = format!("{DEFAULT_TYPE} {num}");
378        let annotation_data = AnnotationData::new(id, name, num);
379
380        ArrowAnnotation {
381            from: from.clone(),
382            to: to.clone(),
383            created_at: Local::now(),
384            length: to.screen_pos.y - from.screen_pos.y,
385            head_mode,
386            annotation_data,
387        }
388    }
389
390    #[must_use]
391    pub fn created_at_string(&self) -> String {
392        self.created_at.format("%Y-%m-%d %H:%M").to_string()
393    }
394    pub fn toggle_arrow_visibility(&mut self) {
395        self.annotation_data.visible = !self.annotation_data.visible;
396    }
397
398    fn hit_radius(&self) -> f32 {
399        self.annotation_data.stroke.width + HITBOX_SIZE
400    }
401
402    // Builds all drawable and hit-testable arrow segments from the current screen positions.
403    fn segments(&self) -> Option<ArrowSegments> {
404        let end_head = arrow_geometry(
405            self.from.screen_pos,
406            self.to.screen_pos,
407            self.annotation_data.stroke.width,
408        )?;
409        let (end_base, end_left, end_right) = end_head;
410
411        let start_head: Option<(Pos2, Pos2, Pos2)> = match self.head_mode {
412            ArrowHeadMode::End => None,
413            ArrowHeadMode::Double => arrow_geometry(
414                self.to.screen_pos,
415                self.from.screen_pos,
416                self.annotation_data.stroke.width,
417            ),
418        };
419
420        let shaft_start = match start_head {
421            Some((start_base, _, _)) => start_base,
422            None => self.from.screen_pos,
423        };
424
425        let shaft_end = end_base;
426
427        let (start_tip, start_left, start_right) = match start_head {
428            Some((_base, left, right)) => (Some(self.from.screen_pos), Some(left), Some(right)),
429            None => (None, None, None),
430        };
431
432        Some(ArrowSegments {
433            shaft_start,
434            shaft_end,
435            end_tip: self.to.screen_pos,
436            end_left,
437            end_right,
438            start_tip,
439            start_left,
440            start_right,
441        })
442    }
443    /// Returns the pointer distance to the arrow if it is inside the hit radius.
444    #[must_use]
445    pub fn hit_distance_screen(&self, pointer: Pos2) -> Option<f32> {
446        if self.is_visible() {
447            let seg = self.segments()?;
448            let hit_radius = self.hit_radius();
449
450            let mut best = f32::INFINITY;
451
452            // Compare to the shaft
453            best = best.min(distance_to_segment(pointer, seg.shaft_start, seg.shaft_end));
454
455            // Compare to the end point 3 segment
456            best = best.min(distance_to_segment(pointer, seg.end_tip, seg.end_left));
457            best = best.min(distance_to_segment(pointer, seg.end_tip, seg.end_right));
458            best = best.min(distance_to_segment(pointer, seg.end_left, seg.end_right));
459
460            // Compare to the arrow head at start, if it is dubbelheaded arrow.
461            if let (Some(start_tip), Some(start_left), Some(start_right)) =
462                (seg.start_tip, seg.start_left, seg.start_right)
463            {
464                best = best.min(distance_to_segment(pointer, start_tip, start_left));
465                best = best.min(distance_to_segment(pointer, start_tip, start_right));
466                best = best.min(distance_to_segment(pointer, start_left, start_right));
467            }
468
469            if best <= hit_radius { Some(best) } else { None }
470        } else {
471            let radius = (self.annotation_data.stroke.width * 2.0) + HITBOX_SIZE;
472            let mut best = (pointer - self.to.screen_pos).length();
473
474            if let ArrowHeadMode::Double = self.head_mode {
475                best = best.min((pointer - self.from.screen_pos).length());
476            }
477
478            if best <= radius { Some(best) } else { None }
479        }
480    }
481
482    fn paint_arrow_head(&self, ui: &mut Ui, tip: Pos2, left: Pos2, right: Pos2) {
483        ui.painter()
484            .line_segment([tip, left], self.annotation_data.stroke);
485        ui.painter()
486            .line_segment([tip, right], self.annotation_data.stroke);
487        ui.painter()
488            .line_segment([left, right], self.annotation_data.stroke);
489    }
490
491    /// Returns arrow `end_position` in global coordinates
492    #[must_use]
493    pub fn get_pos(
494        &self,
495        waves: &WaveData,
496        viewport: &Viewport,
497        ctx: &DrawingContext,
498        offset_y: f32,
499    ) -> Option<Pos2> {
500        let range = waves.time_range();
501
502        let to_x = viewport.pixel_from_time(&self.to.time, ctx.cfg.canvas_size.x, range);
503        let to_y = self.to.screen_pos.y;
504        let mut position = (ctx.to_screen)(to_x, to_y);
505        position.y = to_y + offset_y;
506
507        Some(position)
508    }
509}
510
511impl Widget for ArrowAnnotation {
512    fn ui(self, ui: &mut Ui) -> Response {
513        // The widget does custom painting and uses explicit hit detection elsewhere,
514        // so it only allocates an empty egui response here.
515        let _response = ui.allocate_response(egui::Vec2::ZERO, egui::Sense::empty());
516        if !self.is_visible() {
517            self.hide_annotation(ui, self.annotation_data.stroke, self.to.screen_pos);
518
519            if let ArrowHeadMode::Double = self.head_mode {
520                self.hide_annotation(ui, self.annotation_data.stroke, self.from.screen_pos);
521            }
522        } else if let Some(seg) = self.segments() {
523            // Paint shaft
524            ui.painter().line_segment(
525                [seg.shaft_start, seg.shaft_end],
526                self.annotation_data.stroke,
527            );
528
529            // Paint arrow head at the end of the arrow
530            self.paint_arrow_head(ui, seg.end_tip, seg.end_left, seg.end_right);
531
532            // Paint arrow head at the start if it is a doubleheaded arrow.
533            if let (Some(start_tip), Some(start_left), Some(start_right)) =
534                (seg.start_tip, seg.start_left, seg.start_right)
535            {
536                self.paint_arrow_head(ui, start_tip, start_left, start_right);
537            }
538        }
539        _response
540    }
541}