use ratatui::layout::{Position, Rect};
use super::{EventCtx, MouseButton, MouseEvent, MouseKind};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct CellOffset {
pub x: i16,
pub y: i16,
}
impl CellOffset {
#[must_use]
pub const fn new(x: i16, y: i16) -> Self {
Self { x, y }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DragOptions {
offset: CellOffset,
button: MouseButton,
can_start: bool,
}
impl DragOptions {
#[must_use]
pub const fn new(offset: CellOffset) -> Self {
Self {
offset,
button: MouseButton::Left,
can_start: true,
}
}
#[must_use]
pub const fn button(mut self, button: MouseButton) -> Self {
self.button = button;
self
}
#[must_use]
pub const fn start_if(mut self, can_start: bool) -> Self {
self.can_start = can_start;
self
}
}
impl Default for DragOptions {
fn default() -> Self {
Self::new(CellOffset::default())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragPhase {
Down,
Moved {
offset: CellOffset,
position: Position,
},
Ended {
position: Position,
moved: bool,
},
Ignored,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct CapturedDrag {
button: Option<MouseButton>,
anchor: DragAnchor,
moved: bool,
}
impl EventCtx<'_> {
pub fn drag(&mut self, mouse: &MouseEvent, options: DragOptions) -> DragPhase {
let position = Position::new(mouse.column, mouse.row);
match mouse.kind {
MouseKind::Down(button) if button == options.button && options.can_start => {
self.capture_pointer(button);
let state = self.transient::<CapturedDrag>();
state.button = Some(button);
state.anchor.begin(mouse.column, mouse.row, options.offset);
state.moved = false;
DragPhase::Down
}
MouseKind::Drag(button) => {
let Some(state) = self.transient_if_present::<CapturedDrag>() else {
return DragPhase::Ignored;
};
if state.button != Some(button) {
return DragPhase::Ignored;
}
let Some(offset) = state.anchor.offset_at(mouse.column, mouse.row) else {
return DragPhase::Ignored;
};
state.moved = true;
DragPhase::Moved { offset, position }
}
MouseKind::Up(button) => {
let Some(state) = self.transient_if_present::<CapturedDrag>().copied() else {
return DragPhase::Ignored;
};
if state.button != Some(button) {
return DragPhase::Ignored;
}
let state = self
.take_transient::<CapturedDrag>()
.expect("captured drag disappeared during release");
DragPhase::Ended {
position,
moved: state.moved,
}
}
_ => DragPhase::Ignored,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DragAnchor {
point: Option<AnchorPoint>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AnchorPoint {
column: u16,
row: u16,
offset: CellOffset,
}
impl DragAnchor {
pub const fn begin(&mut self, column: u16, row: u16, offset: CellOffset) {
self.point = Some(AnchorPoint {
column,
row,
offset,
});
}
#[must_use]
pub fn offset_at(&self, column: u16, row: u16) -> Option<CellOffset> {
let point = self.point?;
Some(CellOffset {
x: axis_delta(point.offset.x, point.column, column),
y: axis_delta(point.offset.y, point.row, row),
})
}
}
fn axis_delta(anchor_offset: i16, anchor_cell: u16, current_cell: u16) -> i16 {
clamp_i16(i32::from(anchor_offset) + i32::from(current_cell) - i32::from(anchor_cell))
}
#[expect(
clippy::cast_possible_truncation,
reason = "value is clamped to the i16 range on the line above the cast"
)]
fn clamp_i16(value: i32) -> i16 {
value.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
}
#[must_use]
pub fn clamp_offset(area: Rect, rect: Rect, offset: CellOffset) -> CellOffset {
CellOffset {
x: clamp_axis(offset.x, rect.x, rect.width, area.x, area.width),
y: clamp_axis(offset.y, rect.y, rect.height, area.y, area.height),
}
}
#[must_use]
pub fn offset_rect(area: Rect, rect: Rect, offset: CellOffset) -> Rect {
let offset = clamp_offset(area, rect, offset);
Rect {
x: offset_u16(rect.x, offset.x),
y: offset_u16(rect.y, offset.y),
..rect
}
}
#[must_use]
pub(crate) fn offset_u16(value: u16, offset: i16) -> u16 {
if offset.is_negative() {
value.saturating_sub(offset.unsigned_abs())
} else {
value.saturating_add(offset.unsigned_abs())
}
}
fn clamp_axis(offset: i16, rect_start: u16, rect_len: u16, area_start: u16, area_len: u16) -> i16 {
let min = i32::from(area_start) - i32::from(rect_start);
let max =
i32::from(area_start) + i32::from(area_len) - i32::from(rect_start) - i32::from(rect_len);
clamp_i16(i32::from(offset).clamp(min.min(max), min.max(max)))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::runtime::{ChildId, Modifiers};
fn mouse(kind: MouseKind, column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column,
row,
modifiers: Modifiers::NONE,
}
}
#[test]
fn captured_drag_reports_click_without_drag_and_cleans_up() {
let path = [ChildId::Static("drag")];
let mut transients = HashMap::new();
let mut capture = None;
let mut ctx = EventCtx::at(
&path,
Rect::ZERO,
&mut transients,
&mut capture,
Some(MouseButton::Left),
);
assert_eq!(
ctx.drag(
&mouse(MouseKind::Down(MouseButton::Left), 2, 3),
DragOptions::new(CellOffset::new(4, -1)),
),
DragPhase::Down
);
assert_eq!(capture, Some(path.to_vec()));
let mut no_capture = None;
let mut ctx = EventCtx::at(&path, Rect::ZERO, &mut transients, &mut no_capture, None);
assert_eq!(
ctx.drag(
&mouse(MouseKind::Up(MouseButton::Left), 2, 3),
DragOptions::new(CellOffset::new(4, -1)),
),
DragPhase::Ended {
position: Position::new(2, 3),
moved: false,
}
);
assert!(transients.is_empty());
}
#[test]
fn captured_drag_tracks_offset_and_position_across_context_replacement() {
let path = [ChildId::Static("drag")];
let mut transients = HashMap::new();
let mut capture = None;
EventCtx::at(
&path,
Rect::ZERO,
&mut transients,
&mut capture,
Some(MouseButton::Left),
)
.drag(
&mouse(MouseKind::Down(MouseButton::Left), 5, 5),
DragOptions::new(CellOffset::new(2, -1)),
);
let mut no_capture = None;
let phase = EventCtx::at(&path, Rect::ZERO, &mut transients, &mut no_capture, None).drag(
&mouse(MouseKind::Drag(MouseButton::Left), 9, 2),
DragOptions::new(CellOffset::default()).start_if(false),
);
assert_eq!(
phase,
DragPhase::Moved {
offset: CellOffset::new(6, -4),
position: Position::new(9, 2),
}
);
}
#[test]
fn captured_drag_ignores_unrelated_buttons_without_transient_or_capture() {
let path = [ChildId::Static("drag")];
let mut transients = HashMap::new();
let mut capture = None;
let mut ctx = EventCtx::at(
&path,
Rect::ZERO,
&mut transients,
&mut capture,
Some(MouseButton::Right),
);
assert_eq!(
ctx.drag(
&mouse(MouseKind::Down(MouseButton::Right), 1, 1),
DragOptions::default(),
),
DragPhase::Ignored
);
assert!(transients.is_empty());
assert!(capture.is_none());
}
#[test]
fn drag_tracks_delta_from_the_anchor_offset() {
let mut anchor = DragAnchor::default();
assert_eq!(anchor.offset_at(10, 10), None, "no anchor, no offset");
anchor.begin(5, 5, CellOffset::new(2, -1));
assert_eq!(anchor.offset_at(8, 4), Some(CellOffset::new(5, -2)));
assert_eq!(anchor.offset_at(5, 5), Some(CellOffset::new(2, -1)));
}
#[test]
fn clamp_keeps_the_rect_inside_the_area() {
let area = Rect::new(0, 0, 20, 10);
let rect = Rect::new(8, 4, 4, 2);
assert_eq!(
clamp_offset(area, rect, CellOffset::new(2, 1)),
CellOffset::new(2, 1)
);
assert_eq!(
clamp_offset(area, rect, CellOffset::new(99, 0)).x,
(area.width - rect.width - rect.x).cast_signed()
);
assert_eq!(
clamp_offset(area, rect, CellOffset::new(-99, 0)).x,
-rect.x.cast_signed()
);
}
#[test]
fn offset_u16_saturates() {
assert_eq!(offset_u16(10, 5), 15);
assert_eq!(offset_u16(10, -3), 7);
assert_eq!(offset_u16(2, -9), 0);
}
}