Skip to content

board

カードをドラッグして列の間を移動しても、入力中のタイトルはそのまま残る。

ほかのサンプルは状態を 1 か所に持ち、それを描きます。これは アイテム に属する状態の話です。誰かが入力しかけたタイトルと、カードが並べ替えられ、別の列へ移され、フィルタで消え、また戻ってきたときに、それがどうなるか。カードのタイトルを打ちかけて、そのカードを別の列へドラッグしてみてください。下書きは一緒に付いていきます。

そうなるのは、下書きの鍵がカードの描かれる場所ではなく、カードの同一性だからです。違いを生んでいるのは、このサンプル自身の自作フック の 1 つ、use_identity です。key=だけでは足りません。カードを動かすと親が変わってしまうからです。削除されたカード ― フィルタで視界から外れたカードも、同じようにアンマウントされます ―の状態は、パス終わりの掃除が落とします。素の egui 版が手で書かなければならない後始末が、これです。

あとは組み立ての話です。<Toolbar>、<Column>、<Card>、<TitleEdit>、<Chip>、<IconButton> はどれも style: ItemStyle を取るので、どこに座るかは呼び出し側が決めます。そして何が起きたかをイベントの props で知らせます。undo / redo、検索のデバウンス、ドラッグの一連の流れは、アプリケーションが使えるのと同じ公開API に対して書かれた自作フックの中にあります。どれもライブラリの変更を必要としませんでした。何がどちら向きに流れるかは、ここでは 2 回決められています。コンポーネントが したこと はイベントとして上がり、木全体で共有するもの(テーマ、進行中のドラッグ、Dispatch)はコンテキスト で下ります。テーマは egui 自身のものです。ボードは自前のテーマを持たず、ページの外観の切り替えに従います。

タブのラベルを見比べてください。素の egui 版も同じ仕事をします。違うのは、カードごとの状態がどこに置かれることになるかです。

自分で動かす ​

sh
cargo run -p board
cargo run -p board --bin board-plain    # 素の egui 版
trunk serve --config examples/board/Trunk.toml

ソースは examples/board/src/lib.rsと plain.rs です。

rust
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;

pub mod board;
pub mod hooks;
pub mod look;

use board::{
    Board, Card as CardData, CardId, Column as ColumnData, ColumnId, DropTarget, Msg, reduce,
    visible,
};
use hooks::{Dnd, Undoable, use_debounced, use_dnd, use_identity, use_undoable};
use look::{PLACEHOLDER_H, Theme, gap_amount, ghost, lifted, placeholder};

/// The drag session: a `CardId` is picked up, a [`DropTarget`] is where it
/// would land. Named once, because both the type and `use_context` need it.
pub type CardDnd = Dnd<CardId, DropTarget>;

/// What the toolbar, the columns and the cards all send to the reducer.
///
/// `Dispatch` is `Clone + Send + 'static`, which is what makes this the half of
/// the state that can travel by context: a `State` guard borrows the store and
/// could not be put in a `Handle` at all (ARCHITECTURE 3.5). Data goes down as
/// props, changes come back through here — the shape React ends up with too.
pub type Actions = Dispatch<Undoable<Msg>>;

/// How long the search box has to be quiet before the columns are refiltered.
const DEBOUNCE: f64 = 0.3;

/// The height of a column's footer, which is also the "drop it at the end"
/// zone. Big enough to aim at with a card in hand.
const FOOTER_H: f32 = 26.0;

/// The space between two cards in a column. It is drawn by [`Placeholder`]
/// rather than by the column's `gap`, because it is also the gap that opens
/// when a card is about to land there.
const CARD_GAP: f32 = 6.0;

/// The undo and redo glyphs, from egui's own icon font.
const UNDO: &str = "⟲";
const REDO: &str = "⟳";

/// The theme, or the dark one if this is drawn outside a provider.
///
/// A three-line `#[hook]`, because six components ask the same question.
#[hook]
fn use_theme(cx: &mut Cx) -> Theme {
    use_context::<Theme>(cx).map_or(Theme::DARK, |theme| theme.get())
}

/// The drag session, or an unattached one outside a provider.
#[hook]
fn use_drag(cx: &mut Cx) -> CardDnd {
    use_context::<CardDnd>(cx).map_or_else(CardDnd::new, |dnd| dnd.get())
}

/// Send one message to the board's reducer, if this is drawn under one.
fn send(actions: &Option<Actions>, msg: Msg) {
    if let Some(actions) = actions {
        actions.send(Undoable::Do(msg));
    }
}

#[component]
pub fn App(cx: &mut Cx) {
    rsx! {
        <BoardProvider>
            <BoardView/>
        </BoardProvider>
    }
}

/// Reads egui's theme, owns the drag session, and publishes both.
///
/// A provider has to make the value it provides: `provide_context` takes a
/// `Handle`, which borrows the store, and a prop may not name that lifetime
/// (ARCHITECTURE 6). The same shape as the `theme` example. The theme handle
/// is written only when the theme changed: a write on every frame would ask
/// for a repaint on every frame (ARCHITECTURE 5.6).
#[component(shares_ui)]
fn BoardProvider(cx: &mut Cx, children: impl View) {
    let current = Theme::of(cx.ctx());
    let theme = use_handle(cx, || current);
    if theme.get() != current {
        theme.set(current);
    }
    // One session for the whole tree: the card that is picked up and the
    // column it lands in are in different branches of it.
    let dnd = use_dnd::<CardId, DropTarget>(cx);

    provide_context(cx, theme, |cx| {
        provide_context(cx, dnd, |cx| children.show(cx))
    });
}

/// The board: the toolbar, the columns, and the reducer behind them.
#[component]
fn BoardView(cx: &mut Cx) {
    // The reducer owns the board and the persisted slot mirrors it, the way
    // `showcase` does. `use_persisted` first, so its value is there to seed the
    // history on the very first frame.
    let mut saved = use_persisted(cx, "board/board", Board::demo);
    let (history, dispatch) = use_undoable(cx, reduce, || saved.clone());
    if *saved != history.present {
        *saved = history.present.clone();
    }
    // The `Dispatch` is what goes into the context; see [`Actions`].
    let actions = use_handle(cx, || dispatch.clone());

    let mut search = use_state(cx, String::new);
    let mut filter = use_state(cx, || None::<bool>);
    // Read once: an element may not hold a shared borrow of a state *and* a
    // handler that writes it (ARCHITECTURE 3.7), and the toolbar does both.
    let done = *filter;
    // The box types on every keystroke; the columns filter on this instead.
    let live = search.clone();
    let query = use_debounced(cx, &live, DEBOUNCE);

    let theme = use_theme(cx);
    let dnd = use_drag(cx);

    // A drag that ended over a slot becomes exactly one message, which is what
    // makes it exactly one step of the undo history.
    if let Some((card, target)) = dnd.take_drop() {
        let to_index = history.present.drop_index(card, target);
        dispatch.send(Undoable::Do(Msg::MoveCard {
            card,
            to_column: target.column,
            to_index,
        }));
    }

    let board = &history.present;
    let carried = dnd.carrying().and_then(|id| board.card(id));

    let view = rsx! {
        <View direction="column" grow={1.0} w="100%" h="100%" gap={8} p={8}>
            <Toolbar
                search={search.bind()}
                done={&done}
                count={board.len()}
                can_undo={history.can_undo()}
                can_redo={history.can_redo()}
                on_filter={|picked: bool| {
                    // Clicking the chip that is already on clears the filter.
                    *filter = (*filter != Some(picked)).then_some(picked);
                }}
                on_undo={|| dispatch.send(Undoable::Undo)}
                on_redo={|| dispatch.send(Undoable::Redo)}
            />
            <Separator/>
            <View direction="row" grow={1.0} min_h={0.0} w="100%" gap={8}>
                for column in board.columns.iter() {
                    // The key is the column's id, so a column keeps its own
                    // scroll position and rename box.
                    <Column
                        key={column.id}
                        grow={1.0}
                        min_w={0.0}
                        column={column}
                        rev={board.rev}
                        search={query.as_str()}
                        done={&done}
                        on_add={|title: String| {
                            dispatch.send(Undoable::Do(Msg::AddCard { column: column.id, title }));
                        }}
                    />
                }
            </View>
        </View>
    };
    provide_context(cx, actions, |cx| view.show(cx));

    // The card in hand, drawn on the top layer at the pointer. Painted rather
    // than built out of widgets: a widget here would put a second copy of the
    // card's title into the accessibility tree, where a screen reader — and
    // `kittest` — would find two of everything.
    if let (Some(card), Some(at)) = (carried, dnd.pointer()) {
        ghost(cx.ctx(), at, &card.title, theme);
    }
}

