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