teksilo-core 0.13.1

Core of the Teksilo GUI framework — widget trait, arena, layout engine, event dispatch, focus, signals and theming.
Documentation
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

use teksilo_canvas::{Point, Vec2};

use crate::event::PointerButton;

use super::{GestureEvent, GestureRecognizer, GestureResult, RawPointerEvent, RecognizerContext};

/// Recognizes a drag gesture (pointer down + move beyond threshold).
///
/// The threshold comes from the active profile's `drag_slop` — 5 dp for a
/// mouse (exactly the pre-P06 constant), 18 dp for a finger — unless
/// [`threshold`](Self::threshold) pins it.
#[derive(Debug)]
pub struct DragRecognizer {
    threshold: Option<f32>,
    down_position: Option<Point>,
    down_button: Option<PointerButton>,
    dragging: bool,
    last_position: Option<Point>,
}

impl DragRecognizer {
    pub fn new() -> Self {
        Self {
            threshold: None,
            down_position: None,
            down_button: None,
            dragging: false,
            last_position: None,
        }
    }

    /// Pin the travel that arms the drag, overriding the profile's
    /// `drag_slop`.
    pub fn threshold(mut self, t: f32) -> Self {
        self.threshold = Some(t);
        self
    }

    fn slop(&self, cx: &RecognizerContext) -> f32 {
        self.threshold.unwrap_or(cx.profile.drag_slop)
    }
}

impl Default for DragRecognizer {
    fn default() -> Self {
        Self::new()
    }
}

impl GestureRecognizer for DragRecognizer {
    fn process(&mut self, event: &RawPointerEvent, cx: &RecognizerContext) -> GestureResult {
        let threshold = self.slop(cx);
        match event {
            RawPointerEvent::Down {
                position, button, ..
            } => {
                self.down_position = Some(*position);
                self.down_button = Some(*button);
                self.last_position = Some(*position);
                self.dragging = false;
                GestureResult::Pending
            }
            RawPointerEvent::Move {
                position, pointer, ..
            } => {
                let Some(down) = self.down_position else {
                    return GestureResult::Pending;
                };

                if self.dragging {
                    let last = self.last_position.unwrap_or(down);
                    let delta = Vec2::new(position.x - last.x, position.y - last.y);
                    self.last_position = Some(*position);
                    return GestureResult::Recognized(GestureEvent::DragMoved {
                        position: *position,
                        delta,
                        pointer: *pointer,
                    });
                }

                let dx = position.x - down.x;
                let dy = position.y - down.y;
                if (dx * dx + dy * dy).sqrt() >= threshold {
                    self.dragging = true;
                    self.last_position = Some(*position);
                    // `DragStarted.position` reports the *initial press*
                    // (where `down_position` was stored on `Down`), not the
                    // threshold-crossing position. Widgets that need to
                    // classify where the grab originated (e.g. ScrollBar's
                    // thumb-vs-track hit test) can rely on this; widgets
                    // that want the current pointer will get it in the
                    // immediately-following `DragMoved`.
                    return GestureResult::Recognized(GestureEvent::DragStarted {
                        position: down,
                        button: self.down_button.unwrap_or(PointerButton::Primary),
                        pointer: *pointer,
                    });
                }

                GestureResult::Pending
            }
            RawPointerEvent::Up {
                position, pointer, ..
            } => {
                if self.dragging {
                    self.dragging = false;
                    self.down_position = None;
                    return GestureResult::Recognized(GestureEvent::DragEnded {
                        position: *position,
                        pointer: *pointer,
                    });
                }
                self.down_position = None;
                GestureResult::Failed
            }
            RawPointerEvent::Cancel {
                position,
                pointer,
                reason,
                ..
            } => {
                // A drag that was already running owes its handler an unwind;
                // one that never armed just clears.
                let was_dragging = self.dragging;
                self.reset();
                if was_dragging {
                    return GestureResult::Recognized(GestureEvent::DragCancelled {
                        position: *position,
                        pointer: *pointer,
                        reason: *reason,
                    });
                }
                GestureResult::Failed
            }
        }
    }

    fn reset(&mut self) {
        self.down_position = None;
        self.down_button = None;
        self.dragging = false;
        self.last_position = None;
    }

    fn priority(&self) -> u32 {
        20
    }
}

#[cfg(test)]
impl DragRecognizer {
    /// The pre-P06 call shape — see `TapRecognizer::process`.
    fn process(&mut self, event: &RawPointerEvent) -> GestureResult {
        let cx = super::config::RecognizerContext::for_event(event);
        GestureRecognizer::process(self, event, &cx)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::gesture::test_helpers::*;

    #[test]
    fn drag_recognizer_fires_after_threshold() {
        let mut rec = DragRecognizer::new().threshold(5.0);

        assert!(matches!(
            rec.process(&down(Point::new(10.0, 10.0))),
            GestureResult::Pending
        ));

        // Small move — still pending
        assert!(matches!(
            rec.process(&move_to(Point::new(12.0, 10.0))),
            GestureResult::Pending
        ));

        // Large move — drag started
        assert!(matches!(
            rec.process(&move_to(Point::new(20.0, 10.0))),
            GestureResult::Recognized(GestureEvent::DragStarted { .. })
        ));
    }

    #[test]
    fn drag_emits_moved_and_ended() {
        let mut rec = DragRecognizer::new().threshold(1.0);
        rec.process(&down(Point::new(0.0, 0.0)));
        // Cross threshold
        rec.process(&move_to(Point::new(10.0, 0.0)));

        // Subsequent move
        let result = rec.process(&move_to(Point::new(15.0, 0.0)));
        assert!(matches!(
            result,
            GestureResult::Recognized(GestureEvent::DragMoved { .. })
        ));

        // Release
        let result = rec.process(&up(Point::new(15.0, 0.0)));
        assert!(matches!(
            result,
            GestureResult::Recognized(GestureEvent::DragEnded { .. })
        ));
    }

    #[test]
    fn drag_fails_on_up_without_movement() {
        let mut rec = DragRecognizer::new().threshold(5.0);
        rec.process(&down(Point::new(10.0, 10.0)));
        assert!(matches!(
            rec.process(&up(Point::new(10.0, 10.0))),
            GestureResult::Failed
        ));
    }

    #[test]
    fn reset_clears_state() {
        let mut rec = DragRecognizer::new().threshold(1.0);
        rec.process(&down(Point::new(0.0, 0.0)));
        rec.process(&move_to(Point::new(10.0, 0.0)));
        rec.reset();

        // After reset, move without down should be pending (not drag)
        assert!(matches!(
            rec.process(&move_to(Point::new(20.0, 0.0))),
            GestureResult::Pending
        ));
    }
}