/// The search box, the done filter, the counts and the history buttons.
///
/// Everything here is the *board's* state rather than the card's, so the
/// toolbar owns none of it: it is handed what to show and reports what was
/// pressed. Undo and redo are events too — the toolbar does not know that a
/// board message exists.
#[component]
#[allow(clippy::too_many_arguments)]
fn Toolbar(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    search: &mut String,
    // `&Option<bool>`, not `Option<bool>`: an `Option` prop is the *optional*
    // kind, whose setter takes the inner value and defaults to `None`.
    done: &Option<bool>,
    count: usize,
    can_undo: bool,
    can_redo: bool,
    #[event] on_filter: bool,
    #[event] on_undo: (),
    #[event] on_redo: (),
) {
    let theme = use_theme(cx);

    rsx! {
        <View style={style} direction="row" w="100%" gap={6} align="center">
            <Text size={20.0} strong color={theme.accent()}>"board"</Text>
            <TextEdit w={160.0} bind={search} hint="search"/>
            // Two chips, one per answer to the only question a card now has.
            for (name, value) in [("open", false), ("done", true)] {
                <Chip
                    key={name}
                    label={name}
                    color={theme.accent()}
                    active={*done == Some(value)}
                    on_click={|| on_filter.emit(value)}
                />
            }
            <Text grow={1.0}>{format!("{count} cards")}</Text>
            // Glyphs on screen, words in the tree: `name` is what a screen
            // reader, and the test, call the button.
            <IconButton name="undo" enabled={can_undo} on_click={|| on_undo.emit(())}>
                {UNDO}
            </IconButton>
            <IconButton name="redo" enabled={can_redo} on_click={|| on_redo.emit(())}>
                {REDO}
            </IconButton>
        </View>
    }
}

