use crate::core::buffer::Buffer;
use crate::core::rect::Rect;
use crate::interaction::{HitRegion, InteractionLayer, WidgetAction, WidgetId, WidgetRole};
use crate::sanitize;
use crate::theme::ThemeTokens;
use crate::widgets::block::{Block, BorderStyle};
use crate::widgets::Widget;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KanbanCard {
pub id: String,
pub title: String,
pub badges: Vec<String>,
pub note: Option<String>,
}
impl KanbanCard {
pub fn new(id: &str, title: &str) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
badges: Vec::new(),
note: None,
}
}
pub fn with_badge(mut self, badge: &str) -> Self {
self.badges.push(badge.to_string());
self
}
pub fn with_note(mut self, note: &str) -> Self {
self.note = Some(note.to_string());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KanbanLane {
pub id: String,
pub title: String,
pub cards: Vec<KanbanCard>,
pub empty_hint: String,
}
impl KanbanLane {
pub fn new(id: &str, title: &str) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
cards: Vec::new(),
empty_hint: "empty".to_string(),
}
}
pub fn with_card(mut self, card: KanbanCard) -> Self {
self.cards.push(card);
self
}
pub fn with_empty_hint(mut self, hint: &str) -> Self {
self.empty_hint = hint.to_string();
self
}
}
#[derive(Debug, Clone)]
pub struct KanbanBoard {
pub lanes: Vec<KanbanLane>,
pub tokens: ThemeTokens,
pub intake_label: Option<String>,
pub region_id: Option<WidgetId>,
}
impl KanbanBoard {
pub fn new(lanes: Vec<KanbanLane>) -> Self {
Self {
lanes,
tokens: ThemeTokens::SCRIN,
intake_label: None,
region_id: None,
}
}
pub fn with_tokens(mut self, tokens: ThemeTokens) -> Self {
self.tokens = tokens;
self
}
pub fn with_intake_label(mut self, label: &str) -> Self {
self.intake_label = Some(label.to_string());
self
}
pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
self.region_id = Some(id.into());
self
}
pub fn lane_rects(&self, area: Rect) -> Vec<Rect> {
if self.lanes.is_empty() || area.is_empty() {
return Vec::new();
}
let count = self.lanes.len() as u16;
let width = (area.width / count).max(1);
let mut rects = Vec::with_capacity(self.lanes.len());
let mut x = area.x;
for idx in 0..self.lanes.len() {
let w = if idx + 1 == self.lanes.len() {
area.right().saturating_sub(x)
} else {
width.min(area.right().saturating_sub(x))
};
rects.push(Rect::new(x, area.y, w, area.height));
x = x.saturating_add(w);
}
rects
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
) {
self.render(buffer, area);
if area.is_empty() {
return;
}
let region_id = self
.region_id
.clone()
.unwrap_or_else(|| WidgetId::new("kanban"));
layer.push_region(
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Panel)
.with_label("kanban board"),
);
for (lane, rect) in self.lanes.iter().zip(self.lane_rects(area)) {
layer.push_region(
HitRegion::new(format!("{}:lane:{}", region_id.as_ref(), lane.id), rect)
.with_role(WidgetRole::BoardColumn)
.with_label(format!("{} ({})", lane.title, lane.cards.len()))
.with_action(WidgetAction::Focus)
.with_z_index(1),
);
let inner = Block::inner_for_bordered(rect);
let max_cards = inner.height.saturating_sub(2) as usize;
for (row, card) in lane.cards.iter().take(max_cards).enumerate() {
let y = inner.y.saturating_add(1 + row as u16);
let card_area = Rect::new(inner.x, y, inner.width, 1);
layer.push_region(
HitRegion::new(
format!("{}:card:{}", region_id.as_ref(), card.id),
card_area,
)
.with_role(WidgetRole::BoardCard)
.with_label(card.title.clone())
.with_action(WidgetAction::Open)
.with_row(row)
.with_z_index(2),
);
}
}
}
}
impl Widget for KanbanBoard {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
buffer.fill(area, ' ', self.tokens.text, Some(self.tokens.panel));
for (lane, rect) in self.lanes.iter().zip(self.lane_rects(area)) {
let title = format!("{} ({})", lane.title, lane.cards.len());
Block::new(&title)
.with_borders(BorderStyle::Rounded)
.with_border_color(self.tokens.dim)
.render(buffer, rect);
let inner = Block::inner_for_bordered(rect);
if inner.is_empty() {
continue;
}
if lane.cards.is_empty() {
buffer.set_str(
inner.x as usize,
inner.y as usize,
&sanitize::truncate_str(&lane.empty_hint, inner.width as usize),
self.tokens.dim,
Some(self.tokens.panel),
);
}
let max_cards = inner.height.saturating_sub(2) as usize;
for (row, card) in lane.cards.iter().take(max_cards).enumerate() {
let y = inner.y as usize + 1 + row;
let badges = if card.badges.is_empty() {
String::new()
} else {
format!(" [{}]", card.badges.join(","))
};
let text = format!("- {}{}", card.title, badges);
buffer.set_str(
inner.x as usize,
y,
&sanitize::truncate_str(&text, inner.width as usize),
self.tokens.text,
Some(self.tokens.panel),
);
}
if lane.cards.len() > max_cards && inner.height > 0 {
let more = format!("+{} more", lane.cards.len() - max_cards);
buffer.set_str(
inner.x as usize,
inner.bottom().saturating_sub(1) as usize,
&sanitize::truncate_str(&more, inner.width as usize),
self.tokens.warning,
Some(self.tokens.panel),
);
} else if let Some(label) = &self.intake_label {
buffer.set_str(
inner.x as usize,
inner.bottom().saturating_sub(1) as usize,
&sanitize::truncate_str(label, inner.width as usize),
self.tokens.accent,
Some(self.tokens.panel),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kanban_board_registers_card_hit_regions() {
let board = KanbanBoard::new(vec![
KanbanLane::new("todo", "Todo").with_card(KanbanCard::new("a", "Build")),
KanbanLane::new("done", "Done"),
KanbanLane::new("later", "Later"),
])
.with_region_id("board");
let mut buffer = Buffer::new(60, 8);
let mut layer = InteractionLayer::new();
board.render_with_interaction(&mut buffer, Rect::new(0, 0, 60, 8), &mut layer);
assert!(layer
.regions
.iter()
.any(|region| region.id.as_ref() == "board:card:a"));
}
}