Pages and layouts
A page connects features to the interface: it installs the features it needs, reads their state and sends actions in response to input. How it describes the interface depends on the toolkit.
This chapter implements the same counter on WinUI and egui. The tabs let you compare their code. Feature installation, state access and page lifetimes follow the same rules on both backends.
A page
Section titled “A page”A page implements Page. Its struct holds the view’s own data: here,
step is the amount the button will add. The counter’s value belongs to the
Counter feature from the introduction.
use guinea::winui::reactor::{Button, StackPanel, TextBlock, View};use guinea::winui::{MarkExt, Page, PageCx, UpdateCx, page};
#[derive(guinea::Mark)]pub enum Marks { Add, Bigger,}
pub struct CounterPage { step: u32,}
pub enum Msg { Bigger,}
#[page]impl Page for CounterPage { type Message = Msg;
fn install(cx: &Install) -> anyhow::Result<Self> { cx.install::<Counter>(&())?; Ok(Self { step: 1 }) }
fn update(&mut self, message: Msg, _cx: &mut UpdateCx<'_, Self>) { match message { Msg::Bigger => self.step += 1, } }
fn view(&self, cx: &mut PageCx<'_, '_, Self>) -> View { let (count, dispatch) = cx.read::<Count>(); let step = self.step;
StackPanel::new() .children(( TextBlock::new().text(format!("{} by {step}", count.0)), Button::new() .content(TextBlock::new().text("Add")) .on_click(move || dispatch.emit(Add(step))) .mark(Marks::Add), Button::new() .content(TextBlock::new().text("Bigger")) .on_click(cx.on(|()| Msg::Bigger)) .mark(Marks::Bigger), )) .into() }}On WinUI, describing the interface and handling messages are separate:
viewbuilds aViewdescribing the interface.cx.read::<Count>()returns the state and aDispatchfor its owning feature. It also subscribes the page component toCount: when the state changes, guinea requests a new view and Reactor updates the element tree.updatechanges the page’s own state.cx.on(|()| Msg::Bigger)turns a button event intoMsg::Bigger. The handler increasesstep, and the component callsviewagain.
The Add button sends its action straight to the feature. Its handler
outlives the call to view, so the closure captures dispatch and step
with move. Both types implement Copy, so the values are copied into it.
use guinea::eframe::{Page, PageCx, page};
pub struct CounterPage { step: u32,}
#[page]impl Page for CounterPage { fn install(cx: &Install) -> anyhow::Result<Self> { cx.install::<Counter>(&())?; Ok(Self { step: 1 }) }
fn render(&mut self, cx: &mut PageCx<'_, Self>) { let (count, dispatch) = cx.read::<Count>(); let ui = cx.ui();
ui.label(format!("{} by {}", count.0, self.step)); if ui.button("Add").clicked() { dispatch.emit(Add(self.step)); } if ui.button("Bigger").clicked() { self.step += 1; } }}On egui, render both builds the interface and handles input. It takes
&mut self, so it can change the page’s fields directly. clicked() reports
a button click, which the code handles in that call. No separate page
messages or update method are needed.
cx.read::<Count>() returns the state and a Dispatch. Unlike WinUI, the
read does not subscribe a component: render reads the current value again
when building the next frame.
install creates the page instance when it is first mounted. Here it
installs Counter and returns a CounterPage whose initial step is 1.
Subsequent redraws do not repeat installation. The feature’s resources live
in the page’s scope; see Scopes.
The two buttons illustrate the distinction between page and feature state.
Bigger changes the page’s step field. Add sends Add(step) to the
feature, which handles the action and updates Count.
#[page] fills in optional parts of the implementation. With no install,
it creates the page through Default. With no declared Params, it uses
().
Dependencies during installation
Section titled “Dependencies during installation”For how dependencies pass from plugins through the application to pages, see dependencies and application wiring.
#[page] and #[layout] also resolve dependencies declared as arguments
after cx in install, just as #[installs] does for a feature. For example,
fn install(cx: &Install, store: Store) -> anyhow::Result<Self> receives a
clone of the Store returned under a feature’s or plugin’s provides
declaration. No manual registration or service locator is involved.
A plain Store argument is required: if it was not provided, installation
panics before entering the method body. An Option<Store> argument
receives None only when there is no declared provider. If a provider is
declared but its output is unavailable, installation panics instead. Apply a fallback
explicitly in the body if needed, for example with unwrap_or_default().
These arguments do not replace the page’s or layout’s Params: values passed
when mounting remain available through cx.params(). Resolving a service does
not install a feature automatically; the body still calls cx.install for
the features the page or layout needs. See declaration validation.
#[derive(Clone)]pub struct PageSettings(pub u32);
feature! { pub Configuration { provides { PageSettings } }}
#[installs]fn configuration(_app: &mut FeatureBuilder) -> anyhow::Result<Configuration> { Ok(Configuration(PageSettings(3)))}
pub struct ConfiguredPage { step: u32,}
#[page]impl Page for ConfiguredPage { fn install(_cx: &Install, settings: PageSettings) -> anyhow::Result<Self> { Ok(Self { step: settings.0 }) }
fn view(&self, _cx: &mut PageCx<'_, '_, Self>) -> View { TextBlock::new().text(format!("page {}", self.step)).into() }}#[derive(Clone)]pub struct PageSettings(pub u32);
feature! { pub Configuration { provides { PageSettings } }}
#[installs]fn configuration(_app: &mut FeatureBuilder) -> anyhow::Result<Configuration> { Ok(Configuration(PageSettings(3)))}
pub struct ConfiguredPage { step: u32,}
#[page]impl Page for ConfiguredPage { fn install(_cx: &Install, settings: PageSettings) -> anyhow::Result<Self> { Ok(Self { step: settings.0 }) }
fn render(&mut self, cx: &mut PageCx<'_, Self>) { cx.ui().label(format!("page {}", self.step)); }}Install Configuration with app.install::<Configuration>()? before
opening ConfiguredPage. A layout declares its own inputs in the same way;
it does not need to forward application dependencies to its child pages.
A layout
Section titled “A layout”A layout implements Layout. It places shared interface elements and
mounts child pages or layouts.
Here Shell shows a navigation button and one of two pages. The Place
enum describes the route. cx.outlet supplies the current route, and the
layout’s match chooses what to mount.
#[derive(Clone, PartialEq, Debug, Routable)]pub enum Place { Counter, About,}
#[derive(Default)]pub struct Shell;
#[layout]impl Layout for Shell { fn view(&self, cx: &mut LayoutCx<'_, '_, Self>) -> View { let nav = cx.navigate::<Place>(); let to_about = nav.clone();
StackPanel::new() .children(( Button::new() .content(TextBlock::new().text("About")) .on_click(move || { let _ = to_about.to(Place::About); }), cx.outlet(&nav, |place, cx| match place { Place::Counter => cx.mount::<CounterPage>(&()), Place::About => cx.mount::<About>(&()), }), )) .into() }}#[derive(Clone, PartialEq, Debug, Routable)]pub enum Place { Counter, About,}
#[derive(Default)]pub struct Shell;
#[layout]impl Layout for Shell { fn render(&mut self, cx: &mut LayoutCx<'_, Self>) { let nav = cx.navigate::<Place>(); if cx.ui().button("About").clicked() { let _ = nav.to(Place::About); }
let page = cx.outlet(&nav, |place, cx| match place { Place::Counter => cx.mount::<CounterPage>(&()), Place::About => cx.mount::<About>(&()), }); page.draw(cx.ui()); }}On egui, cx.outlet returns a Node containing a deferred drawing call.
draw executes it in the supplied ui, letting the layout choose where
its child content appears.
cx.navigate::<Place>() looks for a navigator of that type in the current
scope and its ancestors. Here the window creates it. Calling
nav.to(Place::About) requests a route change. Once allowed, the next
content build selects About through the outlet. mount reuses a page with
the same type and equal parameters, or installs a new instance. Children no longer mounted
are removed; those marked keep sleep instead.
A layout’s install works like a page’s. Features installed in the layout
can export state to its child pages.
An application
Section titled “An application”The app function receives a FeatureBuilder. It installs shared features
and plugins, provides application data and returns a declaration for the
first window. This example has no shared dependencies, so it only creates
the Window:
use guinea::winui::Window;
fn app(_app: &mut FeatureBuilder) -> anyhow::Result<Window> { Ok(Window::of::<Shell>().title("Counter").at(Place::Counter))}use guinea::eframe::Window;
fn app(_app: &mut FeatureBuilder) -> anyhow::Result<Window> { Ok(Window::of::<Shell>().title("Counter").at(Place::Counter))}Window::of::<Shell>() selects the root layout. .at(..) configures the
window to open a navigator at the initial route. Features installed with
app.install live in the application scope, shared by its windows.
The executable’s entry point calls the backend’s run(app) to open the
window and enter its event loop. The tests below call app too, but mount
the returned window without starting an operating-system event loop.
Testing
Section titled “Testing”A backend harness runs page code without an operating-system window: it
delivers input, runs the work caused by actions and checks the rendered
result. It uses the same domain Harness as feature tests.
#[guinea::test]fn adds_the_step_it_was_told(h: &mut Harness) { let mut page = Mounted::<CounterPage>::mount(h.segment(), ()).unwrap();
page.click(Marks::Bigger).settle(); page.click(Marks::Add).settle(); page.settle();
assert!(page.find_text("2 by 2").is_some(), "{:#?}", page.tree());}#[derive(guinea::Mark)] and .mark(..) name elements for tests. A button’s
name can stay the same when its label or position changes. click_text
finds an element by its text instead.
settle runs the work caused by the click: handling the action, updating
the reducer and redrawing the page. The test seed determines background
execution order so a failing run can be reproduced.
A layout is tested the same way, started at a route:
#[guinea::test]fn the_about_button_moves_the_window(h: &mut Harness) { let mut shell = Mounted::<Shell>::mount_at(h.segment(), (), Place::Counter).unwrap(); assert!(shell.find_text("0 by 1").is_some(), "{:#?}", shell.tree());
shell.click_text("About").settle(); shell.settle();
assert_eq!(shell.navigator::<Place>().current(), Some(Place::About)); assert!(shell.find_text("guinea").is_some(), "{:#?}", shell.tree());}Mounted is behind the harness feature:
[dev-dependencies]guinea = { version = "0.32", features = ["harness"] }The window returned by app can also be tested directly, including its
initial route:
#[guinea::test]fn the_application_opens_at_its_declared_route(h: &mut Harness) { let window = h.app(app).unwrap(); let mut shell = Mounted::<Place>::from_window(h, window).unwrap(); assert_eq!(shell.route(), Place::Counter); assert!(shell.find_text("0 by 1").is_some());
shell.click_text("About").settle();
assert_eq!(shell.route(), Place::About); assert!(shell.find_text("guinea").is_some());}The egui harness runs frames without an operating-system window. It delivers pointer presses and releases to egui and reads the text from the shapes the page drew:
#[guinea::test]fn adds_the_step_it_was_told(h: &mut Harness) { let mut page = Mounted::window(h, Window::of::<CounterPage>());
page.click_text("Bigger"); page.click_text("Add");
assert!(page.has_text("2 by 2"));}click_text clicks the center of the matching text, runs the domain’s
pending work and draws another frame. It fails if the text is missing or
ambiguous. has_text checks what the latest frame drew.
The same harness can mount the window returned by app. This checks the
declared initial route and the layout’s navigation button:
#[guinea::test]fn the_about_button_moves_the_window(h: &mut Harness) { let window = h.app(app).unwrap(); let mut shell = Mounted::window(h, window); assert!(shell.has_text("0 by 1"));
shell.click_text("About");
assert!(shell.has_text("guinea")); assert!(!shell.has_text("0 by 1"));}Enable guinea’s eframe and harness features to use
guinea::eframe::harness::Mounted.