/// One column: a name that can be renamed in place, a count, its cards, and a
/// footer that adds one.
///
/// The column reads the `Dispatch` from the context and turns its cards'
/// events into messages. It is the innermost place that knows a column id, and
/// catching the events here rather than passing them on saves the loop above
/// from threading four callbacks through every `<Column>` it writes.
#[component]
fn Column(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    column: &ColumnData,
    // The board's revision, for the memo below: hashing every card on every
    // frame would cost more than the filtering it saves.
    rev: u64,
    search: &str,
    done: &Option<bool>,
    #[event] on_add: String,
) {
    let theme = use_theme(cx);
    let dnd = use_drag(cx);
    let actions = use_context::<Actions>(cx).map(|actions| actions.get());

    let mut renaming = use_state(cx, || false);
    let mut draft = use_state(cx, || column.name.clone());
    // A new card is written here and only reaches the board when it is
    // confirmed. Putting an empty one on the board to be filled in instead
    // would cost two steps of undo for one card, and `use_persisted` would
    // save the nameless card if the window closed in between.
    let mut adding = use_state(cx, || false);
    let mut new_title = use_state(cx, String::new);

    let shown: &Vec<CardId> = use_memo(cx, (column.id, rev, search, done), || {
        visible(column, search, *done)
    });
    // Each card with the one below it: dropping on the lower half of a card
    // means "in front of whatever comes next".
    let cards: Vec<&CardData> = shown
        .iter()
        .filter_map(|id| column.cards.iter().find(|card| card.id == *id))
        .collect();
    let after: Vec<Option<CardId>> = (0..cards.len())
        .map(|i| cards.get(i + 1).map(|card| card.id))
        .collect();

    let carried = dnd.carrying();
    let carried_at = carried.and_then(|id| cards.iter().position(|card| card.id == id));
    // Where the card in hand would land — unless that is where it already is.
    // A drop that moves nothing gets no gap opened for it, because the eye
    // would read the gap as "it would go *there*" and it would not.
    let preview = dnd
        .hovered()
        .filter(|target| target.column == column.id)
        .filter(|target| {
            target.before != carried && carried_at.is_none_or(|i| target.before != after[i])
        });
    let gap = preview.map(|target| target.before);

    // The gaps are animated, but not in the layout: a taffy node whose height
    // changed makes the layout engine lay the column out again and ask egui for a
    // second pass, every frame, for as long as the animation runs. So the
    // layout jumps to where it will end up, and what slides is the picture —
    // each card below a gap is drawn shifted by however far its gap still has
    // to go. `lift` adds those up on the way down the column.
    let carrying = carried.is_some();
    let ctx = cx.ctx().clone();
    let mut lift = 0.0;
    // Each gap, and the lift of whatever comes right after it.
    let mut animate = |target: DropTarget, open: bool| -> (Gap, f32) {
        let amount = gap_amount(&ctx, egui::Id::new(("board/gap", target)), open, carrying);
        let gap = Gap { open, amount, lift };
        lift += (amount - f32::from(u8::from(open))) * PLACEHOLDER_H;
        (gap, lift)
    };
    let (gaps, lifts): (Vec<Gap>, Vec<f32>) = cards
        .iter()
        .map(|card| {
            let target = DropTarget {
                column: column.id,
                before: Some(card.id),
            };
            animate(target, gap == Some(Some(card.id)))
        })
        .unzip();
    let end = DropTarget {
        column: column.id,
        before: None,
    };
    let (end_gap, footer_lift) = animate(end, gap == Some(None));

    let renaming_now = *renaming;
    let adding_now = *adding;

    rsx! {
        <View style={style} direction="column" gap={6}>
            <View direction="row" w="100%" gap={4} align="center">
                if renaming_now {
                    <TitleEdit
                        grow={1.0}
                        bind={draft.bind()}
                        name="column name"
                        on_commit={|name: String| {
                            send(&actions, Msg::RenameColumn { column: column.id, name });
                            *renaming = false;
                        }}
                        on_cancel={|| *renaming = false}
                    />
                } else {
                    // The name, and a rename button that is only there while
                    // the pointer is over the name. One leaf for both: the
                    // question "is the pointer over this" needs a rectangle,
                    // and a `<View>` hands none back (plan.md section 8.4).
                    // The rectangle is the whole row the name is given, so the
                    // button does not vanish as the pointer moves onto it.
                    {view(|cx| {
                        let clicked = cx.leaf(
                            &ItemStyle::default().grow(1.0).min_w(0.0),
                            |ui| {
                                let over = ui.rect_contains_pointer(ui.max_rect());
                                ui.horizontal(|ui| {
                                    ui.spacing_mut().item_spacing.x = 4.0;
                                    ui.style_mut().wrap_mode =
                                        Some(egui::TextWrapMode::Truncate);
                                    let text = egui::RichText::new(column.name.as_str())
                                        .strong()
                                        .color(theme.accent());
                                    ui.add(egui::Label::new(text).selectable(false));
                                    over && icon_button(ui, "✏", Some("rename"))
                                })
                                .inner
                            },
                        );
                        if clicked {
                            *draft = column.name.clone();
                            *renaming = true;
                        }
                    })}
                }
                <Text>{format!("{}/{}", cards.len(), column.cards.len())}</Text>
            </View>

            // Everything that scrolls, including the footer: a `ScrollArea` is
            // a `leaf_fill`, so it measures as all the height there is rather
            // than as what its siblings left over (plan.md section 8). Below
            // it, a footer would be pushed off the bottom of the window; after
            // the last card, it is where the eye is anyway.
            <ScrollArea grow={1.0}>
                // No `gap`: the space between two cards is the closed
                // placeholder that lives between them, which is what lets a
                // gap open without any node appearing or disappearing.
                <View direction="column" w="100%" pr={4}>
                    for (i, card) in cards.iter().enumerate() {
                        <Placeholder
                            key={(card.id, "gap")}
                            gap={gaps[i]}
                            target={DropTarget { column: column.id, before: Some(card.id) }}
                        />
                        // The key is the card's id. It is what tells two cards
                        // apart inside this column — and, together with
                        // `use_identity` in the card itself, what makes a
                        // card's own state follow it out of this column.
                        <Card
                            key={card.id}
                            card={card}
                            column={column.id}
                            next={&after[i]}
                            lift={lifts[i]}
                            on_title={|title: String| {
                                send(&actions, Msg::SetTitle { card: card.id, title });
                            }}
                            on_done={|done: bool| {
                                send(&actions, Msg::SetDone { card: card.id, done });
                            }}
                            on_remove={|| send(&actions, Msg::RemoveCard { card: card.id })}
                        />
                    }
                    if cards.is_empty() {
                        <Text mt={CARD_GAP}>"nothing here"</Text>
                    }

                    // The new card, in the same box the saved ones wear, so
                    // that what is being typed looks like what it will become.
                    if adding_now {
                        <View
                            w="100%"
                            mt={CARD_GAP}
                            p={6}
                            bg={theme.card()}
                            radius={4.0}
                        >
                            <TitleEdit
                                w="100%"
                                bind={new_title.bind()}
                                name="new card"
                                on_commit={|title: String| {
                                    if !title.trim().is_empty() {
                                        on_add.emit(title);
                                    }
                                    *adding = false;
                                }}
                                on_cancel={|| *adding = false}
                            />
                        </View>
                    }

                    // The new card's box above sits before this gap, so it
                    // is only ever lifted by the gaps between the cards, and
                    // that is a drag started while typing: not worth a leaf.
                    <Placeholder
                        gap={end_gap}
                        target={DropTarget { column: column.id, before: None }}
                    />

                    // The footer is both the "add a card" button and the place
                    // a drag ends when it means "at the end of this column".
                    // One leaf, so the rectangle offered to the drag session is
                    // the one the eye sees.
                    {view(|cx| {
                        let (rect, clicked) = cx.leaf_fill(
                            &ItemStyle::default().w("100%").h(FOOTER_H),
                            |ui| {
                                let rect = ui.max_rect();
                                // Fainter than a card: a place to make one.
                                let button = egui::Button::new("+ card")
                                    .fill(theme.footer())
                                    .wrap_mode(egui::TextWrapMode::Extend);
                                let clicked = ui
                                    .with_visual_transform(lifted(footer_lift), |ui| {
                                        ui.add_sized(rect.size(), button).clicked()
                                    })
                                    .inner;
                                (rect, clicked)
                            },
                        );
                        dnd.slot(rect, DropTarget { column: column.id, before: None });
                        if clicked {
                            *new_title = String::new();
                            *adding = true;
                        }
                    })}
                </View>
            </ScrollArea>
        </View>
    }
}

