board
Cards that keep the title being typed into them while they are dragged between columns.
Every other example holds its state in one place and draws it. This one is about state that belongs to the items: the half-typed title someone is in the middle of, and what happens to it when the cards are reordered, moved to another column, filtered away and brought back. Start a card's title, then drag that card to another column — the draft goes with it.
That works because the draft is keyed by the card's identity rather than by where the card is drawn. use_identity, one of the example's own custom hooks, is what makes the difference; key= alone would not, because moving a card changes its parent. A card that is deleted — or filtered out of view, which unmounts it just the same — has its state dropped by the end-of-pass sweep, which is the housekeeping the plain egui version has to write out by hand.
The rest is composition. <Toolbar>, <Column>, <Card>, <TitleEdit>, <Chip> and <IconButton> each take a style: ItemStyle so the caller decides where they sit, and report what happened through event props. Undo/redo, the search debounce and the whole drag session live in custom hooks written against the same public API an application has — none of them needed a change to the library. Which way something travels is a decision made twice here: what a component did goes up as an event, and what the whole tree shares (the theme, the drag in progress, the Dispatch) comes down through context. The theme is egui's own — the board follows the page's appearance switch rather than keeping one of its own.
Compare the tab labels. The plain egui version does the same job; the difference is where the per-card state has to live.
Run it yourself
cargo run -p board
cargo run -p board --bin board-plain # the plain egui version
trunk serve --config examples/board/Trunk.tomlThe source is examples/board/src/lib.rs and 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()
}