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 serde::Deserialize;
7
8use crate::config::{SurferConfig, SurferTheme};
9use crate::time::TimeFormatter;
10use crate::view::DrawingContext;
11use crate::{Message, SystemState, wave_data::WaveData};
12
13/// Geometric constant: tan(22.5°) used for gesture zone calculations
14const TAN_22_5_DEGREES: f32 = 0.41421357;
15
16/// Helper function to create a stroke with appropriate color and width based on mode
17fn create_gesture_stroke(config: &SurferConfig, is_measure: bool) -> Stroke {
18    let line_style = if is_measure {
19        &config.theme.measure
20    } else {
21        &config.theme.gesture
22    };
23    Stroke::from(line_style)
24}
25
26/// The supported mouse gesture operations.
27#[derive(Clone, PartialEq, Copy, Display, Debug, Deserialize)]
28enum GestureKind {
29    #[display("Zoom to fit")]
30    ZoomToFit,
31    #[display("Zoom in")]
32    ZoomIn,
33    #[display("Zoom out")]
34    ZoomOut,
35    #[display("Go to end")]
36    GoToEnd,
37    #[display("Go to start")]
38    GoToStart,
39    Cancel,
40}
41
42/// The supported mouse gesture zones.
43#[derive(Clone, PartialEq, Copy, Debug, Deserialize)]
44pub struct GestureZones {
45    north: GestureKind,
46    northeast: GestureKind,
47    east: GestureKind,
48    southeast: GestureKind,
49    south: GestureKind,
50    southwest: GestureKind,
51    west: GestureKind,
52    northwest: GestureKind,
53}
54
55impl SystemState {
56    /// Draw the mouse gesture widget, i.e., the line(s) and text showing which gesture is being drawn.
57    #[allow(clippy::too_many_arguments)]
58    pub fn draw_mouse_gesture_widget(
59        &self,
60        egui_ctx: &Context,
61        waves: &WaveData,
62        pointer_pos_canvas: Option<Pos2>,
63        response: &Response,
64        msgs: &mut Vec<Message>,
65        ctx: &mut DrawingContext,
66        viewport_idx: usize,
67    ) {
68        if let Some(start_location) = self.gesture_start_location {
69            let modifiers = egui_ctx.input(|i| i.modifiers);
70            if response.dragged_by(PointerButton::Middle)
71                || modifiers.command && response.dragged_by(PointerButton::Primary)
72            {
73                self.start_dragging(
74                    pointer_pos_canvas,
75                    start_location,
76                    ctx,
77                    response,
78                    waves,
79                    viewport_idx,
80                );
81            }
82
83            if response.drag_stopped_by(PointerButton::Middle)
84                || modifiers.command && response.drag_stopped_by(PointerButton::Primary)
85            {
86                let frame_width = response.rect.width();
87                self.stop_dragging(
88                    pointer_pos_canvas,
89                    start_location,
90                    msgs,
91                    viewport_idx,
92                    waves,
93                    frame_width,
94                );
95            }
96        }
97    }
98
99    fn stop_dragging(
100        &self,
101        pointer_pos_canvas: Option<Pos2>,
102        start_location: Pos2,
103        msgs: &mut Vec<Message>,
104        viewport_idx: usize,
105        waves: &WaveData,
106        frame_width: f32,
107    ) {
108        let num_timestamps = waves.safe_num_timestamps();
109        let Some(end_location) = pointer_pos_canvas else {
110            return;
111        };
112        let distance = end_location - start_location;
113        if distance.length_sq() >= self.user.config.gesture.deadzone {
114            match gesture_type(self.user.config.gesture.mapping, distance) {
115                GestureKind::ZoomToFit => {
116                    msgs.push(Message::ZoomToFit { viewport_idx });
117                }
118                GestureKind::ZoomIn => {
119                    let (minx, maxx) = if end_location.x < start_location.x {
120                        (end_location.x, start_location.x)
121                    } else {
122                        (start_location.x, end_location.x)
123                    };
124                    msgs.push(Message::ZoomToRange {
125                        // FIXME: No need to go via bigint here, this could all be relative
126                        start: waves.viewports[viewport_idx].as_time_bigint(
127                            minx,
128                            frame_width,
129                            &num_timestamps,
130                        ),
131                        end: waves.viewports[viewport_idx].as_time_bigint(
132                            maxx,
133                            frame_width,
134                            &num_timestamps,
135                        ),
136                        viewport_idx,
137                    });
138                }
139                GestureKind::GoToStart => {
140                    msgs.push(Message::GoToStart { viewport_idx });
141                }
142                GestureKind::GoToEnd => {
143                    msgs.push(Message::GoToEnd { viewport_idx });
144                }
145                GestureKind::ZoomOut => {
146                    msgs.push(Message::CanvasZoom {
147                        mouse_ptr: None,
148                        delta: 2.0,
149                        viewport_idx,
150                    });
151                }
152                GestureKind::Cancel => {}
153            }
154        }
155        msgs.push(Message::SetMouseGestureDragStart(None));
156    }
157
158    fn start_dragging(
159        &self,
160        pointer_pos_canvas: Option<Pos2>,
161        start_location: Pos2,
162        ctx: &mut DrawingContext<'_>,
163        response: &Response,
164        waves: &WaveData,
165        viewport_idx: usize,
166    ) {
167        let Some(current_location) = pointer_pos_canvas else {
168            return;
169        };
170        let distance = current_location - start_location;
171        if distance.length_sq() >= self.user.config.gesture.deadzone {
172            match gesture_type(self.user.config.gesture.mapping, distance) {
173                GestureKind::ZoomToFit => self.draw_gesture_line(
174                    start_location,
175                    current_location,
176                    "Zoom to fit",
177                    true,
178                    ctx,
179                ),
180                GestureKind::ZoomIn => self.draw_zoom_in_gesture(
181                    start_location,
182                    current_location,
183                    response,
184                    ctx,
185                    waves,
186                    viewport_idx,
187                    false,
188                ),
189
190                GestureKind::GoToStart => self.draw_gesture_line(
191                    start_location,
192                    current_location,
193                    "Go to start",
194                    true,
195                    ctx,
196                ),
197                GestureKind::GoToEnd => {
198                    self.draw_gesture_line(
199                        start_location,
200                        current_location,
201                        "Go to end",
202                        true,
203                        ctx,
204                    );
205                }
206                GestureKind::ZoomOut => {
207                    self.draw_gesture_line(start_location, current_location, "Zoom out", true, ctx);
208                }
209                GestureKind::Cancel => {
210                    self.draw_gesture_line(start_location, current_location, "Cancel", false, ctx);
211                }
212            }
213        } else {
214            draw_gesture_help(
215                &self.user.config,
216                response,
217                ctx.painter,
218                Some(start_location),
219                true,
220            );
221        }
222    }
223
224    /// Draw the line used by most mouse gestures.
225    fn draw_gesture_line(
226        &self,
227        start: Pos2,
228        end: Pos2,
229        text: &str,
230        active: bool,
231        ctx: &mut DrawingContext,
232    ) {
233        let color = if active {
234            self.user.config.theme.gesture.color
235        } else {
236            self.user.config.theme.gesture.color.gamma_multiply(0.3)
237        };
238        let stroke = Stroke {
239            color,
240            width: self.user.config.theme.gesture.width,
241        };
242        ctx.painter.line_segment(
243            [
244                (ctx.to_screen)(end.x, end.y),
245                (ctx.to_screen)(start.x, start.y),
246            ],
247            stroke,
248        );
249        draw_gesture_text(
250            ctx,
251            (ctx.to_screen)(end.x, end.y),
252            text.to_string(),
253            &self.user.config.theme,
254        );
255    }
256
257    /// Draw the lines used for the zoom-in gesture.
258    #[allow(clippy::too_many_arguments)]
259    fn draw_zoom_in_gesture(
260        &self,
261        start_location: Pos2,
262        current_location: Pos2,
263        response: &Response,
264        ctx: &mut DrawingContext<'_>,
265        waves: &WaveData,
266        viewport_idx: usize,
267        measure: bool,
268    ) {
269        let stroke = create_gesture_stroke(&self.user.config, measure);
270        let height = response.rect.height();
271        let width = response.rect.width();
272        let segments = [
273            ((start_location.x, 0.0), (start_location.x, height)),
274            ((current_location.x, 0.0), (current_location.x, height)),
275            (
276                (start_location.x, start_location.y),
277                (current_location.x, start_location.y),
278            ),
279        ];
280        for (start, end) in segments {
281            ctx.painter.line_segment(
282                [
283                    (ctx.to_screen)(start.0, start.1),
284                    (ctx.to_screen)(end.0, end.1),
285                ],
286                stroke,
287            );
288        }
289        let (minx, maxx) = if measure || current_location.x > start_location.x {
290            (start_location.x, current_location.x)
291        } else {
292            (current_location.x, start_location.x)
293        };
294        let num_timestamps = waves.safe_num_timestamps();
295        let start_time = waves.viewports[viewport_idx].as_time_bigint(minx, width, &num_timestamps);
296        let end_time = waves.viewports[viewport_idx].as_time_bigint(maxx, width, &num_timestamps);
297        let diff_time = &end_time - &start_time;
298        let time_formatter = TimeFormatter::new(
299            &waves.inner.metadata().timescale,
300            &self.user.wanted_timeunit,
301            &self.get_time_format(),
302        );
303        let start_time_str = time_formatter.format(&start_time);
304        let end_time_str = time_formatter.format(&end_time);
305        let diff_time_str = time_formatter.format(&diff_time);
306        draw_gesture_text(
307            ctx,
308            (ctx.to_screen)(current_location.x, current_location.y),
309            if measure {
310                format!("{start_time_str} to {end_time_str}\nΔ = {diff_time_str}")
311            } else {
312                format!("Zoom in: {diff_time_str}\n{start_time_str} to {end_time_str}")
313            },
314            &self.user.config.theme,
315        );
316    }
317
318    /// Draw the mouse gesture help window.
319    pub fn mouse_gesture_help(&self, ctx: &Context, msgs: &mut Vec<Message>) {
320        let mut open = true;
321        Window::new("Mouse gestures")
322            .open(&mut open)
323            .collapsible(false)
324            .resizable(true)
325            .show(ctx, |ui| {
326                ui.vertical_centered(|ui| {
327                    ui.label(RichText::new(
328                        "Press middle mouse button (or ctrl+primary mouse button) and drag",
329                    ));
330                    ui.add_space(20.);
331                    let (response, painter) = ui.allocate_painter(
332                        Vec2 {
333                            x: self.user.config.gesture.size,
334                            y: self.user.config.gesture.size,
335                        },
336                        Sense::empty(),
337                    );
338                    draw_gesture_help(&self.user.config, &response, &painter, None, false);
339                    ui.add_space(10.);
340                    ui.separator();
341                    if ui.button("Close").clicked() {
342                        msgs.push(Message::SetGestureHelpVisible(false));
343                    }
344                });
345            });
346        if !open {
347            msgs.push(Message::SetGestureHelpVisible(false));
348        }
349    }
350
351    #[allow(clippy::too_many_arguments)]
352    pub fn draw_measure_widget(
353        &self,
354        egui_ctx: &Context,
355        waves: &WaveData,
356        pointer_pos_canvas: Option<Pos2>,
357        response: &Response,
358        msgs: &mut Vec<Message>,
359        ctx: &mut DrawingContext,
360        viewport_idx: usize,
361    ) {
362        if let Some(start_location) = self.measure_start_location {
363            let modifiers = egui_ctx.input(|i| i.modifiers);
364            if !modifiers.command
365                && response.dragged_by(PointerButton::Primary)
366                && self.do_measure(&modifiers)
367                && let Some(current_location) = pointer_pos_canvas
368            {
369                self.draw_zoom_in_gesture(
370                    start_location,
371                    current_location,
372                    response,
373                    ctx,
374                    waves,
375                    viewport_idx,
376                    true,
377                );
378            }
379            if response.drag_stopped_by(PointerButton::Primary) {
380                msgs.push(Message::SetMeasureDragStart(None));
381            }
382        }
383    }
384}
385
386/// Draw the "compass" showing the boundaries for different gestures.
387fn draw_gesture_help(
388    config: &SurferConfig,
389    response: &Response,
390    painter: &Painter,
391    midpoint: Option<Pos2>,
392    draw_bg: bool,
393) {
394    let frame_size = response.rect.size();
395    // Compute sizes and coordinates
396    let (midx, midy, deltax, deltay) = if let Some(midpoint) = midpoint {
397        let halfsize = config.gesture.size * 0.5;
398        (midpoint.x, midpoint.y, halfsize, halfsize)
399    } else {
400        let halfwidth = frame_size.x * 0.5;
401        let halfheight = frame_size.y * 0.5;
402        (halfwidth, halfheight, halfwidth, halfheight)
403    };
404
405    let container_rect = Rect::from_min_size(Pos2::ZERO, frame_size);
406    let to_screen = &|x, y| {
407        RectTransform::from_to(container_rect, response.rect).transform_pos(Pos2::new(x, y))
408    };
409    let stroke = Stroke::from(&config.theme.gesture);
410    let tan225deltax = TAN_22_5_DEGREES * deltax;
411    let tan225deltay = TAN_22_5_DEGREES * deltay;
412    let left = midx - deltax;
413    let right = midx + deltax;
414    let top = midy - deltay;
415    let bottom = midy + deltay;
416    // Draw background
417    if draw_bg {
418        let bg_radius = config.gesture.background_radius * deltax;
419        painter.circle_filled(
420            to_screen(midx, midy),
421            bg_radius,
422            config
423                .theme
424                .canvas_colors
425                .background
426                .gamma_multiply(config.gesture.background_gamma),
427        );
428    }
429    // Draw lines
430    let segments = [
431        ((left, midy + tan225deltax), (right, midy - tan225deltax)),
432        ((left, midy - tan225deltax), (right, midy + tan225deltax)),
433        ((midx + tan225deltay, top), (midx - tan225deltay, bottom)),
434        ((midx - tan225deltay, top), (midx + tan225deltay, bottom)),
435    ];
436    for (start, end) in segments {
437        painter.line_segment(
438            [to_screen(start.0, start.1), to_screen(end.0, end.1)],
439            stroke,
440        );
441    }
442
443    let halfwaytexty_upper = top + (deltay - tan225deltax) * 0.5;
444    let halfwaytexty_lower = bottom - (deltay - tan225deltax) * 0.5;
445
446    // Draw commands using a table-driven approach
447    let directions = [
448        (left, midy, Align2::LEFT_CENTER, config.gesture.mapping.west),
449        (
450            right,
451            midy,
452            Align2::RIGHT_CENTER,
453            config.gesture.mapping.east,
454        ),
455        (
456            left,
457            halfwaytexty_upper,
458            Align2::LEFT_CENTER,
459            config.gesture.mapping.northwest,
460        ),
461        (
462            right,
463            halfwaytexty_upper,
464            Align2::RIGHT_CENTER,
465            config.gesture.mapping.northeast,
466        ),
467        (midx, top, Align2::CENTER_TOP, config.gesture.mapping.north),
468        (
469            left,
470            halfwaytexty_lower,
471            Align2::LEFT_CENTER,
472            config.gesture.mapping.southwest,
473        ),
474        (
475            right,
476            halfwaytexty_lower,
477            Align2::RIGHT_CENTER,
478            config.gesture.mapping.southeast,
479        ),
480        (
481            midx,
482            bottom,
483            Align2::CENTER_BOTTOM,
484            config.gesture.mapping.south,
485        ),
486    ];
487
488    for (x, y, align, text) in directions {
489        painter.text(
490            to_screen(x, y),
491            align,
492            text,
493            FontId::default(),
494            config.theme.foreground,
495        );
496    }
497}
498
499/// Determine which mouse gesture ([`GestureKind`]) is currently drawn.
500fn gesture_type(zones: GestureZones, delta: Vec2) -> GestureKind {
501    let tan225x = TAN_22_5_DEGREES * delta.x;
502    let tan225y = TAN_22_5_DEGREES * delta.y;
503    if delta.x < 0.0 {
504        if delta.y.abs() < -tan225x {
505            // West
506            zones.west
507        } else if delta.y < 0.0 && delta.x < tan225y {
508            // North west
509            zones.northwest
510        } else if delta.y > 0.0 && delta.x < -tan225y {
511            // South west
512            zones.southwest
513        } else if delta.y < 0.0 {
514            // North
515            zones.north
516        } else {
517            // South
518            zones.south
519        }
520    } else if tan225x > delta.y.abs() {
521        // East
522        zones.east
523    } else if delta.y < 0.0 && delta.x > -tan225y {
524        // North east
525        zones.northeast
526    } else if delta.y > 0.0 && delta.x > tan225y {
527        // South east
528        zones.southeast
529    } else if delta.y < 0.0 {
530        // North
531        zones.north
532    } else {
533        // South
534        zones.south
535    }
536}
537
538fn draw_gesture_text(
539    ctx: &mut DrawingContext,
540    pos: Pos2,
541    text: impl ToString,
542    theme: &SurferTheme,
543) {
544    // Translate away from the mouse cursor so the text isn't hidden by it
545    let pos = pos + Vec2::new(10.0, -10.0);
546
547    let galley = ctx
548        .painter
549        .layout_no_wrap(text.to_string(), FontId::default(), theme.foreground);
550
551    ctx.painter.rect(
552        galley.rect.translate(pos.to_vec2()).expand(3.0),
553        2.0,
554        theme.primary_ui_color.background,
555        Stroke::default(),
556        epaint::StrokeKind::Inside,
557    );
558
559    ctx.painter
560        .galley(pos, galley, theme.primary_ui_color.foreground);
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn default_zones() -> GestureZones {
568        GestureZones {
569            north: GestureKind::ZoomToFit,
570            northeast: GestureKind::ZoomIn,
571            east: GestureKind::GoToEnd,
572            southeast: GestureKind::ZoomOut,
573            south: GestureKind::Cancel,
574            southwest: GestureKind::ZoomOut,
575            west: GestureKind::GoToStart,
576            northwest: GestureKind::ZoomIn,
577        }
578    }
579
580    #[test]
581    fn gesture_type_cardinal_directions() {
582        let zones = default_zones();
583
584        // Pure cardinal directions
585        assert_eq!(
586            gesture_type(zones, Vec2::new(100.0, 0.0)),
587            GestureKind::GoToEnd
588        ); // East
589        assert_eq!(
590            gesture_type(zones, Vec2::new(-100.0, 0.0)),
591            GestureKind::GoToStart
592        ); // West
593        assert_eq!(
594            gesture_type(zones, Vec2::new(0.0, -100.0)),
595            GestureKind::ZoomToFit
596        ); // North
597        assert_eq!(
598            gesture_type(zones, Vec2::new(0.0, 100.0)),
599            GestureKind::Cancel
600        ); // South
601    }
602
603    #[test]
604    fn gesture_type_diagonal_directions() {
605        let zones = default_zones();
606
607        // 45-degree diagonals (should be in the diagonal zones)
608        assert_eq!(
609            gesture_type(zones, Vec2::new(100.0, -100.0)),
610            GestureKind::ZoomIn
611        ); // Northeast
612        assert_eq!(
613            gesture_type(zones, Vec2::new(100.0, 100.0)),
614            GestureKind::ZoomOut
615        ); // Southeast
616        assert_eq!(
617            gesture_type(zones, Vec2::new(-100.0, 100.0)),
618            GestureKind::ZoomOut
619        ); // Southwest
620        assert_eq!(
621            gesture_type(zones, Vec2::new(-100.0, -100.0)),
622            GestureKind::ZoomIn
623        ); // Northwest
624    }
625
626    #[test]
627    fn gesture_type_boundary_zones() {
628        let zones = default_zones();
629
630        // Test vectors just inside the east zone boundary (tan(22.5°) ≈ 0.414)
631        // For east: |y| < tan(22.5°) * x
632        assert_eq!(
633            gesture_type(zones, Vec2::new(100.0, 40.0)),
634            GestureKind::GoToEnd
635        ); // East
636        assert_eq!(
637            gesture_type(zones, Vec2::new(100.0, -40.0)),
638            GestureKind::GoToEnd
639        ); // East
640
641        // Test vectors just outside the east zone boundary (should be southeast/northeast)
642        assert_eq!(
643            gesture_type(zones, Vec2::new(100.0, 50.0)),
644            GestureKind::ZoomOut
645        ); // Southeast
646        assert_eq!(
647            gesture_type(zones, Vec2::new(100.0, -50.0)),
648            GestureKind::ZoomIn
649        ); // Northeast
650    }
651
652    #[test]
653    fn gesture_type_west_boundary_zones() {
654        let zones = default_zones();
655
656        // Test vectors just inside the west zone boundary
657        assert_eq!(
658            gesture_type(zones, Vec2::new(-100.0, 40.0)),
659            GestureKind::GoToStart
660        ); // West
661        assert_eq!(
662            gesture_type(zones, Vec2::new(-100.0, -40.0)),
663            GestureKind::GoToStart
664        ); // West
665
666        // Test vectors just outside the west zone boundary
667        assert_eq!(
668            gesture_type(zones, Vec2::new(-100.0, 50.0)),
669            GestureKind::ZoomOut
670        ); // Southwest
671        assert_eq!(
672            gesture_type(zones, Vec2::new(-100.0, -50.0)),
673            GestureKind::ZoomIn
674        ); // Northwest
675    }
676}