/// One card: a tick box, a title that can be edited in place, and — the point
/// of the example — state of its own.
///
/// `editing` and `draft` belong to *this card*. Nothing above it knows they
/// exist, nothing has to make room for them when a card is added, and nothing
/// has to clean up after them when one is deleted.
///
/// The whole card is one `cx.leaf`, which is what a `<View bg p radius>` would
/// have been anyway, plus the one thing an element cannot hand back: the
/// rectangle. Three things want it — the card is the drag handle, the card is
/// the drop zone, and the card is what the cursor changes over — and none of
/// them can be told where the card is by a `<View>`, which returns no
/// `Response` (plan.md section 8.4).
///
/// The `egui::Frame` inside the leaf stays for a second reason: the lift
/// transform has to move the background with the content, and only what is
/// drawn in the leaf's own `Ui` is under that transform.
#[component]
fn Card(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    card: &CardData,
    column: ColumnId,
    // The card below this one; `None` at the end of the column.
    next: &Option<CardId>,
    // How far the picture of this card is from where the layout put it, while
    // a gap above it is still opening or closing. See `<Column>`.
    #[prop(default)] lift: f32,
    #[event] on_title: String,
    #[event] on_done: bool,
    #[event] on_remove: (),
) {
    let theme = use_theme(cx);
    let dnd = use_drag(cx);

    // Keyed by the card, not by where the card is: `key=` distinguishes
    // siblings under one parent, and a card that moves changes parents. See
    // `hooks::use_identity`.
    let mut editing = use_identity(cx, (card.id, "editing"), || false);
    let mut draft = use_identity(cx, (card.id, "draft"), || card.title.clone());

    let carried = dnd.carrying() == Some(card.id);
    let dragging = dnd.carrying().is_some();
    let editing_now = *editing;
    let next = *next;
    let (store, scope) = (cx.store, cx.scope_id());

    cx.leaf(&style.w("100%"), move |ui| {
        // Taken before anything is drawn, because it is what taffy gave the
        // whole card rather than what the row of widgets ended up covering.
        // On the very first frame the height is not right yet; from the second
        // it is, which is the same deal the footer's `leaf_fill` takes.
        let rect = ui.max_rect();
        // Registered *before* the children, and that order is the feature. egui
        // picks the topmost click candidate and the topmost drag candidate
        // separately, so the checkbox and the buttons — which only click —
        // still take their clicks, while a press that turns into a movement
        // falls through to here. Pressing on the checkbox and moving therefore
        // drags the card, which is what a hand expects and what the test does.
        let bg = ui.interact(
            rect,
            egui::Id::new(("board/card", card.id)),
            egui::Sense::drag(),
        );
        // A drag handle is a control, so it says what it is a handle for. Not
        // the bare title: that is already the label beside it, and two nodes
        // with one name is the thing a screen reader cannot tell apart.
        ui.ctx().accesskit_node_builder(bg.id, |node| {
            node.set_label(format!("card: {}", card.title));
        });
        if bg.drag_started() {
            dnd.pick_up(card.id);
        }
        if bg.contains_pointer() {
            // `contains_pointer`, not `hovered`: the pointer is over the card
            // even when it is over a widget drawn on top of it.
            ui.ctx().set_cursor_icon(if dragging {
                egui::CursorIcon::Grabbing
            } else {
                egui::CursorIcon::PointingHand
            });
        }
        // The whole card split in two: the top half means "in front of me",
        // the bottom half "in front of the next one", which is how a list of
        // cards is also a list of the gaps between them.
        let (top, bottom) = rect.split_top_bottom_at_fraction(0.5);
        dnd.slot(
            top,
            DropTarget {
                column,
                before: Some(card.id),
            },
        );
        dnd.slot(
            bottom,
            DropTarget {
                column,
                before: next,
            },
        );

        // The shift is visual only: the drag surface above and the slots are
        // where the card will be, which is where the pointer is aiming.
        ui.with_visual_transform(lifted(lift), move |ui| {
            egui::Frame::default()
            .fill(theme.card())
            .inner_margin(6i8)
            .corner_radius(4u8)
            .show(ui, move |ui| {
                let mut cx = Cx::new(store, ui, scope);
                let row = rsx! {
                    <View direction="row" w="100%" gap={6} align="center">
                        {view(|cx| {
                            let toggled = cx.leaf(&ItemStyle::default().shrink(0.0), |ui| {
                                // `done` is a prop, so the box is drawn against
                                // a copy: what comes back out is an event, not
                                // a write. `<Checkbox bind>` wants the `&mut`
                                // this card does not have.
                                let mut done = card.done;
                                theme.style_controls(ui);
                                let response = ui.add(egui::Checkbox::without_text(&mut done));
                                // A card is found by this name — by a screen
                                // reader, and by the test, which needs a hold
                                // on a card whose title has become an editor.
                                ui.ctx().accesskit_node_builder(response.id, |node| {
                                    node.set_label(format!("done: {}", card.title));
                                });
                                response.changed()
                            });
                            if toggled {
                                on_done.emit(!card.done);
                            }
                        })}

                        if editing_now {
                            <TitleEdit
                                grow={1.0}
                                min_w={0.0}
                                bind={draft.bind()}
                                name="title"
                                on_commit={|title: String| {
                                    // Confirming an empty title is a cancel:
                                    // a nameless card would leave nothing to
                                    // click on to name it again.
                                    if !title.trim().is_empty() {
                                        on_title.emit(title);
                                    }
                                    *editing = false;
                                }}
                                on_cancel={|| *editing = false}
                            />
                        } else {
                            {view(|cx| {
                                cx.leaf(&ItemStyle::default().grow(1.0).min_w(0.0), |ui| {
                                    ui.style_mut().wrap_mode =
                                        Some(egui::TextWrapMode::Truncate);
                                    let mut text = egui::RichText::new(card.title.as_str());
                                    if card.done {
                                        text = text.weak().strikethrough();
                                    }
                                    if carried {
                                        text = text.weak();
                                    }
                                    // Not selectable, and it senses nothing.
                                    // A label that senses a drag still starts a
                                    // text selection on the press, and egui
                                    // then runs that selection across every
                                    // label the pointer passes over on its way
                                    // (`label_text_selection.rs`). The card is
                                    // dragged by the background above.
                                    ui.add(egui::Label::new(text).selectable(false));
                                });
                            })}
                        }

                        <IconButton name="edit" on_click={|| {
                            // Opening takes a fresh copy of the saved title, so
                            // closing the editor and opening it again starts
                            // from what was saved rather than from an old draft.
                            *draft = card.title.clone();
                            *editing = true;
                        }}>"✏"</IconButton>
                        <IconButton name="remove" on_click={|| on_remove.emit(())}>"×"</IconButton>
                    </View>
                };
                row.show(&mut cx);
            });
        });
    });
}

/// A gap's visual state for one frame, worked out by the column: whether it
/// is open in the layout, how open the picture of it is, and how far the
/// picture of everything above it has already been shifted.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Gap {
    open: bool,
    amount: f32,
    lift: f32,
}

/// A one-line editor that opens with its text selected: the card's title, the
/// column's name, and the card being added are all the same three keys.
///
/// Enter confirms, Escape puts it back, and clicking elsewhere confirms — the
/// last because a board is clicked around rather than tabbed through, and
/// losing what was typed for looking away is not a thing anyone means.
#[component]
fn TitleEdit(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    bind: &mut String,
    // The name in the accessibility tree: the text says nothing about which
    // of the three this is, and a nameless text field is what the gallery's
    // a11y test counts.
    name: &str,
    #[event] on_commit: String,
    #[event] on_cancel: (),
) {
    // Focus and select-all happen once, on the frame the field appears: after
    // that the caret is the user's business. The field is unmounted when the
    // editor closes, so the next opening is fresh again — and so is a card that
    // is dragged into another column, which remounts it. The draft survives
    // that (it is a `use_identity` hook); the caret goes back to selecting
    // everything, which is the same place it started.
    let mut fresh = use_state(cx, || true);
    let first = *fresh;

    let response = cx.leaf(&style, |ui| {
        let width = ui.available_width();
        let response = ui.add(egui::TextEdit::singleline(bind).desired_width(width));
        ui.ctx()
            .accesskit_node_builder(response.id, |node| node.set_label(name));
        if first {
            response.request_focus();
            let mut state = egui::TextEdit::load_state(ui.ctx(), response.id).unwrap_or_default();
            let end = egui::text::CCursor::new(bind.chars().count());
            let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), end);
            state.cursor.set_char_range(Some(all));
            state.store(ui.ctx(), response.id);
        }
        response
    });
    if first {
        *fresh = false;
    }

    if response.lost_focus() {
        // egui hands focus back on Escape, so the two arrive together and the
        // only question is which of them ended the edit.
        if cx.ui().input(|i| i.key_pressed(egui::Key::Escape)) {
            on_cancel.emit(());
        } else {
            on_commit.emit(bind.clone());
        }
    }
}

/// The space between two cards, and the gap a card in hand would drop into.
///
/// One of these sits in front of every card and in front of the footer, open
/// or closed. **Closed it is not nothing**: it is the column's card spacing,
/// and that is what keeps it in the tree. A gap that came and went would be a
/// taffy node that came and went, and a node taffy has not laid out yet has an
/// empty rectangle for one frame — the very frame the pointer needs it, since
/// opening the gap is what pushed the card out from under the pointer. The
/// slot would find nothing, the gap would shut, the card would come back, and
/// the column would shake once a frame. A node that only changes height has a
/// rectangle at every moment.
///
/// The open one registers itself as a drop slot for the target it is showing,
/// which is the other half of the same argument: the pointer ends up over the
/// gap, so the gap has to be an answer to "what is under the pointer".
#[component]
fn Placeholder(cx: &mut Cx, #[prop(default)] style: ItemStyle, gap: Gap, target: DropTarget) {
    let theme = use_theme(cx);
    let dnd = use_drag(cx);
    let Gap { open, amount, lift } = gap;
    // The layout opens all at once; the picture catches up. See `<Column>`.
    let extra = if open { PLACEHOLDER_H } else { 0.0 };
    let shown = amount * PLACEHOLDER_H;

    // `leaf_fill`, not `leaf`: a content-measured leaf is measured in a
    // zero-width `Ui` on its first frame and taffy keeps it that way
    // (ARCHITECTURE 6). Here the size is the style's — the whole width, and
    // one card's height once there is a card to make room for.
    let rect = cx.leaf_fill(&style.w("100%").h(CARD_GAP + extra), |ui| {
        let rect = ui.max_rect();
        let response = ui.allocate_rect(rect, egui::Sense::hover());
        if shown > 0.5 {
            // The spacing stays spacing: the card is drawn in what is new —
            // below the card above, wherever its picture is at the moment.
            let top = egui::pos2(rect.left(), rect.top() + lift + CARD_GAP);
            let seen = egui::Rect::from_min_size(top, egui::vec2(rect.width(), shown));
            placeholder(ui.painter(), seen, theme);
        }
        if open {
            // Painted, not a widget, so the name has to be said out loud; the
            // test asks for it to know whether a gap is open.
            response.widget_info(|| {
                egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), "drop here")
            });
        }
        rect
    });
    if open {
        dnd.slot(rect, target);
    }
}

