Skip to main content

libsurfer/
mousegestures.rs

1//! Code related to the mouse gesture handling.
2use derive_more::Display;
3use egui::{Context, Painter, PointerButton, Response, RichText, Sense, Window};
4use emath::{Align2, Pos2, Rect, RectTransform, Vec2};
5use epaint::{FontId, Stroke};
6use num::BigInt;
7use serde::Deserialize;
8
9use crate::arrow::{ArrowHeadMode, WavePoint};
10use crate::config::{SurferConfig, SurferTheme};
11use crate::graphics::{Anchor, GraphicsY};
12use crate::time::TimeFormatter;
13use crate::view::DrawingContext;
14use crate::{Message, SystemState, wave_data::WaveData};
15
16/// Geometric constant: tan(22.5°) used for gesture zone calculations
17const TAN_22_5_DEGREES: f32 = 0.41421357;
18
19/// Helper function to create a stroke with appropriate color and width based on mode
20fn create_gesture_stroke(config: &SurferConfig, is_measure: bool) -> Stroke {
21    let line_style = if is_measure {
22        &config.theme.measure
23    } else {
24        &config.theme.gesture
25    };
26    Stroke::from(line_style)
27}
28
29/// The supported mouse gesture operations.
30#[derive(Clone, PartialEq, Copy, Display, Debug, Deserialize)]
31enum GestureKind {
32    #[display("Zoom to fit")]
33    ZoomToFit,
34    #[display("Zoom in")]
35    ZoomIn,
36    #[display("Zoom out")]
37    ZoomOut,
38    #[display("Go to end")]
39    GoToEnd,
40    #[display("Go to start")]
41    GoToStart,
42    Cancel,
43}
44
45/// The supported mouse gesture zones.
46#[derive(Clone, PartialEq, Copy, Debug, Deserialize)]
47pub struct GestureZones {
48    north: GestureKind,
49    northeast: GestureKind,
50    east: GestureKind,
51    southeast: GestureKind,
52    south: GestureKind,
53    southwest: GestureKind,
54    west: GestureKind,
55    northwest: GestureKind,
56}
57
58// The supported annotations.
59#[derive(Clone, PartialEq, Copy, Display, Debug, Deserialize)]
60pub enum AnnotationKind {
61    Rectangle,
62    ArrowSingleHead,
63    ArrowDoubleHead,
64}
65
66impl SystemState {
67    //Adjusts y_value to not go without scope and whether it should snap to waves or not.
68    #[allow(clippy::too_many_arguments)]
69    fn clamp_y(
70        &self,
71        pos: Pos2,
72        max_y: f32,
73        snap_y: bool,
74        waves: &WaveData,
75        ctx: &mut DrawingContext<'_>,
76        anchor: Anchor,
77        y_offset: f32,
78    ) -> Pos2 {
79        let mut y = pos.y.clamp(waves.get_content_start(ctx), max_y);
80        if snap_y {
81            let local_y = y - y_offset;
82
83            if let Some(snapped_y) = waves.item_ref_at_canvas_y(local_y).and_then(|item_ref| {
84                let gy = GraphicsY {
85                    item: item_ref,
86                    anchor,
87                };
88
89                waves.get_item_y(&gy)
90            }) {
91                y = snapped_y + y_offset;
92            }
93        }
94
95        Pos2 {
96            x: pos.x,
97            y: y.min(max_y),
98        }
99    }
100
101    /// Draw the mouse gesture widget, i.e., the line(s) and text showing which gesture is being drawn.
102    #[allow(clippy::too_many_arguments)]
103    pub(crate) fn draw_mouse_gesture_widget(
104        &self,
105        egui_ctx: &Context,
106        waves: &WaveData,
107        pointer_pos_canvas: Option<Pos2>,
108        response: &Response,
109        msgs: &mut Vec<Message>,
110        ctx: &mut DrawingContext,
111        viewport_idx: usize,
112        y_offset: f32,
113    ) {
114        if let Some(mut start_location) = self.gesture_start_location {
115            if self.annotation_kind == Some(AnnotationKind::Rectangle)
116                && start_location.y
117                    > (waves.get_content_height(ctx) + self.user.config.layout.waveforms_gap)
118            {
119                return;
120            }
121            //Attach position to canvas, so it doesn't follow screen movement.
122            if let Some(time) = &self.gesture_start_time {
123                let time_offset = waves.time_offset();
124                let x_pixel = waves.viewports[viewport_idx].pixel_from_time(
125                    time,
126                    ctx.cfg.canvas_size.x,
127                    &waves.safe_max_timestamp(),
128                    time_offset,
129                );
130                start_location.x = x_pixel;
131            }
132            let modifiers = egui_ctx.input(|i| i.modifiers);
133            if response.dragged_by(PointerButton::Middle)
134                || modifiers.command && response.dragged_by(PointerButton::Primary)
135                || self.annotation_kind.is_some() && response.dragged_by(PointerButton::Primary)
136            {
137                self.start_dragging(
138                    pointer_pos_canvas,
139                    start_location,
140                    ctx,
141                    egui_ctx,
142                    response,
143                    waves,
144                    viewport_idx,
145                    y_offset,
146                );
147            }
148
149            if response.drag_stopped_by(PointerButton::Middle)
150                || modifiers.command && response.drag_stopped_by(PointerButton::Primary)
151                || self.annotation_kind.is_some()
152                    && response.drag_stopped_by(PointerButton::Primary)
153            {
154                let frame_width = response.rect.width();
155                self.stop_dragging(
156                    pointer_pos_canvas,
157                    start_location,
158                    msgs,
159                    viewport_idx,
160                    waves,
161                    frame_width,
162                    ctx,
163                    egui_ctx,
164                    y_offset,
165                );
166            }
167        }
168    }
169
170    #[allow(clippy::too_many_arguments)]
171    fn stop_dragging(
172        &self,
173        pointer_pos_canvas: Option<Pos2>,
174        start_location: Pos2,
175        msgs: &mut Vec<Message>,
176        viewport_idx: usize,
177        waves: &WaveData,
178        frame_width: f32,
179        ctx: &mut DrawingContext<'_>,
180        ui: &Context,
181        y_offset: f32,
182    ) {
183        let max_timestamp = waves.safe_max_timestamp();
184        let Some(end_location) = pointer_pos_canvas else {
185            return;
186        };
187        let distance = end_location - start_location;
188        if distance.length_sq() >= self.user.config.gesture.deadzone {
189            match self.annotation_kind {
190                Some(AnnotationKind::Rectangle) => {
191                    self.create_rectangle(
192                        end_location,
193                        start_location,
194                        msgs,
195                        viewport_idx,
196                        waves,
197                        &max_timestamp,
198                        frame_width,
199                        ctx,
200                        ui,
201                        y_offset,
202                    );
203                }
204                Some(AnnotationKind::ArrowSingleHead | AnnotationKind::ArrowDoubleHead) => {
205                    self.create_arrow(
206                        end_location,
207                        start_location,
208                        msgs,
209                        viewport_idx,
210                        waves,
211                        &max_timestamp,
212                        frame_width,
213                        ctx,
214                        y_offset,
215                    );
216                }
217                _ => {
218                    match gesture_type(self.user.config.gesture.mapping, distance) {
219                        GestureKind::ZoomToFit => {
220                            msgs.push(Message::ZoomToFit { viewport_idx });
221                        }
222                        GestureKind::ZoomIn => {
223                            let (min_x, max_x) = if end_location.x < start_location.x {
224                                (end_location.x, start_location.x)
225                            } else {
226                                (start_location.x, end_location.x)
227                            };
228                            let time_offset = waves.time_offset();
229                            msgs.push(Message::ZoomToRange {
230                                // FIXME: No need to go via bigint here, this could all be relative
231                                start: waves.viewports[viewport_idx].as_time_bigint(
232                                    min_x,
233                                    frame_width,
234                                    &max_timestamp,
235                                    time_offset,
236                                ),
237                                end: waves.viewports[viewport_idx].as_time_bigint(
238                                    max_x,
239                                    frame_width,
240                                    &max_timestamp,
241                                    time_offset,
242                                ),
243                                viewport_idx,
244                            });
245                        }
246                        GestureKind::GoToStart => {
247                            msgs.push(Message::GoToStart { viewport_idx });
248                        }
249                        GestureKind::GoToEnd => {
250                            msgs.push(Message::GoToEnd { viewport_idx });
251                        }
252                        GestureKind::ZoomOut => {
253                            msgs.push(Message::CanvasZoom {
254                                mouse_ptr: None,
255                                delta: 2.0,
256                                viewport_idx,
257                            });
258                        }
259                        GestureKind::Cancel => {}
260                    }
261                }
262            }
263        }
264        msgs.push(Message::SetMouseGestureDragStart(None, None));
265        msgs.push(Message::SetMouseGestureAnnotation(None));
266    }
267
268    #[allow(clippy::too_many_arguments)]
269    fn start_dragging(
270        &self,
271        pointer_pos_canvas: Option<Pos2>,
272        start_location: Pos2,
273        ctx: &mut DrawingContext<'_>,
274        ui: &Context,
275        response: &Response,
276        waves: &WaveData,
277        viewport_idx: usize,
278        y_offset: f32,
279    ) {
280        let Some(current_location) = pointer_pos_canvas else {
281            return;
282        };
283        let distance = current_location - start_location;
284        if distance.length_sq() >= self.user.config.gesture.deadzone {
285            match self.annotation_kind {
286                Some(AnnotationKind::Rectangle) => {
287                    self.draw_gesture_rectangle(
288                        start_location,
289                        waves,
290                        ui,
291                        current_location,
292                        ctx,
293                        y_offset,
294                    );
295                }
296                Some(AnnotationKind::ArrowSingleHead | AnnotationKind::ArrowDoubleHead) => {
297                    self.draw_arrow_line(start_location, current_location, "Add arrow", true, ctx);
298                }
299                _ => match gesture_type(self.user.config.gesture.mapping, distance) {
300                    GestureKind::ZoomToFit => self.draw_gesture_line(
301                        start_location,
302                        current_location,
303                        "Zoom to fit",
304                        true,
305                        ctx,
306                    ),
307                    GestureKind::ZoomIn => self.draw_zoom_in_gesture(
308                        start_location,
309                        current_location,
310                        response,
311                        ctx,
312                        waves,
313                        viewport_idx,
314                        false,
315                    ),
316
317                    GestureKind::GoToStart => self.draw_gesture_line(
318                        start_location,
319                        current_location,
320                        "Go to start",
321                        true,
322                        ctx,
323                    ),
324                    GestureKind::GoToEnd => {
325                        self.draw_gesture_line(
326                            start_location,
327                            current_location,
328                            "Go to end",
329                            true,
330                            ctx,
331                        );
332                    }
333                    GestureKind::ZoomOut => {
334                        self.draw_gesture_line(
335                            start_location,
336                            current_location,
337                            "Zoom out",
338                            true,
339                            ctx,
340                        );
341                    }
342                    GestureKind::Cancel => {
343                        self.draw_gesture_line(
344                            start_location,
345                            current_location,
346                            "Cancel",
347                            false,
348                            ctx,
349                        );
350                    }
351                },
352            }
353        } else if self.annotation_kind.is_none() {
354            draw_gesture_help(
355                &self.user.config,
356                response,
357                ctx.painter,
358                Some(start_location),
359                true,
360            );
361        }
362    }
363
364    fn draw_gesture_rectangle(
365        &self,
366        start_location: Pos2,
367        waves: &WaveData,
368        ui: &Context,
369        current_location: Pos2,
370        ctx: &mut DrawingContext,
371        y_offset: f32,
372    ) {
373        let modifiers = ui.input(|i| i.modifiers);
374        let max_y = waves.get_content_height(ctx);
375        let current_anchor = {
376            if current_location.y > start_location.y {
377                Anchor::Bottom
378            } else {
379                Anchor::Top
380            }
381        };
382        let start_anchor = {
383            if start_location.y < current_location.y {
384                Anchor::Top
385            } else {
386                Anchor::Bottom
387            }
388        };
389        let end = self.clamp_y(
390            current_location,
391            max_y,
392            !modifiers.shift,
393            waves,
394            ctx,
395            current_anchor,
396            y_offset,
397        );
398        let start = self.clamp_y(
399            start_location,
400            max_y,
401            !modifiers.shift,
402            waves,
403            ctx,
404            start_anchor,
405            y_offset,
406        );
407        let color = self.user.config.theme.annotation_rectangle.color;
408        let stroke = Stroke {
409            color,
410            width: self.user.config.theme.annotation_rectangle.width,
411        };
412
413        let start_pos = (ctx.to_screen)(start.x, start.y);
414        let end_pos = (ctx.to_screen)(end.x, end.y);
415
416        let temp_rect = emath::Rect::from_two_pos(start_pos, end_pos);
417
418        ctx.painter
419            .rect_stroke(temp_rect, 0.0, stroke, egui::StrokeKind::Middle);
420    }
421
422    #[allow(clippy::too_many_arguments)]
423    fn create_rectangle(
424        &self,
425        end_location: Pos2,
426        start_location: Pos2,
427        msgs: &mut Vec<Message>,
428        viewport_idx: usize,
429        waves: &WaveData,
430        max_timestamp: &BigInt,
431        frame_width: f32,
432        ctx: &mut DrawingContext<'_>,
433        ui: &Context,
434        y_offset: f32,
435    ) {
436        let modifiers = ui.input(|i| i.modifiers);
437        let max_y = waves.get_content_height(ctx);
438
439        let end_anchor = if end_location.y > start_location.y {
440            Anchor::Bottom
441        } else {
442            Anchor::Top
443        };
444
445        let start_anchor = if start_location.y < end_location.y {
446            Anchor::Top
447        } else {
448            Anchor::Bottom
449        };
450
451        let end = self.clamp_y(
452            end_location,
453            max_y,
454            !modifiers.shift,
455            waves,
456            ctx,
457            end_anchor,
458            y_offset,
459        );
460
461        let start = self.clamp_y(
462            start_location,
463            max_y,
464            !modifiers.shift,
465            waves,
466            ctx,
467            start_anchor,
468            y_offset,
469        );
470
471        let rect = emath::Rect::from_two_pos(start, end);
472
473        let viewport = &waves.viewports[viewport_idx];
474
475        let time_offset = waves.time_offset();
476
477        let t1 = viewport.as_time_bigint(start_location.x, frame_width, max_timestamp, time_offset);
478        let t2 = viewport.as_time_bigint(end_location.x, frame_width, max_timestamp, time_offset);
479
480        let (time_start, time_end) = (t1.clone().min(t2.clone()), t1.max(t2));
481
482        let get_anchored_y = |y: f32, anchor: Anchor| {
483            waves
484                .item_ref_at_canvas_y(y)
485                .map(|item| GraphicsY { item, anchor })
486        };
487
488        let get_percentual_y = |lookup_y: f32, scale_y: f32| {
489            waves.item_ref_at_canvas_y(lookup_y).map(|item| {
490                let p = waves.get_item_y_scale(item, scale_y);
491
492                GraphicsY {
493                    item,
494                    anchor: Anchor::Percentual(p.unwrap_or(0.)),
495                }
496            })
497        };
498
499        let (wave_from, wave_to) = if modifiers.shift {
500            let from =
501                get_percentual_y(start.y.min(end.y) - y_offset, start.y.min(end.y) - y_offset);
502
503            let to = get_percentual_y(
504                end.y.max(start.y) - y_offset - self.user.config.layout.waveforms_gap * 2.,
505                end.y.max(start.y) - y_offset,
506            );
507
508            (from, to)
509        } else {
510            let y_from = start.y.min(end.y);
511            let y_to = start.y.max(end.y);
512
513            let from = get_anchored_y(y_from - y_offset, Anchor::Top);
514
515            let mut adjusted_y = y_to - y_offset;
516            if y_to > waves.get_content_start(ctx) {
517                adjusted_y -= self.user.config.layout.waveforms_gap * 2.0;
518            }
519
520            let to = get_anchored_y(adjusted_y, Anchor::Bottom);
521
522            (from, to)
523        };
524
525        msgs.push(Message::RectangleAdded {
526            time_at_start: time_start,
527            time_at_end: time_end,
528            wave_from,
529            wave_to,
530            rect,
531        });
532    }
533
534    #[allow(clippy::too_many_arguments)]
535    fn create_arrow(
536        &self,
537        end_location: Pos2,
538        start_location: Pos2,
539        msgs: &mut Vec<Message>,
540        viewport_idx: usize,
541        waves: &WaveData,
542        max_timestamp: &BigInt,
543        frame_width: f32,
544        ctx: &mut DrawingContext<'_>,
545        offset: f32,
546    ) {
547        let start_pos = (ctx.to_screen)(start_location.x, start_location.y);
548        let end_pos = (ctx.to_screen)(end_location.x, end_location.y);
549
550        let time_offset = waves.time_offset();
551        let time_from: BigInt = waves.viewports[viewport_idx].as_time_bigint(
552            start_location.x,
553            frame_width,
554            max_timestamp,
555            time_offset,
556        );
557
558        let snap_pos = Some(Pos2::new(end_location.x, end_location.y - offset));
559
560        let time_to = self
561            .snap_to_edge(snap_pos, waves, frame_width, viewport_idx)
562            .unwrap_or_else(|| {
563                waves.viewports[viewport_idx].as_time_bigint(
564                    end_location.x,
565                    frame_width,
566                    max_timestamp,
567                    time_offset,
568                )
569            });
570
571        let attached_item_to = waves.item_ref_at_canvas_y(end_location.y - offset);
572        let attached_item_from = waves.item_ref_at_canvas_y(start_location.y - offset);
573
574        let mut head_mode = ArrowHeadMode::End;
575
576        if self.annotation_kind == Some(AnnotationKind::ArrowDoubleHead) {
577            head_mode = ArrowHeadMode::Double;
578        }
579
580        let wave_point_from = WavePoint {
581            time: time_from.clone(),
582            attached_item: attached_item_from,
583            screen_pos: start_pos,
584        };
585
586        let wave_point_to = WavePoint {
587            time: time_to.clone(),
588            attached_item: attached_item_to,
589            screen_pos: end_pos,
590        };
591
592        if attached_item_to.is_some() {
593            msgs.push(Message::ArrowAdded {
594                wave_point_from,
595                wave_point_to,
596                head_mode,
597            });
598        }
599    }
600
601    /// Draw the line used by most mouse gestures.
602    fn draw_gesture_line(
603        &self,
604        start: Pos2,
605        end: Pos2,
606        text: &str,
607        active: bool,
608        ctx: &mut DrawingContext,
609    ) {
610        let color = if active {
611            self.user.config.theme.gesture.color
612        } else {
613            self.user.config.theme.gesture.color.gamma_multiply(0.3)
614        };
615        let stroke = Stroke {
616            color,
617            width: self.user.config.theme.gesture.width,
618        };
619        ctx.painter.line_segment(
620            [
621                (ctx.to_screen)(end.x, end.y),
622                (ctx.to_screen)(start.x, start.y),
623            ],
624            stroke,
625        );
626        draw_gesture_text(
627            ctx,
628            (ctx.to_screen)(end.x, end.y),
629            text,
630            &self.user.config.theme,
631        );
632    }
633
634    fn draw_arrow_line(
635        &self,
636        start: Pos2,
637        end: Pos2,
638        text: &str,
639        active: bool,
640        ctx: &mut DrawingContext,
641    ) {
642        let color = if active {
643            self.user.config.theme.annotation_arrow.color
644        } else {
645            self.user.config.theme.gesture.color.gamma_multiply(0.3)
646        };
647        let stroke = Stroke {
648            color,
649            width: self.user.config.theme.gesture.width,
650        };
651        ctx.painter.line_segment(
652            [
653                (ctx.to_screen)(end.x, end.y),
654                (ctx.to_screen)(start.x, start.y),
655            ],
656            stroke,
657        );
658        draw_gesture_text(
659            ctx,
660            (ctx.to_screen)(end.x, end.y),
661            text,
662            &self.user.config.theme,
663        );
664    }
665
666    /// Draw the lines used for the zoom-in gesture.
667    #[allow(clippy::too_many_arguments)]
668    fn draw_zoom_in_gesture(
669        &self,
670        start_location: Pos2,
671        current_location: Pos2,
672        response: &Response,
673        ctx: &mut DrawingContext<'_>,
674        waves: &WaveData,
675        viewport_idx: usize,
676        measure: bool,
677    ) {
678        let stroke = create_gesture_stroke(&self.user.config, measure);
679        let height = response.rect.height();
680        let width = response.rect.width();
681        let segments = [
682            ((start_location.x, 0.0), (start_location.x, height)),
683            ((current_location.x, 0.0), (current_location.x, height)),
684            (
685                (start_location.x, start_location.y),
686                (current_location.x, start_location.y),
687            ),
688        ];
689        for (start, end) in segments {
690            ctx.painter.line_segment(
691                [
692                    (ctx.to_screen)(start.0, start.1),
693                    (ctx.to_screen)(end.0, end.1),
694                ],
695                stroke,
696            );
697        }
698        let (minx, maxx) = if measure || current_location.x > start_location.x {
699            (start_location.x, current_location.x)
700        } else {
701            (current_location.x, start_location.x)
702        };
703        let max_timestamp = waves.safe_max_timestamp();
704        let time_offset = waves.time_offset();
705        let start_time =
706            waves.viewports[viewport_idx].as_time_bigint(minx, width, &max_timestamp, time_offset);
707        let end_time =
708            waves.viewports[viewport_idx].as_time_bigint(maxx, width, &max_timestamp, time_offset);
709        let diff_time = &end_time - &start_time;
710        let time_formatter = TimeFormatter::new(
711            &waves.inner.metadata().timescale,
712            &self.user.wanted_timeunit,
713            &self.get_time_format(),
714        );
715        let start_time_str = time_formatter.format(&start_time);
716        let end_time_str = time_formatter.format(&end_time);
717        let diff_time_str = time_formatter.format(&diff_time);
718        let text = if measure {
719            format!("{start_time_str} to {end_time_str}\nΔ = {diff_time_str}")
720        } else {
721            format!("Zoom in: {diff_time_str}\n{start_time_str} to {end_time_str}")
722        };
723        draw_gesture_text(
724            ctx,
725            (ctx.to_screen)(current_location.x, current_location.y),
726            text,
727            &self.user.config.theme,
728        );
729    }
730
731    /// Draw the mouse gesture help window.
732    pub(crate) fn mouse_gesture_help(&self, ctx: &Context, msgs: &mut Vec<Message>) {
733        let mut open = true;
734        Window::new("Mouse gestures")
735            .open(&mut open)
736            .collapsible(false)
737            .resizable(true)
738            .show(ctx, |ui| {
739                ui.vertical_centered(|ui| {
740                    ui.label(RichText::new(
741                        "Press middle mouse button (or ctrl+primary mouse button) and drag",
742                    ));
743                    ui.add_space(20.);
744                    let (response, painter) = ui.allocate_painter(
745                        Vec2 {
746                            x: self.user.config.gesture.size,
747                            y: self.user.config.gesture.size,
748                        },
749                        Sense::empty(),
750                    );
751                    draw_gesture_help(&self.user.config, &response, &painter, None, false);
752                    ui.add_space(10.);
753                    ui.separator();
754                    if ui.button("Close").clicked() {
755                        msgs.push(Message::SetGestureHelpVisible(false));
756                    }
757                });
758            });
759        if !open {
760            msgs.push(Message::SetGestureHelpVisible(false));
761        }
762    }
763
764    #[allow(clippy::too_many_arguments)]
765    pub(crate) fn draw_measure_widget(
766        &self,
767        egui_ctx: &Context,
768        waves: &WaveData,
769        pointer_pos_item_space: Option<Pos2>,
770        pointer_pos_canvas: Option<Pos2>,
771        response: &Response,
772        msgs: &mut Vec<Message>,
773        ctx: &mut DrawingContext,
774        viewport_idx: usize,
775    ) {
776        if let Some(start_location) = self.measure_start_location {
777            let modifiers = egui_ctx.input(|i| i.modifiers);
778            if !modifiers.command
779                && response.dragged_by(PointerButton::Primary)
780                && self.do_measure(&modifiers)
781                && let Some(mut current_location) = pointer_pos_canvas
782            {
783                // Snap current X to nearest edge/time (same logic as cursor placement)
784                let frame_width = response.rect.width();
785                if let Some(snap_time) =
786                    self.snap_to_edge(pointer_pos_item_space, waves, frame_width, viewport_idx)
787                {
788                    let x = waves.viewports[viewport_idx].pixel_from_time(
789                        &snap_time,
790                        frame_width,
791                        &waves.safe_max_timestamp(),
792                        waves.time_offset(),
793                    );
794                    current_location.x = x;
795                }
796
797                self.draw_zoom_in_gesture(
798                    start_location,
799                    current_location,
800                    response,
801                    ctx,
802                    waves,
803                    viewport_idx,
804                    true,
805                );
806            }
807            if response.drag_stopped_by(PointerButton::Primary) {
808                msgs.push(Message::SetMeasureDragStart(None));
809            }
810        }
811    }
812}
813
814/// Draw the "compass" showing the boundaries for different gestures.
815fn draw_gesture_help(
816    config: &SurferConfig,
817    response: &Response,
818    painter: &Painter,
819    midpoint: Option<Pos2>,
820    draw_bg: bool,
821) {
822    let frame_size = response.rect.size();
823    // Compute sizes and coordinates
824    let (midx, midy, deltax, deltay) = if let Some(midpoint) = midpoint {
825        let halfsize = config.gesture.size * 0.5;
826        (midpoint.x, midpoint.y, halfsize, halfsize)
827    } else {
828        let halfwidth = frame_size.x * 0.5;
829        let halfheight = frame_size.y * 0.5;
830        (halfwidth, halfheight, halfwidth, halfheight)
831    };
832
833    let container_rect = Rect::from_min_size(Pos2::ZERO, frame_size);
834    let to_screen = &|x, y| {
835        RectTransform::from_to(container_rect, response.rect).transform_pos(Pos2::new(x, y))
836    };
837    let stroke = Stroke::from(&config.theme.gesture);
838    let tan225deltax = TAN_22_5_DEGREES * deltax;
839    let tan225deltay = TAN_22_5_DEGREES * deltay;
840    let left = midx - deltax;
841    let right = midx + deltax;
842    let top = midy - deltay;
843    let bottom = midy + deltay;
844    // Draw background
845    if draw_bg {
846        let bg_radius = config.gesture.background_radius * deltax;
847        painter.circle_filled(
848            to_screen(midx, midy),
849            bg_radius,
850            config
851                .theme
852                .canvas_colors
853                .background
854                .gamma_multiply(config.gesture.background_gamma),
855        );
856    }
857    // Draw lines
858    let segments = [
859        ((left, midy + tan225deltax), (right, midy - tan225deltax)),
860        ((left, midy - tan225deltax), (right, midy + tan225deltax)),
861        ((midx + tan225deltay, top), (midx - tan225deltay, bottom)),
862        ((midx - tan225deltay, top), (midx + tan225deltay, bottom)),
863    ];
864    for (start, end) in segments {
865        painter.line_segment(
866            [to_screen(start.0, start.1), to_screen(end.0, end.1)],
867            stroke,
868        );
869    }
870
871    let halfwaytexty_upper = top + (deltay - tan225deltax) * 0.5;
872    let halfwaytexty_lower = bottom - (deltay - tan225deltax) * 0.5;
873
874    // Draw commands using a table-driven approach
875    let directions = [
876        (left, midy, Align2::LEFT_CENTER, config.gesture.mapping.west),
877        (
878            right,
879            midy,
880            Align2::RIGHT_CENTER,
881            config.gesture.mapping.east,
882        ),
883        (
884            left,
885            halfwaytexty_upper,
886            Align2::LEFT_CENTER,
887            config.gesture.mapping.northwest,
888        ),
889        (
890            right,
891            halfwaytexty_upper,
892            Align2::RIGHT_CENTER,
893            config.gesture.mapping.northeast,
894        ),
895        (midx, top, Align2::CENTER_TOP, config.gesture.mapping.north),
896        (
897            left,
898            halfwaytexty_lower,
899            Align2::LEFT_CENTER,
900            config.gesture.mapping.southwest,
901        ),
902        (
903            right,
904            halfwaytexty_lower,
905            Align2::RIGHT_CENTER,
906            config.gesture.mapping.southeast,
907        ),
908        (
909            midx,
910            bottom,
911            Align2::CENTER_BOTTOM,
912            config.gesture.mapping.south,
913        ),
914    ];
915
916    for (x, y, align, text) in directions {
917        painter.text(
918            to_screen(x, y),
919            align,
920            text,
921            FontId::default(),
922            config.theme.foreground,
923        );
924    }
925}
926
927/// Determine which mouse gesture ([`GestureKind`]) is currently drawn.
928fn gesture_type(zones: GestureZones, delta: Vec2) -> GestureKind {
929    let tan225x = TAN_22_5_DEGREES * delta.x;
930    let tan225y = TAN_22_5_DEGREES * delta.y;
931    if delta.x < 0.0 {
932        if delta.y.abs() < -tan225x {
933            // West
934            zones.west
935        } else if delta.y < 0.0 && delta.x < tan225y {
936            // North west
937            zones.northwest
938        } else if delta.y > 0.0 && delta.x < -tan225y {
939            // South west
940            zones.southwest
941        } else if delta.y < 0.0 {
942            // North
943            zones.north
944        } else {
945            // South
946            zones.south
947        }
948    } else if tan225x > delta.y.abs() {
949        // East
950        zones.east
951    } else if delta.y < 0.0 && delta.x > -tan225y {
952        // North east
953        zones.northeast
954    } else if delta.y > 0.0 && delta.x > tan225y {
955        // South east
956        zones.southeast
957    } else if delta.y < 0.0 {
958        // North
959        zones.north
960    } else {
961        // South
962        zones.south
963    }
964}
965
966fn draw_gesture_text(
967    ctx: &mut DrawingContext,
968    pos: Pos2,
969    text: impl ToString,
970    theme: &SurferTheme,
971) {
972    // Translate away from the mouse cursor so the text isn't hidden by it
973    let pos = pos + Vec2::new(10.0, -10.0);
974
975    let galley = ctx
976        .painter
977        .layout_no_wrap(text.to_string(), FontId::default(), theme.foreground);
978
979    ctx.painter.rect(
980        galley.rect.translate(pos.to_vec2()).expand(3.0),
981        2.0,
982        theme.primary_ui_color.background,
983        Stroke::default(),
984        epaint::StrokeKind::Inside,
985    );
986
987    ctx.painter
988        .galley(pos, galley, theme.primary_ui_color.foreground);
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    fn default_zones() -> GestureZones {
996        GestureZones {
997            north: GestureKind::ZoomToFit,
998            northeast: GestureKind::ZoomIn,
999            east: GestureKind::GoToEnd,
1000            southeast: GestureKind::ZoomOut,
1001            south: GestureKind::Cancel,
1002            southwest: GestureKind::ZoomOut,
1003            west: GestureKind::GoToStart,
1004            northwest: GestureKind::ZoomIn,
1005        }
1006    }
1007
1008    #[test]
1009    fn gesture_type_cardinal_directions() {
1010        let zones = default_zones();
1011
1012        // Pure cardinal directions
1013        assert_eq!(
1014            gesture_type(zones, Vec2::new(100.0, 0.0)),
1015            GestureKind::GoToEnd
1016        ); // East
1017        assert_eq!(
1018            gesture_type(zones, Vec2::new(-100.0, 0.0)),
1019            GestureKind::GoToStart
1020        ); // West
1021        assert_eq!(
1022            gesture_type(zones, Vec2::new(0.0, -100.0)),
1023            GestureKind::ZoomToFit
1024        ); // North
1025        assert_eq!(
1026            gesture_type(zones, Vec2::new(0.0, 100.0)),
1027            GestureKind::Cancel
1028        ); // South
1029    }
1030
1031    #[test]
1032    fn gesture_type_diagonal_directions() {
1033        let zones = default_zones();
1034
1035        // 45-degree diagonals (should be in the diagonal zones)
1036        assert_eq!(
1037            gesture_type(zones, Vec2::new(100.0, -100.0)),
1038            GestureKind::ZoomIn
1039        ); // Northeast
1040        assert_eq!(
1041            gesture_type(zones, Vec2::new(100.0, 100.0)),
1042            GestureKind::ZoomOut
1043        ); // Southeast
1044        assert_eq!(
1045            gesture_type(zones, Vec2::new(-100.0, 100.0)),
1046            GestureKind::ZoomOut
1047        ); // Southwest
1048        assert_eq!(
1049            gesture_type(zones, Vec2::new(-100.0, -100.0)),
1050            GestureKind::ZoomIn
1051        ); // Northwest
1052    }
1053
1054    #[test]
1055    fn gesture_type_boundary_zones() {
1056        let zones = default_zones();
1057
1058        // Test vectors just inside the east zone boundary (tan(22.5°) ≈ 0.414)
1059        // For east: |y| < tan(22.5°) * x
1060        assert_eq!(
1061            gesture_type(zones, Vec2::new(100.0, 40.0)),
1062            GestureKind::GoToEnd
1063        ); // East
1064        assert_eq!(
1065            gesture_type(zones, Vec2::new(100.0, -40.0)),
1066            GestureKind::GoToEnd
1067        ); // East
1068
1069        // Test vectors just outside the east zone boundary (should be southeast/northeast)
1070        assert_eq!(
1071            gesture_type(zones, Vec2::new(100.0, 50.0)),
1072            GestureKind::ZoomOut
1073        ); // Southeast
1074        assert_eq!(
1075            gesture_type(zones, Vec2::new(100.0, -50.0)),
1076            GestureKind::ZoomIn
1077        ); // Northeast
1078    }
1079
1080    #[test]
1081    fn gesture_type_west_boundary_zones() {
1082        let zones = default_zones();
1083
1084        // Test vectors just inside the west zone boundary
1085        assert_eq!(
1086            gesture_type(zones, Vec2::new(-100.0, 40.0)),
1087            GestureKind::GoToStart
1088        ); // West
1089        assert_eq!(
1090            gesture_type(zones, Vec2::new(-100.0, -40.0)),
1091            GestureKind::GoToStart
1092        ); // West
1093
1094        // Test vectors just outside the west zone boundary
1095        assert_eq!(
1096            gesture_type(zones, Vec2::new(-100.0, 50.0)),
1097            GestureKind::ZoomOut
1098        ); // Southwest
1099        assert_eq!(
1100            gesture_type(zones, Vec2::new(-100.0, -50.0)),
1101            GestureKind::ZoomIn
1102        ); // Northwest
1103    }
1104}