Skip to main content

frus_widgets/
kanban.rs

1//! [`Kanban`]: a **columns + cards** board with **drag and drop** between columns.
2//!
3//! Like the rest of frus, the widget is **controlled**: the application holds the columns and
4//! their cards, and reacts to a single `on_move(from_col, from_pos, to_col, to_pos)` message
5//! emitted when a card is dropped. Drag and drop reuses the framework's **reordering**
6//! mechanism (`reorder_index` / `on_reorder`): every slot carries a **flat index**
7//! `col * STRIDE + pos`, and the card grabbed decodes the **target** slot (another card, or
8//! the drop zone at the bottom of a column) to work out the destination.
9
10use 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
23/// A card moving between columns: `(from_col, from_pos, to_col, to_pos)` into a
24/// message.
25type MoveFn<Msg> = Rc<dyn Fn(usize, usize, usize, usize) -> Msg>;
26
27/// The encoding stride of a `(column, position)` slot into a flat index: it bounds the number
28/// of cards per column (amply enough for a board). See [`kanban_slot`].
29const STRIDE: usize = 1000;
30/// A column's width.
31const COL_W: f32 = 220.0;
32/// A column panel's (uniform) inner padding.
33const COL_PAD: f32 = 12.0;
34/// A card's height.
35const CARD_H: f32 = 44.0;
36/// A text card's label: what the caller said, else what the theme says, else `bodyLarge`.
37///
38/// The reference has no board, so this one is argued rather than read: a card is a small
39/// piece of content read on its own, which is the step the reference gives a list tile's
40/// title and this framework already gives a `Tree` row.
41///
42/// **Resolved once**, so that the number the box is measured with is the number the glyphs
43/// are drawn at.
44fn 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
50/// A column's heading: `titleMedium`, the step a heading over a short list takes.
51///
52/// Left as a [`TextStyle`] rather than resolved, because the heading is a [`Text`] child
53/// and a `Text` resolves its own.
54fn 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
59/// The **flat** index of a `(col, pos)` slot: `col * STRIDE + pos`. This is a card's
60/// [`reorder_index`](Widget::reorder_index) value (both source **and** target). Reusable to
61/// test the routing of drag and drop.
62pub fn kanban_slot(col: usize, pos: usize) -> usize {
63    // Past STRIDE cards in a column, `pos` would overflow into the column field (the flat
64    // index would silently target the next column). A debug guard; STRIDE stays generous.
65    debug_assert!(
66        pos < STRIDE,
67        "position {pos} out of bounds (STRIDE = {STRIDE}): column overflow"
68    );
69    col * STRIDE + pos
70}
71
72/// Decodes a flat index into `(col, pos)` (the inverse of [`kanban_slot`]).
73fn decode(slot: usize) -> (usize, usize) {
74    (slot / STRIDE, slot % STRIDE)
75}
76
77/// A card: both **source** and **target** of drag and drop. Painted as a raised tile, it shows
78/// either a **label** (a text card) or **widget content** supplied by the application (a rich
79/// card: a label + tags + a delete button…), placed as a child.
80struct Card<Msg> {
81    label: String,
82    /// **Rich** content (0 or 1 widget): when present, the card hosts it instead of the label.
83    content: Vec<Box<dyn Widget<Msg>>>,
84    /// Its own slot (a flat index): serves as `reorder_index`, both grabbed source **and** drop target.
85    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            // A text card: a fixed height, with the label painted by the card.
96            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            // A rich card: the content is a child, the card adapts (floor `CARD_H`), and has padding.
107            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        // A raised tile; tinted on hover, as a grab affordance.
135        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        // A label only for a **text** card (a rich card paints its own content).
145        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        // The target `to` is the flat index of the hovered slot (another card, or a drop zone).
167        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 // cards slide vertically
175    }
176}
177
178/// The drop zone at the bottom of a column: the insertion target **at the end** of the column
179/// (and the only target of an empty one). Not a useful source (its `on_reorder` moves nothing).
180struct 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        // A discreet simulated dashed outline (a faded border): "drop here".
199        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 // the drop zone is not a move source
218    }
219
220    fn reorder_axis(&self) -> ReorderAxis {
221        ReorderAxis::Vertical
222    }
223
224    fn reorder_draggable(&self) -> bool {
225        false // target **only**: you drop onto it, you do not lift it
226    }
227}
228
229/// A column's **panel**: title + cards + drop zone, on a discreet themed background. It is a
230/// vertical `Flex` container that paints its own background (the panel's clip holds its cards).
231struct 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        // The panel's **themed** background (a default overridable through the theme): a discreet veil.
252        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
267/// A **Kanban** board: titled columns of cards, with drag and drop between columns.
268///
269/// ```
270/// use frus_widgets::Kanban;
271/// let board: Kanban = Kanban::new(|fc, fp, tc, tp| { let _ = (fc, fp, tc, tp); })
272///     .column("To do", ["Design", "Spec"])
273///     .column("Doing", ["Build"])
274///     .column("Done", ["Kickoff"]);
275/// ```
276/// The factory for a card's **rich content**: called again on every rebuild (a fresh widget), for
277/// cards beyond plain text (a label + tags + a delete button…).
278pub type CardFactory<Msg> = Rc<dyn Fn() -> Box<dyn Widget<Msg>>>;
279
280/// A column's cards: plain **text**, or rich **widgets**, one per card.
281enum 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
295/// A **board** of columns holding cards, which can be dragged from one column to another
296/// or reordered inside one.
297///
298/// It is controlled: the application holds the columns and their cards, and the board only
299/// says where a card was dropped, through the callback given to [`Kanban::new`].
300pub struct Kanban<Msg = crate::callback::Callback> {
301    on_move: Option<MoveFn<Msg>>,
302    /// A "+ Add card" button at the bottom of every column (milestone 249): `on_add(col)` on adding.
303    on_add: Option<Rc<dyn Fn(usize) -> Msg>>,
304    /// An explicit height for each column's **scrollable card area** (milestone 264, Trello
305    /// style). `Some(h)`: the cards scroll vertically in a region of height `h` (with the title
306    /// fixed above and the "+ Add card" button fixed below). `None`: the column stretches to
307    /// the height of its content (the original behaviour). See [`Kanban::card_area_height`].
308    card_area_height: Option<f32>,
309    /// Per-column vertical scrolling **Trello style, with no explicit height** (milestone 266):
310    /// the columns **fill** the board's height (through a stretched `Row`) and each column's
311    /// card area is a `flex(1)` `SingleChildScrollView` that takes the rest and then scrolls. It takes
312    /// precedence over `card_area_height`. See [`Kanban::scrollable_columns`].
313    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    /// Creates a board; `on_move(from_col, from_pos, to_col, to_pos)` is emitted when a card is
322    /// **dropped** onto a slot (another card, or the end of a column).
323    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    /// The text cards' type, over the theme's and the framework's.
339    #[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    /// The column headings' type, over the theme's and the framework's.
347    #[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    /// Adds a titled **column** with its (text) cards, in order.
355    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    /// Adds a column in which every card is a **widget** (a rich card: a label + tags + a delete
367    /// button…), supplied by a **factory** called again on every rebuild. The card stays a drag
368    /// and drop source and target; its clickable elements (a × button…) capture their own click.
369    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    /// Adds a **"+ Add card"** button at the bottom of every column; `on_add(col)` on click (the
381    /// app adds a card to the column). Without this call, there is no add button.
382    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    /// Makes each column's cards **scroll vertically** within a region of height `h` (in logical
392    /// pixels), Trello style: the **title** stays fixed above, the "+ Add card" button fixed
393    /// below, and only the cards (with the final drop zone) scroll. Without this call, the
394    /// column stretches to the height of its content.
395    ///
396    /// This is a stopgap **controlled by the application**, which supplies the height: a
397    /// `flex(1)` `SingleChildScrollView` receives no usable height until its chain of ancestors gives it a
398    /// **definite** height (milestone 263), hence an explicit height here.
399    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    /// Per-column vertical scrolling **Trello style, with no explicit height** (milestone 266):
406    /// the columns **fill** the board's height and each column's card area takes the rest
407    /// (below the title, above the "+ Add card" button) and then **scrolls**. Unlike
408    /// [`Kanban::card_area_height`], the app has no height to compute: the board sits in an
409    /// ancestor of **definite height** (a window, a bounded horizontal `SingleChildScrollView`…) and the flex
410    /// fill does the rest. Takes precedence over `card_area_height` when both are set.
411    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    /// Builds one column: title + cards (each a drop source and target) + the final drop zone +
427    /// an optional add button.
428    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        // The cards (each both source **and** target) + the final drop zone: this is the
439        // column's **scrollable** part (the title and the add button stay fixed).
440        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        // The insertion slot at the end of the column (and the target of an empty one).
454        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        // The column's inner width (the column width minus its padding). The scrollable card
463        // area wraps the cards in a vertical `Flex` (a single child for the `SingleChildScrollView`).
464        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            // **Filling** (milestone 266): the column fills the board's height (a stretched Row);
472            // the card area, a `flex(1)` `SingleChildScrollView`, takes the rest (below the title, above the
473            // button) and then scrolls. No explicit height: the flex does the arithmetic.
474            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            // An **explicit** height (milestone 264): the fallback when the ancestor has no definite height.
483            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            // A column that stretches to the height of its content (the original behaviour, bare cards).
492            children.extend(cards_v);
493        }
494        // The "+ Add card" button (milestone 249), if requested.
495        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        // **Filling** mode (milestone 266): the row takes its parent's height (`Percent(1.0)`
511        // — the ancestor has a definite height) and **stretches** its columns
512        // (`Align::Stretch`) so each fills the board; their `flex(1)` card area then takes
513        // the rest and scrolls. Otherwise: the content's height, columns aligned to the top.
514        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    /// Collects, in tree order, every non-null `on_click` message of a subtree.
552    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    /// Finds a subtree's first card (a widget with a `reorder_index`) and returns
562    /// `(reorder_index, on_reorder(target))`.
563    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        // The first card = column 0, position 0 ("A"): its flat index, and the move produced
588        // when it is dropped onto slot (1, 0) of the "Doing" column.
589        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        // A **rich** column: each card hosts a × (delete) button; plus an add button.
619        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        // The rich card stays **reorderable** (dragging wired up) and routes a Move.
631        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        // The reachable clicks include deletion (each card's ×) and adding (the column button).
639        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        /// Returns the `reorder_draggable` of the first reorderable (a card) and looks for a
654        /// drop zone (a reorderable that is **not** grabbable) in the subtree.
655        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        // Two grabbable cards + one non-grabbable drop zone.
668        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}