/// A small toggle: the two filters in the toolbar.
///
/// `egui-reactor-elements` has no chip and no toggle, so this is the escape
/// hatch, one leaf deep — and it is also the smallest example of the shape
/// every component here has: take a `style`, draw one thing, report the click.
#[component]
fn Chip(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    label: &str,
    color: egui::Color32,
    #[prop(default)] active: bool,
    #[event] on_click: (),
) {
    let clicked = cx.leaf(&style.shrink(0.0), |ui| {
        // A hand-written leaf sets the wrap mode itself: measured in the
        // zero-width `Ui` of its first draw, a wrapping widget reports one
        // character wide and taffy keeps it that way (ARCHITECTURE 6).
        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
        let text = egui::RichText::new(label).small().color(color);
        ui.selectable_label(active, text).clicked()
    });
    if clicked {
        on_click.emit(());
    }
}

/// [`IconButton`] as a plain egui call, for the one place a button is drawn
/// inside a hand-written leaf.
fn icon_button(ui: &mut egui::Ui, glyph: &str, name: Option<&str>) -> bool {
    let button = egui::Button::new(glyph)
        .small()
        .frame(false)
        .wrap_mode(egui::TextWrapMode::Extend);
    let response = ui.add(button);
    if let Some(name) = name {
        ui.ctx()
            .accesskit_node_builder(response.id, |node| node.set_label(name));
    }
    response.clicked()
}

/// A small flat button, for the things a card and a column do to themselves.
#[component]
fn IconButton(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    #[prop(default = true)] enabled: bool,
    // What the button is called, when what it shows is a picture. The tree
    // says words even where the screen says a glyph.
    name: Option<&str>,
    #[event] on_click: (),
    children: impl Into<egui::WidgetText>,
) {
    let clicked = cx.leaf(&style.shrink(0.0), |ui| {
        let button = egui::Button::new(children)
            .small()
            .frame(false)
            .wrap_mode(egui::TextWrapMode::Extend);
        let response = ui.add_enabled(enabled, button);
        if let Some(name) = name {
            // The widget has already written its node for this pass, so this
            // overwrites the label egui took from the glyph.
            ui.ctx()
                .accesskit_node_builder(response.id, |node| node.set_label(name));
        }
        response.clicked()
    });
    if clicked {
        on_click.emit(());
    }
}
rust
use std::collections::HashMap;

use serde::{Deserialize, Serialize};

use crate::board::{Board, CardId, ColumnId, DropTarget, Msg, reduce, visible};
use crate::look::{PLACEHOLDER_H, Theme, gap_amount, ghost, lifted, placeholder};

/// The key the standalone binary stores the board under.
pub const STORAGE_KEY: &str = "board_plain";

/// How long the search box has to be quiet, and how far back undo goes. The
/// same numbers as the egui-reactor version.
const DEBOUNCE: f64 = 0.3;
const DEPTH: usize = 64;

/// The height of a column's footer, which is also its "drop at the end" zone.
const FOOTER_H: f32 = 26.0;

/// The space between two cards. Drawn by the gap that sits between them rather
/// than by the column's item spacing, for the reason [`drop_gap`] gives.
const CARD_GAP: f32 = 6.0;

/// One card's own state. The egui-reactor version has this too — as two
/// `use_identity` hooks inside `<Card>`, where nothing else can see them.
#[derive(Clone, Debug, Default)]
struct CardUi {
    editing: bool,
    draft_title: String,
    /// Where the card was drawn last frame. Immediate mode draws a card before
    /// it knows how big it is, and the background that senses the drag has to
    /// be registered *before* the widgets on top of it, so it senses last
    /// frame's rectangle. The other version reads the one taffy already has.
    rect: Option<egui::Rect>,
}

/// Everything the plain version keeps between frames.
#[derive(Serialize, Deserialize)]
pub struct PlainState {
    pub board: Board,

    /// **The difference.** Per-card UI state, keyed by identity so that it
    /// follows a card that is moved, and swept by hand below so that it does
    /// not outlive one that is deleted.
    #[serde(skip)]
    ui: HashMap<CardId, CardUi>,

    /// Undo and redo: `use_undoable` in the other version.
    #[serde(skip)]
    past: Vec<Board>,
    #[serde(skip)]
    future: Vec<Board>,

    /// The search box, and `use_debounced` written out.
    #[serde(skip)]
    search: String,
    #[serde(skip)]
    query: String,
    #[serde(skip)]
    typed_at: f64,
    /// `None` shows every card, `Some(true)` only the ticked ones.
    #[serde(skip)]
    filter: Option<bool>,

    /// The drag session: `use_dnd` written out.
    #[serde(skip)]
    carrying: Option<CardId>,
    #[serde(skip)]
    slots: Vec<(egui::Rect, DropTarget)>,
    #[serde(skip)]
    hovered: Option<DropTarget>,
    #[serde(skip)]
    pointer: Option<egui::Pos2>,

    /// Which column is being renamed, and to what. And which column is having a
    /// card added to it, and what is being typed as its title.
    ///
    /// One at a time each, unlike the egui-reactor version, where every
    /// `<Column>` has a `use_state` of its own and two could be open at once. A
    /// map here would be another thing to sweep, for a case nobody asked for —
    /// which is exactly the choice a caller is forced to make when the state of
    /// the parts has to live in the whole.
    #[serde(skip)]
    renaming: Option<(ColumnId, String)>,
    #[serde(skip)]
    adding: Option<(ColumnId, String)>,

    /// The one-line editor that should take focus and select its text on the
    /// next frame. `<TitleEdit>` keeps this as a `use_state` of its own and
    /// never has to name the field; here the field has to be named, so its id
    /// is spelled out at both ends.
    #[serde(skip)]
    fresh: Option<egui::Id>,
}

impl Default for PlainState {
    fn default() -> Self {
        Self {
            board: Board::demo(),
            ui: HashMap::new(),
            past: Vec::new(),
            future: Vec::new(),
            search: String::new(),
            query: String::new(),
            typed_at: f64::NEG_INFINITY,
            filter: None,
            carrying: None,
            slots: Vec::new(),
            hovered: None,
            pointer: None,
            renaming: None,
            adding: None,
            fresh: None,
        }
    }
}

impl PlainState {
    /// Read the board back. `use_persisted` is this, plus the key.
    pub fn load(json: &str) -> Self {
        serde_json::from_str::<Board>(json).map_or_else(
            |_| Self::default(),
            |board| Self {
                board,
                ..Self::default()
            },
        )
    }

