todo
reducer がリストを動かし、use_persisted が再起動をまたいで保持する。
リストを変えうるものはすべてメッセージです ― Add、Toggle、Remove、ClearDone ― そして 1 つの reduce 関数がそれを適用します。use_reducer は状態と Dispatch を返すので、リストの奥にあるボタンも、上にコールバックの鎖を通さずに「何が起きたか」を言えます。
データ自体は use_persisted から来ます。鍵は明示的な文字列 "todo/todos"です。永続化スロットを決めるのが呼び出し位置ではなくこの鍵なのは、まさに、上に1 行足しただけで保存データが読めなくなっては困るからです。このサンプルが鍵に自分の名前を前置しているのは、アプリが永続化するものがすべて 1 つの名前空間を共有するからです。ネイティブで動かし、終了して、もう一度立ち上げてみてください。リストは eframe のストレージの中に残っています。
分かりにくいのはループの中です。todos は for に借りられているので、そこにあるハンドラはそれを触れません。削除ボタンは Msg::Remove(i) を送り、チェックボックスは作業用のコピーに束縛して、本当の変更は Dispatch を通して次のフレームで着地します。この借用から抜ける道の 1 つが reducer で、もう 1 つがupdate_later です。
2 つのタブを見比べてください。素の egui 版も同じ Vec<Todo> を持ち、同じ仕事をします。加えて持っているのが帳簿です。メッセージ、永続化、そして手で遅らせる削除。
自分で動かす
cargo run -p todo
cargo run -p todo --bin todo-plain # 素の egui 版
trunk serve --config examples/todo/Trunk.tomlソースは examples/todo/src/lib.rsと plain.rs です。
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
use serde::{Deserialize, Serialize};
/// One item. `pub` so [`plain`] can share it: the data model is the same, only
/// the code around it differs.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Todo {
pub text: String,
pub done: bool,
}
/// Everything that can change the list.
enum Msg {
Add(String),
Toggle(usize),
Remove(usize),
ClearDone,
}
fn reduce(todos: &mut Vec<Todo>, msg: Msg) {
match msg {
Msg::Add(text) => todos.push(Todo { text, done: false }),
Msg::Toggle(i) => {
if let Some(todo) = todos.get_mut(i) {
todo.done = !todo.done;
}
}
Msg::Remove(i) => {
if i < todos.len() {
todos.remove(i);
}
}
Msg::ClearDone => todos.retain(|todo| !todo.done),
}
}
#[component]
pub fn App(cx: &mut Cx) {
// The data lives in storage; the reducer is what changes it. The key is
// prefixed with the example name because the gallery runs every example
// against one storage slot.
let mut saved = use_persisted(cx, "todo/todos", Vec::<Todo>::new);
let mut draft = use_state(cx, String::new);
let (todos, dispatch) = use_reducer(
cx,
|state: &mut Vec<Todo>, msg| reduce(state, msg),
|| saved.clone(),
);
// Mirror the reducer's state into the persisted slot; both are cheap Vecs
// and this keeps the storage copy up to date without a second reducer.
if *saved != *todos {
*saved = todos.clone();
}
let remaining = todos.iter().filter(|todo| !todo.done).count();
let done: Vec<(usize, String)> = todos
.iter()
.enumerate()
.filter(|(_, todo)| todo.done)
.map(|(i, todo)| (i, todo.text.clone()))
.collect();
rsx! {
<View direction="column" gap={8} p={12} grow={1.0}>
<Text size={22.0} strong>"todo"</Text>
<View direction="row" gap={8} align="center">
// The field owns the `&mut String`, so `on_submit` gets the
// text as its payload and `clear_on_submit` empties the field.
<TextEdit
grow={1.0}
bind={draft.bind()}
hint="what needs doing?"
clear_on_submit
on_submit={|text: String| {
let text = text.trim().to_owned();
if !text.is_empty() {
dispatch.send(Msg::Add(text));
}
}}
/>
<Text>{format!("{remaining} left")}</Text>
</View>
<Separator/>
for (i, todo) in todos.iter().enumerate().filter(|(_, t)| !t.done) {
<View key={i} direction="row" gap={8} align="center">
// `todos` is borrowed by the loop, so the checkbox binds to
// a scratch copy and the real change goes through the
// `Dispatch`, which lands on the next frame.
<Checkbox
bind={&mut { todo.done }}
on_change={|_: bool| dispatch.send(Msg::Toggle(i))}
/>
<Text grow={1.0}>{todo.text.as_str()}</Text>
// `label` is what a screen reader says; "x" is what is drawn.
<Button label="remove" on_click={|| dispatch.send(Msg::Remove(i))}>"x"</Button>
</View>
}
if !done.is_empty() {
<Collapsing header={&format!("done ({})", done.len())}>
<View direction="column" gap={4}>
for (i, text) in done.iter() {
<View key={i} direction="row" gap={8} align="center">
<Text grow={1.0}>{text.as_str()}</Text>
<Button on_click={|| dispatch.send(Msg::Toggle(*i))}>"undo"</Button>
</View>
}
<Button on_click={|| dispatch.send(Msg::ClearDone)}>"clear done"</Button>
</View>
</Collapsing>
}
</View>
}
}use serde::{Deserialize, Serialize};
use crate::Todo;
/// Everything the plain version keeps between frames.
///
/// The egui-reactor version has no such type. `saved`, `draft` and the reducer's
/// list are three hooks, each owned by the component that reads it.
#[derive(Default, Serialize, Deserialize)]
pub struct PlainState {
pub todos: Vec<Todo>,
/// The text field's buffer. Not saved: it is not part of the list.
#[serde(skip)]
pub draft: String,
}
/// The key the standalone binary stores the list under.
pub const STORAGE_KEY: &str = "todo_plain";
impl PlainState {
/// Read the list back. `use_persisted` is this, plus the key.
pub fn load(json: &str) -> Self {
serde_json::from_str(json).unwrap_or_default()
}
/// Serialize the list for the caller to write into eframe's storage.
pub fn save(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
}
}
pub fn ui(ui: &mut egui::Ui, state: &mut PlainState) {
// `p={12}` and `gap={8}` on the root `<View>`.
egui::Frame::new().inner_margin(12.0).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
ui.label(egui::RichText::new("todo").size(22.0).strong());
let remaining = format!("{} left", state.todos.iter().filter(|t| !t.done).count());
ui.horizontal(|ui| {
// `<TextEdit grow={1.0}>` next to a label: the field's width is
// whatever the label does not need.
let label_width = text_width(ui, &remaining);
let width = ui.available_width() - label_width - ui.spacing().item_spacing.x;
let edit = ui.add_sized(
egui::vec2(width, ui.spacing().interact_size.y),
egui::TextEdit::singleline(&mut state.draft).hint_text("what needs doing?"),
);
if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
let text = state.draft.trim().to_owned();
if !text.is_empty() {
state.todos.push(Todo { text, done: false });
}
state.draft.clear();
}
ui.label(remaining);
});
ui.separator();
// The loop borrows the list, so it cannot remove from it. The index
// is carried out here and applied below; the egui-reactor version
// sends `Msg::Remove(i)` and never sees this.
let mut remove = None;
for (i, todo) in state
.todos
.iter_mut()
.enumerate()
.filter(|(_, todo)| !todo.done)
{
ui.horizontal(|ui| {
ui.checkbox(&mut todo.done, "");
ui.label(&todo.text);
// `<Text grow={1.0}>` before the button: here the button is
// put in a right-to-left `Ui` filling the rest of the row.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let button = ui.button("x");
// `<Button label="remove">`, by hand: an "x" is drawn but
// "remove" is the name assistive technology reads.
ui.ctx()
.accesskit_node_builder(button.id, |node| node.set_label("remove"));
if button.clicked() {
remove = Some(i);
}
});
});
}
if let Some(i) = remove {
state.todos.remove(i);
}
let done: Vec<usize> = state
.todos
.iter()
.enumerate()
.filter(|(_, todo)| todo.done)
.map(|(i, _)| i)
.collect();
if !done.is_empty() {
// Same again: the clicks are collected, then applied.
let mut undo = None;
let mut clear = false;
egui::CollapsingHeader::new(format!("done ({})", done.len()))
.id_salt("done")
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 4.0;
for &i in &done {
ui.horizontal(|ui| {
ui.label(&state.todos[i].text);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.button("undo").clicked() {
undo = Some(i);
}
},
);
});
}
clear = ui.button("clear done").clicked();
});
if let Some(i) = undo {
state.todos[i].done = false;
}
if clear {
state.todos.retain(|todo| !todo.done);
}
}
});
}
/// The width `text` will take as a default label.
fn text_width(ui: &egui::Ui, text: &str) -> f32 {
let font = egui::TextStyle::Body.resolve(ui.style());
ui.painter()
.layout_no_wrap(text.to_owned(), font, egui::Color32::PLACEHOLDER)
.size()
.x
}