1use 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
13const TAN_22_5_DEGREES: f32 = 0.41421357;
15
16fn 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#[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#[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 #[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 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 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 #[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 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
386fn 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 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 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 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 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
499fn 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 zones.west
507 } else if delta.y < 0.0 && delta.x < tan225y {
508 zones.northwest
510 } else if delta.y > 0.0 && delta.x < -tan225y {
511 zones.southwest
513 } else if delta.y < 0.0 {
514 zones.north
516 } else {
517 zones.south
519 }
520 } else if tan225x > delta.y.abs() {
521 zones.east
523 } else if delta.y < 0.0 && delta.x > -tan225y {
524 zones.northeast
526 } else if delta.y > 0.0 && delta.x > tan225y {
527 zones.southeast
529 } else if delta.y < 0.0 {
530 zones.north
532 } else {
533 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 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 assert_eq!(
586 gesture_type(zones, Vec2::new(100.0, 0.0)),
587 GestureKind::GoToEnd
588 ); assert_eq!(
590 gesture_type(zones, Vec2::new(-100.0, 0.0)),
591 GestureKind::GoToStart
592 ); assert_eq!(
594 gesture_type(zones, Vec2::new(0.0, -100.0)),
595 GestureKind::ZoomToFit
596 ); assert_eq!(
598 gesture_type(zones, Vec2::new(0.0, 100.0)),
599 GestureKind::Cancel
600 ); }
602
603 #[test]
604 fn gesture_type_diagonal_directions() {
605 let zones = default_zones();
606
607 assert_eq!(
609 gesture_type(zones, Vec2::new(100.0, -100.0)),
610 GestureKind::ZoomIn
611 ); assert_eq!(
613 gesture_type(zones, Vec2::new(100.0, 100.0)),
614 GestureKind::ZoomOut
615 ); assert_eq!(
617 gesture_type(zones, Vec2::new(-100.0, 100.0)),
618 GestureKind::ZoomOut
619 ); assert_eq!(
621 gesture_type(zones, Vec2::new(-100.0, -100.0)),
622 GestureKind::ZoomIn
623 ); }
625
626 #[test]
627 fn gesture_type_boundary_zones() {
628 let zones = default_zones();
629
630 assert_eq!(
633 gesture_type(zones, Vec2::new(100.0, 40.0)),
634 GestureKind::GoToEnd
635 ); assert_eq!(
637 gesture_type(zones, Vec2::new(100.0, -40.0)),
638 GestureKind::GoToEnd
639 ); assert_eq!(
643 gesture_type(zones, Vec2::new(100.0, 50.0)),
644 GestureKind::ZoomOut
645 ); assert_eq!(
647 gesture_type(zones, Vec2::new(100.0, -50.0)),
648 GestureKind::ZoomIn
649 ); }
651
652 #[test]
653 fn gesture_type_west_boundary_zones() {
654 let zones = default_zones();
655
656 assert_eq!(
658 gesture_type(zones, Vec2::new(-100.0, 40.0)),
659 GestureKind::GoToStart
660 ); assert_eq!(
662 gesture_type(zones, Vec2::new(-100.0, -40.0)),
663 GestureKind::GoToStart
664 ); assert_eq!(
668 gesture_type(zones, Vec2::new(-100.0, 50.0)),
669 GestureKind::ZoomOut
670 ); assert_eq!(
672 gesture_type(zones, Vec2::new(-100.0, -50.0)),
673 GestureKind::ZoomIn
674 ); }
676}