    /// Serialize the board for the caller to write into eframe's storage.
    pub fn save(&self) -> String {
        serde_json::to_string(&self.board).unwrap_or_else(|_| String::from("{}"))
    }

    /// A card's own state, made the first time it is asked for.
    fn card_ui(&mut self, card: CardId) -> &mut CardUi {
        self.ui.entry(card).or_default()
    }

    /// Apply one message, remembering the board it changed.
    fn apply(&mut self, msg: Msg) {
        let before = self.board.clone();
        reduce(&mut self.board, msg);
        if before == self.board {
            return;
        }
        self.past.push(before);
        if self.past.len() > DEPTH {
            self.past.remove(0);
        }
        self.future.clear();
    }

    fn undo(&mut self) {
        if let Some(previous) = self.past.pop() {
            let present = std::mem::replace(&mut self.board, previous);
            self.future.push(present);
        }
    }

    fn redo(&mut self) {
        if let Some(next) = self.future.pop() {
            let present = std::mem::replace(&mut self.board, next);
            self.past.push(present);
        }
    }

    /// Read the pointer, resolve a release against the slots the previous frame
    /// offered, and clear them for this one.
    fn begin_frame(&mut self, ctx: &egui::Context) -> Option<Msg> {
        let (pointer, released, cancelled) = ctx.input(|i| {
            (
                i.pointer.interact_pos(),
                i.pointer.any_released(),
                i.key_pressed(egui::Key::Escape),
            )
        });
        self.pointer = pointer;
        self.hovered = pointer.and_then(|pointer| {
            self.slots
                .iter()
                .find(|(rect, _)| rect.contains(pointer))
                .map(|(_, target)| *target)
        });
        self.slots.clear();
        if cancelled {
            self.carrying = None;
        }
        if !released {
            return None;
        }
        let card = self.carrying.take()?;
        let target = self.hovered?;
        Some(Msg::MoveCard {
            card,
            to_column: target.column,
            to_index: self.board.drop_index(card, target),
        })
    }

    /// The search text, once it has been still for [`DEBOUNCE`] seconds.
    fn debounce(&mut self, ctx: &egui::Context, typed: bool) {
        let now = ctx.input(|i| i.time);
        if typed {
            self.typed_at = now;
        }
        if self.query == self.search {
            return;
        }
        let waited = now - self.typed_at;
        if waited >= DEBOUNCE {
            self.query = self.search.clone();
        } else {
            ctx.request_repaint_after(std::time::Duration::from_secs_f64(DEBOUNCE - waited));
        }
    }
}

pub fn ui(ui: &mut egui::Ui, state: &mut PlainState) {
    // egui's own theme, the way `BoardProvider` reads it.
    let theme = Theme::of(ui.ctx());
    // A drag that ended over a slot becomes exactly one message, so that undo
    // walks back one drag in one step.
    let mut pending: Vec<Msg> = state.begin_frame(ui.ctx()).into_iter().collect();
    // Undo and redo are not messages, so they are collected separately.
    let (mut undo, mut redo) = (false, false);

    // `p={8}` and `gap={8}` on the egui-reactor version's root `<View>`.
    egui::Frame::new().inner_margin(8.0).show(ui, |ui| {
        ui.spacing_mut().item_spacing.y = 8.0;
        toolbar(ui, state, theme, &mut undo, &mut redo);
        ui.separator();
        columns(ui, state, theme, &mut pending);
    });

    for msg in pending {
        state.apply(msg);
    }
    if undo {
        state.undo();
    }
    if redo {
        state.redo();
    }

    // **The line.** Card state is keyed by the card, so it has to be dropped
    // when the card is: nothing else will. The egui-reactor version's cards are
    // components, and the pass-end sweep frees the hooks of a component that
    // stopped being drawn.
    state.ui.retain(|id, _| state.board.card(*id).is_some());

    if let (Some(card), Some(at)) = (
        state.carrying.and_then(|id| state.board.card(id)),
        state.pointer,
    ) {
        ghost(ui.ctx(), at, &card.title, theme);
    }
}

/// The search box, the done filter, the counts and the history buttons.
fn toolbar(
    ui: &mut egui::Ui,
    state: &mut PlainState,
    theme: Theme,
    undo: &mut bool,
    redo: &mut bool,
) {
    ui.horizontal(|ui| {
        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
        // `gap={6}` on the egui-reactor version's toolbar row.
        ui.spacing_mut().item_spacing.x = 6.0;
        ui.label(
            egui::RichText::new("board")
                .size(20.0)
                .strong()
                .color(theme.accent()),
        );
        let typed = ui
            .add_sized(
                egui::vec2(160.0, ui.spacing().interact_size.y),
                egui::TextEdit::singleline(&mut state.search).hint_text("search"),
            )
            .changed();
        state.debounce(ui.ctx(), typed);

        // Two chips, one per answer to the only question a card now has.
        for (name, value) in [("open", false), ("done", true)] {
            let text = egui::RichText::new(name).small().color(theme.accent());
            if ui
                .selectable_label(state.filter == Some(value), text)
                .clicked()
            {
                // Clicking the chip that is already on clears the filter.
                state.filter = (state.filter != Some(value)).then_some(value);
            }
        }

        ui.label(format!("{} cards", state.board.len()));
        // `<Text grow={1.0}>` before the buttons: here the buttons go in a
        // right-to-left `Ui` filling the rest of the row.
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            *redo = icon_button(ui, "⟳", Some("redo"), !state.future.is_empty());
            *undo = icon_button(ui, "⟲", Some("undo"), !state.past.is_empty());
        });
    });
}

/// The three columns, side by side and equally wide.
fn columns(ui: &mut egui::Ui, state: &mut PlainState, theme: Theme, pending: &mut Vec<Msg>) {
    let ids: Vec<ColumnId> = state.board.columns.iter().map(|column| column.id).collect();
    let query = state.query.clone();
    let filter = state.filter;

    ui.columns(ids.len(), |uis| {
        for (ui, id) in uis.iter_mut().zip(ids) {
            column(ui, state, theme, id, &query, filter, pending);
        }
    });
}

