Перейти к содержимому

Страницы и лейауты

Страница связывает фичи с интерфейсом: устанавливает нужные ей фичи, читает их состояние и отправляет действия из обработчиков ввода. Как описать сам интерфейс, зависит от тулкита.

В этой главе один счётчик реализован на WinUI и egui. Во вкладках можно сравнить код двух бэкендов. Правила установки фич, доступа к состоянию и времени жизни страниц у них общие.

Страница реализует трейт Page. В её структуре хранятся данные самого представления. Здесь это step — число, которое кнопка прибавит к счётчику. Значение счётчика хранится в фиче Counter из введения.

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()
}
}

В бэкенде WinUI построение представления и обработка сообщений разделены:

  • view строит описание интерфейса — View. cx.read::<Count>() возвращает состояние и Dispatch для действий фиче-владельцу. Чтение также подписывает компонент страницы на изменения Count: при изменении состояния guinea запрашивает новое представление, а Reactor обновляет дерево элементов.
  • update меняет собственное состояние страницы. cx.on(|()| Msg::Bigger) превращает событие кнопки в сообщение Msg::Bigger. Обработчик увеличивает step, после чего компонент снова вызывает view.

Кнопка Add отправляет действие напрямую в фичу. Её обработчик сохраняется после возврата из view, поэтому замыкание захватывает dispatch и step через move. Оба значения имеют типы с Copy и копируются внутрь замыкания.

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;
}
}
}

В egui описание интерфейса и обработка ввода находятся в render. Метод получает &mut self, поэтому может сразу менять поля страницы. clicked() сообщает о нажатии кнопки, и код обрабатывает его в этом же вызове. Отдельные сообщения страницы и метод update не нужны.

cx.read::<Count>() возвращает состояние и Dispatch. В отличие от WinUI, чтение не создаёт подписку компонента: при построении следующего кадра render снова прочитает текущее значение.

install создаёт экземпляр страницы при первом монтировании. В примере он устанавливает Counter и возвращает CounterPage с начальным шагом 1. При последующих перерисовках установка не повторяется. Ресурсы фичи живут в скоупе страницы; подробнее об этом — в главе «Скоупы».

Две кнопки показывают разницу между состоянием страницы и фичи. Bigger меняет поле step самой страницы. Add отправляет Add(step) фиче, которая обрабатывает действие и обновляет Count.

Макрос #[page] заполняет необязательные части реализации: если install не написан, он создаёт страницу через Default. Если не объявлен тип Params, используется ().

Как зависимости проходят от плагинов через приложение к страницам, описано в главе «Зависимости и сборка приложения».

#[page] и #[layout] также разрешают зависимости, перечисленные аргументами после cx в install, как #[installs] у фичи. Например, fn install(cx: &Install, store: Store) -> anyhow::Result<Self> получает копию Store, возвращённого в секции provides фичи или плагина: значение передаётся через Clone. Ни ручная регистрация, ни сервис-локатор не нужны.

Аргумент Store обязателен: если сервис не предоставлен, установка вызовет панику до входа в тело метода. Аргумент Option<Store> получит None, только если поставщик вообще не объявлен. Если он объявлен, но выход недоступен, установка паникует. Запасное значение выбирается явно в теле метода, например через unwrap_or_default().

Эти аргументы не заменяют Params страницы или лейаута: значения, переданные при монтировании, по-прежнему доступны через cx.params(). Получение сервиса не устанавливает фичу автоматически: нужные фичи тело метода по-прежнему устанавливает через cx.install. Подробнее — в разделе о проверке деклараций.

#[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));
}
}

Установи Configuration через app.install::<Configuration>()? до открытия ConfiguredPage. Лейаут объявляет свои входы так же; ему не нужно вручную пробрасывать зависимости приложения в дочерние страницы.

Лейаут реализует трейт Layout. Он размещает общие элементы интерфейса и монтирует дочерние страницы или лейауты.

В примере Shell показывает кнопку перехода и одну из двух страниц. Маршрут описан перечислением Place. Через cx.outlet лейаут получает текущий маршрут, а в match выбирает, что смонтировать.

#[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());
}
}

В egui cx.outlet возвращает Node — объект с отложенным вызовом отрисовки. Метод draw выполняет его в переданном ui. Так лейаут выбирает, где разместить дочернее содержимое.

cx.navigate::<Place>() ищет навигатор этого типа в текущем скоупе и его предках. В примере он создан на уровне окна. Вызов nav.to(Place::About) запрашивает переход. Если переход разрешён, при следующем построении содержимого outlet выбирает About. mount повторно использует страницу с тем же типом и равными параметрами или устанавливает новый экземпляр. Страницы, которые лейаут больше не монтирует, удаляются, а отмеченные keep — засыпают.

Метод install лейаута работает так же, как у страницы. Фичи, установленные в лейауте, могут экспортировать состояние для его дочерних страниц.

Функция app получает FeatureBuilder. Через него она устанавливает общие фичи и плагины, предоставляет данные приложения и возвращает описание первого окна. В примере общих зависимостей нет, поэтому достаточно создать 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>() задаёт корневой лейаут окна, а .at(..) создаёт для окна навигатор с начальным маршрутом. Фичи, установленные через app.install, живут в скоупе приложения, общем для всех его окон.

Точка входа исполняемого файла вызывает run(app) выбранного бэкенда: он открывает окно и запускает цикл событий. Тесты ниже тоже вызывают app, но монтируют возвращённое окно без системного цикла событий.

Харнес бэкенда позволяет выполнить код страницы без системного окна: передать ввод, дождаться обработки действий и проверить результат отрисовки. Он использует тот же Harness, что и тесты фич.

#[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)] и .mark(..) задают имена элементов, по которым их находит тест. При изменении надписи или положения кнопки такое имя может остаться прежним. Для поиска по тексту есть click_text.

settle выполняет работу, вызванную нажатием: обработку действия, обновление редьюсера и перерисовку страницы. Порядок выполнения фоновой работы задаёт сид теста, чтобы неудачный запуск можно было воспроизвести.

Лейаут тестируется так же, только монтируется сразу на нужный маршрут:

#[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 доступен с фичей harness:

[dev-dependencies]
guinea = { version = "0.32", features = ["harness"] }

Можно проверить и само окно, которое вернула функция app, включая объявленный начальный маршрут:

#[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());
}

Харнес egui выполняет кадры без системного окна. Он передаёт egui события нажатия и отпускания кнопки мыши, а затем читает текст из фигур, которые отрисовала страница:

#[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 нажимает в центре найденного текста, выполняет ожидающую работу фич и отрисовывает следующий кадр. Если текст не найден или совпадений несколько, тест падает. has_text проверяет текст последнего кадра.

Тот же харнес монтирует окно, возвращённое app. Так проверяются начальный маршрут и кнопка перехода в лейауте:

#[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"));
}

Для guinea::eframe::harness::Mounted включи у guinea фичи eframe и harness.