form
Every bound widget, a change log, and settings that survive a restart.
One settings screen with every bound element in it: <TextEdit>, <Checkbox>, <Slider> and <ComboBox>. All of them bind into fields of a single Settings struct held by use_persisted, so what you change here is still here after a restart; the change log next to them is an ordinary use_state.
bind is the thing to look at. It hands the widget &mut the state, so the widget writes into it directly and nothing has to be copied back — and, unlike &mut *state, it does not mark the state dirty, which is what stops a widget that writes every frame from asking for a repaint every frame. The cost is a rule: on_change on the same element may not touch the same state, because the widget already holds the only &mut to it and a second borrow would not compile. So on_change here carries whatever the widget can hand over — the new bool, the new index — and the change log is somewhere else entirely. State and hooks has the longer version.
Compare the tabs: the plain egui version has the same widgets and the same persistence, written as explicit reads and writes around each one.
Run it yourself
cargo run -p form
cargo run -p form --bin form-plain # the plain egui version
trunk serve --config examples/form/Trunk.tomlThe source is examples/form/src/lib.rs and plain.rs.
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>
}
}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);
}