#[allow(clippy::too_many_arguments)]
fn column(
    ui: &mut egui::Ui,
    state: &mut PlainState,
    theme: Theme,
    id: ColumnId,
    query: &str,
    filter: Option<bool>,
    pending: &mut Vec<Msg>,
) {
    let Some(index) = state.board.columns.iter().position(|c| c.id == id) else {
        return;
    };
    let name = state.board.columns[index].name.clone();
    let total = state.board.columns[index].cards.len();
    let shown = visible(&state.board.columns[index], query, filter);

    // Where the card in hand would land, unless that is where it already is: a
    // drop that moves nothing gets no gap opened for it.
    let carried = state.carrying;
    let carried_at = carried.and_then(|card| shown.iter().position(|shown| *shown == card));
    let gap = state
        .hovered
        .filter(|target| target.column == id)
        .filter(|target| {
            target.before != carried
                && carried_at.is_none_or(|i| target.before != shown.get(i + 1).copied())
        })
        .map(|target| target.before);

    ui.spacing_mut().item_spacing.y = 6.0;
    ui.horizontal(|ui| {
        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
        ui.spacing_mut().item_spacing.x = 4.0;
        let renaming_here = matches!(&state.renaming, Some((column, _)) if *column == id);
        if renaming_here {
            let (_, mut draft) = state.renaming.take().expect("just looked");
            let field = egui::Id::new(("board_plain/rename", id));
            match title_edit(ui, &mut state.fresh, field, &mut draft, "column name") {
                Edited::Typing => state.renaming = Some((id, draft)),
                Edited::Commit(name) => pending.push(Msg::RenameColumn { column: id, name }),
                Edited::Cancel => {}
            }
        } else {
            // The rename button is only there while the pointer is over the
            // header row, which is the rectangle the row is about to take.
            let row = egui::Rect::from_min_size(
                ui.cursor().min,
                egui::vec2(ui.available_width(), ui.spacing().interact_size.y),
            );
            let over = ui.rect_contains_pointer(row);
            ui.label(egui::RichText::new(&name).strong().color(theme.accent()));
            if over && icon_button(ui, "✏", Some("rename"), true) {
                state.renaming = Some((id, name.clone()));
                state.fresh = Some(egui::Id::new(("board_plain/rename", id)));
            }
        }
        // The same reading order as the egui-reactor column header, where the
        // name has `grow={1.0}` and pushes the count to the right.
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            ui.label(format!("{}/{}", shown.len(), total));
        });
    });

    // The cards and, after them, the footer that adds one and catches a drop
    // meant for the end of the column — the same order as the egui-reactor
    // version, where the footer is inside the `<ScrollArea>` too.
    egui::ScrollArea::vertical().id_salt(id).show(ui, |ui| {
        ui.set_min_width(ui.available_width());
        // No item spacing: the space between two cards is the closed gap that
        // sits between them. See [`drop_gap`].
        ui.spacing_mut().item_spacing.y = 0.0;

        // The gaps are animated in the picture, not in the layout, for the
        // same reason as the other version (a relayout per frame would cost a
        // second pass per frame): each card below a gap is drawn shifted by
        // however far its gap still has to go. `lift` adds those up.
        let carrying = state.carrying.is_some();
        let mut lift = 0.0;
        for (i, card) in shown.iter().enumerate() {
            let target = DropTarget {
                column: id,
                before: Some(*card),
            };
            let open = gap == Some(Some(*card));
            let amount = gap_amount(
                ui.ctx(),
                egui::Id::new(("board_plain/gap", target)),
                open,
                carrying,
            );
            drop_gap(ui, state, theme, target, open, amount, lift);
            lift += (amount - f32::from(u8::from(open))) * PLACEHOLDER_H;
            self::card(
                ui,
                state,
                theme,
                id,
                *card,
                shown.get(i + 1).copied(),
                lift,
                pending,
            );
        }
        if shown.is_empty() {
            ui.add_space(CARD_GAP);
            ui.label("nothing here");
        }

        // The new card, in the same frame the saved ones wear, so that what is
        // being typed looks like what it will become. It is not on the board
        // until it is confirmed: an empty card put there and then taken off
        // again would be two steps of undo for one card, and the storage would
        // hold a nameless card if the window closed in between.
        if matches!(&state.adding, Some((column, _)) if *column == id) {
            let (_, mut draft) = state.adding.take().expect("just looked");
            ui.add_space(CARD_GAP);
            egui::Frame::new()
                .fill(theme.card())
                .corner_radius(4.0)
                .inner_margin(6.0)
                .show(ui, |ui| {
                    ui.set_min_width(ui.available_width());
                    let field = egui::Id::new(("board_plain/new", id));
                    match title_edit(ui, &mut state.fresh, field, &mut draft, "new card") {
                        Edited::Typing => state.adding = Some((id, draft)),
                        Edited::Commit(title) => {
                            if !title.trim().is_empty() {
                                pending.push(Msg::AddCard { column: id, title });
                            }
                        }
                        Edited::Cancel => {}
                    }
                });
        }

        let end = DropTarget {
            column: id,
            before: None,
        };
        let open = gap == Some(None);
        let amount = gap_amount(
            ui.ctx(),
            egui::Id::new(("board_plain/gap", end)),
            open,
            carrying,
        );
        drop_gap(ui, state, theme, end, open, amount, lift);
        lift += (amount - f32::from(u8::from(open))) * PLACEHOLDER_H;

        let rect =
            egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), FOOTER_H));
        if state.carrying.is_some() {
            state.slots.push((rect, end));
        }
        // Fainter than a card: a place to make one.
        let button = egui::Button::new("+ card")
            .fill(theme.footer())
            .wrap_mode(egui::TextWrapMode::Extend);
        let clicked = ui
            .with_visual_transform(lifted(lift), |ui| ui.put(rect, button).clicked())
            .inner;
        if clicked {
            state.adding = Some((id, String::new()));
            state.fresh = Some(egui::Id::new(("board_plain/new", id)));
        }
    });
}

/// The space between two cards, and the gap a card in hand would drop into.
///
/// One of these goes in front of every card and in front of the footer, open or
/// closed, and closed it is the column's card spacing. The egui-reactor version
/// has the same rule for a reason that does not apply here — a taffy node that
/// comes and goes has no rectangle on the frame it appears — but the two are
/// laid out to the same numbers, so this one keeps the rule too and the same
/// test measures both.
fn drop_gap(
    ui: &mut egui::Ui,
    state: &mut PlainState,
    theme: Theme,
    target: DropTarget,
    open: bool,
    // How open the picture of the gap is, and how far the picture of the card
    // above it has been shifted; the same numbers as the other version.
    amount: f32,
    lift: f32,
) {
    let extra = if open { PLACEHOLDER_H } else { 0.0 };
    let size = egui::vec2(ui.available_width(), CARD_GAP + extra);
    let (rect, response) = ui.allocate_exact_size(size, egui::Sense::hover());
    let shown = amount * PLACEHOLDER_H;
    if shown > 0.5 {
        // The spacing stays spacing: the card is drawn in what is new — below
        // the card above, wherever its picture is at the moment.
        let top = egui::pos2(rect.left(), rect.top() + lift + CARD_GAP);
        let seen = egui::Rect::from_min_size(top, egui::vec2(rect.width(), shown));
        placeholder(ui.painter(), seen, theme);
    }
    if !open {
        return;
    }
    // Painted, not a widget, so the name has to be said out loud; the test asks
    // for it to know whether a gap is open.
    response.widget_info(|| {
        egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), "drop here")
    });
    state.slots.push((rect, target));
}

