use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::dnd::{DragPayload, DragSession, DropEffect, DropTarget};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const CARD_PAYLOAD_TYPE: &str = "kanban_card";
const CARD_DRAG_THRESHOLD: i32 = 4;
const COLUMN_WIDTH: u32 = 220;
const COLUMN_GAP: i32 = 12;
const HEADER_HEIGHT: u32 = 32;
const CARD_HEIGHT: u32 = 48;
const CARD_GAP: i32 = 8;
#[derive(Debug, Clone, PartialEq)]
pub struct KanbanCard {
pub id: String,
pub title: String,
pub description: String,
pub done: bool,
}
impl KanbanCard {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self { id: id.into(), title: title.into(), description: String::new(), done: false }
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn with_done(mut self, done: bool) -> Self {
self.done = done;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct KanbanColumn {
pub id: String,
pub title: String,
pub cards: Vec<KanbanCard>,
pub collapsed: bool,
pub wip_limit: Option<usize>,
}
impl KanbanColumn {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
cards: Vec::new(),
collapsed: false,
wip_limit: None,
}
}
pub fn with_wip_limit(mut self, limit: usize) -> Self {
self.wip_limit = Some(limit);
self
}
pub fn is_at_wip_limit(&self) -> bool {
self.wip_limit.is_some_and(|limit| self.cards.len() >= limit)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CardPosition {
pub column: usize,
pub card: usize,
}
pub struct KanbanBoard {
base: BaseWidget,
columns: Vec<KanbanColumn>,
drag: Option<DragSession>,
drag_origin: Option<CardPosition>,
hovered_column: Option<usize>,
pub card_moved: Signal1<(String, CardPosition)>,
pub card_activated: Signal1<String>,
}
impl KanbanBoard {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::KanbanBoard, geometry, "KanbanBoard"),
columns: Vec::new(),
drag: None,
drag_origin: None,
hovered_column: None,
card_moved: Signal1::new(),
card_activated: Signal1::new(),
}
}
pub fn add_column(&mut self, column: KanbanColumn) -> usize {
let index = self.columns.len();
self.columns.push(column);
self.base.request_redraw();
index
}
pub fn remove_column(&mut self, index: usize) -> Option<KanbanColumn> {
if index >= self.columns.len() {
return None;
}
let removed = self.columns.remove(index);
if self.drag_origin.is_some_and(|origin| origin.column >= self.columns.len()) {
self.cancel_drag();
}
self.base.request_redraw();
Some(removed)
}
pub fn columns(&self) -> &[KanbanColumn] {
&self.columns
}
pub fn column_count(&self) -> usize {
self.columns.len()
}
pub fn column(&self, index: usize) -> Option<&KanbanColumn> {
self.columns.get(index)
}
pub fn column_mut(&mut self, index: usize) -> Option<&mut KanbanColumn> {
self.columns.get_mut(index)
}
pub fn add_card(&mut self, column: usize, card: KanbanCard) -> Option<CardPosition> {
let column_ref = self.columns.get_mut(column)?;
let position = CardPosition { column, card: column_ref.cards.len() };
column_ref.cards.push(card);
self.base.request_redraw();
Some(position)
}
pub fn cards(&self) -> Vec<(CardPosition, &KanbanCard)> {
let mut out = Vec::new();
for (column, entry) in self.columns.iter().enumerate() {
for (card, value) in entry.cards.iter().enumerate() {
out.push((CardPosition { column, card }, value));
}
}
out
}
pub fn position_of(&self, card_id: &str) -> Option<CardPosition> {
for (column, entry) in self.columns.iter().enumerate() {
if let Some(card) = entry.cards.iter().position(|c| c.id == card_id) {
return Some(CardPosition { column, card });
}
}
None
}
pub fn remove_card(&mut self, card_id: &str) -> Option<(KanbanCard, CardPosition)> {
let position = self.position_of(card_id)?;
let column = self.columns.get_mut(position.column)?;
let card = column.cards.remove(position.card);
self.base.request_redraw();
Some((card, position))
}
pub fn insert_card(
&mut self,
column: usize,
index: usize,
card: KanbanCard,
) -> Option<CardPosition> {
let column_ref = self.columns.get_mut(column)?;
let index = index.min(column_ref.cards.len());
column_ref.cards.insert(index, card);
self.base.request_redraw();
Some(CardPosition { column, card: index })
}
pub fn move_card(&mut self, from: CardPosition, to: CardPosition) -> Option<CardPosition> {
if to.column >= self.columns.len() {
return None;
}
if from.column >= self.columns.len() || from.card >= self.columns[from.column].cards.len() {
return None;
}
let card = self.columns[from.column].cards.remove(from.card);
let card_id = card.id.clone();
let target = self.insert_card(to.column, to.card, card)?;
self.base.request_redraw();
self.card_moved.emit((card_id, target));
Some(target)
}
pub fn column_rect(&self, index: usize) -> Option<Rect> {
if index >= self.columns.len() {
return None;
}
let rect = self.geometry();
let x = rect.x + (index as i32) * (COLUMN_WIDTH as i32 + COLUMN_GAP);
Some(Rect::new(x, rect.y, COLUMN_WIDTH, rect.height))
}
pub fn card_rect(&self, position: CardPosition) -> Option<Rect> {
let column = self.columns.get(position.column)?;
if column.collapsed || position.card >= column.cards.len() {
return None;
}
let column_rect = self.column_rect(position.column)?;
let y = column_rect.y
+ HEADER_HEIGHT as i32
+ (position.card as i32) * (CARD_HEIGHT as i32 + CARD_GAP);
Some(Rect::new(column_rect.x, y, COLUMN_WIDTH, CARD_HEIGHT))
}
fn column_at(&self, pos: Point) -> Option<usize> {
(0..self.columns.len())
.find(|index| self.column_rect(*index).is_some_and(|rect| rect.contains_point(pos)))
}
fn card_at(&self, pos: Point) -> Option<CardPosition> {
let column = self.column_at(pos)?;
let entries = self.columns.get(column)?;
for index in 0..entries.cards.len() {
let position = CardPosition { column, card: index };
if self.card_rect(position).is_some_and(|rect| rect.contains_point(pos)) {
return Some(position);
}
}
None
}
fn insert_index_at(&self, column: usize, pos: Point) -> usize {
let Some(entries) = self.columns.get(column) else {
return 0;
};
let Some(column_rect) = self.column_rect(column) else {
return 0;
};
let first_card_y = column_rect.y + HEADER_HEIGHT as i32;
if pos.y <= first_card_y {
return 0;
}
let stride = CARD_HEIGHT as i32 + CARD_GAP;
let offset = pos.y - first_card_y;
let index = (offset + stride / 2) / stride;
(index as usize).min(entries.cards.len())
}
pub fn is_dragging_card(&self) -> bool {
self.drag.as_ref().is_some_and(DragSession::is_active)
}
pub fn dragged_card_id(&self) -> Option<&str> {
self.drag.as_ref().map(|session| session.payload().item_id.as_str())
}
pub fn hovered_column(&self) -> Option<usize> {
self.hovered_column
}
pub fn cancel_drag(&mut self) {
if self.drag.take().is_some() {
self.drag_origin = None;
self.hovered_column = None;
self.base.request_redraw();
}
}
fn begin_card_drag(&mut self, position: CardPosition, pos: Point) {
let Some(column) = self.columns.get(position.column) else {
return;
};
let Some(card) = column.cards.get(position.card) else {
return;
};
let payload = DragPayload::new(CARD_PAYLOAD_TYPE, card.id.clone())
.with_label(card.title.clone())
.with_origin(pos);
self.drag = Some(DragSession::begin(payload, pos));
self.drag_origin = Some(position);
self.hovered_column = Some(position.column);
self.base.request_redraw();
}
fn update_card_drag(&mut self, pos: Point) {
let Some(session) = self.drag.as_mut() else {
return;
};
session.update(pos, CARD_DRAG_THRESHOLD);
if !session.is_active() {
return;
}
let hovered = self.column_at(pos);
if hovered != self.hovered_column {
self.hovered_column = hovered;
self.base.request_redraw();
}
}
fn finish_card_drag(&mut self, pos: Point) -> bool {
let Some(session) = self.drag.take() else {
return false;
};
let origin = self.drag_origin.take();
self.hovered_column = None;
if !session.is_active() {
let activated = origin
.and_then(|start| {
self.columns.get(start.column).and_then(|c| c.cards.get(start.card))
})
.map(|card| card.id.clone());
self.base.request_redraw();
if let Some(card_id) = activated {
self.card_activated.emit(card_id);
}
return false;
}
let mut session = session;
let effect = session.drop_on(self, pos);
if !effect.is_accepted() {
self.base.request_redraw();
return false;
}
let Some(origin) = origin else {
return false;
};
let Some(column) = self.column_at(pos) else {
return false;
};
let card_id = session.payload().item_id.clone();
let raw_index = self.insert_index_at(column, pos);
let target_index = if origin.column == column && raw_index > origin.card {
raw_index - 1
} else {
raw_index
};
let Some(current) = self.position_of(&card_id) else {
return false;
};
let target = CardPosition { column, card: target_index };
if current == target {
self.base.request_redraw();
return false;
}
self.move_card(current, target).is_some()
}
}
impl DropTarget for KanbanBoard {
fn can_accept(&self, payload: &DragPayload) -> bool {
if !payload.is_type(CARD_PAYLOAD_TYPE) {
return false;
}
if self.columns.is_empty() {
return false;
}
self.columns.iter().any(|column| !column.is_at_wip_limit() || column.cards.is_empty())
}
fn on_drop(&mut self, payload: &DragPayload, pos: Point) -> DropEffect {
if !payload.is_type(CARD_PAYLOAD_TYPE) {
return DropEffect::None;
}
let Some(column) = self.column_at(pos) else {
return DropEffect::None;
};
let arriving_from_here = self.drag_origin.is_some_and(|origin| origin.column == column);
if !arriving_from_here {
if let Some(target) = self.columns.get(column) {
if target.is_at_wip_limit() {
return DropEffect::None;
}
}
}
DropEffect::Move
}
fn preview_rect(&self, payload: &DragPayload, pos: Point) -> Option<Rect> {
if !payload.is_type(CARD_PAYLOAD_TYPE) {
return None;
}
let column = self.column_at(pos)?;
self.column_rect(column)
}
}
impl Widget for KanbanBoard {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(
COLUMN_WIDTH * 2 + COLUMN_GAP as u32,
HEADER_HEIGHT + (CARD_HEIGHT + CARD_GAP as u32) * 3,
)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for KanbanBoard {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"column_count" => Ok(CapabilityValue::UInt(self.column_count() as u64)),
"card_count" => Ok(CapabilityValue::UInt(self.cards().len() as u64)),
"dragging_card_id" => Ok(match self.dragged_card_id() {
Some(id) => CapabilityValue::String(id.to_string()),
None => CapabilityValue::Null,
}),
"hovered_column" => Ok(match self.hovered_column() {
Some(index) => CapabilityValue::UInt(index as u64),
None => CapabilityValue::Null,
}),
"column_width" => Ok(CapabilityValue::UInt(COLUMN_WIDTH as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"column_count" | "card_count" | "dragging_card_id" | "hovered_column" => {
Err(CapabilityAccessError::ReadOnlyProperty)
}
"column_width" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"column_count",
"card_count",
"dragging_card_id",
"hovered_column",
"column_width",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"cancel_drag" => {
self.cancel_drag();
Ok(())
}
"add_column" | "add_card" | "move_card" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for KanbanBoard {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button } if *button == 1 => {
if let Some(position) = self.card_at(*pos) {
self.begin_card_drag(position, *pos);
}
}
Event::MouseMove { pos } if self.drag.is_some() => {
self.update_card_drag(*pos);
}
Event::MouseRelease { pos, button } if *button == 1 => {
if self.drag.is_some() {
self.finish_card_drag(*pos);
}
}
Event::MouseLeave { .. } => self.cancel_drag(),
_ => {}
}
}
}
impl Draw for KanbanBoard {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
context.fill_rect(rect, Color::rgb(245, 246, 248));
for index in 0..self.columns.len() {
self.draw_column(context, index);
}
self.draw_drag_overlay(context);
}
}
impl KanbanBoard {
fn draw_column(&mut self, context: &mut RenderContext, index: usize) {
let Some(column_rect) = self.column_rect(index) else {
return;
};
let (title, collapsed, cards, at_limit) = {
let Some(column) = self.columns.get(index) else {
return;
};
(column.title.clone(), column.collapsed, column.cards.clone(), column.is_at_wip_limit())
};
let is_drop_target = self.is_dragging_card() && self.hovered_column == Some(index);
let background =
if is_drop_target { Color::rgb(226, 238, 255) } else { Color::rgb(235, 236, 240) };
context.fill_rounded_rect(column_rect, 8, background);
let header_rect = Rect::new(column_rect.x, column_rect.y, column_rect.width, HEADER_HEIGHT);
context.fill_rounded_rect(header_rect, 8, Color::rgb(220, 222, 228));
let header_color = if at_limit { Color::rgb(200, 60, 50) } else { Color::rgb(50, 55, 65) };
context.draw_text(
Point::new(header_rect.x + 10, header_rect.y + 21),
&title,
&Font::simple("Sans", 13.0),
header_color,
HorizontalAlignment::Left,
);
let badge = if collapsed {
format!("({})", cards.len())
} else if let Some(limit) = self.columns.get(index).and_then(|c| c.wip_limit) {
format!("{}/{}", cards.len(), limit)
} else {
cards.len().to_string()
};
context.draw_text(
Point::new(header_rect.x + header_rect.width as i32 - 10, header_rect.y + 21),
&badge,
&Font::simple("Sans", 11.0),
Color::rgb(110, 115, 125),
HorizontalAlignment::Left,
);
if collapsed {
return;
}
for (card_index, card) in cards.iter().enumerate() {
let position = CardPosition { column: index, card: card_index };
let Some(card_rect) = self.card_rect(position) else {
continue;
};
context.push_clip(column_rect.x, column_rect.y, column_rect.width, column_rect.height);
self.draw_card(context, card, card_rect, position);
context.pop_clip();
}
}
fn draw_card(
&self,
context: &mut RenderContext,
card: &KanbanCard,
card_rect: Rect,
position: CardPosition,
) {
let is_dragged = self.drag_origin == Some(position) && self.is_dragging_card();
let alpha = if is_dragged { 90 } else { 255 };
context.fill_rounded_rect(card_rect, 6, Color::rgba(255, 255, 255, alpha));
context.draw_rounded_rect_stroke(card_rect, 6, Color::rgba(210, 212, 218, alpha), 1);
let title_color = if card.done {
Color::rgba(140, 145, 155, alpha)
} else {
Color::rgba(35, 40, 50, alpha)
};
let title = if card.done { format!("✓ {}", card.title) } else { card.title.clone() };
context.draw_text(
Point::new(card_rect.x + 10, card_rect.y + 20),
&title,
&Font::simple("Sans", 12.0),
title_color,
HorizontalAlignment::Left,
);
if !card.description.is_empty() {
context.draw_text(
Point::new(card_rect.x + 10, card_rect.y + 38),
&card.description,
&Font::simple("Sans", 10.0),
Color::rgba(120, 125, 135, alpha),
HorizontalAlignment::Left,
);
}
}
fn draw_drag_overlay(&mut self, context: &mut RenderContext) {
let Some(session) = self.drag.as_ref() else {
return;
};
if !session.is_active() {
return;
}
let current = session.current();
let preview = self.preview_rect(session.payload(), current);
if let Some(column_rect) = preview {
let insert_index = self.insert_index_at(self.hovered_column.unwrap_or(0), current);
let line_y = column_rect.y
+ HEADER_HEIGHT as i32
+ (insert_index as i32) * (CARD_HEIGHT as i32 + CARD_GAP)
- CARD_GAP / 2;
context.draw_line_stroke(
Point::new(column_rect.x + 6, line_y),
Point::new(column_rect.x + column_rect.width as i32 - 6, line_y),
Color::rgb(66, 133, 244),
3,
);
}
let label = session.payload().label.clone();
let ghost = Rect::new(
current.x - (COLUMN_WIDTH as i32 / 2),
current.y - (CARD_HEIGHT as i32 / 2),
COLUMN_WIDTH,
CARD_HEIGHT,
);
context.fill_rounded_rect(ghost, 6, Color::rgba(255, 255, 255, 230));
context.draw_rounded_rect_stroke(ghost, 6, Color::rgb(66, 133, 244), 2);
context.draw_text(
Point::new(ghost.x + 10, ghost.y + 20),
&label,
&Font::simple("Sans", 12.0),
Color::rgb(35, 40, 50),
HorizontalAlignment::Left,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{PaintBackend, SoftwarePaintBackend};
use crate::compat::{Arc, Mutex};
fn board() -> KanbanBoard {
let mut board = KanbanBoard::new(Rect::new(0, 0, 800, 400));
board.add_column(KanbanColumn::new("todo", "To Do"));
board.add_column(KanbanColumn::new("doing", "Doing"));
board.add_column(KanbanColumn::new("done", "Done"));
board.add_card(0, KanbanCard::new("a", "First"));
board
}
fn render(board: &mut KanbanBoard, size: Size) -> Vec<u8> {
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::WHITE);
let mut context = RenderContext::new(&mut backend);
board.draw(&mut context);
backend.end_frame();
backend.frame_rgba().to_vec()
}
fn card_center(board: &KanbanBoard, position: CardPosition) -> Point {
let rect = board.card_rect(position).expect("card is laid out");
Point::new(rect.x + rect.width as i32 / 2, rect.y + rect.height as i32 / 2)
}
#[test]
fn kanban_creation_defaults() {
let board = KanbanBoard::new(Rect::new(0, 0, 400, 300));
assert_eq!(board.kind(), WidgetKind::KanbanBoard);
assert_eq!(board.column_count(), 0);
assert!(board.cards().is_empty());
assert!(!board.is_dragging_card());
assert_eq!(board.dragged_card_id(), None);
}
#[test]
fn kanban_add_and_remove_columns() {
let mut board = KanbanBoard::new(Rect::new(0, 0, 400, 300));
board.add_column(KanbanColumn::new("a", "A"));
board.add_column(KanbanColumn::new("b", "B"));
assert_eq!(board.column_count(), 2);
assert_eq!(board.column(0).map(|c| c.title.as_str()), Some("A"));
let removed = board.remove_column(0).expect("column 0");
assert_eq!(removed.id, "a");
assert_eq!(board.column_count(), 1);
assert!(board.remove_column(9).is_none());
}
#[test]
fn kanban_add_card_ignores_wip_limit() {
let mut board = KanbanBoard::new(Rect::new(0, 0, 400, 300));
board.add_column(KanbanColumn::new("wip", "WIP").with_wip_limit(1));
assert!(board.add_card(0, KanbanCard::new("a", "A")).is_some());
assert!(board.add_card(0, KanbanCard::new("b", "B")).is_some());
assert_eq!(board.column(0).unwrap().cards.len(), 2);
assert!(board.column(0).unwrap().is_at_wip_limit());
}
#[test]
fn kanban_add_card_rejects_unknown_column() {
let mut board = board();
assert!(board.add_card(9, KanbanCard::new("x", "X")).is_none());
}
#[test]
fn kanban_position_of_finds_a_card_anywhere() {
let mut board = board();
board.add_card(1, KanbanCard::new("b", "Second"));
assert_eq!(board.position_of("a"), Some(CardPosition { column: 0, card: 0 }));
assert_eq!(board.position_of("b"), Some(CardPosition { column: 1, card: 0 }));
assert_eq!(board.position_of("missing"), None);
}
#[test]
fn kanban_move_card_across_columns() {
let mut board = board();
let moved = board
.move_card(CardPosition { column: 0, card: 0 }, CardPosition { column: 1, card: 0 });
assert_eq!(moved, Some(CardPosition { column: 1, card: 0 }));
assert!(board.column(0).unwrap().cards.is_empty());
assert_eq!(board.column(1).unwrap().cards[0].id, "a");
}
#[test]
fn kanban_move_card_within_a_column() {
let mut board = board();
board.add_card(0, KanbanCard::new("b", "Second"));
board.add_card(0, KanbanCard::new("c", "Third"));
let moved = board
.move_card(CardPosition { column: 0, card: 0 }, CardPosition { column: 0, card: 2 });
assert!(moved.is_some());
let ids: Vec<&str> = board.column(0).unwrap().cards.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, vec!["b", "c", "a"]);
}
#[test]
fn kanban_move_card_down_by_one_lands_one_step_lower() {
let mut board = board();
board.add_card(0, KanbanCard::new("b", "Second"));
board.add_card(0, KanbanCard::new("c", "Third"));
board.move_card(CardPosition { column: 0, card: 0 }, CardPosition { column: 0, card: 1 });
let ids: Vec<&str> = board.column(0).unwrap().cards.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, vec!["b", "a", "c"], "a moved down exactly one place");
}
#[test]
fn kanban_move_card_rejects_bad_indices() {
let mut board = board();
assert!(board
.move_card(CardPosition { column: 0, card: 0 }, CardPosition { column: 9, card: 0 })
.is_none());
assert!(board
.move_card(CardPosition { column: 0, card: 9 }, CardPosition { column: 1, card: 0 })
.is_none());
assert_eq!(board.position_of("a"), Some(CardPosition { column: 0, card: 0 }));
}
#[test]
fn kanban_move_card_emits_the_signal() {
let mut board = board();
let moved =
std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, usize, usize)>::new()));
let sink = moved.clone();
board.card_moved.connect(move |value| {
if let Ok(mut guard) = sink.lock() {
guard.push((value.0.clone(), value.1.column, value.1.card));
}
});
board.move_card(CardPosition { column: 0, card: 0 }, CardPosition { column: 2, card: 0 });
assert_eq!(*moved.lock().expect("signal lock poisoned"), vec![("a".to_string(), 2, 0)]);
}
#[test]
fn kanban_remove_card_returns_it_with_its_position() {
let mut board = board();
let (card, position) = board.remove_card("a").expect("card a");
assert_eq!(card.title, "First");
assert_eq!(position, CardPosition { column: 0, card: 0 });
assert!(board.remove_card("a").is_none());
}
#[test]
fn kanban_press_on_a_card_opens_a_drag() {
let mut board = board();
let centre = card_center(&board, CardPosition { column: 0, card: 0 });
board.handle_event(&Event::mouse_press(centre.x, centre.y, 1));
assert_eq!(board.dragged_card_id(), Some("a"));
assert!(!board.is_dragging_card());
}
#[test]
fn kanban_press_away_from_a_card_opens_no_drag() {
let mut board = board();
board.handle_event(&Event::mouse_press(100, 300, 1));
assert_eq!(board.dragged_card_id(), None);
}
#[test]
fn kanban_drag_below_threshold_stays_a_click() {
let mut board = board();
let centre = card_center(&board, CardPosition { column: 0, card: 0 });
board.handle_event(&Event::mouse_press(centre.x, centre.y, 1));
board.handle_event(&Event::mouse_move(centre.x + 1, centre.y));
assert!(!board.is_dragging_card());
}
#[test]
fn kanban_click_without_drag_emits_card_activated() {
let mut board = board();
let centre = card_center(&board, CardPosition { column: 0, card: 0 });
let seen = Arc::new(Mutex::new(Option::<String>::None));
let sink = Arc::clone(&seen);
board.card_activated.connect(move |id: Arc<String>| {
*sink.lock().expect("activation sink poisoned") = Some(id.as_str().to_string());
});
board.handle_event(&Event::mouse_press(centre.x, centre.y, 1));
board.handle_event(&Event::mouse_move(centre.x + 1, centre.y));
board.handle_event(&Event::mouse_release(centre.x + 1, centre.y, 1));
assert_eq!(
seen.lock().expect("activation sink poisoned").as_deref(),
Some("a"),
"a click on card 'a' must emit card_activated with its id"
);
assert_eq!(board.position_of("a"), Some(CardPosition { column: 0, card: 0 }));
}
#[test]
fn kanban_drag_does_not_emit_card_activated() {
let mut board = board();
let start = card_center(&board, CardPosition { column: 0, card: 0 });
let activations = Arc::new(Mutex::new(0usize));
let counter = Arc::clone(&activations);
board.card_activated.connect(move |_id: Arc<String>| {
*counter.lock().expect("activation counter poisoned") += 1;
});
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
board.handle_event(&Event::mouse_move(start.x + 40, start.y + 10));
board.handle_event(&Event::mouse_release(start.x + 40, start.y + 10, 1));
assert_eq!(
*activations.lock().expect("activation counter poisoned"),
0,
"a drag that moved the card must not also report an activation"
);
}
#[test]
fn kanban_drag_across_columns_moves_the_card() {
let mut board = board();
let start = card_center(&board, CardPosition { column: 0, card: 0 });
let target_column = board.column_rect(2).expect("column 2");
let target = Point::new(target_column.x + 100, target_column.y + HEADER_HEIGHT as i32 + 20);
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
board.handle_event(&Event::mouse_move(start.x + 40, start.y + 10));
assert!(board.is_dragging_card(), "the drag must be active before the drop");
board.handle_event(&Event::mouse_move(target.x, target.y));
board.handle_event(&Event::mouse_release(target.x, target.y, 1));
assert_eq!(board.position_of("a"), Some(CardPosition { column: 2, card: 0 }));
assert!(board.column(0).unwrap().cards.is_empty());
}
#[test]
fn kanban_drop_outside_any_column_leaves_the_card_alone() {
let mut board = board();
let start = card_center(&board, CardPosition { column: 0, card: 0 });
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
board.handle_event(&Event::mouse_move(start.x + 40, start.y));
board.handle_event(&Event::mouse_release(5000, 200, 1));
assert_eq!(board.position_of("a"), Some(CardPosition { column: 0, card: 0 }));
assert!(!board.is_dragging_card());
}
#[test]
fn kanban_wip_limit_refuses_a_card_from_another_column() {
let mut board = KanbanBoard::new(Rect::new(0, 0, 800, 400));
board.add_column(KanbanColumn::new("todo", "To Do"));
board.add_column(KanbanColumn::new("full", "Full").with_wip_limit(1));
board.add_card(0, KanbanCard::new("dragged", "Dragged"));
board.add_card(1, KanbanCard::new("resident", "Resident"));
let start = card_center(&board, CardPosition { column: 0, card: 0 });
let target_column = board.column_rect(1).expect("column 1");
let target = Point::new(target_column.x + 100, target_column.y + HEADER_HEIGHT as i32 + 20);
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
board.handle_event(&Event::mouse_move(start.x + 40, start.y));
board.handle_event(&Event::mouse_move(target.x, target.y));
board.handle_event(&Event::mouse_release(target.x, target.y, 1));
assert_eq!(
board.position_of("dragged"),
Some(CardPosition { column: 0, card: 0 }),
"a full column must refuse an incoming card"
);
assert_eq!(board.column(1).unwrap().cards.len(), 1);
}
#[test]
fn kanban_wip_limit_allows_reordering_within_the_full_column() {
let mut board = KanbanBoard::new(Rect::new(0, 0, 800, 400));
board.add_column(KanbanColumn::new("full", "Full").with_wip_limit(1));
board.add_card(0, KanbanCard::new("only", "Only"));
let payload = DragPayload::new(CARD_PAYLOAD_TYPE, "only");
assert!(
!board.can_accept(&payload),
"a board whose only column is full has nowhere to put a card"
);
let column_rect = board.column_rect(0).expect("column");
let inside = Point::new(column_rect.x + 10, column_rect.y + HEADER_HEIGHT as i32 + 10);
board.drag_origin = Some(CardPosition { column: 0, card: 0 });
assert_eq!(
board.on_drop(&payload, inside),
DropEffect::Move,
"a card already in the full column may be reordered within it"
);
}
#[test]
fn kanban_rejects_a_payload_of_another_type() {
let board = board();
assert!(!board.can_accept(&DragPayload::new("file", "x")));
}
#[test]
fn kanban_empty_board_accepts_nothing() {
let board = KanbanBoard::new(Rect::new(0, 0, 400, 300));
assert!(!board.can_accept(&DragPayload::new(CARD_PAYLOAD_TYPE, "x")));
}
#[test]
fn kanban_preview_rect_is_the_hovered_column() {
let board = board();
let payload = DragPayload::new(CARD_PAYLOAD_TYPE, "a");
let column = board.column_rect(1).expect("column 1");
let inside = Point::new(column.x + 10, column.y + 10);
assert_eq!(board.preview_rect(&payload, inside), Some(column));
assert_eq!(board.preview_rect(&payload, Point::new(5000, 5000)), None);
}
#[test]
fn kanban_cancel_drag_restores_the_state() {
let mut board = board();
let start = card_center(&board, CardPosition { column: 0, card: 0 });
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
board.handle_event(&Event::mouse_move(start.x + 40, start.y));
board.cancel_drag();
assert!(!board.is_dragging_card());
assert_eq!(board.dragged_card_id(), None);
assert_eq!(board.hovered_column(), None);
assert_eq!(board.position_of("a"), Some(CardPosition { column: 0, card: 0 }));
}
#[test]
fn kanban_mouse_leave_cancels_the_drag() {
let mut board = board();
let start = card_center(&board, CardPosition { column: 0, card: 0 });
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
board.handle_event(&Event::mouse_move(start.x + 40, start.y));
assert!(board.is_dragging_card());
board.handle_event(&Event::MouseLeave { pos: start });
assert!(!board.is_dragging_card(), "losing the pointer must not strand a drag");
}
#[test]
fn kanban_disabled_ignores_drags() {
let mut board = board();
let start = card_center(&board, CardPosition { column: 0, card: 0 });
board.set_enabled(false);
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
assert_eq!(board.dragged_card_id(), None);
}
#[test]
fn kanban_removing_a_dragged_card_column_cancels_the_drag() {
let mut board = KanbanBoard::new(Rect::new(0, 0, 800, 400));
board.add_column(KanbanColumn::new("a", "A"));
board.add_card(0, KanbanCard::new("card", "Card"));
let start = card_center(&board, CardPosition { column: 0, card: 0 });
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
assert_eq!(board.dragged_card_id(), Some("card"));
board.remove_column(0);
assert_eq!(board.dragged_card_id(), None, "the dragged card's column is gone");
}
#[test]
fn kanban_columns_lay_out_side_by_side() {
let board = board();
let first = board.column_rect(0).expect("column 0");
let second = board.column_rect(1).expect("column 1");
assert_eq!(second.x - first.x, COLUMN_WIDTH as i32 + COLUMN_GAP);
assert_eq!(first.width, COLUMN_WIDTH);
assert!(board.column_rect(9).is_none());
}
#[test]
fn kanban_collapsed_column_lays_out_no_cards() {
let mut board = board();
assert!(board.card_rect(CardPosition { column: 0, card: 0 }).is_some());
board.column_mut(0).unwrap().collapsed = true;
assert_eq!(
board.card_rect(CardPosition { column: 0, card: 0 }),
None,
"a collapsed column shows no cards"
);
}
#[test]
fn kanban_draw_paints_without_panicking() {
let mut board = board();
let rgba = render(&mut board, Size::new(800, 400));
assert!(!rgba.is_empty());
let painted = rgba
.chunks_exact(4)
.filter(|px| !(px[0] == 255 && px[1] == 255 && px[2] == 255))
.count();
assert!(painted > 0, "the board must paint its columns");
}
#[test]
fn kanban_draw_zero_geometry_does_not_panic() {
let mut board = board();
let rgba = render(&mut board, Size::new(4, 4));
assert!(!rgba.is_empty());
}
#[test]
fn kanban_drag_overlay_differs_from_the_resting_frame() {
let mut board = board();
let resting = render(&mut board, Size::new(800, 400));
let start = card_center(&board, CardPosition { column: 0, card: 0 });
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
board.handle_event(&Event::mouse_move(start.x + 40, start.y + 20));
let dragging = render(&mut board, Size::new(800, 400));
assert_ne!(resting, dragging, "a drag must be visible");
}
#[test]
fn kanban_derived_properties_are_read_only() {
let mut board = board();
for name in
["column_count", "card_count", "dragging_card_id", "hovered_column", "column_width"]
{
assert_eq!(
board.set(name, CapabilityValue::UInt(3)),
Err(CapabilityAccessError::ReadOnlyProperty),
"{name} must be read-only"
);
}
assert_eq!(board.get("column_count").unwrap(), CapabilityValue::UInt(3));
assert_eq!(board.get("card_count").unwrap(), CapabilityValue::UInt(1));
assert_eq!(board.get("column_width").unwrap(), CapabilityValue::UInt(COLUMN_WIDTH as u64));
}
#[test]
fn kanban_drag_state_is_reported_through_properties() {
let mut board = board();
assert_eq!(board.get("dragging_card_id").unwrap(), CapabilityValue::Null);
let start = card_center(&board, CardPosition { column: 0, card: 0 });
board.handle_event(&Event::mouse_press(start.x, start.y, 1));
assert_eq!(
board.get("dragging_card_id").unwrap(),
CapabilityValue::String("a".to_string())
);
assert_eq!(board.get("hovered_column").unwrap(), CapabilityValue::UInt(0));
}
#[test]
fn kanban_insert_card_clamps_an_over_large_index() {
let mut board = board();
let position = board.insert_card(0, 99, KanbanCard::new("z", "Z")).expect("insert");
assert_eq!(position, CardPosition { column: 0, card: 1 });
assert_eq!(board.column(0).unwrap().cards[1].id, "z");
}
#[test]
fn kanban_insert_index_uses_the_midpoint_rule() {
let mut board = board();
board.add_card(0, KanbanCard::new("b", "Second"));
let first = board.card_rect(CardPosition { column: 0, card: 0 }).expect("card 0");
let column_x = first.x + 10;
assert_eq!(board.insert_index_at(0, Point::new(column_x, first.y + 2)), 0);
assert_eq!(
board.insert_index_at(0, Point::new(column_x, first.y + CARD_HEIGHT as i32 - 2)),
1
);
let column_rect = board.column_rect(0).expect("column");
assert_eq!(board.insert_index_at(0, Point::new(column_x, column_rect.y + 2)), 0);
assert_eq!(board.insert_index_at(0, Point::new(column_x, column_rect.y + 2000)), 2);
}
}