board
カードをドラッグして列の間を移動しても、入力中のタイトルはそのまま残る。
ほかのサンプルは状態を 1 か所に持ち、それを描きます。これは アイテム に属する状態の話です。誰かが入力しかけたタイトルと、カードが並べ替えられ、別の列へ移され、フィルタで消え、また戻ってきたときに、それがどうなるか。カードのタイトルを打ちかけて、そのカードを別の列へドラッグしてみてください。下書きは一緒に付いていきます。
そうなるのは、下書きの鍵がカードの描かれる場所ではなく、カードの同一性だからです。違いを生んでいるのは、このサンプル自身の自作フック の 1 つ、use_identity です。key=だけでは足りません。カードを動かすと親が変わってしまうからです。削除されたカード ― フィルタで視界から外れたカードも、同じようにアンマウントされます ―の状態は、パス終わりの掃除が落とします。素の egui 版が手で書かなければならない後始末が、これです。
あとは組み立ての話です。<Toolbar>、<Column>、<Card>、<TitleEdit>、<Chip>、<IconButton> はどれも style: ItemStyle を取るので、どこに座るかは呼び出し側が決めます。そして何が起きたかをイベントの props で知らせます。undo / redo、検索のデバウンス、ドラッグの一連の流れは、アプリケーションが使えるのと同じ公開API に対して書かれた自作フックの中にあります。どれもライブラリの変更を必要としませんでした。何がどちら向きに流れるかは、ここでは 2 回決められています。コンポーネントが したこと はイベントとして上がり、木全体で共有するもの(テーマ、進行中のドラッグ、Dispatch)はコンテキスト で下ります。テーマは egui 自身のものです。ボードは自前のテーマを持たず、ページの外観の切り替えに従います。
タブのラベルを見比べてください。素の egui 版も同じ仕事をします。違うのは、カードごとの状態がどこに置かれることになるかです。
自分で動かす
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 です。
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(());
}
}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()
}