Skip to content

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> を持ち、同じ仕事をします。加えて持っているのが帳簿です。メッセージ、永続化、そして手で遅らせる削除。

自分で動かす ​

sh
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 です。

rust
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>
    }
}
rust
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
}

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