#[allow(clippy::too_many_arguments)]
fn card(
    ui: &mut egui::Ui,
    state: &mut PlainState,
    theme: Theme,
    column: ColumnId,
    id: CardId,
    next: Option<CardId>,
    // How far the picture of this card is from where it was laid out, while a
    // gap above it is still opening or closing.
    lift: f32,
    pending: &mut Vec<Msg>,
) {
    let Some(card) = state.board.card(id).cloned() else {
        return;
    };
    let carried = state.carrying == Some(id);
    let dragging = state.carrying.is_some();
    // Every read of a card's own state goes through the map, and every write
    // has to put it back. This is the shape all of `plain.rs` takes.
    let ui_state = state.card_ui(id).clone();
    let field = egui::Id::new(("board_plain/title", id));

    // The card is grabbed and dropped on by the whole of itself, so the drag
    // lives on a rectangle behind the widgets rather than on the title. It is
    // registered first, and that order is the feature: egui picks the topmost
    // click candidate and the topmost drag candidate separately, so the
    // checkbox and the buttons still take their clicks while a press that turns
    // into a movement falls through to here. The rectangle is the one the last
    // frame left behind, because this one has not been drawn yet.
    if let Some(rect) = ui_state.rect {
        let bg = ui.interact(
            rect,
            egui::Id::new(("board_plain/card", id)),
            egui::Sense::drag(),
        );
        // A drag handle is a control, so it says what it is a handle for. Not
        // the bare title: that is already the label beside it, and two nodes
        // with one name is the thing a screen reader cannot tell apart.
        ui.ctx().accesskit_node_builder(bg.id, |node| {
            node.set_label(format!("card: {}", card.title));
        });
        if bg.drag_started() {
            state.carrying = Some(id);
        }
        if bg.contains_pointer() {
            // `contains_pointer`, not `hovered`: the pointer is over the card
            // even when it is over a widget drawn on top of it.
            ui.ctx().set_cursor_icon(if dragging {
                egui::CursorIcon::Grabbing
            } else {
                egui::CursorIcon::PointingHand
            });
        }
    }

    // The shift is visual only: the drag surface and the slots are where the
    // card will be, which is where the pointer is aiming.
    let drawn = ui
        .with_visual_transform(lifted(lift), |ui| {
            egui::Frame::new()
                .fill(theme.card())
                .corner_radius(4.0)
                .inner_margin(6.0)
                .show(ui, |ui| {
                    ui.set_min_width(ui.available_width());
                    ui.horizontal(|ui| {
                        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
                        ui.spacing_mut().item_spacing.x = 6.0;

                        // `done` is read out of the board, so the box is drawn against
                        // a copy and what comes back is a message, not a write.
                        let mut done = card.done;
                        theme.style_controls(ui);
                        let box_ = ui.add(egui::Checkbox::without_text(&mut done));
                        // A card is found by this name — by a screen reader, and by the
                        // test, which needs a hold on a card whose title has become an
                        // editor.
                        ui.ctx().accesskit_node_builder(box_.id, |node| {
                            node.set_label(format!("done: {}", card.title));
                        });
                        if box_.changed() {
                            pending.push(Msg::SetDone {
                                card: id,
                                done: !card.done,
                            });
                        }

                        // The buttons are pinned to the right and the title fills what
                        // is left, which is `grow={1.0}` on the other side.
                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                            if icon_button(ui, "×", Some("remove"), true) {
                                pending.push(Msg::RemoveCard { card: id });
                            }
                            if icon_button(ui, "✏", Some("edit"), true) {
                                // Opening takes a fresh copy of the saved title, so
                                // closing the editor and opening it again starts from
                                // what was saved rather than from an old draft.
                                let entry = state.card_ui(id);
                                entry.draft_title = card.title.clone();
                                entry.editing = true;
                                state.fresh = Some(field);
                            }
                            ui.with_layout(
                                egui::Layout::left_to_right(egui::Align::Center),
                                |ui| {
                                    if ui_state.editing {
                                        title(ui, state, id, field, pending);
                                    } else {
                                        let mut text = egui::RichText::new(&card.title);
                                        if card.done {
                                            text = text.weak().strikethrough();
                                        }
                                        if carried {
                                            text = text.weak();
                                        }
                                        // Not selectable, and it senses nothing. A label
                                        // that senses a drag still starts a text selection
                                        // on the press, and egui then runs that selection
                                        // across every label the pointer passes over on its
                                        // way. The card is dragged by the background above.
                                        ui.add(egui::Label::new(text).truncate().selectable(false));
                                    }
                                },
                            );
                        });
                    });
                })
                .response
        })
        .inner;

    state.card_ui(id).rect = Some(drawn.rect);
    // The whole card split in two: the top half means "in front of me", the
    // bottom half "in front of the next one", which is how a list of cards is
    // also a list of the gaps between them.
    if dragging {
        let (top, bottom) = drawn.rect.split_top_bottom_at_fraction(0.5);
        state.slots.push((
            top,
            DropTarget {
                column,
                before: Some(id),
            },
        ));
        state.slots.push((
            bottom,
            DropTarget {
                column,
                before: next,
            },
        ));
    }
}

/// The card's title while it is being edited, taken out of the map and put
/// back — the borrow checker's way of saying that this state is the whole
/// board's, not the card's.
fn title(
    ui: &mut egui::Ui,
    state: &mut PlainState,
    id: CardId,
    field: egui::Id,
    pending: &mut Vec<Msg>,
) {
    let mut draft = std::mem::take(&mut state.card_ui(id).draft_title);
    let edited = title_edit(ui, &mut state.fresh, field, &mut draft, "title");
    match edited {
        Edited::Typing => state.card_ui(id).draft_title = draft,
        Edited::Commit(title) => {
            // Confirming an empty title is a cancel: a nameless card would
            // leave nothing to click on to name it again.
            if !title.trim().is_empty() {
                pending.push(Msg::SetTitle { card: id, title });
            }
            state.card_ui(id).editing = false;
        }
        Edited::Cancel => state.card_ui(id).editing = false,
    }
}

/// What a one-line editor did on this frame.
enum Edited {
    Typing,
    Commit(String),
    Cancel,
}

/// The plain twin of `<TitleEdit>`: the card's title, the column's name and the
/// card being added are all the same three keys.
///
/// Enter confirms, Escape puts it back, and clicking elsewhere confirms — the
/// last because a board is clicked around rather than tabbed through, and
/// losing what was typed for looking away is not a thing anyone means.
fn title_edit(
    ui: &mut egui::Ui,
    fresh: &mut Option<egui::Id>,
    field: egui::Id,
    text: &mut String,
    name: &str,
) -> Edited {
    let width = ui.available_width();
    let response = ui.add(
        egui::TextEdit::singleline(text)
            .id(field)
            .desired_width(width),
    );
    // A nameless text field is what the gallery's a11y test counts, and the
    // text says nothing about which of the three this one is.
    ui.ctx()
        .accesskit_node_builder(response.id, |node| node.set_label(name));

    // Focus and select-all on the frame the field first appears; after that the
    // caret is the user's business. Whoever opened the editor said which field
    // it was, because in immediate mode there is no "first frame" to ask.
    if *fresh == Some(field) {
        response.request_focus();
        let mut state = egui::TextEdit::load_state(ui.ctx(), field).unwrap_or_default();
        let end = egui::text::CCursor::new(text.chars().count());
        let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), end);
        state.cursor.set_char_range(Some(all));
        state.store(ui.ctx(), field);
        *fresh = None;
    }

    if response.lost_focus() {
        // egui hands focus back on Escape, so the two arrive together and the
        // only question is which of them ended the edit.
        if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
            return Edited::Cancel;
        }
        return Edited::Commit(text.clone());
    }
    Edited::Typing
}

/// The plain twin of `<IconButton>`. `name` is what the accessibility tree says
/// when the button shows a picture instead of a word.
fn icon_button(ui: &mut egui::Ui, label: &str, name: Option<&str>, enabled: bool) -> bool {
    let button = egui::Button::new(label)
        .small()
        .frame(false)
        .wrap_mode(egui::TextWrapMode::Extend);
    let response = ui.add_enabled(enabled, button);
    if let Some(name) = name {
        // The widget has already written its node for this pass, so this
        // overwrites the label egui took from the glyph.
        ui.ctx()
            .accesskit_node_builder(response.id, |node| node.set_label(name));
    }
    response.clicked()
}

MIT または Apache-2.0 ライセンスで公開しています。