Quick start
A small notes app with a route table, one screen type, one scope and a frame loop, drawn with egui.
This guide builds a cut-down version of the notes example that ships with Carte. It has three screens, a scope that exists only while the editor is open, and a frame loop. egui draws it, but nothing on the Carte side knows that. It assumes you have finished Installation and added eframe = "0.31" to your dependencies.
1. Describe the table
use carte::schema::{Field, Kind, Lit, Schema};
use carte::{
set, Departure, Escape, Node, Occlusion, Outbox, Producer, RouteId, RouteTable, Router, Scope,
Store,
};
use eframe::egui;
fn table() -> RouteTable {
let mut table = RouteTable::new();
table
.at_root("home")
.under("note", "home")
.under("settings", "home")
.under("settings", "note"); // one screen, two parents
table
}
The table says what may appear beneath what. It does not say what is showing. settings has two parents, so Back returns to whichever one you came from with no extra bookkeeping.
2. Write the model and its actions
The model is your app. Carte reads it to derive the path, but never writes it. The UI asks for changes with actions.
struct Model {
notes: Vec<String>,
open: Option<usize>,
/// The child each screen is showing, if any.
home_child: Option<RouteId>,
note_child: Option<RouteId>,
}
#[derive(Debug)]
enum Action {
Open(usize),
Edit(String),
OpenSettings { from: RouteId },
/// `parent` stops showing its child: what a pop turns into.
Closed { parent: RouteId },
}
impl Model {
fn apply(&mut self, action: &Action) {
match action {
Action::Open(i) => {
self.open = Some(*i);
self.home_child = Some("note");
}
Action::Edit(text) => {
if let Some(i) = self.open {
self.notes[i] = text.clone();
}
}
Action::OpenSettings { from: "note" } => self.note_child = Some("settings"),
Action::OpenSettings { .. } => self.home_child = Some("settings"),
Action::Closed { parent: "home" } => {
self.home_child = None;
self.note_child = None;
self.open = None;
}
Action::Closed { parent: "note" } => self.note_child = None,
Action::Closed { .. } => {}
}
}
}
Opening a screen is setting home_child. There is no push.
3. Declare a scope
The editor's state lives in a scope keyed on the note node. It mounts when note joins the path and is dropped when it leaves.
#[derive(Clone, Debug, PartialEq)]
struct Editor {
body: String,
words: usize,
}
impl Schema for Editor {
fn reflect() -> Kind {
Kind::Record(vec![
Field::new("body", Kind::Str),
Field::new("words", Kind::Int),
])
}
fn at_mount(_: Option<&Lit>) -> Self {
Self {
body: String::at_mount(None),
words: usize::at_mount(None),
}
}
}
const EDITOR: Scope = Scope::Node("note");
The Schema impl is written by hand here. A derive will write it for you once it lands in the crate. See Schemas.
4. Implement a node
Every id in the table needs a handler that implements Node. One small type covers all three screens here.
struct Screen {
id: RouteId,
child: fn(&Model) -> Option<RouteId>,
}
impl Node<Model, Action> for Screen {
fn resolve_child(&self, model: &Model) -> Option<RouteId> {
(self.child)(model)
}
fn occludes(&self) -> Occlusion {
Occlusion::View
}
fn update(&mut self, _: &Model, _: &mut Outbox<Action>, _: f32) {}
fn escape(&mut self, _: &Model, _: &mut Outbox<Action>) -> Escape {
match self.id {
"home" => Escape::Fall,
_ => Escape::Pop,
}
}
fn take_leave_request(&mut self) -> bool {
false
}
fn child_popped(&mut self, out: &mut Outbox<Action>, _: RouteId) {
out.push(Action::Closed { parent: self.id });
}
fn enter(&mut self, _: &mut Model) {}
fn leave(&mut self, _: &mut Model) {}
fn depart(&mut self, _: Option<RouteId>) -> Departure {
Departure::Cut
}
}
resolve_child is the important method: each frame, every node on the path names at most one child. child_popped is how a pop becomes an action that stops the parent claiming its child.
5. Assemble the app
type Screens = Router<Model, Action, dyn Node<Model, Action>>;
struct App {
model: Model,
router: Screens,
store: Store,
outbox: Outbox<Action>,
editor: Producer<Editor>,
}
impl App {
fn new() -> Result<Self, Box<dyn std::error::Error>> {
let table = table();
let mut store = Store::over(&table);
store.provides::<Editor>(EDITOR)?;
let editor = store.producer::<Editor>(EDITOR, &["body", "words"])?;
let routes: Vec<(RouteId, Box<dyn Node<Model, Action>>)> = vec![
("home", Box::new(Screen { id: "home", child: |m| m.home_child })),
("note", Box::new(Screen { id: "note", child: |m| m.note_child })),
("settings", Box::new(Screen { id: "settings", child: |_| None })),
];
let router = Router::new(table, routes, |_: &Model| "home")?;
let model = Model {
notes: vec!["Welcome".into(), "Shopping\nbread\nleeks".into()],
open: None,
home_child: None,
note_child: None,
};
let mut app = Self { model, router, store, outbox: Outbox::new(), editor };
app.settle();
Ok(app)
}
/// Apply what was asked for, then re-derive the path.
fn settle(&mut self) -> bool {
let asked = !self.outbox.is_empty();
self.tick();
let moved = self.router.resolve(&mut self.model, &mut self.store);
if moved {
self.tick(); // fill scopes that just mounted
}
asked || moved
}
fn tick(&mut self) {
let Self { model, store, outbox, editor, .. } = self;
store.tick(outbox, |actions, barrier| {
for action in actions {
model.apply(action);
}
// Refused while `note` is off the path: the scope does not exist.
if let Ok(mut w) = barrier.writer(editor) {
let body = model.open.map(|i| model.notes[i].clone()).unwrap_or_default();
set!(w, words, body.split_whitespace().count()).unwrap();
set!(w, body, body).unwrap();
}
});
}
}
Every mistake in the table or the store setup (a typo'd parent, a scope on an unregistered id, two producers claiming one field) fails here, at startup, with a message that names it.
6. Draw and run
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
let mut back = ctx.input(|i| i.key_pressed(egui::Key::Escape));
for id in self.router.visible_views() {
egui::CentralPanel::default().show(ctx, |ui| match id {
"home" => {
ui.heading("Notes");
for (i, note) in self.model.notes.iter().enumerate() {
if ui.link(note.lines().next().unwrap_or("Untitled")).clicked() {
self.outbox.push(Action::Open(i));
}
}
if ui.button("Settings").clicked() {
self.outbox.push(Action::OpenSettings { from: "home" });
}
}
"note" => {
if let Some(editor) = self.store.read::<Editor>(EDITOR) {
let mut body = editor.body.clone();
if ui.text_edit_multiline(&mut body).changed() {
self.outbox.push(Action::Edit(body));
}
ui.label(format!("{} words", editor.words));
}
if ui.button("Settings").clicked() {
self.outbox.push(Action::OpenSettings { from: "note" });
}
if ui.button("Close").clicked() {
back = true;
}
}
"settings" => {
ui.heading("Settings");
if ui.button("Back").clicked() {
back = true;
}
}
_ => {}
});
}
if back {
self.router.escape(&self.model, &mut self.outbox);
}
if self.settle() {
ctx.request_repaint();
}
}
}
fn main() -> eframe::Result {
let app = App::new().expect("the table and the store are valid");
eframe::run_native(
"notes",
eframe::NativeOptions::default(),
Box::new(|_| Ok(Box::new(app))),
)
}
The home list reads the model directly to keep this short. The full example puts the list in a process-wide scope too, so every view draws from the store.
What just happened
- The path was never pushed or popped. It was derived from
home_childandnote_childeach frame. - Escape went to the deepest screen first.
settingspopped, its parent pushedClosed, the next tick applied it, and the next walk shortened the path. - The
Editorscope appeared whennotejoined the path and vanished when it left. Nothing tore it down by hand.
Next
- Learn why the frame runs in this order in The frame.
- Read the full example:
examples/notes.rsadds a guarded trash, an unsaved-changes prompt that stacks over the editor, and a subscriber.