#![forbid(unsafe_code)]
use crate::drag::DragPayload;
use crate::measure_cache::WidgetId;
use ftui_core::geometry::Rect;
use ftui_render::cell::Cell;
use ftui_render::cell::PackedRgba;
use ftui_render::frame::Frame;
use ftui_style::Style;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum KeyboardDragMode {
#[default]
Inactive,
Holding,
Navigating,
}
impl KeyboardDragMode {
#[must_use]
pub fn is_active(self) -> bool {
!matches!(self, Self::Inactive)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Inactive => "inactive",
Self::Holding => "holding",
Self::Navigating => "navigating",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Up,
Down,
Left,
Right,
}
impl Direction {
#[must_use]
pub const fn opposite(self) -> Self {
match self {
Self::Up => Self::Down,
Self::Down => Self::Up,
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
#[must_use]
pub const fn is_vertical(self) -> bool {
matches!(self, Self::Up | Self::Down)
}
}
#[derive(Debug, Clone)]
pub struct DropTargetInfo {
pub id: WidgetId,
pub name: String,
pub bounds: Rect,
pub accepted_types: Vec<String>,
pub enabled: bool,
}
impl DropTargetInfo {
#[must_use]
pub fn new(id: WidgetId, name: impl Into<String>, bounds: Rect) -> Self {
Self {
id,
name: name.into(),
bounds,
accepted_types: Vec::new(),
enabled: true,
}
}
#[must_use]
pub fn with_accepted_types(mut self, types: Vec<String>) -> Self {
self.accepted_types = types;
self
}
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub fn can_accept(&self, drag_type: &str) -> bool {
if !self.enabled {
return false;
}
if self.accepted_types.is_empty() {
return true; }
self.accepted_types.iter().any(|pattern| {
if pattern == "*" || pattern == "*/*" {
true
} else if let Some(prefix) = pattern.strip_suffix("/*") {
drag_type.starts_with(prefix)
&& drag_type.as_bytes().get(prefix.len()) == Some(&b'/')
} else {
pattern == drag_type
}
})
}
#[must_use]
pub fn center(&self) -> (u16, u16) {
(
self.bounds.x + self.bounds.width / 2,
self.bounds.y + self.bounds.height / 2,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement {
pub text: String,
pub priority: AnnouncementPriority,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum AnnouncementPriority {
Low,
#[default]
Normal,
High,
}
impl Announcement {
#[must_use]
pub fn normal(text: impl Into<String>) -> Self {
Self {
text: text.into(),
priority: AnnouncementPriority::Normal,
}
}
#[must_use]
pub fn high(text: impl Into<String>) -> Self {
Self {
text: text.into(),
priority: AnnouncementPriority::High,
}
}
}
#[derive(Debug, Clone)]
pub struct KeyboardDragConfig {
pub activate_keys: Vec<ActivateKey>,
pub cancel_on_escape: bool,
pub target_highlight_style: TargetHighlightStyle,
pub invalid_target_style: TargetHighlightStyle,
pub wrap_navigation: bool,
pub max_announcement_queue: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivateKey {
Space,
Enter,
}
impl Default for KeyboardDragConfig {
fn default() -> Self {
Self {
activate_keys: vec![ActivateKey::Space, ActivateKey::Enter],
cancel_on_escape: true,
target_highlight_style: TargetHighlightStyle::default(),
invalid_target_style: TargetHighlightStyle::invalid_default(),
wrap_navigation: true,
max_announcement_queue: 5,
}
}
}
#[derive(Debug, Clone)]
pub struct TargetHighlightStyle {
pub border_char: char,
pub border_fg: PackedRgba,
pub background: Option<PackedRgba>,
pub animate_pulse: bool,
}
impl Default for TargetHighlightStyle {
fn default() -> Self {
Self {
border_char: 'â–ˆ',
border_fg: PackedRgba::rgb(100, 180, 255), background: Some(PackedRgba::rgba(100, 180, 255, 40)), animate_pulse: true,
}
}
}
impl TargetHighlightStyle {
#[must_use]
pub fn invalid_default() -> Self {
Self {
border_char: 'â–ª',
border_fg: PackedRgba::rgb(180, 100, 100), background: Some(PackedRgba::rgba(180, 100, 100, 20)), animate_pulse: false,
}
}
#[must_use]
pub fn new(border_char: char, fg: PackedRgba) -> Self {
Self {
border_char,
border_fg: fg,
background: None,
animate_pulse: false,
}
}
#[must_use]
pub fn with_background(mut self, bg: PackedRgba) -> Self {
self.background = Some(bg);
self
}
#[must_use]
pub fn with_pulse(mut self) -> Self {
self.animate_pulse = true;
self
}
}
#[derive(Debug, Clone)]
pub struct KeyboardDragState {
pub source_id: WidgetId,
pub payload: DragPayload,
pub selected_target_index: Option<usize>,
pub mode: KeyboardDragMode,
pub animation_tick: u8,
}
impl KeyboardDragState {
fn new(source_id: WidgetId, payload: DragPayload) -> Self {
Self {
source_id,
payload,
selected_target_index: None,
mode: KeyboardDragMode::Holding,
animation_tick: 0,
}
}
pub fn tick_animation(&mut self) {
self.animation_tick = self.animation_tick.wrapping_add(1);
}
#[must_use]
pub fn pulse_intensity(&self) -> f32 {
let angle = self.animation_tick as f32 * 0.15;
0.5 + 0.5 * angle.sin()
}
}
#[derive(Debug)]
pub struct KeyboardDragManager {
config: KeyboardDragConfig,
state: Option<KeyboardDragState>,
announcements: Vec<Announcement>,
}
impl KeyboardDragManager {
#[must_use]
pub fn new(config: KeyboardDragConfig) -> Self {
Self {
config,
state: None,
announcements: Vec::new(),
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(KeyboardDragConfig::default())
}
#[must_use]
pub fn mode(&self) -> KeyboardDragMode {
self.state
.as_ref()
.map(|s| s.mode)
.unwrap_or(KeyboardDragMode::Inactive)
}
#[must_use]
pub fn is_active(&self) -> bool {
self.state.is_some()
}
#[must_use = "use the returned state (if any)"]
pub fn state(&self) -> Option<&KeyboardDragState> {
self.state.as_ref()
}
#[must_use = "use the returned state (if any)"]
pub fn state_mut(&mut self) -> Option<&mut KeyboardDragState> {
self.state.as_mut()
}
pub fn start_drag(&mut self, source_id: WidgetId, payload: DragPayload) -> bool {
if self.state.is_some() {
return false;
}
let description = payload
.display_text
.as_deref()
.or_else(|| payload.as_text())
.unwrap_or("item");
self.queue_announcement(Announcement::high(format!("Picked up: {description}")));
self.state = Some(KeyboardDragState::new(source_id, payload));
true
}
#[must_use = "use the returned target (if any)"]
pub fn navigate_targets<'a>(
&mut self,
direction: Direction,
targets: &'a [DropTargetInfo],
) -> Option<&'a DropTargetInfo> {
let state = self.state.as_mut()?;
if targets.is_empty() {
state.selected_target_index = None;
state.mode = KeyboardDragMode::Holding;
return None;
}
let valid_indices: Vec<usize> = targets
.iter()
.enumerate()
.filter(|(_, t)| t.can_accept(&state.payload.drag_type))
.map(|(i, _)| i)
.collect();
if valid_indices.is_empty() {
state.selected_target_index = None;
state.mode = KeyboardDragMode::Holding;
self.queue_announcement(Announcement::normal("No valid drop targets available"));
return None;
}
state.mode = KeyboardDragMode::Navigating;
let current_valid_idx = state
.selected_target_index
.and_then(|idx| valid_indices.iter().position(|&i| i == idx));
let next_valid_idx = match (current_valid_idx, direction) {
(None, _) => 0, (Some(idx), Direction::Down | Direction::Right) => {
if idx + 1 < valid_indices.len() {
idx + 1
} else if self.config.wrap_navigation {
0
} else {
idx
}
}
(Some(idx), Direction::Up | Direction::Left) => {
if idx > 0 {
idx - 1
} else if self.config.wrap_navigation {
valid_indices.len() - 1
} else {
idx
}
}
};
let target_idx = valid_indices[next_valid_idx];
state.selected_target_index = Some(target_idx);
let target = &targets[target_idx];
let position = format!("{} of {}", next_valid_idx + 1, valid_indices.len());
self.queue_announcement(Announcement::normal(format!(
"Drop target: {} ({})",
target.name, position
)));
Some(target)
}
pub fn select_target(&mut self, target_index: usize, targets: &[DropTargetInfo]) -> bool {
let Some(state) = self.state.as_mut() else {
return false;
};
if target_index >= targets.len() {
state.selected_target_index = None;
state.mode = KeyboardDragMode::Holding;
return false;
}
let target = &targets[target_index];
if !target.can_accept(&state.payload.drag_type) {
state.selected_target_index = None;
state.mode = KeyboardDragMode::Holding;
return false;
}
state.mode = KeyboardDragMode::Navigating;
state.selected_target_index = Some(target_index);
self.queue_announcement(Announcement::normal(format!(
"Drop target: {}",
target.name
)));
true
}
#[must_use = "use the returned (payload, target_index) to complete the drop"]
pub fn complete_drag(&mut self) -> Option<(DragPayload, usize)> {
let state = self.state.take()?;
let target_idx = state.selected_target_index?;
Some((state.payload, target_idx))
}
#[must_use = "use the drop result (if any) to apply the drop"]
pub fn drop_on_target(&mut self, targets: &[DropTargetInfo]) -> Option<KeyboardDropResult> {
let (target_idx, drag_type) = {
let state = self.state.as_ref()?;
(
state.selected_target_index?,
state.payload.drag_type.clone(),
)
};
let Some(target) = targets.get(target_idx) else {
self.clear_selected_target();
self.queue_announcement(Announcement::normal("Selected drop target unavailable"));
return None;
};
if !target.can_accept(&drag_type) {
self.clear_selected_target();
self.queue_announcement(Announcement::normal(
"Selected drop target no longer accepts this item",
));
return None;
}
let target_id = target.id;
let target_name = target.name.clone();
let state = self.state.take()?;
self.queue_announcement(Announcement::high(format!("Dropped on: {target_name}")));
Some(KeyboardDropResult {
payload: state.payload,
source_id: state.source_id,
target_id,
target_index: target_idx,
})
}
#[must_use = "use the returned payload (if any) to restore state"]
pub fn cancel_drag(&mut self) -> Option<DragPayload> {
let state = self.state.take()?;
self.queue_announcement(Announcement::normal("Drop cancelled"));
Some(state.payload)
}
pub fn handle_key(&mut self, key: KeyboardDragKey) -> KeyboardDragAction {
match key {
KeyboardDragKey::Activate => {
if self.is_active() {
if let Some(state) = &self.state
&& state.selected_target_index.is_some()
{
KeyboardDragAction::Drop
} else {
KeyboardDragAction::None
}
} else {
KeyboardDragAction::PickUp
}
}
KeyboardDragKey::Cancel => {
if self.is_active() && self.config.cancel_on_escape {
KeyboardDragAction::Cancel
} else {
KeyboardDragAction::None
}
}
KeyboardDragKey::Navigate(dir) => {
if self.is_active() {
KeyboardDragAction::Navigate(dir)
} else {
KeyboardDragAction::None
}
}
}
}
pub fn tick(&mut self) {
if let Some(state) = &mut self.state {
state.tick_animation();
}
}
pub fn drain_announcements(&mut self) -> Vec<Announcement> {
std::mem::take(&mut self.announcements)
}
#[must_use]
pub fn announcements(&self) -> &[Announcement] {
&self.announcements
}
fn queue_announcement(&mut self, announcement: Announcement) {
if self.config.max_announcement_queue == 0 {
return;
}
if self.announcements.len() >= self.config.max_announcement_queue {
if let Some((pos, lowest_priority)) = self
.announcements
.iter()
.enumerate()
.min_by_key(|(_, a)| a.priority)
.map(|(i, a)| (i, a.priority))
{
if announcement.priority < lowest_priority {
return;
}
self.announcements.remove(pos);
}
}
self.announcements.push(announcement);
}
fn clear_selected_target(&mut self) {
if let Some(state) = &mut self.state {
state.selected_target_index = None;
state.mode = KeyboardDragMode::Holding;
}
}
pub fn render_highlight(&self, targets: &[DropTargetInfo], frame: &mut Frame) {
let Some(state) = &self.state else {
return;
};
let Some(target_idx) = state.selected_target_index else {
return;
};
let Some(target) = targets.get(target_idx) else {
return;
};
let style = if target.can_accept(&state.payload.drag_type) {
&self.config.target_highlight_style
} else {
&self.config.invalid_target_style
};
let bounds = target.bounds;
if bounds.is_empty() {
return;
}
if let Some(bg) = style.background {
let alpha = if style.animate_pulse {
let base_alpha = (bg.0 & 0xFF) as f32 / 255.0;
let pulsed = base_alpha * (0.5 + 0.5 * state.pulse_intensity());
(pulsed * 255.0) as u8
} else {
(bg.0 & 0xFF) as u8
};
let effective_bg = PackedRgba((bg.0 & 0xFFFF_FF00) | alpha as u32);
for y in bounds.y..bounds.y.saturating_add(bounds.height) {
for x in bounds.x..bounds.x.saturating_add(bounds.width) {
if let Some(cell) = frame.buffer.get_mut(x, y) {
cell.bg = effective_bg;
}
}
}
}
let fg_style = Style::new().fg(style.border_fg);
let border_char = style.border_char;
for x in bounds.x..bounds.x.saturating_add(bounds.width) {
let mut cell = Cell::from_char(border_char);
cell.fg = fg_style.fg.unwrap_or(style.border_fg);
frame.buffer.set_fast(x, bounds.y, cell);
let bottom_y = bounds.y.saturating_add(bounds.height.saturating_sub(1));
if bounds.height > 1 {
let mut cell_b = Cell::from_char(border_char);
cell_b.fg = fg_style.fg.unwrap_or(style.border_fg);
frame.buffer.set_fast(x, bottom_y, cell_b);
}
}
for y in
bounds.y.saturating_add(1)..bounds.y.saturating_add(bounds.height.saturating_sub(1))
{
let mut cell = Cell::from_char(border_char);
cell.fg = fg_style.fg.unwrap_or(style.border_fg);
frame.buffer.set_fast(bounds.x, y, cell);
let right_x = bounds.x.saturating_add(bounds.width.saturating_sub(1));
if bounds.width > 1 {
let mut cell_r = Cell::from_char(border_char);
cell_r.fg = fg_style.fg.unwrap_or(style.border_fg);
frame.buffer.set_fast(right_x, y, cell_r);
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyboardDragKey {
Activate,
Cancel,
Navigate(Direction),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyboardDragAction {
None,
PickUp,
Navigate(Direction),
Drop,
Cancel,
}
#[derive(Debug, Clone)]
pub struct KeyboardDropResult {
pub payload: DragPayload,
pub source_id: WidgetId,
pub target_id: WidgetId,
pub target_index: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_is_active() {
assert!(!KeyboardDragMode::Inactive.is_active());
assert!(KeyboardDragMode::Holding.is_active());
assert!(KeyboardDragMode::Navigating.is_active());
}
#[test]
fn mode_as_str() {
assert_eq!(KeyboardDragMode::Inactive.as_str(), "inactive");
assert_eq!(KeyboardDragMode::Holding.as_str(), "holding");
assert_eq!(KeyboardDragMode::Navigating.as_str(), "navigating");
}
#[test]
fn direction_opposite() {
assert_eq!(Direction::Up.opposite(), Direction::Down);
assert_eq!(Direction::Down.opposite(), Direction::Up);
assert_eq!(Direction::Left.opposite(), Direction::Right);
assert_eq!(Direction::Right.opposite(), Direction::Left);
}
#[test]
fn direction_is_vertical() {
assert!(Direction::Up.is_vertical());
assert!(Direction::Down.is_vertical());
assert!(!Direction::Left.is_vertical());
assert!(!Direction::Right.is_vertical());
}
#[test]
fn drop_target_info_new() {
let target = DropTargetInfo::new(WidgetId(1), "Test Target", Rect::new(0, 0, 10, 5));
assert_eq!(target.id, WidgetId(1));
assert_eq!(target.name, "Test Target");
assert!(target.enabled);
assert!(target.accepted_types.is_empty());
}
#[test]
fn drop_target_info_can_accept_any() {
let target = DropTargetInfo::new(WidgetId(1), "Any", Rect::new(0, 0, 1, 1));
assert!(target.can_accept("text/plain"));
assert!(target.can_accept("application/json"));
}
#[test]
fn drop_target_info_can_accept_filtered() {
let target = DropTargetInfo::new(WidgetId(1), "Text Only", Rect::new(0, 0, 1, 1))
.with_accepted_types(vec!["text/plain".to_string()]);
assert!(target.can_accept("text/plain"));
assert!(!target.can_accept("application/json"));
}
#[test]
fn drop_target_info_can_accept_wildcard() {
let target = DropTargetInfo::new(WidgetId(1), "All Text", Rect::new(0, 0, 1, 1))
.with_accepted_types(vec!["text/*".to_string()]);
assert!(target.can_accept("text/plain"));
assert!(target.can_accept("text/html"));
assert!(!target.can_accept("application/json"));
}
#[test]
fn drop_target_info_disabled() {
let target =
DropTargetInfo::new(WidgetId(1), "Disabled", Rect::new(0, 0, 1, 1)).with_enabled(false);
assert!(!target.can_accept("text/plain"));
}
#[test]
fn drop_target_info_center() {
let target = DropTargetInfo::new(WidgetId(1), "Test", Rect::new(10, 20, 10, 6));
assert_eq!(target.center(), (15, 23));
}
#[test]
fn announcement_normal() {
let a = Announcement::normal("Test message");
assert_eq!(a.text, "Test message");
assert_eq!(a.priority, AnnouncementPriority::Normal);
}
#[test]
fn announcement_high() {
let a = Announcement::high("Important!");
assert_eq!(a.priority, AnnouncementPriority::High);
}
#[test]
fn config_defaults() {
let config = KeyboardDragConfig::default();
assert!(config.cancel_on_escape);
assert!(config.wrap_navigation);
assert_eq!(config.activate_keys.len(), 2);
}
#[test]
fn drag_state_animation() {
let payload = DragPayload::text("test");
let mut state = KeyboardDragState::new(WidgetId(1), payload);
let initial_tick = state.animation_tick;
state.tick_animation();
assert_eq!(state.animation_tick, initial_tick.wrapping_add(1));
}
#[test]
fn drag_state_pulse_intensity() {
let payload = DragPayload::text("test");
let state = KeyboardDragState::new(WidgetId(1), payload);
let intensity = state.pulse_intensity();
assert!((0.0..=1.0).contains(&intensity));
}
#[test]
fn manager_start_drag() {
let mut manager = KeyboardDragManager::with_defaults();
assert!(!manager.is_active());
let payload = DragPayload::text("item");
assert!(manager.start_drag(WidgetId(1), payload));
assert!(manager.is_active());
assert_eq!(manager.mode(), KeyboardDragMode::Holding);
}
#[test]
fn manager_double_start_fails() {
let mut manager = KeyboardDragManager::with_defaults();
assert!(manager.start_drag(WidgetId(1), DragPayload::text("first")));
assert!(!manager.start_drag(WidgetId(2), DragPayload::text("second")));
}
#[test]
fn manager_cancel_drag() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
let payload = manager.cancel_drag();
assert!(payload.is_some());
assert!(!manager.is_active());
}
#[test]
fn manager_cancel_inactive() {
let mut manager = KeyboardDragManager::with_defaults();
assert!(manager.cancel_drag().is_none());
}
#[test]
fn manager_navigate_targets() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
let targets = vec![
DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(20, 0, 10, 5)),
];
let selected = manager.navigate_targets(Direction::Down, &targets);
assert!(selected.is_some());
assert_eq!(selected.unwrap().name, "Target A");
assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
}
#[test]
fn manager_navigate_empty_targets() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
manager
.state_mut()
.expect("drag active")
.selected_target_index = Some(3);
manager.state_mut().expect("drag active").mode = KeyboardDragMode::Navigating;
let targets: Vec<DropTargetInfo> = vec![];
let selected = manager.navigate_targets(Direction::Down, &targets);
assert!(selected.is_none());
assert_eq!(manager.mode(), KeyboardDragMode::Holding);
assert!(
manager
.state()
.expect("drag remains active")
.selected_target_index
.is_none()
);
}
#[test]
fn manager_navigate_wrap() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
let targets = vec![
DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(20, 0, 10, 5)),
];
let _ = manager.navigate_targets(Direction::Down, &targets);
let _ = manager.navigate_targets(Direction::Down, &targets);
let selected = manager.navigate_targets(Direction::Down, &targets);
assert_eq!(selected.unwrap().name, "Target A");
}
#[test]
fn manager_complete_drag() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
let targets = vec![DropTargetInfo::new(
WidgetId(10),
"Target A",
Rect::new(0, 0, 10, 5),
)];
let _ = manager.navigate_targets(Direction::Down, &targets);
let result = manager.complete_drag();
assert!(result.is_some());
let (payload, idx) = result.unwrap();
assert_eq!(payload.as_text(), Some("item"));
assert_eq!(idx, 0);
assert!(!manager.is_active());
}
#[test]
fn manager_complete_without_target() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
let result = manager.complete_drag();
assert!(result.is_none());
}
#[test]
fn manager_navigate_no_valid_targets_clears_stale_selection() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::new("text/plain", vec![]));
let valid_targets = vec![
DropTargetInfo::new(WidgetId(10), "Text Target", Rect::new(0, 0, 10, 5))
.with_accepted_types(vec!["text/plain".to_string()]),
];
let _ = manager.navigate_targets(Direction::Down, &valid_targets);
assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
let invalid_targets = vec![
DropTargetInfo::new(WidgetId(11), "Image Target", Rect::new(20, 0, 10, 5))
.with_accepted_types(vec!["image/*".to_string()]),
];
let selected = manager.navigate_targets(Direction::Down, &invalid_targets);
assert!(selected.is_none());
assert_eq!(manager.mode(), KeyboardDragMode::Holding);
assert!(
manager
.state()
.expect("drag remains active")
.selected_target_index
.is_none()
);
assert_eq!(
manager.handle_key(KeyboardDragKey::Activate),
KeyboardDragAction::None
);
}
#[test]
fn manager_handle_key_pickup() {
let mut manager = KeyboardDragManager::with_defaults();
let action = manager.handle_key(KeyboardDragKey::Activate);
assert_eq!(action, KeyboardDragAction::PickUp);
}
#[test]
fn manager_handle_key_drop() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
manager.state_mut().unwrap().selected_target_index = Some(0);
let action = manager.handle_key(KeyboardDragKey::Activate);
assert_eq!(action, KeyboardDragAction::Drop);
}
#[test]
fn manager_handle_key_cancel() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
let action = manager.handle_key(KeyboardDragKey::Cancel);
assert_eq!(action, KeyboardDragAction::Cancel);
}
#[test]
fn manager_handle_key_navigate() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
let action = manager.handle_key(KeyboardDragKey::Navigate(Direction::Down));
assert_eq!(action, KeyboardDragAction::Navigate(Direction::Down));
}
#[test]
fn manager_announcements() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::text("item"));
let announcements = manager.drain_announcements();
assert!(!announcements.is_empty());
assert!(announcements[0].text.contains("Picked up"));
}
#[test]
fn manager_announcement_queue_limit() {
let config = KeyboardDragConfig {
max_announcement_queue: 2,
..Default::default()
};
let mut manager = KeyboardDragManager::new(config);
manager.start_drag(WidgetId(1), DragPayload::text("item1"));
let _ = manager.cancel_drag();
manager.start_drag(WidgetId(2), DragPayload::text("item2"));
assert!(manager.announcements().len() <= 2);
}
#[test]
fn manager_announcement_queue_zero_discards_announcements() {
let config = KeyboardDragConfig {
max_announcement_queue: 0,
..Default::default()
};
let mut manager = KeyboardDragManager::new(config);
assert!(manager.start_drag(WidgetId(1), DragPayload::text("item")));
let _ = manager.cancel_drag();
assert!(manager.announcements().is_empty());
}
#[test]
fn manager_lower_priority_announcement_does_not_evict_higher_priority() {
let config = KeyboardDragConfig {
max_announcement_queue: 1,
..Default::default()
};
let mut manager = KeyboardDragManager::new(config);
assert!(manager.start_drag(WidgetId(1), DragPayload::text("item")));
let _ = manager.cancel_drag();
assert_eq!(manager.announcements().len(), 1);
assert_eq!(
manager.announcements()[0].priority,
AnnouncementPriority::High
);
assert!(manager.announcements()[0].text.contains("Picked up"));
}
#[test]
fn manager_navigate_skips_incompatible() {
let mut manager = KeyboardDragManager::with_defaults();
manager.start_drag(WidgetId(1), DragPayload::new("text/plain", vec![]));
let targets = vec![
DropTargetInfo::new(WidgetId(10), "Text Target", Rect::new(0, 0, 10, 5))
.with_accepted_types(vec!["text/plain".to_string()]),
DropTargetInfo::new(WidgetId(11), "Image Target", Rect::new(20, 0, 10, 5))
.with_accepted_types(vec!["image/*".to_string()]),
DropTargetInfo::new(WidgetId(12), "Text Target 2", Rect::new(40, 0, 10, 5))
.with_accepted_types(vec!["text/plain".to_string()]),
];
let selected = manager.navigate_targets(Direction::Down, &targets);
assert_eq!(selected.unwrap().name, "Text Target");
let selected = manager.navigate_targets(Direction::Down, &targets);
assert_eq!(selected.unwrap().name, "Text Target 2");
}
#[test]
fn full_keyboard_drag_lifecycle() {
let mut manager = KeyboardDragManager::with_defaults();
assert!(manager.start_drag(WidgetId(1), DragPayload::text("dragged_item")));
assert_eq!(manager.mode(), KeyboardDragMode::Holding);
let targets = vec![
DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(0, 10, 10, 5)),
];
let _ = manager.navigate_targets(Direction::Down, &targets);
assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
let _ = manager.navigate_targets(Direction::Down, &targets);
let result = manager.drop_on_target(&targets);
assert!(result.is_some());
let result = result.unwrap();
assert_eq!(result.payload.as_text(), Some("dragged_item"));
assert_eq!(result.target_id, WidgetId(11));
assert_eq!(result.target_index, 1);
assert!(!manager.is_active());
}
#[test]
fn manager_drop_on_invalidated_target_keeps_drag_active() {
let mut manager = KeyboardDragManager::with_defaults();
assert!(manager.start_drag(WidgetId(1), DragPayload::new("text/plain", vec![])));
let targets = vec![
DropTargetInfo::new(WidgetId(10), "Text Target", Rect::new(0, 0, 10, 5))
.with_accepted_types(vec!["text/plain".to_string()]),
];
let _ = manager.navigate_targets(Direction::Down, &targets);
assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
let invalidated_targets = vec![
DropTargetInfo::new(WidgetId(10), "Image Target", Rect::new(0, 0, 10, 5))
.with_accepted_types(vec!["image/*".to_string()]),
];
let result = manager.drop_on_target(&invalidated_targets);
assert!(result.is_none());
assert!(manager.is_active());
assert_eq!(manager.mode(), KeyboardDragMode::Holding);
assert!(
manager
.state()
.expect("drag remains active")
.selected_target_index
.is_none()
);
}
}