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);
}