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.command
368                && response.dragged_by(PointerButton::Primary)
369                && self.do_measure(&modifiers)
370            {
371                let current_location = pointer_pos_canvas.unwrap();
372                self.draw_zoom_in_gesture(
373                    start_location,
374                    current_location,
375                    response,
376                    ctx,
377                    waves,
378                    viewport_idx,
379                    true,
380                );
381            }
382            if response.drag_stopped_by(PointerButton::Primary) {
383                msgs.push(Message::SetMeasureDragStart(None));
384            }
385        }
386    }
387}
388
389/// Draw the "compass" showing the boundaries for different gestures.
390fn draw_gesture_help(
391    config: &SurferConfig,
392    response: &Response,
393    painter: &Painter,
394    midpoint: Option<Pos2>,
395    draw_bg: bool,
396) {
397    // Compute sizes and coordinates
398    let tan225 = 0.41421357;
399    let (midx, midy, deltax, deltay) = if let Some(midpoint) = midpoint {
400        let halfsize = config.gesture.size * 0.5;
401        (midpoint.x, midpoint.y, halfsize, halfsize)
402    } else {
403        let halfwidth = response.rect.width() * 0.5;
404        let halfheight = response.rect.height() * 0.5;
405        (halfwidth, halfheight, halfwidth, halfheight)
406    };
407
408    let container_rect = Rect::from_min_size(Pos2::ZERO, response.rect.size());
409    let to_screen = &|x, y| {
410        RectTransform::from_to(container_rect, response.rect)
411            .transform_pos(Pos2::new(x, y) + Vec2::new(0.5, 0.5))
412    };
413    let stroke = Stroke {
414        color: config.theme.gesture.color,
415        width: config.theme.gesture.width,
416    };
417    let tan225deltax = tan225 * deltax;
418    let tan225deltay = tan225 * deltay;
419    let left = midx - deltax;
420    let right = midx + deltax;
421    let top = midy - deltay;
422    let bottom = midy + deltay;
423    // Draw background
424    if draw_bg {
425        let bg_radius = config.gesture.background_radius * deltax;
426        painter.circle_filled(
427            to_screen(midx, midy),
428            bg_radius,
429            config
430                .theme
431                .canvas_colors
432                .background
433                .gamma_multiply(config.gesture.background_gamma),
434        );
435    }
436    // Draw lines
437    painter.line_segment(
438        [
439            to_screen(left, midy + tan225deltax),
440            to_screen(right, midy - tan225deltax),
441        ],
442        stroke,
443    );
444    painter.line_segment(
445        [
446            to_screen(left, midy - tan225deltax),
447            to_screen(right, midy + tan225deltax),
448        ],
449        stroke,
450    );
451    painter.line_segment(
452        [
453            to_screen(midx + tan225deltay, top),
454            to_screen(midx - tan225deltay, bottom),
455        ],
456        stroke,
457    );
458    painter.line_segment(
459        [
460            to_screen(midx - tan225deltay, top),
461            to_screen(midx + tan225deltay, bottom),
462        ],
463        stroke,
464    );
465
466    let halfwaytexty_upper = top + (deltay - tan225deltax) * 0.5;
467    let halfwaytexty_lower = bottom - (deltay - tan225deltax) * 0.5;
468    // Draw commands
469    // West
470    painter.text(
471        to_screen(left, midy),
472        Align2::LEFT_CENTER,
473        config.gesture.mapping.west,
474        FontId::default(),
475        config.theme.foreground,
476    );
477    // East
478    painter.text(
479        to_screen(right, midy),
480        Align2::RIGHT_CENTER,
481        config.gesture.mapping.east,
482        FontId::default(),
483        config.theme.foreground,
484    );
485    // NorthWest
486    painter.text(
487        to_screen(left, halfwaytexty_upper),
488        Align2::LEFT_CENTER,
489        config.gesture.mapping.northwest,
490        FontId::default(),
491        config.theme.foreground,
492    );
493    // NorthEast
494    painter.text(
495        to_screen(right, halfwaytexty_upper),
496        Align2::RIGHT_CENTER,
497        config.gesture.mapping.northeast,
498        FontId::default(),
499        config.theme.foreground,
500    );
501    // North
502    painter.text(
503        to_screen(midx, top),
504        Align2::CENTER_TOP,
505        config.gesture.mapping.north,
506        FontId::default(),
507        config.theme.foreground,
508    );
509    // SouthWest
510    painter.text(
511        to_screen(left, halfwaytexty_lower),
512        Align2::LEFT_CENTER,
513        config.gesture.mapping.southwest,
514        FontId::default(),
515        config.theme.foreground,
516    );
517    // SouthEast
518    painter.text(
519        to_screen(right, halfwaytexty_lower),
520        Align2::RIGHT_CENTER,
521        config.gesture.mapping.southeast,
522        FontId::default(),
523        config.theme.foreground,
524    );
525    // South
526    painter.text(
527        to_screen(midx, bottom),
528        Align2::CENTER_BOTTOM,
529        config.gesture.mapping.south,
530        FontId::default(),
531        config.theme.foreground,
532    );
533}
534
535/// Determine which mouse gesture ([`GestureKind`]) is currently drawn.
536fn gesture_type(zones: &GestureZones, delta: Vec2) -> GestureKind {
537    let tan225 = 0.41421357;
538    let tan225x = tan225 * delta.x;
539    let tan225y = tan225 * delta.y;
540    if delta.x < 0.0 {
541        if delta.y.abs() < -tan225x {
542            // West
543            zones.west
544        } else if delta.y < 0.0 && delta.x < tan225y {
545            // North west
546            zones.northwest
547        } else if delta.y > 0.0 && delta.x < -tan225y {
548            // South west
549            zones.southwest
550        } else if delta.y < 0.0 {
551            // North
552            zones.north
553        } else {
554            // South
555            zones.south
556        }
557    } else if tan225x > delta.y.abs() {
558        // East
559        zones.east
560    } else if delta.y < 0.0 && delta.x > -tan225y {
561        // North east
562        zones.northeast
563    } else if delta.y > 0.0 && delta.x > tan225y {
564        // South east
565        zones.southeast
566    } else if delta.y > 0.0 {
567        // North
568        zones.north
569    } else {
570        // South
571        zones.south
572    }
573}
574
575fn draw_gesture_text(
576    ctx: &mut DrawingContext,
577    pos: Pos2,
578    text: impl ToString,
579    theme: &SurferTheme,
580) {
581    // Translate away from the mouse cursor so the text isn't hidden by it
582    let pos = pos + Vec2::new(10.0, -10.0);
583
584    let galley = ctx
585        .painter
586        .layout_no_wrap(text.to_string(), FontId::default(), theme.foreground);
587
588    ctx.painter.rect(
589        galley.rect.translate(pos.to_vec2()).expand(3.0),
590        2.0,
591        theme.primary_ui_color.background,
592        Stroke::default(),
593        egui::StrokeKind::Inside,
594    );
595
596    ctx.painter
597        .galley(pos, galley, theme.primary_ui_color.foreground);
598}