Skip to main content

libsurfer/
arrow.rs

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