Skip to content

form

束縛できるウィジェットを全部、変更ログ付きで。設定は再起動しても残る。

束縛できる要素を全部入れた設定画面です。<TextEdit>、<Checkbox>、<Slider>、<ComboBox>。どれも use_persisted が持つ 1 つの Settings 構造体のフィールドに束縛されているので、ここで変えたものは再起動してもここにあります。隣の変更ログは普通のuse_state です。

見どころは bind です。ウィジェットに状態の &mut を渡すので、ウィジェットが直接そこに書き込み、コピーして戻すものは何もありません。しかも &mut *stateと違って dirty の印を付けないので、毎フレーム書くウィジェットが毎フレーム再描画を要求することもありません。代わりに規則が 1 つ付きます。同じ要素のon_change から同じ状態を触ることはできません。唯一の &mut はすでにウィジェットが握っていて、二度目の借用はコンパイルが通らないからです。だからここでの on_change は、ウィジェットが渡せるものを運びます。新しい bool、新しいインデックス。変更ログはまったく別の場所にあります。長い版は状態とフック にあります。

タブを見比べてください。素の egui 版も同じウィジェットと同じ永続化を持ち、それぞれの周りに読み書きを明示的に書いてあります。

自分で動かす ​

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

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

rust
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
use serde::{Deserialize, Serialize};

/// The options the theme dropdown offers. `ComboBox` binds an index, so this
/// is the list the index points into.
pub const THEMES: [&str; 3] = ["dark", "light", "system"];

/// Everything the form edits. `pub` so [`plain`] can edit the same thing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Settings {
    pub name: String,
    pub notify: bool,
    pub autosave: bool,
    pub volume: u8,
    pub theme: usize,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            name: String::from("anon"),
            notify: true,
            autosave: false,
            volume: 50,
            theme: 0,
        }
    }
}

impl Settings {
    /// One line saying what the settings are, so the form has something to
    /// show for itself (and a test has something to read).
    pub fn summary(&self) -> String {
        format!(
            "{}, {}, volume {}",
            self.name, THEMES[self.theme], self.volume
        )
    }
}

/// The width of the label column, so the widgets line up.
const LABEL: f32 = 90.0;

#[component]
pub fn App(cx: &mut Cx) {
    let mut settings = use_persisted(cx, "form/settings", Settings::default);
    let mut log = use_state(cx, Vec::<String>::new);

    let summary = settings.summary();
    let entries: Vec<(usize, String)> = log.iter().cloned().enumerate().rev().take(8).collect();

    rsx! {
        <View direction="column" gap={8} p={12} grow={1.0}>
            <Text size={22.0} strong>"settings"</Text>

            <Field label="name">
                <TextEdit
                    w={200.0}
                    bind={&mut settings.bind().name}
                    on_change={|| log.push(String::from("name edited"))}
                />
            </Field>

            <Field label="notify">
                <Checkbox
                    bind={&mut settings.bind().notify}
                    on_change={|on: bool| log.push(format!("notify = {on}"))}
                />
            </Field>

            <Field label="autosave">
                <Checkbox
                    bind={&mut settings.bind().autosave}
                    on_change={|on: bool| log.push(format!("autosave = {on}"))}
                />
            </Field>

            <Field label="volume">
                <Slider
                    bind={&mut settings.bind().volume}
                    range={0..=100}
                    on_change={|| log.push(String::from("volume changed"))}
                />
            </Field>

            <Field label="theme">
                <ComboBox
                    bind={&mut settings.bind().theme}
                    options={&THEMES}
                    on_change={|i: usize| log.push(format!("theme = {}", THEMES[i]))}
                />
            </Field>

            <Separator/>

            <View direction="row" gap={8} align="center" w="100%">
                <Text grow={1.0}>{summary}</Text>
                <Button on_click={|| {
                    *settings = Settings::default();
                    log.push(String::from("reset"));
                }}>"reset"</Button>
            </View>

            if !entries.is_empty() {
                <Collapsing header={&format!("log ({})", entries.len())}>
                    <View direction="column" gap={2}>
                        for (i, line) in entries.iter() {
                            <Text key={i}>{line.as_str()}</Text>
                        }
                    </View>
                </Collapsing>
            }
        </View>
    }
}

