Start here
On this page
A second screen
tutorial.md took you from an empty file to one working screen.
This is the next four things, in the order you will want them: a list, a screen
that opens another, a page taller than the panel, and a widget of your own.
Everything below runs with no backend at all — xpui::testing is a host that
draws into memory and records what it drew. Put a window under it whenever you
like; you will not need one to follow this.
examples/gallery in the gallery repository
is the worked version of all of it, running on seven panels.
1. A list is not a stack of rows
The obvious thing is a VStack of Texts. It will look close and behave
wrong: no selection marker, no scroll indicator, and rows that do not match
what the rest of the device paints.
A List goes through the backend's own Chrome::draw_list, so a row here and
a row in a screen somebody else wrote are the same row:
use xpui::screen::Screen;
use xpui::{App, List, ListRow, NavigationScreen, View, testing};
#[derive(Clone, Copy)]
enum Message {
Open(usize),
}
struct Settings;
impl Screen for Settings {
type Message = Message;
fn body(&self) -> impl View<Self::Message> {
NavigationScreen::new(
List::new()
.push(ListRow::new("Wi-Fi").value("Off").on_tap(Message::Open(0)))
.push(ListRow::new("Storage").subtitle("3.1 GB free").on_tap(Message::Open(1))),
)
.title("Settings")
}
fn update(&mut self, _message: Self::Message) {}
}
testing::install();
testing::reset();
App::new(Settings).render();
// One entry per list drawn, each holding that list's rows.
let lists = testing::drawn_list_rows();
assert_eq!(lists.len(), 1, "one list");
assert_eq!(lists[0].len(), 2, "and the theme drew both of its rows");
// Each row is [title, subtitle, value] — which is the paragraph below, made
// checkable. A count alone would pass with the two swapped.
assert_eq!(lists[0][0][0].as_deref(), Some("Wi-Fi"));
assert_eq!(lists[0][0][2].as_deref(), Some("Off"));
assert_eq!(lists[0][1][1].as_deref(), Some("3.1 GB free"));
Three things worth knowing:
valueis the right-hand text, andsubtitleis the second line. A row with a subtitle is taller, and the theme decides by how much — you do not.on_taptakes your message, not a closure. The runtime delivers it toupdatewhen the row is chosen, whether by a finger or by a key.- Rows are pulled at paint time, not handed over. The theme asks for cell
n through a callback rather than being given a buffer, so nothing is
copied for the backend. The rows themselves are not free:
ListRow::newowns itsString, and building the list insidebody()allocates one per cell on every frame. §4 is about that.
2. Opening a screen, and coming back
Navigation is not a function you call on a parent. A screen asks the
application to present another, and the application owns the stack:
struct Detail;
fn update(&mut self, message: Message) {
match message {
Message::Open => {
present(Detail);
}
}
}
That is the same shape xpui-gallery's gallery/src/menu.rs uses for all seven of its
examples.
Back means three things, in order
This is the one that catches people, and it caught this repository:
- Leave a value being edited. A stepper that is open cancels.
- A screen claims it. A screen with a picker open closes the picker.
- Pop the stack. Nothing above claimed it, so the screen finishes.
A firmware once fixed a root screen finishing — pressing Back on the first screen and ending the app — by suppressing the key at the pin. That killed the other two meanings on every host: a screen could not dismiss its own picker, and a value opened on a root screen could be committed but never cancelled.
The answer is one call, and it is not discoverable from the reference:
// Back still arrives everywhere. The root simply declines to finish.
let mut app = App::new(Menu).keep_root();
testing::press(Button::Back);
app.tick();
assert!(app.is_running(), "the root kept it");
// Without it, the same key ends the application.
let mut plain = App::new(Menu);
testing::press(Button::Back);
plain.tick();
assert!(!plain.is_running(), "and this is what a firmware went to the pin over");
3. A page taller than the panel
Wrap it in a ScrollView. The runtime keeps the offset beside focus, so moving
focus off the bottom scrolls on its own:
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
use xpui::screen::Screen;
use xpui::{App, NavigationScreen, ScrollView, Text, View, testing, vstack};
struct About {
lines: Vec<String>,
}
impl About {
fn new() -> Self {
// Built once, when the screen is. `body` runs on every frame.
Self { lines: (1..=40).map(|n| alloc::format!("Line {n}")).collect() }
}
}
impl Screen for About {
type Message = ();
fn body(&self) -> impl View<Self::Message> {
let mut lines = vstack![10];
// Taller than the panel on purpose. Content that fits scrolls no
// further than content with no ScrollView around it, so a test built
// on it cannot tell the two apart.
for line in &self.lines {
lines = lines.push(Text::new(line.as_str()));
}
NavigationScreen::new(ScrollView::new(lines)).title("About")
}
fn update(&mut self, _message: Self::Message) {}
}
testing::install();
testing::reset();
App::new(About::new()).render();
// The theme is told what to draw a thumb from: how tall the content is, how
// much of it fits, and where the offset sits. Nothing reports this but a
// ScrollView, so removing the wrapper empties the vector.
let indicators = testing::drawn_indicators();
assert_eq!(indicators.len(), 1, "one ScrollView reports one thumb; nothing else reports any");
let (content, visible, offset) = indicators[0];
assert!(content > visible, "{content} of content, {visible} of panel");
assert_eq!(offset, 0, "and it opens at the top");
One ScrollView per screen. The offset lives in the runtime beside focus,
so a second one would share the first one's position.
A ScrollView measures its content against UNBOUNDED height — a large
sentinel rather than i32::MAX, because several views echo the height they
were offered and three of them added together must not wrap into a negative.
That is why layout arithmetic here saturates, and it is worth knowing before
you write a view that reports its own height.
4. Your own widget
When a List, a Stepper, a Toggle and a Modal are not the thing you
need, write a View. That is a trait with three required
methods — measure, size and render — and
writing-a-widget.md is the walk through them,
including the one about tap targets that a stack-depth test cannot catch.
Before you do, one rule that bites hardest here:
Keep format! off body(). It runs on every repaint and every frame
carrying input, so a format! in it allocates while a finger is dragging. Build
the String in update, when the value actually changes. Slider and
Stepper format into a stack buffer for exactly this reason — see
reference.md for what they do instead.
Where next
reference.md— every widget, modifier and host traitwriting-a-widget.md— adding to the frameworktesting.md— the four layers, and eight tests this repository shipped that could not failarchitecture.md— how one frame actually runs
Edit this page on GitHubIt lives in xpui-framework; a correction goes there.