notes
メモアプリ: reducer、永続化、コンテキスト、メモ化、エディタをまとめて。
ここに新しいものは何もありません。ほかのサンプルが 1 つずつ見せている部品を、アプリケーションがまとめるであろう形でまとめただけです。use_reducer がメモを持ち、use_persisted が再起動をまたいで保持し、use_memo が検索文字列を鍵にリストを絞り込んで並べ替え、provide_context がテーマを両方の列に渡します。間にある 2 つの <View> は、どちらもテーマを運びません。テーマは egui 自身のダーク / ライトです。プロバイダは木のてっぺんでそれを読み、変わったときだけハンドルに書きます。
立ち止まる価値のある細かい点が 2 つあります。1 つは、reducer がメモを持ち、永続化スロットがそれを写している点です。reducer は他人のスロット に向かって畳むことはできないので、片方をもう片方へコピーする 2 行が、use_reducer と use_persisted を両方使うための代金です。もう 1 つは、メモのエディタがタイトルには bind を使い、on_change でメモのタイムスタンプを更新している点です。テキストへの唯一の &mut を握っているのはウィジェットなので、時計は同じ状態をもう一度借りるのではなく、メッセージで動かします。この制約は状態とフック で説明しています。ここには手書きの葉が2 つあります。要素では埋められない理由が 1 つずつあるからです。new でメモを作ったとき、タイトルはテキストを選択した状態でフォーカスを取ります。本文はegui::TextEdit を egui::ScrollArea で包んであるので、長いメモはウィンドウの下へはみ出さず、列の中でスクロールします。
テキストは日本語の入ったフォントで描いています。egui は渡されたフォントのバイト列からラスタライズし、自前のフォントには CJK のグリフがありません。だから何もしなければ日本語のメモは豆腐になります。Fonts のスタックを 1 つ作り、Noto Sans JP のサブセットを egui のフォントの前に置いて、それを既定にしています。この話の長い版が font です。
サンプルを順に読んでいるなら、まずここから始めて、あとは欲しい部品を 1 つずつ見ていってください。
自分で動かす
cargo run -p notes
trunk serve --config examples/notes/Trunk.tomlソースは examples/notes/src/lib.rs です。
use egui_reactor::prelude::*;
use egui_reactor_app::fonts::{FontSource, Fonts, Generic};
use egui_reactor_elements::prelude::*;
pub mod notes;
use notes::{Msg, Note, Notes, reduce, visible};
/// Provided to the whole tree; the leaves read it with `use_context`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Theme {
pub dark: bool,
}
impl Theme {
/// The colour the selected note and the headings are drawn in.
pub fn accent(self) -> egui::Color32 {
if self.dark {
egui::Color32::from_rgb(0x7f, 0xb2, 0xf0)
} else {
egui::Color32::from_rgb(0x1c, 0x54, 0x9c)
}
}
}
/// How tall a note's row is.
const ROW_PAD: f32 = 6.0;
/// The subset of Noto Sans JP the `font` example ships: kana, the common
/// kanji, and Latin. 433 KB, so it is compiled in rather than fetched.
const NOTO_SANS_JP_SUBSET: &[u8] = include_bytes!("../../font/fonts/NotoSansJP-Subset.ttf");
/// One chain, `jp`: the subset first, then egui's own font for whatever the
/// subset lacks. Made the default proportional family, so every widget draws
/// with it and no `<Text font>` is needed.
pub fn fonts() -> Fonts {
Fonts::new()
.stack(
"jp",
[
FontSource::Bundled(NOTO_SANS_JP_SUBSET),
FontSource::Generic(Generic::SansSerif),
],
)
.default_proportional("jp")
}
#[component]
pub fn App(cx: &mut Cx) {
// Applied once, on the first frame. `set_fonts` is per `egui::Context`,
// so inside the gallery this restyles the gallery too, the way the `font`
// example does; an app would do it from `Options::setup` instead.
let ctx = cx.ctx().clone();
use_effect(cx, (), move || fonts().apply(&ctx));
rsx! {
<Themed>
<Notebook/>
</Themed>
}
}
/// Reads egui's theme and publishes it. The same shape as the `theme`
/// example: a provider has to make the value it provides, because a `Handle`
/// cannot be a prop.
///
/// The handle is written only when the theme changed. A write on every frame
/// would ask for a repaint on every frame, and the app would never go idle.
#[component(shares_ui)]
fn Themed(cx: &mut Cx, children: impl View) {
let dark = cx.ctx().theme() == egui::Theme::Dark;
let theme = use_handle(cx, || Theme { dark });
if theme.get().dark != dark {
theme.set(Theme { dark });
}
provide_context(cx, theme, |cx| children.show(cx));
}
#[component]
fn Notebook(cx: &mut Cx) {
// The reducer owns the notes; the persisted slot is the copy that survives
// a restart. `use_persisted` first, so its value is there to seed the
// reducer on the very first frame.
let mut saved = use_persisted(cx, "notes/notes", Notes::default);
let (mut notes, dispatch) = use_reducer(cx, reduce, || saved.clone());
if *saved != *notes {
*saved = notes.clone();
}
let mut search = use_state(cx, String::new);
let mut selected = use_state(cx, || None::<u64>);
// The note whose title should take the caret, with its text selected:
// the one `new` just made, until the editor has done it.
let mut fresh = use_state(cx, || None::<u64>);
let now = cx.ui().input(|i| i.time);
let theme = use_context::<Theme>(cx).map_or(Theme { dark: true }, |theme| theme.get());
// Filtering and sorting every frame would be wasted work. `next_id` stands
// in for "the notes changed": it moves on every add, and the length covers
// deletes. Editing a body sends `Touched`, which moves `updated` and so the
// sort order, so that is in the deps too.
let version = (notes.items.len(), notes.next_id);
let touched = notes
.items
.iter()
.map(|note| note.updated.to_bits())
.fold(0u64, |acc, bits| acc ^ bits);
let listed: &Vec<Note> = use_memo(cx, (search.as_str(), version, touched), || {
visible(¬es, search.as_str())
});
// The selection follows the list: a note that was deleted or filtered away
// is not selectable, and there is always something open if there is
// anything to open.
let current = selected
.filter(|id| listed.iter().any(|note| note.id == *id))
.or_else(|| listed.first().map(|note| note.id));
if *selected != current {
*selected = current;
}
// Read once: an element may not hold a shared borrow of a state *and* a
// handler that writes it (ARCHITECTURE 3.7).
let focus_title = current.is_some() && *fresh == current;
rsx! {
<View direction="row" grow={1.0} w="100%" h="100%">
<Sidebar
notes={listed}
selected={¤t}
search={search.bind()}
theme={theme}
on_select={|id: u64| *selected = Some(id)}
on_new={|| {
dispatch.send(Msg::Add { now });
// Clearing the selection makes the newest note open
// itself: the rule below picks the first of the list
// whenever nothing valid is selected. The reducer numbers
// notes from `next_id`, so the new one's id is known here.
*selected = None;
*fresh = Some(notes.next_id + 1);
}}
/>
<Separator vertical/>
<Editor
notes={notes.bind()}
selected={¤t}
now={now}
theme={theme}
focus_title={focus_title}
on_touch={|id: u64| dispatch.send(Msg::Touched { id, now })}
on_delete={|id: u64| dispatch.send(Msg::Delete { id })}
on_focused={|| *fresh = None}
/>
</View>
}
}
/// The list, the search box, and the button that makes a note.
#[component]
fn Sidebar(
cx: &mut Cx,
notes: &[Note],
// `&Option<..>`, not `Option<..>`: an `Option` prop is the optional kind,
// whose setter takes the inner value and defaults to `None`. A reference
// is an ordinary prop that happens to hold one.
selected: &Option<u64>,
search: &mut String,
theme: Theme,
#[event] on_select: u64,
#[event] on_new: (),
) {
rsx! {
<View direction="column" w={220.0} shrink={0.0} h="100%" gap={6} p={8}>
<View direction="row" gap={6} align="center" w="100%">
<Text grow={1.0} strong color={theme.accent()}>"notes"</Text>
<Button on_click={|| on_new.emit(())}>"new"</Button>
</View>
<TextEdit w="100%" bind={search} hint="search"/>
<Separator/>
<ScrollArea grow={1.0}>
<View direction="column" w="100%">
for note in notes.iter() {
// A selectable row: `egui-reactor-elements` has no toggle
// element, so this is the escape hatch, one leaf deep.
{view(|cx| {
let picked = cx.leaf(&ItemStyle::default().w("100%").my(ROW_PAD), |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
ui.selectable_label(
Some(note.id) == *selected,
note.title.as_str(),
)
.clicked()
});
if picked {
on_select.emit(note.id);
}
})}
}
if notes.is_empty() {
<Text>"no notes"</Text>
}
</View>
</ScrollArea>
</View>
}
}
/// The open note: its title, its body, and what the app knows about it.
#[component]
#[allow(clippy::too_many_arguments)]
fn Editor(
cx: &mut Cx,
notes: &mut Notes,
selected: &Option<u64>,
now: f64,
theme: Theme,
// Put the caret in the title with the text selected, this frame, and say
// so: a new note is named first.
focus_title: bool,
#[event] on_touch: u64,
#[event] on_delete: u64,
#[event] on_focused: (),
) {
let Some(id) = *selected else {
return rsx! {
<View direction="column" grow={1.0} p={12}>
<Text>"nothing open. make a note."</Text>
</View>
}
.show(cx);
};
let Some(index) = notes.items.iter().position(|note| note.id == id) else {
return;
};
let words = notes.items[index].words();
let age = (now - notes.items[index].updated).max(0.0);
rsx! {
<View direction="column" grow={1.0} min_w={0.0} h="100%" gap={8} p={12}>
<View direction="row" gap={8} align="center" w="100%">
// The title, by hand rather than `<TextEdit bind>`: the one
// thing the element cannot do is take the focus on request,
// and a new note wants its name typed before anything else.
// The field still holds the only `&mut` to the title, so the
// edit is recorded by a message, not by a second borrow.
{view(|cx| {
let style = ItemStyle::default().grow(1.0).min_w(0.0);
let response = cx.leaf(&style, |ui| {
let title = &mut notes.items[index].title;
let response = ui.add(
egui::TextEdit::singleline(title).desired_width(ui.available_width()),
);
if focus_title {
response.request_focus();
let mut state = egui::TextEdit::load_state(ui.ctx(), response.id)
.unwrap_or_default();
let end = egui::text::CCursor::new(title.chars().count());
let all =
egui::text::CCursorRange::two(egui::text::CCursor::new(0), end);
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), response.id);
}
response
});
if focus_title {
on_focused.emit(());
}
if response.changed() {
on_touch.emit(id);
}
})}
<Button on_click={|| on_delete.emit(id)}>"delete"</Button>
</View>
<View direction="row" gap={12} align="center" w="100%">
<Text color={theme.accent()}>{format!("{words} words")}</Text>
<Text>{format!("updated {age:.0}s ago")}</Text>
</View>
// The body: `egui::ScrollArea` around `egui::TextEdit`, by hand.
// `<TextEdit multiline>` on its own is as tall as its text, and a
// long note would run off the bottom of the window; here the
// field is as tall as the column and a longer note scrolls.
// `leaf_fill` with `h={0}` beside `grow`, because a scroll area
// reports "all the room there is" as its size, and the column
// would otherwise be that tall plus the rows above it.
{view(|cx| {
let style = ItemStyle::default().grow(1.0).h(0.0).w("100%");
let changed = cx.leaf_fill(&style, |ui| {
// As many rows as fit, so a short note still gets the
// whole column to be typed into.
let row = ui.text_style_height(&egui::TextStyle::Body);
let rows = ((ui.available_height() - 8.0) / row).max(1.0) as usize;
egui::ScrollArea::vertical()
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut notes.items[index].body)
.hint_text("write something")
.desired_rows(rows)
.desired_width(f32::INFINITY),
)
.changed()
})
.inner
});
if changed {
on_touch.emit(id);
}
})}
</View>
}
}