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