use core::cell::RefCell;
use core::fmt::Write as _;
use crate::compat::HashMap;
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::{GenericSignal, Signal1};
use crate::widget::capability::coercion::{expect_bool, expect_string, expect_string_list};
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::metrics::role_font;
use crate::widget::{draw_icon_at, BaseWidget, Draw, IconName, 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 DEFAULT_COLUMN_WIDTH: u32 = 220;
const COLUMN_GAP: i32 = 12;
const MIN_COLUMN_WIDTH: u32 = 80;
const HORIZONTAL_SCROLL_STEP: i32 = 48;
const DRAG_AUTOSCROLL_BAND: i32 = 28;
const HEADER_HEIGHT: u32 = 32;
const CARD_HEIGHT: u32 = 48;
const CARD_GAP: i32 = 8;
const CARD_PADDING: i32 = 10;
const LANE_HEADER_WIDTH: u32 = 96;
const LANE_HEADER_HEIGHT: i32 = 24;
const LANE_GAP: i32 = 6;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KanbanLane {
pub id: String,
pub title: String,
}
impl KanbanLane {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self { id: id.into(), title: title.into() }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum KanbanLaneMode {
#[default]
None,
Lanes,
}
impl KanbanLaneMode {
pub fn token(self) -> &'static str {
match self {
Self::None => "none",
Self::Lanes => "lanes",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match token {
"none" => Some(Self::None),
"lanes" => Some(Self::Lanes),
_ => None,
}
}
}
#[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)
}
}
pub const KANBAN_COLUMN_SCHEMA: crate::widget::capability::RecordSchema =
crate::widget::capability::RecordSchema {
fields: &[
crate::widget::capability::RecordField::required(
"id",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::required(
"title",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::optional(
"collapsed",
crate::widget::capability::PropertyValueKind::Bool,
),
crate::widget::capability::RecordField::optional(
"wip_limit",
crate::widget::capability::PropertyValueKind::UInt,
),
],
id_field: "id",
ids_unique: true,
ids_required: true,
};
pub const KANBAN_CARD_SCHEMA: crate::widget::capability::RecordSchema =
crate::widget::capability::RecordSchema {
fields: &[
crate::widget::capability::RecordField::required(
"id",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::required(
"title",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::required(
"column",
crate::widget::capability::PropertyValueKind::UInt,
),
crate::widget::capability::RecordField::optional(
"description",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::optional(
"done",
crate::widget::capability::PropertyValueKind::Bool,
),
crate::widget::capability::RecordField::optional(
"lane",
crate::widget::capability::PropertyValueKind::String,
),
],
id_field: "id",
ids_unique: true,
ids_required: true,
};
fn kanban_column_record(column: &KanbanColumn) -> crate::widget::capability::RecordValue {
let mut record = crate::widget::capability::RecordValue::new()
.with("id", CapabilityValue::String(column.id.clone()))
.with("title", CapabilityValue::String(column.title.clone()))
.with("collapsed", CapabilityValue::Bool(column.collapsed));
if let Some(limit) = column.wip_limit {
record = record.with("wip_limit", CapabilityValue::UInt(limit as u64));
}
record
}
fn kanban_column_from_record(
record: &crate::widget::capability::RecordValue,
) -> Result<KanbanColumn, CapabilityAccessError> {
let text = |name: &str| match record.get(name) {
Some(CapabilityValue::String(value)) => Ok(value.clone()),
_ => Err(CapabilityAccessError::TypeMismatch),
};
let mut column = KanbanColumn::new(text("id")?, text("title")?);
match record.get("collapsed") {
None | Some(CapabilityValue::Null) => {}
Some(CapabilityValue::Bool(collapsed)) => column.collapsed = *collapsed,
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
}
match record.get("wip_limit") {
None | Some(CapabilityValue::Null) => {}
Some(CapabilityValue::UInt(limit)) => {
column.wip_limit =
Some(usize::try_from(*limit).map_err(|_| CapabilityAccessError::TypeMismatch)?);
}
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
}
Ok(column)
}
fn kanban_card_record(
position: CardPosition,
card: &KanbanCard,
lane: Option<&str>,
) -> crate::widget::capability::RecordValue {
let mut record = crate::widget::capability::RecordValue::new()
.with("id", CapabilityValue::String(card.id.clone()))
.with("title", CapabilityValue::String(card.title.clone()))
.with("description", CapabilityValue::String(card.description.clone()))
.with("done", CapabilityValue::Bool(card.done))
.with("column", CapabilityValue::UInt(position.column as u64));
if let Some(lane) = lane {
record = record.with("lane", CapabilityValue::String(lane.to_string()));
}
record
}
fn kanban_card_from_record(
record: &crate::widget::capability::RecordValue,
) -> Result<(usize, Option<String>, KanbanCard), CapabilityAccessError> {
let text = |name: &str| match record.get(name) {
Some(CapabilityValue::String(value)) => Ok(value.clone()),
_ => Err(CapabilityAccessError::TypeMismatch),
};
let column = match record.get("column") {
Some(CapabilityValue::UInt(index)) => {
usize::try_from(*index).map_err(|_| CapabilityAccessError::TypeMismatch)?
}
_ => return Err(CapabilityAccessError::TypeMismatch),
};
let mut card = KanbanCard::new(text("id")?, text("title")?);
match record.get("description") {
None | Some(CapabilityValue::Null) => {}
Some(CapabilityValue::String(description)) => card.description = description.clone(),
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
}
match record.get("done") {
None | Some(CapabilityValue::Null) => {}
Some(CapabilityValue::Bool(done)) => card.done = *done,
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
}
let lane = match record.get("lane") {
None | Some(CapabilityValue::Null) => None,
Some(CapabilityValue::String(lane)) => Some(lane.clone()),
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
};
Ok((column, lane, card))
}
#[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>,
hovered_card: Option<CardPosition>,
focused_card: Option<CardPosition>,
column_scroll: Vec<usize>,
column_width: u32,
horizontal_scroll: i32,
lanes: Vec<KanbanLane>,
card_lanes: HashMap<String, String>,
lane_mode: KanbanLaneMode,
layout_revision: u64,
lane_layout: RefCell<Option<LaneLayout>>,
card_index: RefCell<Option<HashMap<String, CardPosition>>>,
selected_cards: Vec<String>,
pub card_moved: Signal1<(String, CardPosition)>,
pub card_activated: Signal1<String>,
pub card_hovered: Signal1<Option<String>>,
pub selection_changed: Signal1<Vec<String>>,
pub cards_moved: Signal1<Vec<String>>,
}
struct LaneLayout {
revision: u64,
heights: Vec<i32>,
tops: Vec<i32>,
card_lane: Vec<Vec<usize>>,
card_ordinal: Vec<Vec<usize>>,
}
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,
hovered_card: None,
focused_card: None,
column_scroll: Vec::new(),
column_width: DEFAULT_COLUMN_WIDTH,
horizontal_scroll: 0,
lanes: Vec::new(),
card_lanes: HashMap::new(),
lane_mode: KanbanLaneMode::None,
layout_revision: 0,
lane_layout: RefCell::new(None),
card_index: RefCell::new(None),
selected_cards: Vec::new(),
card_moved: Signal1::new(),
card_activated: Signal1::new(),
card_hovered: Signal1::new(),
selection_changed: Signal1::new(),
cards_moved: Signal1::new(),
}
}
pub fn add_column(&mut self, column: KanbanColumn) -> usize {
let index = self.columns.len();
self.columns.push(column);
self.invalidate_layout();
self.base.request_redraw();
index
}
fn invalidate_layout(&mut self) {
self.layout_revision = self.layout_revision.wrapping_add(1);
*self.card_index.borrow_mut() = None;
}
pub fn remove_column(&mut self, index: usize) -> Option<KanbanColumn> {
if index >= self.columns.len() {
return None;
}
let removed = self.columns.remove(index);
let mut selection_touched = false;
for card in &removed.cards {
self.card_lanes.remove(&card.id);
if self.selected_cards.contains(&card.id) {
self.selected_cards.retain(|id| *id != card.id);
selection_touched = true;
}
}
if selection_touched {
self.selection_changed.emit(self.selected_cards.clone());
}
if self.drag_origin.is_some_and(|origin| origin.column >= self.columns.len()) {
self.cancel_drag();
}
self.invalidate_layout();
self.clamp_horizontal_scroll();
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.invalidate_layout();
self.columns.get_mut(index)
}
pub fn add_lane(&mut self, lane: KanbanLane) -> usize {
let index = self.lanes.len();
self.lanes.push(lane);
self.invalidate_layout();
self.base.request_redraw();
index
}
pub fn remove_lane(&mut self, id: &str) -> Option<KanbanLane> {
let index = self.lanes.iter().position(|lane| lane.id == id)?;
let removed = self.lanes.remove(index);
self.card_lanes.retain(|_, lane| lane != &removed.id);
self.invalidate_layout();
self.base.request_redraw();
Some(removed)
}
pub fn lanes(&self) -> &[KanbanLane] {
&self.lanes
}
pub fn lane(&self, id: &str) -> Option<&KanbanLane> {
self.lanes.iter().find(|lane| lane.id == id)
}
pub fn lane_count(&self) -> usize {
self.lanes.len()
}
pub fn lane_mode(&self) -> KanbanLaneMode {
self.lane_mode
}
pub fn set_lane_mode(&mut self, mode: KanbanLaneMode) {
if self.lane_mode == mode {
return;
}
self.lane_mode = mode;
self.invalidate_layout();
self.base.request_layout();
self.base.request_redraw();
}
pub fn set_card_lane(&mut self, card_id: &str, lane_id: &str) -> bool {
if self.position_of(card_id).is_none() || self.lane(lane_id).is_none() {
return false;
}
let next = lane_id.to_string();
if self.card_lanes.get(card_id) == Some(&next) {
return true;
}
self.card_lanes.insert(card_id.to_string(), next);
self.invalidate_layout();
self.base.request_redraw();
true
}
pub fn lane_of(&self, card_id: &str) -> Option<&str> {
self.card_lanes.get(card_id).map(String::as_str)
}
pub fn set_lane(&mut self, card_id: &str, lane_id: &str) -> bool {
self.set_card_lane(card_id, lane_id)
}
pub fn card_lane(&self, card_id: &str) -> Option<&str> {
self.card_lanes.get(card_id).map(String::as_str)
}
fn card_lane_index(&self, card_id: &str) -> Option<usize> {
if self.lanes.is_empty() {
return None;
}
match self.card_lanes.get(card_id) {
Some(lane_id) => self.lanes.iter().position(|lane| &lane.id == lane_id).or(Some(0)),
None => Some(0),
}
}
fn lanes_active(&self) -> bool {
self.lane_mode == KanbanLaneMode::Lanes && !self.lanes.is_empty()
}
fn lane_top(&self, index: usize) -> i32 {
let rect = self.geometry();
let cache = self.lane_cache();
rect.y + cache.tops.get(index).copied().unwrap_or(HEADER_HEIGHT as i32)
}
fn lane_height(&self, index: usize) -> i32 {
self.lane_cache().heights.get(index).copied().unwrap_or(LANE_HEADER_HEIGHT + LANE_GAP)
}
fn lane_cache(&self) -> core::cell::Ref<'_, LaneLayout> {
let fresh = self
.lane_layout
.borrow()
.as_ref()
.is_some_and(|cache| cache.revision == self.layout_revision);
if !fresh {
let built = self.build_lane_layout();
*self.lane_layout.borrow_mut() = Some(built);
}
core::cell::Ref::map(self.lane_layout.borrow(), |slot| {
slot.as_ref().expect("lane_cache() populates the snapshot")
})
}
fn build_lane_layout(&self) -> LaneLayout {
let lane_count = self.lanes.len();
let mut card_lane: Vec<Vec<usize>> = vec![Vec::new(); self.columns.len()];
let mut card_ordinal: Vec<Vec<usize>> = vec![Vec::new(); self.columns.len()];
let mut rows: Vec<usize> = vec![0; lane_count];
for (column_index, column) in self.columns.iter().enumerate() {
let mut seen: Vec<usize> = vec![0; lane_count];
for card in &column.cards {
let lane =
self.card_lane_index(&card.id).unwrap_or(0).min(lane_count.saturating_sub(1));
card_lane[column_index].push(lane);
let ordinal = seen.get(lane).copied().unwrap_or(0);
card_ordinal[column_index].push(ordinal);
if let Some(slot) = seen.get_mut(lane) {
*slot += 1;
if let Some(row) = rows.get_mut(lane) {
*row = (*row).max(*slot);
}
}
}
}
let mut heights = Vec::with_capacity(lane_count);
let mut tops = Vec::with_capacity(lane_count);
let mut y = HEADER_HEIGHT as i32;
for &row in rows.iter().take(lane_count) {
tops.push(y);
let height =
LANE_HEADER_HEIGHT + row as i32 * (CARD_HEIGHT as i32 + CARD_GAP) + LANE_GAP;
heights.push(height);
y += height + LANE_GAP;
}
LaneLayout { revision: self.layout_revision, heights, tops, card_lane, card_ordinal }
}
fn lane_slot(&self, position: CardPosition) -> Option<(usize, usize)> {
let cache = self.lane_cache();
let lane = cache.card_lane.get(position.column)?.get(position.card).copied()?;
let ordinal = cache.card_ordinal.get(position.column)?.get(position.card).copied()?;
Some((lane, ordinal))
}
pub fn lane_band_rect(&self, index: usize) -> Option<Rect> {
if index >= self.lanes.len() {
return None;
}
let rect = self.geometry();
Some(Rect::new(
rect.x + LANE_HEADER_WIDTH as i32,
self.lane_top(index),
rect.width.saturating_sub(LANE_HEADER_WIDTH),
self.lane_height(index).max(1) as u32,
))
}
pub fn lane_header_rect(&self, index: usize) -> Option<Rect> {
if index >= self.lanes.len() {
return None;
}
let rect = self.geometry();
Some(Rect::new(
rect.x,
self.lane_top(index),
LANE_HEADER_WIDTH,
(self.lane_height(index) - LANE_GAP).max(1) as u32,
))
}
fn lane_at(&self, pos: Point) -> Option<usize> {
if !self.lanes_active() || !self.geometry().contains_point(pos) {
return None;
}
(0..self.lanes.len()).find(|index| {
let top = self.lane_top(*index);
pos.y >= top && pos.y < top + self.lane_height(*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.invalidate_layout();
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> {
self.card_positions().get(card_id).copied()
}
fn card_positions(&self) -> core::cell::Ref<'_, HashMap<String, CardPosition>> {
if self.card_index.borrow().is_none() {
let mut map = HashMap::new();
for (column_index, column) in self.columns.iter().enumerate() {
for (card_index, card) in column.cards.iter().enumerate() {
map.entry(card.id.clone())
.or_insert(CardPosition { column: column_index, card: card_index });
}
}
*self.card_index.borrow_mut() = Some(map);
}
core::cell::Ref::map(self.card_index.borrow(), |slot| {
slot.as_ref().expect("card_positions() populates the index")
})
}
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.card_lanes.remove(card_id);
if self.selected_cards.iter().any(|id| id == card_id) {
self.selected_cards.retain(|id| id != card_id);
self.selection_changed.emit(self.selected_cards.clone());
}
self.invalidate_layout();
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.invalidate_layout();
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.invalidate_layout();
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 stride = self.column_width as i32 + COLUMN_GAP;
let scroll = self.horizontal_scroll.min(self.max_horizontal_scroll());
let origin = rect.x + if self.lanes_active() { LANE_HEADER_WIDTH as i32 } else { 0 };
let x = origin - scroll + (index as i32) * stride;
Some(Rect::new(x, rect.y, self.column_width, rect.height))
}
pub fn column_width(&self) -> u32 {
self.column_width
}
pub fn set_column_width(&mut self, width: u32) {
let width = width.max(MIN_COLUMN_WIDTH);
if self.column_width == width {
return;
}
self.column_width = width;
self.clamp_horizontal_scroll();
self.base.request_layout();
self.base.request_redraw();
}
pub fn horizontal_scroll(&self) -> i32 {
self.horizontal_scroll
}
pub fn set_horizontal_scroll(&mut self, offset: i32) {
let next = offset.max(0).min(self.max_horizontal_scroll());
if self.horizontal_scroll == next {
return;
}
self.horizontal_scroll = next;
self.base.request_redraw();
}
pub fn scroll_horizontally_by(&mut self, delta: i32) {
self.set_horizontal_scroll(self.horizontal_scroll.saturating_add(delta));
}
fn max_horizontal_scroll(&self) -> i32 {
if self.columns.is_empty() {
return 0;
}
let content =
self.columns.len() as i32 * (self.column_width as i32 + COLUMN_GAP) - COLUMN_GAP;
let lane_strip = if self.lanes_active() { LANE_HEADER_WIDTH as i32 } else { 0 };
let viewport = (self.geometry().width as i32 - lane_strip).max(0);
(content - viewport).max(0)
}
fn clamp_horizontal_scroll(&mut self) {
self.horizontal_scroll = self.horizontal_scroll.min(self.max_horizontal_scroll());
}
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)?;
if self.lanes_active() {
let (lane, ordinal) = self.lane_slot(position)?;
let band = self.lane_band_rect(lane)?;
let y = band.y + LANE_HEADER_HEIGHT + ordinal as i32 * (CARD_HEIGHT as i32 + CARD_GAP);
return Some(Rect::new(column_rect.x, y, column_rect.width, CARD_HEIGHT));
}
let scroll = self.column_scroll.get(position.column).copied().unwrap_or(0);
let y = column_rect.y
+ HEADER_HEIGHT as i32
+ ((position.card.saturating_sub(scroll)) as i32) * (CARD_HEIGHT as i32 + CARD_GAP);
if position.card < scroll {
return None;
}
Some(Rect::new(column_rect.x, y, column_rect.width, CARD_HEIGHT))
}
fn column_at(&self, pos: Point) -> Option<usize> {
if !self.geometry().contains_point(pos) {
return None;
}
if self.lanes_active() && pos.x < self.geometry().x + LANE_HEADER_WIDTH as i32 {
return None;
}
(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;
};
if self.lanes_active() {
let lane = self.lane_at(pos).unwrap_or(0);
let lane_indices: Vec<usize> = {
let cache = self.lane_cache();
cache
.card_lane
.get(column)
.map(|slots| {
slots
.iter()
.enumerate()
.filter(|(_, slot)| **slot == lane)
.map(|(index, _)| index)
.collect()
})
.unwrap_or_default()
};
if lane_indices.is_empty() {
return entries.cards.len();
}
let first_card_y = self
.lane_band_rect(lane)
.map(|band| band.y + LANE_HEADER_HEIGHT)
.unwrap_or(column_rect.y + HEADER_HEIGHT as i32);
let stride = CARD_HEIGHT as i32 + CARD_GAP;
let offset = (pos.y - first_card_y).max(0);
let slot = ((offset + stride / 2) / stride) as usize;
let slot = slot.min(lane_indices.len());
return if slot < lane_indices.len() {
lane_indices[slot]
} else {
lane_indices.last().copied().unwrap_or(0) + 1
};
}
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 focused_card(&self) -> Option<CardPosition> {
self.focused_card
}
pub fn hovered_card(&self) -> Option<CardPosition> {
self.hovered_card
}
pub fn selected_cards(&self) -> &[String] {
&self.selected_cards
}
pub fn set_card_selected(&mut self, card_id: &str, selected: bool) -> bool {
if self.position_of(card_id).is_none() {
return false;
}
let present = self.selected_cards.iter().any(|id| id == card_id);
if present == selected {
return true;
}
if selected {
self.selected_cards.push(card_id.to_string());
} else {
self.selected_cards.retain(|id| id != card_id);
}
self.sort_selection();
self.selection_changed.emit(self.selected_cards.clone());
self.base.request_redraw();
true
}
pub fn toggle_card_selected(&mut self, card_id: &str) -> bool {
let present = self.selected_cards.iter().any(|id| id == card_id);
self.set_card_selected(card_id, !present)
}
pub fn clear_selection(&mut self) {
if self.selected_cards.is_empty() {
return;
}
self.selected_cards.clear();
self.selection_changed.emit(Vec::new());
self.base.request_redraw();
}
fn sort_selection(&mut self) {
if self.selected_cards.is_empty() {
return;
}
let mut ordered = Vec::with_capacity(self.selected_cards.len());
for column in &self.columns {
for card in &column.cards {
if self.selected_cards.contains(&card.id) {
ordered.push(card.id.clone());
}
}
}
self.selected_cards = ordered;
}
fn autoscroll_drag(&mut self, pos: Point) {
if !self.is_dragging_card() {
return;
}
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let band = DRAG_AUTOSCROLL_BAND;
if pos.x < rect.x + band {
self.scroll_horizontally_by(-HORIZONTAL_SCROLL_STEP);
} else if pos.x > rect.x + rect.width as i32 - band {
self.scroll_horizontally_by(HORIZONTAL_SCROLL_STEP);
}
let column = self.hovered_column.or(self.drag_origin.map(|origin| origin.column));
if let Some(column) = column {
let top = rect.y + HEADER_HEIGHT as i32 + band;
let bottom = rect.y + rect.height as i32 - band;
if pos.y < top {
self.scroll_column(column, -1);
} else if pos.y > bottom {
self.scroll_column(column, 1);
}
}
}
pub fn set_focused_card(&mut self, position: Option<CardPosition>) {
if let Some(position) = position {
if position.column >= self.columns.len()
|| position.card >= self.columns[position.column].cards.len()
{
return;
}
}
if self.focused_card == position {
return;
}
self.focused_card = position;
self.ensure_focused_visible();
self.base.request_redraw();
}
fn ensure_focused_visible(&mut self) {
if self.lanes_active() {
return;
}
let Some(position) = self.focused_card else {
return;
};
let visible = self.visible_cards_per_column();
if self.column_scroll.len() <= position.column {
self.column_scroll.resize(position.column + 1, 0);
}
let scroll = self.column_scroll[position.column];
if position.card < scroll {
self.column_scroll[position.column] = position.card;
} else if position.card >= scroll + visible {
self.column_scroll[position.column] = position.card + 1 - visible;
}
}
fn visible_cards_per_column(&self) -> usize {
let rect = self.geometry();
((rect.height.saturating_sub(HEADER_HEIGHT)) / (CARD_HEIGHT + CARD_GAP as u32)).max(1)
as usize
}
pub fn scroll_column(&mut self, column: usize, delta: isize) {
if self.lanes_active() {
return;
}
let Some(entry) = self.columns.get(column) else {
return;
};
let max = entry.cards.len().saturating_sub(self.visible_cards_per_column());
if self.column_scroll.len() <= column {
self.column_scroll.resize(column + 1, 0);
}
let next = (self.column_scroll[column] as isize + delta).clamp(0, max as isize) as usize;
if self.column_scroll[column] == next {
return;
}
self.column_scroll[column] = next;
self.base.request_redraw();
}
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 };
let target_lane = if self.lanes_active() {
self.lane_at(pos).and_then(|lane| self.lanes.get(lane)).map(|lane| lane.id.clone())
} else {
None
};
let current_lane = self
.card_lane(&card_id)
.map(str::to_string)
.or_else(|| self.lanes.first().map(|lane| lane.id.clone()));
let lane_changed = target_lane.is_some() && target_lane != current_lane;
if current == target && !lane_changed {
self.base.request_redraw();
return false;
}
let moved = self.move_card(current, target).is_some();
if let Some(lane) = target_lane {
self.set_card_lane(&card_id, &lane);
}
if moved && self.selected_cards.len() > 1 && self.selected_cards.contains(&card_id) {
let batch: Vec<String> =
self.selected_cards.iter().filter(|id| **id != card_id).cloned().collect();
let mut destination = target.card + 1;
let mut moved_ids = vec![card_id.clone()];
for id in batch {
if let Some(position) = self.position_of(&id) {
if self
.move_card(position, CardPosition { column, card: destination })
.is_some()
{
moved_ids.push(id);
destination += 1;
}
}
}
if moved_ids.len() > 1 {
self.cards_moved.emit(moved_ids);
}
}
moved || lane_changed
}
}
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)?;
let mut rect = self.column_rect(column)?;
if self.lanes_active() {
if let Some(lane) = self.lane_at(pos) {
if let Some(band) = self.lane_band_rect(lane) {
rect.y = band.y;
rect.height = band.height;
}
}
}
Some(rect)
}
}
impl Widget for KanbanBoard {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(
self.column_width * 2 + COLUMN_GAP as u32,
HEADER_HEIGHT + (CARD_HEIGHT + CARD_GAP as u32) * 3,
)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"card_moved" => Some(EventSignalRef::mapped("card_moved", &self.card_moved, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::String(v.0.clone()),
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::UInt(v.1.column as u64),
CapabilityValue::UInt(v.1.card as u64),
]),
])
})),
"card_activated" => {
Some(EventSignalRef::mapped("card_activated", &self.card_activated, |v| {
CapabilityValue::String(v.clone())
}))
}
"card_hovered" => {
Some(EventSignalRef::mapped("card_hovered", &self.card_hovered, |v| match v {
Some(id) => CapabilityValue::String(id.clone()),
None => CapabilityValue::Null,
}))
}
"cards_moved" => Some(EventSignalRef::mapped("cards_moved", &self.cards_moved, |v| {
CapabilityValue::Tuple(
v.iter().map(|id| CapabilityValue::String(id.clone())).collect(),
)
})),
"selection_changed" => {
Some(EventSignalRef::mapped("selection_changed", &self.selection_changed, |v| {
CapabilityValue::Tuple(
v.iter().map(|id| CapabilityValue::String(id.clone())).collect(),
)
}))
}
_ => None,
}
}
}
impl WidgetProperties for KanbanBoard {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"columns" => {
Ok(CapabilityValue::Records(crate::widget::capability::RecordList::from_items(
&KANBAN_COLUMN_SCHEMA,
self.columns().iter().map(kanban_column_record).collect(),
)))
}
"cards" => {
let mut items = Vec::new();
for (column, entry) in self.columns().iter().enumerate() {
for (card, value) in entry.cards.iter().enumerate() {
items.push(kanban_card_record(
CardPosition { column, card },
value,
self.lane_of(&value.id),
));
}
}
Ok(CapabilityValue::Records(crate::widget::capability::RecordList::from_items(
&KANBAN_CARD_SCHEMA,
items,
)))
}
"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(self.column_width() as u64)),
"horizontal_scroll_offset" => {
Ok(CapabilityValue::UInt(self.horizontal_scroll().max(0) as u64))
}
"lane_mode" => Ok(CapabilityValue::String(self.lane_mode.token().to_string())),
"swimlanes" => Ok(CapabilityValue::Bool(self.lane_mode == KanbanLaneMode::Lanes)),
"lane_count" => Ok(CapabilityValue::UInt(self.lane_count() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"columns" => {
let CapabilityValue::Records(list) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
list.validate_against(&KANBAN_COLUMN_SCHEMA).map_err(|violation| {
log::warn!("kanban_board.columns refused: {violation}");
CapabilityAccessError::ConstraintViolation {
property: crate::compat::String::from("columns"),
reason: crate::compat::format!("{violation}"),
}
})?;
let mut columns = list
.items()
.iter()
.map(kanban_column_from_record)
.collect::<Result<Vec<_>, CapabilityAccessError>>()?;
for column in &mut columns {
if let Some(existing) = self.columns.iter().find(|entry| entry.id == column.id)
{
column.cards = existing.cards.clone();
}
}
self.columns = columns;
let live: Vec<String> = self
.columns
.iter()
.flat_map(|entry| entry.cards.iter().map(|card| card.id.clone()))
.collect();
self.card_lanes.retain(|card_id, _| live.iter().any(|id| id == card_id));
self.invalidate_layout();
self.base.request_redraw();
Ok(())
}
"cards" => {
let CapabilityValue::Records(list) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
list.validate_against(&KANBAN_CARD_SCHEMA).map_err(|violation| {
log::warn!("kanban_board.cards refused: {violation}");
CapabilityAccessError::ConstraintViolation {
property: crate::compat::String::from("cards"),
reason: crate::compat::format!("{violation}"),
}
})?;
let column_count = self.columns.len();
let mut buckets: Vec<Vec<KanbanCard>> = vec![Vec::new(); column_count];
let mut lanes: HashMap<String, String> = HashMap::new();
for record in list.items() {
let (column, lane, card) = kanban_card_from_record(record)?;
let Some(bucket) = buckets.get_mut(column) else {
return Err(CapabilityAccessError::ConstraintViolation {
property: crate::compat::String::from("cards"),
reason: crate::compat::format!(
"card '{}' names column {column}, but the board has {column_count} column(s)",
card.id
),
});
};
if let Some(lane) = &lane {
if !self.lanes.iter().any(|entry| entry.id == *lane) {
return Err(CapabilityAccessError::ConstraintViolation {
property: crate::compat::String::from("cards"),
reason: crate::compat::format!(
"card '{}' names lane '{lane}', which the board does not have",
card.id
),
});
}
lanes.insert(card.id.clone(), lane.clone());
}
bucket.push(card);
}
for (entry, cards) in self.columns.iter_mut().zip(buckets) {
entry.cards = cards;
}
self.card_lanes = lanes;
self.invalidate_layout();
self.base.request_redraw();
Ok(())
}
"column_count" | "card_count" | "dragging_card_id" | "hovered_column" => {
Err(CapabilityAccessError::ReadOnlyProperty)
}
"column_width" => {
let width = match value {
CapabilityValue::UInt(width) => {
u32::try_from(width).map_err(|_| CapabilityAccessError::TypeMismatch)?
}
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_column_width(width);
Ok(())
}
"horizontal_scroll_offset" => {
let offset = match value {
CapabilityValue::UInt(offset) => {
i32::try_from(offset).map_err(|_| CapabilityAccessError::TypeMismatch)?
}
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_horizontal_scroll(offset);
Ok(())
}
"lane_mode" => {
let token = expect_string(value)?;
let mode = KanbanLaneMode::from_token(&token)
.ok_or(CapabilityAccessError::TypeMismatch)?;
self.set_lane_mode(mode);
Ok(())
}
"swimlanes" => {
let enabled = expect_bool(value)?;
self.set_lane_mode(if enabled {
KanbanLaneMode::Lanes
} else {
KanbanLaneMode::None
});
Ok(())
}
"lane_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"columns",
"cards",
"column_count",
"card_count",
"dragging_card_id",
"hovered_column",
"column_width",
"horizontal_scroll_offset",
"lane_mode",
"swimlanes",
"lane_count",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"cancel_drag" => {
self.cancel_drag();
Ok(())
}
_ => self.default_command(name),
}
}
}
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, modifiers } if *button == 1 => {
if let Some(position) = self.card_at(*pos) {
let card_id = self
.columns
.get(position.column)
.and_then(|column| column.cards.get(position.card))
.map(|card| card.id.clone());
let pressed = crate::shortcut::Modifiers::from_event_bits(*modifiers);
if crate::shortcut::event_carries_primary(*modifiers)
|| pressed.contains(crate::shortcut::Modifiers::SHIFT)
{
self.focused_card = Some(position);
if let Some(card_id) = card_id {
self.toggle_card_selected(&card_id);
}
return;
}
self.focused_card = Some(position);
if self.selected_cards.len() > 1
&& card_id
.as_ref()
.is_some_and(|id| self.selected_cards.iter().any(|s| s == id))
{
} else {
self.clear_selection();
}
self.begin_card_drag(position, *pos);
}
}
Event::MouseMove { pos } if self.drag.is_some() => {
self.autoscroll_drag(*pos);
self.update_card_drag(*pos);
}
Event::MouseMove { pos } => {
let hovered = self.card_at(*pos);
let column = self.column_at(*pos);
let changed = hovered != self.hovered_card || column != self.hovered_column;
let hover_changed = hovered != self.hovered_card;
self.hovered_card = hovered;
self.hovered_column = column;
if changed {
self.base.request_redraw();
}
if hover_changed {
let id = hovered.and_then(|position| {
self.columns
.get(position.column)
.and_then(|entry| entry.cards.get(position.card))
.map(|card| card.id.clone())
});
self.card_hovered.emit(id);
}
}
Event::MouseRelease { pos, button } if *button == 1 && self.drag.is_some() => {
self.finish_card_drag(*pos);
}
Event::Wheel { delta, modifiers } => {
let modifiers = crate::shortcut::Modifiers::from_event_bits(*modifiers);
if modifiers.contains(crate::shortcut::Modifiers::SHIFT) || delta.x != 0 {
let step = if delta.x != 0 {
delta.x.signum() * HORIZONTAL_SCROLL_STEP
} else if delta.y > 0 {
-HORIZONTAL_SCROLL_STEP
} else if delta.y < 0 {
HORIZONTAL_SCROLL_STEP
} else {
0
};
self.scroll_horizontally_by(step);
return;
}
if delta.y != 0 {
let column = self.hovered_column.unwrap_or(0);
self.scroll_column(column, if delta.y > 0 { 1 } else { -1 });
}
}
Event::KeyPress { key, .. } => {
let key = crate::shortcut::Key::from_key_code(*key);
let current = self.focused_card.or_else(|| {
self.columns
.iter()
.position(|column| !column.cards.is_empty())
.map(|column| CardPosition { column, card: 0 })
});
let Some(current) = current else {
return;
};
match key {
Some(crate::shortcut::Key::Left) => {
let mut column = current.column;
while column > 0 {
column -= 1;
if !self.columns[column].cards.is_empty() {
let card = current.card.min(self.columns[column].cards.len() - 1);
self.set_focused_card(Some(CardPosition { column, card }));
break;
}
}
}
Some(crate::shortcut::Key::Right) => {
let mut column = current.column;
while column + 1 < self.columns.len() {
column += 1;
if !self.columns[column].cards.is_empty() {
let card = current.card.min(self.columns[column].cards.len() - 1);
self.set_focused_card(Some(CardPosition { column, card }));
break;
}
}
}
Some(crate::shortcut::Key::Up) => {
if current.card > 0 {
self.set_focused_card(Some(CardPosition {
column: current.column,
card: current.card - 1,
}));
}
}
Some(crate::shortcut::Key::Down) => {
if current.card + 1 < self.columns[current.column].cards.len() {
self.set_focused_card(Some(CardPosition {
column: current.column,
card: current.card + 1,
}));
}
}
Some(crate::shortcut::Key::PageUp) => self.scroll_column(current.column, -1),
Some(crate::shortcut::Key::PageDown) => self.scroll_column(current.column, 1),
Some(crate::shortcut::Key::Enter) | Some(crate::shortcut::Key::Space) => {
if let Some(card) = self
.columns
.get(current.column)
.and_then(|column| column.cards.get(current.card))
{
let id = card.id.clone();
self.card_activated.emit(id);
}
}
_ => {}
}
}
Event::MouseLeave { .. } => {
self.cancel_drag();
let hover_changed = self.hovered_card.take().is_some();
if hover_changed {
self.card_hovered.emit(None);
self.base.request_redraw();
}
}
_ => {}
}
}
}
impl Draw for KanbanBoard {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("kanban_board");
let resolved = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or_else(|| crate::core::Color::border_of(resolved));
let text_color = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let background = resolved.blend(&text_color, 0.08);
context.face_with_gradient_corners(
rect,
background,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().resolved_corner_radii().to_array(),
Color::BLACK,
);
let chrome = BoardChrome { background, border, text_color };
let fonts = ColumnFonts {
header: role_font(self.base.style(), 13.0),
badge: role_font(self.base.style(), 11.0),
title: role_font(self.base.style(), 12.0),
desc: role_font(self.base.style(), 10.0),
};
context.push_clip(rect.x, rect.y, rect.width, rect.height);
for index in 0..self.columns.len() {
let Some(column_rect) = self.column_rect(index) else {
continue;
};
if column_rect.x + column_rect.width as i32 <= rect.x
|| column_rect.x >= rect.x + rect.width as i32
{
continue;
}
self.draw_column(context, index, &chrome, &fonts);
}
if self.lanes_active() {
self.draw_lane_headers(context, &chrome, &fonts);
}
context.pop_clip();
self.draw_drag_overlay(context, &chrome);
}
}
#[derive(Debug, Clone, Copy)]
struct BoardChrome {
background: Color,
border: Color,
text_color: Color,
}
struct ColumnFonts {
header: crate::core::Font,
badge: crate::core::Font,
title: crate::core::Font,
desc: crate::core::Font,
}
impl BoardChrome {
fn column(&self) -> Color {
let role = crate::style::layer_color(crate::style::LayerColor::SurfaceContainer);
match role {
Some(role) if role != self.background => role,
_ => self.background.blend(&self.text_color, 0.18),
}
}
fn drop_target(&self) -> Color {
self.background.blend(&self.text_color, 0.3)
}
fn header(&self) -> Color {
self.background.blend(&self.text_color, 0.24)
}
fn text(&self) -> Color {
self.text_color
}
fn muted_text(&self) -> Color {
self.text_color.blend(&self.background, 0.3)
}
fn card(&self) -> Color {
self.background.blend(&self.text_color, 0.1)
}
fn lane_band(&self, index: usize) -> Color {
let step = if index.is_multiple_of(2) { 0.18 } else { 0.13 };
self.background.blend(&self.text_color, step)
}
fn done_text(&self) -> Color {
self.text_color.blend(&self.background, 0.45)
}
fn at_limit(&self) -> Color {
crate::style::semantic_color(crate::style::SemanticColor::Error)
.map(|token| token.blend(&self.background, 0.2))
.unwrap_or_else(|| self.text_color.blend(&self.background, 0.3))
}
fn insertion(&self) -> Color {
self.border.blend(&self.text_color, 0.3)
}
fn ghost(&self) -> Color {
self.background.blend(&self.text_color, 0.08)
}
}
impl KanbanBoard {
fn draw_column(
&mut self,
context: &mut RenderContext,
index: usize,
chrome: &BoardChrome,
fonts: &ColumnFonts,
) {
let Some(column_rect) = self.column_rect(index) else {
return;
};
let Some(column) = self.columns.get(index) else {
return;
};
let title = column.title.as_str();
let collapsed = column.collapsed;
let cards = &column.cards;
let at_limit = column.is_at_wip_limit();
let wip_limit = column.wip_limit;
let is_drop_target = self.is_dragging_card() && self.hovered_column == Some(index);
let background = if is_drop_target { chrome.drop_target() } else { chrome.column() };
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, chrome.header());
let header_color = if at_limit { chrome.at_limit() } else { chrome.text() };
let header_font = &fonts.header;
let badge_font = &fonts.badge;
let title_font = &fonts.title;
let desc_font = &fonts.desc;
let header_line = context.text_line(header_rect, header_font);
let mut badge = String::new();
if collapsed {
let _ = write!(badge, "({})", cards.len());
} else if let Some(limit) = wip_limit {
let _ = write!(badge, "{}/{}", cards.len(), limit);
} else {
let _ = write!(badge, "{}", cards.len());
}
let badge_metrics = context.measure_text(&badge, badge_font);
let badge_line = context.text_line(header_rect, badge_font);
let badge_x =
header_rect.x + header_rect.width as i32 - CARD_PADDING - badge_metrics.width as i32;
let title_width = ((badge_x - 6) - (header_rect.x + CARD_PADDING)).max(0) as u32;
context.draw_text_fitted_cow(
Rect::new(
header_rect.x + CARD_PADDING,
header_line.y,
title_width,
header_line.height.max(1),
),
title,
header_font,
header_color,
HorizontalAlignment::Left,
);
context.draw_text(
Point::new(badge_x, badge_line.y),
&badge,
badge_font,
chrome.muted_text(),
HorizontalAlignment::Left,
);
if collapsed {
return;
}
if self.lanes_active() {
for lane in 0..self.lanes.len() {
let Some(band) = self.lane_band_rect(lane) else {
continue;
};
let cell = Rect::new(column_rect.x, band.y, column_rect.width, band.height);
context.fill_rect(cell, chrome.lane_band(lane));
context.draw_line(
Point::new(cell.x, cell.y + cell.height as i32),
Point::new(cell.x + cell.width as i32, cell.y + cell.height as i32),
chrome.border,
);
}
}
context.push_clip(column_rect.x, column_rect.y, column_rect.width, column_rect.height);
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;
};
self.draw_card(context, card, card_rect, position, chrome, title_font, desc_font);
}
context.pop_clip();
}
fn draw_lane_headers(
&self,
context: &mut RenderContext,
chrome: &BoardChrome,
fonts: &ColumnFonts,
) {
let rect = self.geometry();
let caption = Rect::new(rect.x, rect.y, LANE_HEADER_WIDTH, HEADER_HEIGHT);
context.fill_rounded_rect(caption, 8, chrome.header());
context.draw_text_line_cow(
Rect::new(
caption.x + CARD_PADDING,
caption.y,
caption.width.saturating_sub(2 * CARD_PADDING as u32),
caption.height,
),
"Lanes",
&fonts.header,
chrome.muted_text(),
HorizontalAlignment::Left,
);
for index in 0..self.lanes.len() {
let Some(header) = self.lane_header_rect(index) else {
continue;
};
context.fill_rounded_rect(header, 6, chrome.lane_band(index));
context.draw_text_line_cow(
Rect::new(
header.x + CARD_PADDING,
header.y,
header.width.saturating_sub(2 * CARD_PADDING as u32),
LANE_HEADER_HEIGHT as u32,
),
&self.lanes[index].title,
&fonts.header,
chrome.text(),
HorizontalAlignment::Left,
);
}
let divider_x = rect.x + LANE_HEADER_WIDTH as i32;
context.draw_line(
Point::new(divider_x, rect.y + HEADER_HEIGHT as i32),
Point::new(divider_x, rect.y + rect.height as i32),
chrome.border,
);
}
fn draw_card(
&self,
context: &mut RenderContext,
card: &KanbanCard,
card_rect: Rect,
position: CardPosition,
chrome: &BoardChrome,
title_font: &Font,
desc_font: &Font,
) {
let is_dragged = self.drag_origin == Some(position) && self.is_dragging_card();
if self.hovered_card == Some(position) && !self.is_dragging_card() {
context.draw_box_shadow(card_rect, Color::BLACK.with_alpha(40), 0, 3, 6, 0);
}
let alpha = if is_dragged { 90 } else { 255 };
context.fill_rounded_rect(card_rect, 6, chrome.card().with_alpha(alpha));
let focused = self.focused_card == Some(position);
context.draw_rounded_rect_stroke(
card_rect,
6,
if focused { chrome.text().with_alpha(alpha) } else { chrome.border.with_alpha(alpha) },
if focused { 2 } else { 1 },
);
if self.selected_cards.contains(&card.id) {
context.draw_rounded_rect_stroke(card_rect, 6, chrome.insertion(), 2);
}
let title_color = if card.done {
chrome.done_text().with_alpha(alpha)
} else {
chrome.text().with_alpha(alpha)
};
let title = card.title.as_str();
let title_line = context.text_line(card_rect, title_font);
let mut title_x = card_rect.x + CARD_PADDING;
if card.done {
let icon_side: u32 = 10;
let icon_rect = Rect::new(
title_x,
title_line.y + (title_line.height as i32 - icon_side as i32) / 2,
icon_side,
icon_side,
);
draw_icon_at(context, icon_rect, title_color, IconName::Check);
title_x += icon_side as i32 + 4;
}
if !title.is_empty() {
context.draw_text(
Point::new(title_x, title_line.y),
title,
title_font,
title_color,
HorizontalAlignment::Left,
);
}
if !card.description.is_empty() {
let desc_band = Rect::new(
card_rect.x,
card_rect.y + (card_rect.height / 2) as i32,
card_rect.width,
card_rect.height / 2,
);
let desc_line = context.text_line(desc_band, desc_font);
context.draw_text(
Point::new(card_rect.x + CARD_PADDING, desc_line.y),
&card.description,
desc_font,
chrome.muted_text().with_alpha(alpha),
HorizontalAlignment::Left,
);
}
}
fn draw_drag_overlay(&mut self, context: &mut RenderContext, chrome: &BoardChrome) {
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),
chrome.insertion(),
3,
);
}
let label = session.payload().label.clone();
let board = self.geometry();
let ghost_width = self.column_width;
let ghost_x = (current.x - (ghost_width as i32 / 2))
.clamp(board.x, board.x + board.width.saturating_sub(ghost_width) as i32);
let ghost_y = (current.y - (CARD_HEIGHT as i32 / 2))
.clamp(board.y, board.y + board.height.saturating_sub(CARD_HEIGHT) as i32);
let ghost = Rect::new(ghost_x, ghost_y, ghost_width, CARD_HEIGHT);
context.fill_rounded_rect(ghost, 6, chrome.ghost().with_alpha(230));
context.draw_rounded_rect_stroke(ghost, 6, chrome.insertion(), 2);
if !label.is_empty() {
let ghost_font = role_font(self.base.style(), 12.0);
let ghost_line = context.text_line(ghost, &ghost_font);
context.draw_text(
Point::new(ghost.x + CARD_PADDING, ghost_line.y),
&label,
&ghost_font,
chrome.text(),
HorizontalAlignment::Left,
);
}
}
}
const CARD_INSET: i32 = 8;
const CARD_DONE_BOX: i32 = 14;
const HEADER_SIDE: i32 = 32;
const HEADER_ICON: i32 = 14;
pub struct KanbanCardView {
base: BaseWidget,
title: String,
description: String,
done: bool,
tags: Vec<String>,
assignee: String,
draggable: bool,
edit_buffer: Option<String>,
pub card_clicked: GenericSignal,
pub card_edited: Signal1<String>,
pub card_done_toggled: Signal1<bool>,
}
impl KanbanCardView {
pub fn new(geometry: Rect, title: impl Into<String>) -> Self {
Self {
base: BaseWidget::new(WidgetKind::KanbanCardView, geometry, "KanbanCardView"),
title: title.into(),
description: String::new(),
done: false,
tags: Vec::new(),
assignee: String::new(),
draggable: true,
edit_buffer: None,
card_clicked: GenericSignal::new(),
card_edited: Signal1::new(),
card_done_toggled: Signal1::new(),
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn set_title(&mut self, title: impl Into<String>) {
let title = title.into();
if self.title == title {
return;
}
self.title = title.clone();
self.card_edited.emit(title);
self.base.request_redraw();
}
pub fn description(&self) -> &str {
&self.description
}
pub fn set_description(&mut self, description: impl Into<String>) {
let description = description.into();
if self.description == description {
return;
}
self.description = description;
self.base.request_redraw();
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn set_done(&mut self, done: bool) {
if self.done == done {
return;
}
self.done = done;
self.card_done_toggled.emit(done);
self.base.request_redraw();
}
pub fn tags(&self) -> &[String] {
&self.tags
}
pub fn set_tags(&mut self, tags: Vec<String>) {
self.tags = tags;
self.base.request_redraw();
}
pub fn assignee(&self) -> &str {
&self.assignee
}
pub fn set_assignee(&mut self, assignee: impl Into<String>) {
self.assignee = assignee.into();
self.base.request_redraw();
}
pub fn is_draggable(&self) -> bool {
self.draggable
}
pub fn set_draggable(&mut self, draggable: bool) {
self.draggable = draggable;
}
pub fn is_editing(&self) -> bool {
self.edit_buffer.is_some()
}
pub fn begin_title_edit(&mut self) -> bool {
if self.edit_buffer.is_some() {
return false;
}
self.edit_buffer = Some(self.title.clone());
self.base.request_redraw();
true
}
pub fn commit_title_edit(&mut self) -> bool {
let Some(next) = self.edit_buffer.take() else {
return false;
};
self.base.request_redraw();
if next == self.title {
return false;
}
self.title = next.clone();
self.card_edited.emit(next);
true
}
pub fn cancel_title_edit(&mut self) -> bool {
self.edit_buffer.take().is_some()
}
pub fn done_box_rect(&self) -> Rect {
let rect = self.base.geometry();
Rect::new(
rect.x + CARD_INSET,
rect.y + CARD_INSET,
CARD_DONE_BOX as u32,
CARD_DONE_BOX as u32,
)
}
}
impl Widget for KanbanCardView {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(220, 72)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"card_clicked" => Some(EventSignalRef::unit("card_clicked", &self.card_clicked)),
"card_edited" => Some(EventSignalRef::mapped("card_edited", &self.card_edited, |v| {
CapabilityValue::String(v.clone())
})),
"card_done_toggled" => {
Some(EventSignalRef::mapped("card_done_toggled", &self.card_done_toggled, |v| {
CapabilityValue::Bool(*v)
}))
}
_ => None,
}
}
}
impl WidgetProperties for KanbanCardView {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title.clone())),
"description" => Ok(CapabilityValue::String(self.description.clone())),
"done" => Ok(CapabilityValue::Bool(self.done)),
"tags" => Ok(CapabilityValue::Tuple(
self.tags.iter().map(|tag| CapabilityValue::String(tag.clone())).collect(),
)),
"assignee" => Ok(CapabilityValue::String(self.assignee.clone())),
"draggable" => Ok(CapabilityValue::Bool(self.draggable)),
"editing" => Ok(CapabilityValue::Bool(self.is_editing())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"title" => {
self.set_title(expect_string(value)?);
Ok(())
}
"description" => {
self.set_description(expect_string(value)?);
Ok(())
}
"done" => {
self.set_done(expect_bool(value)?);
Ok(())
}
"tags" => {
let tags = expect_string_list(value)?;
self.set_tags(tags);
Ok(())
}
"assignee" => {
self.set_assignee(expect_string(value)?);
Ok(())
}
"draggable" => {
self.set_draggable(expect_bool(value)?);
Ok(())
}
"editing" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"title",
"description",
"done",
"tags",
"assignee",
"draggable",
"editing",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for KanbanCardView {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
if self.edit_buffer.is_some() {
match event {
Event::TextInput { text } | Event::ImeCommit { text } => {
if let Some(buffer) = self.edit_buffer.as_mut() {
buffer.extend(text.chars().filter(|character| !character.is_control()));
}
self.base.request_redraw();
}
Event::KeyPress { key, .. } => match *key {
13 => {
let _ = self.commit_title_edit();
}
27 => {
self.cancel_title_edit();
self.base.request_redraw();
}
8 => {
if let Some(buffer) = self.edit_buffer.as_mut() {
buffer.pop();
}
self.base.request_redraw();
}
_ => { }
},
_ => {}
}
return;
}
match event {
Event::MousePress { pos, button: 1, .. } => {
if self.done_box_rect().contains_point(*pos) {
self.set_done(!self.done);
} else if self.base.geometry().contains_point(*pos) {
self.card_clicked.emit();
}
}
Event::MouseDoubleClick { pos, button: 1, .. }
if self.base.geometry().contains_point(*pos) =>
{
let _ = self.begin_title_edit();
}
_ => {}
}
}
}
impl Draw for KanbanCardView {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.base.geometry();
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("kanban_board");
let surface = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or_else(|| surface.blend(&ink, 0.20));
let accent = crate::style::palette_shared()
.as_ref()
.map(|colors| colors.primary)
.unwrap_or(Color::PRIMARY);
let font = role_font(style, 12.0);
let small = role_font(style, 10.0);
context.fill_rounded_rect(rect, 6, surface);
context.draw_rounded_rect_stroke(rect, 6, border, 1);
let done_box = self.done_box_rect();
context.draw_rect(done_box, border);
if self.done {
crate::widget::draw_icon_at(context, done_box, accent, IconName::Check);
}
let text_x = done_box.x + done_box.width as i32 + 6;
let title = match &self.edit_buffer {
Some(buffer) => buffer.as_str(),
None => self.title.as_str(),
};
let strike = if self.done { ink.blend(&surface, 0.45) } else { ink };
let fitted = crate::widget::text_utils::elide_end(
title,
((rect.width as i32 - (text_x - rect.x) - CARD_INSET) / 7).max(1) as usize,
);
context.draw_text(
Point::new(text_x, rect.y + CARD_INSET),
&fitted,
&font,
strike,
HorizontalAlignment::Left,
);
if self.done {
let width = context.measure_text(&fitted, &font).width as i32;
context.draw_line(
Point::new(text_x, rect.y + CARD_INSET + 7),
Point::new(text_x + width, rect.y + CARD_INSET + 7),
strike,
);
}
let mut y = rect.y + CARD_INSET + 18;
if !self.description.is_empty() {
let description = crate::widget::text_utils::elide_end(
&self.description,
((rect.width as i32 - CARD_INSET * 2) / 6).max(1) as usize,
);
context.draw_text(
Point::new(rect.x + CARD_INSET, y),
&description,
&small,
ink.blend(&surface, 0.30),
HorizontalAlignment::Left,
);
y += 16;
}
if !self.tags.is_empty() {
let mut x = rect.x + CARD_INSET;
for tag in &self.tags {
let label =
crate::widget::text_utils::elide_end(tag, (rect.width as usize / 7).max(2));
let width = context.measure_text(&label, &small).width + 10;
if x + width as i32 > rect.x + rect.width as i32 - CARD_INSET {
break;
}
let chip = Rect::new(x, y, width, 14);
context.fill_rounded_rect(chip, 4, surface.blend(&accent, 0.22));
context.draw_text(
Point::new(x + 4, y + 1),
&label,
&small,
ink,
HorizontalAlignment::Left,
);
x += width as i32 + 4;
}
}
if !self.assignee.is_empty() {
let label = crate::widget::text_utils::elide_end(&self.assignee, 12);
let width = context.measure_text(&label, &small).width as i32;
context.draw_text(
Point::new(
rect.x + rect.width as i32 - width - CARD_INSET,
rect.y + rect.height as i32 - 18,
),
&label,
&small,
ink.blend(&surface, 0.30),
HorizontalAlignment::Left,
);
}
}
}
pub struct KanbanColumnHeader {
base: BaseWidget,
title: String,
count: usize,
wip_limit: Option<usize>,
collapsed: bool,
editable: bool,
edit_buffer: Option<String>,
pub column_renamed: Signal1<String>,
pub column_collapsed_changed: Signal1<bool>,
pub add_card_requested: GenericSignal,
}
impl KanbanColumnHeader {
pub fn new(geometry: Rect, title: impl Into<String>) -> Self {
Self {
base: BaseWidget::new(WidgetKind::KanbanColumnHeader, geometry, "KanbanColumnHeader"),
title: title.into(),
count: 0,
wip_limit: None,
collapsed: false,
editable: true,
edit_buffer: None,
column_renamed: Signal1::new(),
column_collapsed_changed: Signal1::new(),
add_card_requested: GenericSignal::new(),
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn rename(&mut self, title: impl Into<String>) -> bool {
let title = title.into();
if self.title == title {
return false;
}
self.title = title.clone();
self.column_renamed.emit(title);
self.base.request_redraw();
true
}
pub fn count(&self) -> usize {
self.count
}
pub fn set_count(&mut self, count: usize) {
if self.count == count {
return;
}
self.count = count;
self.base.request_redraw();
}
pub fn wip_limit(&self) -> Option<usize> {
self.wip_limit
}
pub fn set_wip_limit(&mut self, limit: Option<usize>) {
if self.wip_limit == limit {
return;
}
self.wip_limit = limit;
self.base.request_redraw();
}
pub fn is_collapsed(&self) -> bool {
self.collapsed
}
pub fn set_collapsed(&mut self, collapsed: bool) {
if self.collapsed == collapsed {
return;
}
self.collapsed = collapsed;
self.column_collapsed_changed.emit(collapsed);
self.base.request_redraw();
}
pub fn is_editable(&self) -> bool {
self.editable
}
pub fn set_editable(&mut self, editable: bool) {
if self.editable == editable {
return;
}
self.editable = editable;
if !editable {
self.edit_buffer = None;
}
self.base.request_redraw();
}
pub fn is_at_wip_limit(&self) -> bool {
self.wip_limit.is_some_and(|limit| self.count >= limit)
}
pub fn is_editing(&self) -> bool {
self.edit_buffer.is_some()
}
pub fn begin_title_edit(&mut self) -> bool {
if !self.editable || self.edit_buffer.is_some() {
return false;
}
self.edit_buffer = Some(self.title.clone());
self.base.request_redraw();
true
}
pub fn commit_title_edit(&mut self) -> bool {
let Some(next) = self.edit_buffer.take() else {
return false;
};
if next == self.title {
self.base.request_redraw();
return false;
}
let changed = self.rename(next);
self.base.request_redraw();
changed
}
pub fn cancel_title_edit(&mut self) -> bool {
let canceled = self.edit_buffer.take().is_some();
if canceled {
self.base.request_redraw();
}
canceled
}
pub fn collapse_rect(&self) -> Rect {
let rect = self.base.geometry();
Rect::new(
rect.x + 4,
rect.y + (rect.height as i32 - HEADER_ICON) / 2,
HEADER_ICON as u32,
HEADER_ICON as u32,
)
}
pub fn add_rect(&self) -> Rect {
let rect = self.base.geometry();
Rect::new(
rect.x + rect.width as i32 - HEADER_ICON - 4,
rect.y + (rect.height as i32 - HEADER_ICON) / 2,
HEADER_ICON as u32,
HEADER_ICON as u32,
)
}
}
impl Widget for KanbanColumnHeader {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(220, HEADER_SIDE as u32)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"column_renamed" => {
Some(EventSignalRef::mapped("column_renamed", &self.column_renamed, |v| {
CapabilityValue::String(v.clone())
}))
}
"column_collapsed_changed" => Some(EventSignalRef::mapped(
"column_collapsed_changed",
&self.column_collapsed_changed,
|v| CapabilityValue::Bool(*v),
)),
"add_card_requested" => {
Some(EventSignalRef::unit("add_card_requested", &self.add_card_requested))
}
_ => None,
}
}
}
impl WidgetProperties for KanbanColumnHeader {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title.clone())),
"count" => Ok(CapabilityValue::UInt(self.count as u64)),
"wip_limit" => Ok(match self.wip_limit {
Some(limit) => CapabilityValue::UInt(limit as u64),
None => CapabilityValue::Null,
}),
"collapsed" => Ok(CapabilityValue::Bool(self.collapsed)),
"editable" => Ok(CapabilityValue::Bool(self.editable)),
"at_wip_limit" => Ok(CapabilityValue::Bool(self.is_at_wip_limit())),
"editing" => Ok(CapabilityValue::Bool(self.is_editing())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"title" => {
self.rename(expect_string(value)?);
Ok(())
}
"count" => {
let count = match value {
CapabilityValue::UInt(count) => {
usize::try_from(count).map_err(|_| CapabilityAccessError::OutOfRange)?
}
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_count(count);
Ok(())
}
"wip_limit" => match value {
CapabilityValue::Null => {
self.set_wip_limit(None);
Ok(())
}
CapabilityValue::UInt(limit) => {
self.set_wip_limit(Some(
usize::try_from(limit).map_err(|_| CapabilityAccessError::OutOfRange)?,
));
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"collapsed" => {
self.set_collapsed(expect_bool(value)?);
Ok(())
}
"editable" => {
self.set_editable(expect_bool(value)?);
Ok(())
}
"at_wip_limit" | "editing" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"title",
"count",
"wip_limit",
"collapsed",
"editable",
"at_wip_limit",
"editing",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for KanbanColumnHeader {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
if self.edit_buffer.is_some() {
match event {
Event::TextInput { text } | Event::ImeCommit { text } => {
if let Some(buffer) = self.edit_buffer.as_mut() {
buffer.extend(text.chars().filter(|character| !character.is_control()));
}
self.base.request_redraw();
}
Event::KeyPress { key, .. } => match *key {
13 => {
let _ = self.commit_title_edit();
}
27 => {
self.cancel_title_edit();
}
8 => {
if let Some(buffer) = self.edit_buffer.as_mut() {
buffer.pop();
}
self.base.request_redraw();
}
_ => { }
},
_ => {}
}
return;
}
match event {
Event::MousePress { pos, button: 1, .. } => {
if self.collapse_rect().contains_point(*pos) {
self.set_collapsed(!self.collapsed);
} else if self.add_rect().contains_point(*pos) {
self.add_card_requested.emit();
}
}
Event::MouseDoubleClick { pos, button: 1, .. }
if self.editable && self.base.geometry().contains_point(*pos) =>
{
let _ = self.begin_title_edit();
}
_ => {}
}
}
}
impl Draw for KanbanColumnHeader {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.base.geometry();
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("kanban_board");
let surface = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or_else(|| surface.blend(&ink, 0.20));
let accent = crate::style::palette_shared()
.as_ref()
.map(|colors| colors.primary)
.unwrap_or(Color::PRIMARY);
let warning = crate::style::semantic_color(crate::style::SemanticColor::Warning)
.unwrap_or(Color::PRIMARY);
let font = role_font(style, 12.0);
context.face_with_gradient_corners(
rect,
surface.blend(&ink, 0.05),
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().resolved_corner_radii().to_array(),
Color::BLACK,
);
crate::widget::draw::stroke_surface_border(
context,
rect,
self.style().resolved_corner_radii().to_array(),
border,
1,
);
let chevron = if self.collapsed { IconName::ChevronRight } else { IconName::ChevronDown };
crate::widget::draw_icon_at(context, self.collapse_rect(), ink, chevron);
let title = match &self.edit_buffer {
Some(buffer) => buffer.as_str(),
None => self.title.as_str(),
};
let add = self.add_rect();
let badge_width = context.measure_text(&format!("{}", self.count), &font).width as i32 + 10;
let text_left = self.collapse_rect().x + HEADER_ICON + 6;
let text_right = (add.x - 6 - badge_width).max(text_left + 8);
let text = crate::widget::text_utils::elide_end(
title,
((text_right - text_left).max(8) / 7) as usize,
);
context.draw_text(
Point::new(text_left, rect.y + (rect.height as i32 - 12) / 2),
&text,
&font,
ink,
HorizontalAlignment::Left,
);
let badge =
Rect::new(text_right, rect.y + (rect.height as i32 - 16) / 2, badge_width as u32, 16);
context.fill_rounded_rect(badge, 8, surface.blend(&ink, 0.12));
let badge_text = if let Some(limit) = self.wip_limit {
format!("{}/{limit}", self.count)
} else {
format!("{}", self.count)
};
context.draw_text(
Point::new(badge.x + 5, badge.y + 2),
&badge_text,
&font,
if self.is_at_wip_limit() { warning } else { ink },
HorizontalAlignment::Left,
);
crate::widget::draw_icon_at(context, add, ink.blend(&surface, 0.25), IconName::Plus);
if self.is_at_wip_limit() {
context.draw_rect(badge, warning.blend(&accent, 0.2));
}
}
}
#[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 card_view_toggles_done_and_reports_a_committed_edit() {
let mut card = KanbanCardView::new(Rect::new(0, 0, 220, 72), "Write spec");
card.set_description("Owner: Ada");
card.set_tags(vec!["ui".to_string(), "p1".to_string()]);
let done = Arc::new(Mutex::new(Vec::<bool>::new()));
let sink = Arc::clone(&done);
card.card_done_toggled.connect(move |flag| {
sink.lock().expect("done sink").push(*flag);
});
let clicked = Arc::new(Mutex::new(0usize));
let sink = Arc::clone(&clicked);
card.card_clicked.connect(move || {
*sink.lock().expect("click sink") += 1;
});
let edited = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&edited);
card.card_edited.connect(move |title| {
sink.lock().expect("edit sink").push(title.as_str().to_string());
});
let box_rect = card.done_box_rect();
card.handle_event(&Event::mouse_press(box_rect.x + 2, box_rect.y + 2, 1));
assert!(card.is_done());
assert_eq!(*done.lock().expect("done sink"), vec![true]);
card.handle_event(&Event::mouse_press(100, 50, 1));
assert_eq!(*clicked.lock().expect("click sink"), 1);
assert!(card.begin_title_edit());
card.handle_event(&Event::text_input("!"));
card.handle_event(&Event::key_press(13, 0));
assert_eq!(card.title(), "Write spec!");
assert_eq!(edited.lock().expect("edit sink").as_slice(), ["Write spec!".to_string()]);
}
#[test]
fn card_view_properties_round_trip() {
let mut card = KanbanCardView::new(Rect::new(0, 0, 220, 72), "Card");
assert_eq!(card.get("title"), Ok(CapabilityValue::String("Card".to_string())));
assert!(card.set("done", CapabilityValue::Bool(true)).is_ok());
assert!(card.is_done());
assert!(card
.set("tags", CapabilityValue::Tuple(vec![CapabilityValue::String("a".to_string())]))
.is_ok());
assert_eq!(card.tags(), ["a".to_string()]);
assert_eq!(
card.set("editing", CapabilityValue::Bool(false)),
Err(CapabilityAccessError::ReadOnlyProperty)
);
}
#[test]
fn column_header_reports_rename_collapse_and_add() {
let mut header = KanbanColumnHeader::new(Rect::new(0, 0, 220, 32), "Todo");
header.set_count(2);
header.set_wip_limit(Some(2));
assert!(header.is_at_wip_limit());
let renamed = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&renamed);
header.column_renamed.connect(move |title| {
sink.lock().expect("rename sink").push(title.as_str().to_string());
});
let collapsed = Arc::new(Mutex::new(Vec::<bool>::new()));
let sink = Arc::clone(&collapsed);
header.column_collapsed_changed.connect(move |flag| {
sink.lock().expect("collapse sink").push(*flag);
});
let added = Arc::new(Mutex::new(0usize));
let sink = Arc::clone(&added);
header.add_card_requested.connect(move || {
*sink.lock().expect("add sink") += 1;
});
let add = header.add_rect();
header.handle_event(&Event::mouse_press(add.x + 2, add.y + 2, 1));
assert_eq!(*added.lock().expect("add sink"), 1);
let collapse = header.collapse_rect();
header.handle_event(&Event::mouse_press(collapse.x + 2, collapse.y + 2, 1));
assert!(header.is_collapsed());
assert_eq!(*collapsed.lock().expect("collapse sink"), vec![true]);
assert!(header.rename("In Progress"));
assert!(!header.rename("In Progress"), "a no-op rename is not reported");
assert_eq!(renamed.lock().expect("rename sink").as_slice(), ["In Progress".to_string()]);
assert_eq!(header.get("wip_limit"), Ok(CapabilityValue::UInt(2)));
}
#[test]
fn column_header_edits_its_title_when_editable() {
let mut header = KanbanColumnHeader::new(Rect::new(0, 0, 220, 32), "Todo");
assert!(header.begin_title_edit());
header.handle_event(&Event::text_input("s"));
header.handle_event(&Event::key_press(13, 0));
assert_eq!(header.title(), "Todos");
header.set_editable(false);
assert!(!header.begin_title_edit(), "a read-only header cannot be renamed by the reader");
}
#[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, DEFAULT_COLUMN_WIDTH as i32 + COLUMN_GAP);
assert_eq!(first.width, DEFAULT_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
.as_chunks::<4>()
.0
.iter()
.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"] {
assert_eq!(
board.set(name, CapabilityValue::UInt(3)),
Err(CapabilityAccessError::ReadOnlyProperty),
"{name} must be read-only"
);
}
assert!(board.set("column_width", CapabilityValue::UInt(300)).is_ok());
assert_eq!(board.column_width(), 300);
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(300));
}
#[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);
}
#[test]
fn column_width_is_writable_and_moves_the_next_column() {
let mut board = board();
let before = board.column_rect(1).expect("column 1").x;
board.set_column_width(320);
assert_eq!(board.column_width(), 320);
assert_eq!(board.get("column_width").unwrap(), CapabilityValue::UInt(320));
let after = board.column_rect(1).expect("column 1").x;
assert_eq!(after - before, 100, "a wider column must displace the one beside it");
assert_eq!(board.column_rect(0).unwrap().width, 320);
board.set("column_width", CapabilityValue::UInt(1)).expect("writable");
assert_eq!(board.column_width(), MIN_COLUMN_WIDTH);
}
#[test]
fn horizontal_scroll_shifts_columns_and_clamps_to_content() {
let mut board = board();
board.set_column_width(400);
let unscrolled = board.column_rect(0).expect("column 0").x;
board.set_horizontal_scroll(100);
assert_eq!(board.horizontal_scroll(), 100);
assert_eq!(board.column_rect(0).unwrap().x, unscrolled - 100);
board.set_horizontal_scroll(100_000);
assert_eq!(board.horizontal_scroll(), board.max_horizontal_scroll());
board.set_horizontal_scroll(-50);
assert_eq!(board.horizontal_scroll(), 0);
board.handle_event(&Event::wheel(0, -120, 0x0001));
assert!(board.horizontal_scroll() > 0);
}
#[test]
fn horizontal_scroll_is_published_as_a_property() {
let mut board = board();
board.set_column_width(400);
board.set("horizontal_scroll_offset", CapabilityValue::UInt(64)).expect("writable");
assert_eq!(board.horizontal_scroll(), 64);
assert_eq!(board.get("horizontal_scroll_offset").unwrap(), CapabilityValue::UInt(64));
}
fn lane_board() -> KanbanBoard {
let mut board = KanbanBoard::new(Rect::new(0, 0, 600, 400));
board.add_column(KanbanColumn::new("todo", "To Do"));
board.add_card(0, KanbanCard::new("a", "First"));
board.add_card(0, KanbanCard::new("b", "Second"));
board.add_lane(KanbanLane::new("fast", "Fast"));
board.add_lane(KanbanLane::new("slow", "Slow"));
board.set_lane_mode(KanbanLaneMode::Lanes);
board
}
#[test]
fn lanes_assign_cards_and_drive_layout() {
let mut board = lane_board();
assert_eq!(board.lane_count(), 2);
assert_eq!(board.lane("slow").map(|lane| lane.title.as_str()), Some("Slow"));
assert!(board.set_card_lane("b", "slow"));
assert_eq!(board.card_lane("b"), Some("slow"));
assert_eq!(board.lane_of("b"), Some("slow"), "lane_of is the read alias");
assert_eq!(board.card_lane("a"), None, "unassigned cards fall into lane 0");
assert!(!board.set_card_lane("missing", "slow"));
assert!(!board.set_card_lane("a", "missing"));
assert!(board.set_lane("a", "slow"), "set_lane is the write alias");
assert_eq!(board.lane_of("a"), Some("slow"));
assert!(!board.set_lane("missing", "slow"));
assert!(board.set_lane("a", "fast"));
let lane0 = board.lane_band_rect(0).expect("lane 0");
let lane1 = board.lane_band_rect(1).expect("lane 1");
assert_eq!(lane1.x, LANE_HEADER_WIDTH as i32, "bands start after the labels");
assert!(lane1.y >= lane0.y + lane0.height as i32);
assert_eq!(board.lane_header_rect(1).expect("header").x, 0);
let a = board.card_rect(CardPosition { column: 0, card: 0 }).expect("a laid out");
let b = board.card_rect(CardPosition { column: 0, card: 1 }).expect("b laid out");
assert!(a.y >= lane0.y && a.y < lane1.y, "a sits in the first band");
assert!(b.y >= lane1.y, "b sits in the second band");
}
#[test]
fn lane_mode_paints_and_hit_tests_inside_the_band() {
let mut board = lane_board();
assert!(board.set_card_lane("b", "slow"));
let frame = render(&mut board, Size::new(600, 400));
assert!(!frame.is_empty());
let b = board.card_rect(CardPosition { column: 0, card: 1 }).expect("b laid out");
board.handle_event(&Event::mouse_move(b.x + 10, b.y + 10));
assert_eq!(board.hovered_card(), Some(CardPosition { column: 0, card: 1 }));
board.handle_event(&Event::mouse_move(10, b.y + 10));
assert_eq!(board.hovered_card(), None);
}
#[test]
fn dropping_a_card_in_another_lane_reassigns_it() {
let mut board = lane_board();
assert!(board.set_card_lane("b", "slow"));
let a = board.card_rect(CardPosition { column: 0, card: 0 }).expect("a laid out");
let slow = board.lane_band_rect(1).expect("slow lane");
let target_y = slow.y + LANE_HEADER_HEIGHT + 10;
board.handle_event(&Event::mouse_press(a.x + 10, a.y + 10, 1));
board.handle_event(&Event::mouse_move(a.x + 10, target_y));
board.handle_event(&Event::mouse_release(a.x + 10, target_y, 1));
assert_eq!(board.card_lane("a"), Some("slow"));
}
#[test]
fn lane_mode_is_a_property() {
let mut board = lane_board();
assert_eq!(board.get("lane_mode").unwrap(), CapabilityValue::String("lanes".to_string()));
assert_eq!(board.get("swimlanes").unwrap(), CapabilityValue::Bool(true));
board.set("swimlanes", CapabilityValue::Bool(false)).expect("writable");
assert_eq!(board.lane_mode(), KanbanLaneMode::None);
board.set("swimlanes", CapabilityValue::Bool(true)).expect("writable");
assert_eq!(board.lane_mode(), KanbanLaneMode::Lanes);
board.set("lane_mode", CapabilityValue::String("none".to_string())).expect("writable");
assert_eq!(board.lane_mode(), KanbanLaneMode::None);
assert!(board.set("lane_mode", CapabilityValue::String("diagonal".to_string())).is_err());
assert!(board.set("swimlanes", CapabilityValue::UInt(1)).is_err());
assert_eq!(
board.set("lane_count", CapabilityValue::UInt(0)),
Err(CapabilityAccessError::ReadOnlyProperty)
);
}
#[test]
fn columns_and_cards_round_trip_through_records() {
let mut source = lane_board();
assert!(source.set_card_lane("b", "slow"));
let mut target = KanbanBoard::new(Rect::new(0, 0, 600, 400));
target.add_lane(KanbanLane::new("fast", "Fast"));
target.add_lane(KanbanLane::new("slow", "Slow"));
target.set("columns", source.get("columns").unwrap()).expect("columns writable");
target.set("cards", source.get("cards").unwrap()).expect("cards writable");
assert_eq!(target.get("columns").unwrap(), source.get("columns").unwrap());
assert_eq!(target.get("cards").unwrap(), source.get("cards").unwrap());
assert_eq!(target.get("column_count").unwrap(), CapabilityValue::UInt(1));
assert_eq!(target.get("card_count").unwrap(), CapabilityValue::UInt(2));
assert_eq!(target.lane_of("a"), None);
assert_eq!(target.lane_of("b"), Some("slow"));
}
#[test]
fn a_card_naming_an_unknown_column_is_refused_without_side_effects() {
let mut board = board();
let CapabilityValue::Records(mut list) = board.get("cards").unwrap() else {
panic!("cards must be records");
};
list = crate::widget::capability::RecordList::from_items(
&KANBAN_CARD_SCHEMA,
list.items()
.iter()
.map(|row| {
let mut row = row.clone();
row = row.with("column", CapabilityValue::UInt(99));
row
})
.collect(),
);
let refused = board.set("cards", CapabilityValue::Records(list));
assert!(
matches!(refused, Err(CapabilityAccessError::ConstraintViolation { .. })),
"expected a constraint violation, got {refused:?}"
);
assert_eq!(board.get("column_count").unwrap(), CapabilityValue::UInt(3));
assert_eq!(board.get("card_count").unwrap(), CapabilityValue::UInt(1));
assert_eq!(board.columns()[0].cards[0].id, "a");
}
#[test]
fn a_column_write_adopts_its_cards_by_id() {
let mut board = board();
board.set("columns", board.get("columns").unwrap()).expect("identity write");
assert_eq!(board.column_count(), 3);
assert_eq!(board.columns()[0].cards.len(), 1, "todo keeps its card");
assert_eq!(board.columns()[0].cards[0].id, "a");
}
#[test]
fn set_columns_and_set_cards_report_a_missing_payload() {
let mut board = board();
assert_eq!(board.command("set_columns"), Err(CapabilityAccessError::OutOfRange));
assert_eq!(board.command("set_cards"), Err(CapabilityAccessError::OutOfRange));
}
}
#[cfg(test)]
mod issue_2_8_tests {
use super::*;
use crate::event::Event;
use std::sync::{Arc, Mutex};
fn board() -> KanbanBoard {
let mut board = KanbanBoard::new(Rect::new(0, 0, 600, 300));
board.add_column(KanbanColumn::new("todo", "To do"));
board.add_card(0, KanbanCard::new("a", "Alpha"));
board.add_card(0, KanbanCard::new("b", "Beta"));
board.add_card(0, KanbanCard::new("c", "Gamma"));
board
}
#[test]
fn multi_selection_keeps_display_order_and_drops_removed_cards() {
let mut board = board();
assert!(board.set_card_selected("c", true));
assert!(board.set_card_selected("a", true));
assert_eq!(board.selected_cards(), ["a", "c"]);
assert!(board.set_card_selected("a", false));
assert_eq!(board.selected_cards(), ["c"]);
board.remove_card("c");
assert!(board.selected_cards().is_empty());
assert!(!board.set_card_selected("missing", true));
}
#[test]
fn a_primary_modified_press_toggles_the_selection() {
let mut board = board();
let rect = board.card_rect(CardPosition { column: 0, card: 0 }).expect("a laid-out card");
let primary = crate::widget::input_widgets::shortcut_editor::primary_modifier_event_bits();
board.handle_event(&Event::mouse_press_with(rect.x + 4, rect.y + 4, 1, primary));
assert_eq!(board.selected_cards(), ["a"]);
assert!(!board.is_dragging_card());
board.handle_event(&Event::mouse_press_with(rect.x + 4, rect.y + 4, 1, primary));
assert!(board.selected_cards().is_empty());
}
#[test]
fn hovering_a_card_emits_its_id_and_leaving_emits_none() {
let mut board = board();
let seen = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
let sink = seen.clone();
board.card_hovered.connect_ref(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.clone());
}
});
let rect = board.card_rect(CardPosition { column: 0, card: 1 }).expect("a laid-out card");
board.handle_event(&Event::mouse_move(rect.x + 4, rect.y + 4));
board.handle_event(&Event::mouse_leave(rect.x + 4, rect.y + 4));
let seen = seen.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(seen, vec![Some("b".to_string()), None]);
}
#[test]
fn the_card_index_matches_every_card_position() {
let board = board();
for (position, card) in board.cards() {
assert_eq!(board.position_of(&card.id), Some(position));
}
assert!(board.position_of("missing").is_none());
}
}