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