/// One labelled row. The label column has a width so the widgets line up;
/// without it every row would start wherever its own text ended.
#[component]
fn Field(cx: &mut Cx, label: &str, children: impl View) {
    rsx! {
        <View direction="row" gap={8} align="center" w="100%">
            <Text w={LABEL}>{label}</Text>
            {children}
        </View>
    }
}
rust
use crate::{LABEL, Settings, THEMES};

/// Everything the plain version keeps between frames.
#[derive(Default)]
pub struct PlainState {
    pub settings: Settings,
    pub log: Vec<String>,
}

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

impl PlainState {
    /// `use_persisted("form/settings", ..)` is this, plus the key.
    pub fn load(json: &str) -> Self {
        Self {
            settings: serde_json::from_str(json).unwrap_or_default(),
            log: Vec::new(),
        }
    }

    /// Serialize the settings for the caller to write into eframe's storage.
    /// The log is not part of them.
    pub fn save(&self) -> String {
        serde_json::to_string(&self.settings).unwrap_or_else(|_| String::from("{}"))
    }
}

pub fn ui(ui: &mut egui::Ui, state: &mut PlainState) {
    // Read what the rows below will need, before they borrow the state.
    let summary = state.settings.summary();
    let entries: Vec<String> = state.log.iter().rev().take(8).cloned().collect();
    let mut log = Vec::new();

    egui::Frame::new().inner_margin(12.0).show(ui, |ui| {
        ui.spacing_mut().item_spacing.y = 8.0;
        ui.label(egui::RichText::new("settings").size(22.0).strong());

        // A `Grid` does the column alignment that `<Field>`'s label width does
        // on the other side; `min_col_width` is what makes the labels a column
        // rather than each row starting where its own text ended.
        egui::Grid::new("form")
            .num_columns(2)
            .min_col_width(LABEL)
            .show(ui, |ui| {
                let settings = &mut state.settings;

                ui.label("name");
                let name =
                    ui.add(egui::TextEdit::singleline(&mut settings.name).desired_width(200.0));
                if name.changed() {
                    log.push(String::from("name edited"));
                }
                ui.end_row();

                ui.label("notify");
                if ui.checkbox(&mut settings.notify, "").changed() {
                    log.push(format!("notify = {}", settings.notify));
                }
                ui.end_row();

                ui.label("autosave");
                if ui.checkbox(&mut settings.autosave, "").changed() {
                    log.push(format!("autosave = {}", settings.autosave));
                }
                ui.end_row();

                ui.label("volume");
                if ui
                    .add(egui::Slider::new(&mut settings.volume, 0..=100))
                    .changed()
                {
                    log.push(String::from("volume changed"));
                }
                ui.end_row();

                ui.label("theme");
                let theme = egui::ComboBox::from_id_salt("theme").show_index(
                    ui,
                    &mut settings.theme,
                    THEMES.len(),
                    |i| THEMES[i],
                );
                if theme.changed() {
                    log.push(format!("theme = {}", THEMES[settings.theme]));
                }
                ui.end_row();
            });

        ui.separator();

        ui.horizontal(|ui| {
            ui.label(&summary);
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                if ui.button("reset").clicked() {
                    state.settings = Settings::default();
                    log.push(String::from("reset"));
                }
            });
        });

        if !entries.is_empty() {
            egui::CollapsingHeader::new(format!("log ({})", entries.len()))
                .id_salt("log")
                .show(ui, |ui| {
                    ui.spacing_mut().item_spacing.y = 2.0;
                    for line in &entries {
                        ui.label(line);
                    }
                });
        }
    });

    state.log.extend(log);
}

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