1use std::rc::Rc;
11
12use frus_core::{Color, Insets, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
13use frus_layout::{Align, Dimension, FlexDirection, Style};
14
15use crate::button::{Button, Variant};
16use crate::flex::Flex;
17use crate::interaction::Status;
18use crate::scroll::{Axis, SingleChildScrollView};
19use crate::text::Text;
20use crate::theme::Theme;
21use crate::widget::{CellFn, ReorderAxis, Widget};
22
23type MoveFn<Msg> = Rc<dyn Fn(usize, usize, usize, usize) -> Msg>;
26
27const STRIDE: usize = 1000;
30const COL_W: f32 = 220.0;
32const COL_PAD: f32 = 12.0;
34const CARD_H: f32 = 44.0;
36fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
45 over.or(theme.and_then(|t| t.widgets.kanban.card_text_style))
46 .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
47 .resolved()
48}
49
50fn column_title_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
55 over.or(theme.and_then(|t| t.widgets.kanban.column_title_text_style))
56 .unwrap_or_else(|| crate::theme::type_scale(theme).title_medium)
57}
58
59pub fn kanban_slot(col: usize, pos: usize) -> usize {
63 debug_assert!(
66 pos < STRIDE,
67 "position {pos} out of bounds (STRIDE = {STRIDE}): column overflow"
68 );
69 col * STRIDE + pos
70}
71
72fn decode(slot: usize) -> (usize, usize) {
74 (slot / STRIDE, slot % STRIDE)
75}
76
77struct Card<Msg> {
81 label: String,
82 content: Vec<Box<dyn Widget<Msg>>>,
84 slot: usize,
86 from_col: usize,
87 from_pos: usize,
88 on_move: Option<MoveFn<Msg>>,
89 text_style: Option<TextStyle>,
90}
91
92impl<Msg> Card<Msg> {
93 fn sizing(&self, theme: Option<&Theme>) -> Style {
94 if self.content.is_empty() {
95 Style {
97 width: Dimension::Auto,
98 height: Dimension::Length(frus_text::line_box(
99 CARD_H,
100 &label_style(self.text_style, theme),
101 0.0,
102 )),
103 ..Default::default()
104 }
105 } else {
106 Style {
108 width: Dimension::Auto,
109 height: Dimension::Auto,
110 min_height: Dimension::Length(CARD_H),
111 padding: Insets::uniform(8.0),
112 align: Align::Center,
113 ..Default::default()
114 }
115 }
116 }
117}
118
119impl<Msg: Clone> Widget<Msg> for Card<Msg> {
120 fn style(&self) -> Style {
121 self.sizing(None)
122 }
123
124 fn style_themed(&self, theme: &Theme) -> Style {
125 self.sizing(Some(theme))
126 }
127
128 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
129 &self.content
130 }
131
132 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
133 let o = status.opacity;
134 let base = theme.surface.lerp(theme.on_surface, 0.05);
136 let fill = theme.state_layer(base, theme.on_surface, &status);
137 scene.draw_rect(
138 bounds,
139 fill.fade(o),
140 theme.radius,
141 1.0,
142 theme.scheme.outline_variant.fade(o),
143 );
144 if self.content.is_empty() {
146 let style = label_style(self.text_style, Some(theme));
147 let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
148 scene.text(
149 Point::new(bounds.x + 12.0, ty),
150 self.label.clone(),
151 &style,
152 theme.on_surface.fade(o),
153 );
154 }
155 }
156
157 fn on_click(&self) -> Option<Msg> {
158 None
159 }
160
161 fn reorder_index(&self) -> Option<usize> {
162 Some(self.slot)
163 }
164
165 fn on_reorder(&self, to: usize) -> Option<Msg> {
166 let (to_col, to_pos) = decode(to);
168 self.on_move
169 .as_ref()
170 .map(|f| f(self.from_col, self.from_pos, to_col, to_pos))
171 }
172
173 fn reorder_axis(&self) -> ReorderAxis {
174 ReorderAxis::Vertical }
176}
177
178struct DropZone {
181 slot: usize,
182}
183
184impl<Msg: Clone> Widget<Msg> for DropZone {
185 fn style(&self) -> Style {
186 Style {
187 width: Dimension::Auto,
188 height: Dimension::Length(CARD_H * 0.8),
189 ..Default::default()
190 }
191 }
192
193 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
194 &[]
195 }
196
197 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
198 scene.draw_rect(
200 bounds,
201 Color::TRANSPARENT,
202 theme.radius,
203 1.0,
204 theme.scheme.outline_variant.fade(status.opacity * 0.5),
205 );
206 }
207
208 fn on_click(&self) -> Option<Msg> {
209 None
210 }
211
212 fn reorder_index(&self) -> Option<usize> {
213 Some(self.slot)
214 }
215
216 fn on_reorder(&self, _to: usize) -> Option<Msg> {
217 None }
219
220 fn reorder_axis(&self) -> ReorderAxis {
221 ReorderAxis::Vertical
222 }
223
224 fn reorder_draggable(&self) -> bool {
225 false }
227}
228
229struct Column<Msg> {
232 children: Vec<Box<dyn Widget<Msg>>>,
233}
234
235impl<Msg: Clone> Widget<Msg> for Column<Msg> {
236 fn style(&self) -> Style {
237 Style {
238 width: Dimension::Length(COL_W),
239 flex_direction: FlexDirection::Column,
240 gap: 8.0,
241 padding: Insets::uniform(COL_PAD),
242 ..Default::default()
243 }
244 }
245
246 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
247 &self.children
248 }
249
250 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
251 let bg = theme.surface.lerp(theme.on_surface, 0.04);
253 scene.draw_rect(
254 bounds,
255 bg.fade(status.opacity),
256 theme.radius,
257 0.0,
258 Color::TRANSPARENT,
259 );
260 }
261
262 fn on_click(&self) -> Option<Msg> {
263 None
264 }
265}
266
267pub type CardFactory<Msg> = Rc<dyn Fn() -> Box<dyn Widget<Msg>>>;
279
280enum ColCards<Msg> {
282 Text(Vec<String>),
283 Widgets(Vec<CardFactory<Msg>>),
284}
285
286impl<Msg> ColCards<Msg> {
287 fn len(&self) -> usize {
288 match self {
289 ColCards::Text(v) => v.len(),
290 ColCards::Widgets(v) => v.len(),
291 }
292 }
293}
294
295pub struct Kanban<Msg = crate::callback::Callback> {
301 on_move: Option<MoveFn<Msg>>,
302 on_add: Option<Rc<dyn Fn(usize) -> Msg>>,
304 card_area_height: Option<f32>,
309 fill_columns: bool,
314 card_text_style: Option<TextStyle>,
315 column_title_text_style: Option<TextStyle>,
316 columns: Vec<(String, ColCards<Msg>)>,
317 children: Vec<Box<dyn Widget<Msg>>>,
318}
319
320impl<Msg: Clone + 'static> Kanban<Msg> {
321 pub fn new<R: crate::callback::IntoMsg<Msg>>(
324 on_move: impl Fn(usize, usize, usize, usize) -> R + 'static,
325 ) -> Self {
326 Self {
327 on_move: Some(Rc::new(crate::callback::handler4(on_move))),
328 on_add: None,
329 card_area_height: None,
330 fill_columns: false,
331 card_text_style: None,
332 column_title_text_style: None,
333 columns: Vec::new(),
334 children: Vec::new(),
335 }
336 }
337
338 #[must_use]
340 pub fn card_text_style(mut self, style: TextStyle) -> Self {
341 self.card_text_style = Some(style);
342 self.rebuild();
343 self
344 }
345
346 #[must_use]
348 pub fn column_title_text_style(mut self, style: TextStyle) -> Self {
349 self.column_title_text_style = Some(style);
350 self.rebuild();
351 self
352 }
353
354 pub fn column(
356 mut self,
357 title: impl Into<String>,
358 cards: impl IntoIterator<Item = impl Into<String>>,
359 ) -> Self {
360 let cards = ColCards::Text(cards.into_iter().map(Into::into).collect());
361 self.columns.push((title.into(), cards));
362 self.rebuild();
363 self
364 }
365
366 pub fn column_widgets(
370 mut self,
371 title: impl Into<String>,
372 cards: impl IntoIterator<Item = CellFn<Msg>>,
373 ) -> Self {
374 let cards = ColCards::Widgets(cards.into_iter().map(Rc::from).collect());
375 self.columns.push((title.into(), cards));
376 self.rebuild();
377 self
378 }
379
380 pub fn on_add<R: crate::callback::IntoMsg<Msg>>(
383 mut self,
384 on_add: impl Fn(usize) -> R + 'static,
385 ) -> Self {
386 self.on_add = Some(Rc::new(crate::callback::handler1(on_add)));
387 self.rebuild();
388 self
389 }
390
391 pub fn card_area_height(mut self, h: f32) -> Self {
400 self.card_area_height = Some(h);
401 self.rebuild();
402 self
403 }
404
405 pub fn scrollable_columns(mut self) -> Self {
412 self.fill_columns = true;
413 self.rebuild();
414 self
415 }
416
417 fn rebuild(&mut self) {
418 self.children = self
419 .columns
420 .iter()
421 .enumerate()
422 .map(|(c, (title, cards))| self.build_column(c, title, cards))
423 .collect();
424 }
425
426 fn build_column(&self, col: usize, title: &str, cards: &ColCards<Msg>) -> Box<dyn Widget<Msg>> {
429 let make_card = |pos: usize, content: Vec<Box<dyn Widget<Msg>>>, label: String| Card {
430 label,
431 content,
432 slot: kanban_slot(col, pos),
433 from_col: col,
434 from_pos: pos,
435 on_move: self.on_move.clone(),
436 text_style: self.card_text_style,
437 };
438 let mut cards_v: Vec<Box<dyn Widget<Msg>>> = Vec::new();
441 match cards {
442 ColCards::Text(labels) => {
443 for (pos, label) in labels.iter().enumerate() {
444 cards_v.push(Box::new(make_card(pos, Vec::new(), label.clone())));
445 }
446 }
447 ColCards::Widgets(factories) => {
448 for (pos, make) in factories.iter().enumerate() {
449 cards_v.push(Box::new(make_card(pos, vec![make()], String::new())));
450 }
451 }
452 }
453 cards_v.push(Box::new(DropZone {
455 slot: kanban_slot(col, cards.len()),
456 }));
457
458 let mut children: Vec<Box<dyn Widget<Msg>>> = vec![Box::new(Text::styled(
459 title.to_string(),
460 column_title_style(self.column_title_text_style, None),
461 ))];
462 let inner_w = COL_W - 2.0 * COL_PAD;
465 let list = |cards: Vec<Box<dyn Widget<Msg>>>| {
466 cards
467 .into_iter()
468 .fold(Flex::column().gap(8.0).width(inner_w), Flex::child_boxed)
469 };
470 if self.fill_columns {
471 children.push(Box::new(
475 SingleChildScrollView::new()
476 .axis(Axis::Vertical)
477 .width(inner_w)
478 .flex(1.0)
479 .child(list(cards_v)),
480 ));
481 } else if let Some(h) = self.card_area_height {
482 children.push(Box::new(
484 SingleChildScrollView::new()
485 .axis(Axis::Vertical)
486 .width(inner_w)
487 .height(h)
488 .child(list(cards_v)),
489 ));
490 } else {
491 children.extend(cards_v);
493 }
494 if let Some(on_add) = &self.on_add {
496 let on_add = on_add.clone();
497 children.push(Box::new(
498 Button::new("+ Add card")
499 .variant(Variant::Outlined)
500 .size(13.0)
501 .on_press(on_add(col)),
502 ));
503 }
504 Box::new(Column { children })
505 }
506}
507
508impl<Msg: Clone> Widget<Msg> for Kanban<Msg> {
509 fn style(&self) -> Style {
510 let (align, height) = if self.fill_columns {
515 (Align::Stretch, Dimension::Percent(1.0))
516 } else {
517 (Align::Start, Dimension::Auto)
518 };
519 Style {
520 height,
521 flex_direction: FlexDirection::Row,
522 gap: 12.0,
523 align,
524 padding: Insets::ZERO,
525 ..Default::default()
526 }
527 }
528
529 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
530 &self.children
531 }
532
533 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
534
535 fn on_click(&self) -> Option<Msg> {
536 None
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[derive(Clone, Debug, PartialEq)]
545 enum Msg {
546 Move(usize, usize, usize, usize),
547 Add(usize),
548 Del(usize),
549 }
550
551 fn collect_clicks(w: &dyn Widget<Msg>, out: &mut Vec<Msg>) {
553 if let Some(m) = w.on_click() {
554 out.push(m);
555 }
556 for c in w.children() {
557 collect_clicks(c.as_ref(), out);
558 }
559 }
560
561 fn first_card(w: &dyn Widget<Msg>, target: usize) -> Option<(usize, Option<Msg>)> {
564 if let Some(idx) = w.reorder_index() {
565 return Some((idx, w.on_reorder(target)));
566 }
567 for c in w.children() {
568 if let Some(found) = first_card(c.as_ref(), target) {
569 return Some(found);
570 }
571 }
572 None
573 }
574
575 #[test]
576 fn slot_encoding_roundtrips() {
577 assert_eq!(decode(kanban_slot(0, 0)), (0, 0));
578 assert_eq!(decode(kanban_slot(2, 5)), (2, 5));
579 assert_eq!(decode(kanban_slot(1, 3)), (1, 3));
580 }
581
582 #[test]
583 fn dropping_a_card_routes_a_cross_column_move() {
584 let board = Kanban::new(Msg::Move)
585 .column("To do", ["A", "B"])
586 .column("Doing", ["C"]);
587 let col0 = &Widget::<Msg>::children(&board)[0];
590 let (idx, moved) = first_card(col0.as_ref(), kanban_slot(1, 0)).expect("a card");
591 assert_eq!(idx, kanban_slot(0, 0), "the source card's flat index");
592 assert_eq!(
593 moved,
594 Some(Msg::Move(0, 0, 1, 0)),
595 "a drop at (1,0): a cross-column move"
596 );
597 }
598
599 #[test]
600 fn cards_declare_vertical_reorder_axis() {
601 fn first_axis(w: &dyn Widget<Msg>) -> Option<ReorderAxis> {
602 if w.reorder_index().is_some() {
603 return Some(w.reorder_axis());
604 }
605 w.children().iter().find_map(|c| first_axis(c.as_ref()))
606 }
607 let board = Kanban::new(Msg::Move).column("A", ["x"]);
608 let col0 = &Widget::<Msg>::children(&board)[0];
609 assert_eq!(
610 first_axis(col0.as_ref()),
611 Some(ReorderAxis::Vertical),
612 "the cards slide vertically"
613 );
614 }
615
616 #[test]
617 fn rich_cards_host_content_and_add_button_is_present() {
618 let cards: Vec<CellFn<Msg>> = (0..2)
620 .map(|i| {
621 Box::new(move || {
622 Box::new(Button::new("x").on_press(Msg::Del(i))) as Box<dyn Widget<Msg>>
623 }) as CellFn<Msg>
624 })
625 .collect();
626 let board = Kanban::new(Msg::Move)
627 .on_add(Msg::Add)
628 .column_widgets("Col", cards);
629 let col0 = &Widget::<Msg>::children(&board)[0];
630 let (idx, moved) = first_card(col0.as_ref(), kanban_slot(0, 1)).expect("a card");
632 assert_eq!(idx, kanban_slot(0, 0), "the rich card keeps its flat index");
633 assert_eq!(
634 moved,
635 Some(Msg::Move(0, 0, 0, 1)),
636 "and still routes the move"
637 );
638 let mut clicks = Vec::new();
640 collect_clicks(col0.as_ref(), &mut clicks);
641 assert!(
642 clicks.contains(&Msg::Del(0)) && clicks.contains(&Msg::Del(1)),
643 "a × delete per card"
644 );
645 assert!(
646 clicks.contains(&Msg::Add(0)),
647 "the column's + Add card button"
648 );
649 }
650
651 #[test]
652 fn cards_are_draggable_but_the_drop_zone_is_target_only() {
653 fn scan(w: &dyn Widget<Msg>, out: &mut Vec<bool>) {
656 if w.reorder_index().is_some() {
657 out.push(w.reorder_draggable());
658 }
659 for c in w.children() {
660 scan(c.as_ref(), out);
661 }
662 }
663 let board = Kanban::new(Msg::Move).column("To do", ["A", "B"]);
664 let col0 = &Widget::<Msg>::children(&board)[0];
665 let mut flags = Vec::new();
666 scan(col0.as_ref(), &mut flags);
667 assert_eq!(
669 flags.iter().filter(|&&d| d).count(),
670 2,
671 "both cards are grabbable"
672 );
673 assert_eq!(
674 flags.iter().filter(|&&d| !d).count(),
675 1,
676 "the drop zone is target-only"
677 );
678 }
679
680 #[test]
681 fn board_lays_out_one_widget_per_column() {
682 let board = Kanban::new(Msg::Move)
683 .column("A", ["x"])
684 .column("B", Vec::<String>::new());
685 assert_eq!(
686 Widget::<Msg>::children(&board).len(),
687 2,
688 "one widget per column"
689 );
690 }
691}