Страницы и лейауты
Страница связывает фичи с интерфейсом: устанавливает нужные ей фичи, читает их состояние и отправляет действия из обработчиков ввода. Как описать сам интерфейс, зависит от тулкита.
В этой главе один счётчик реализован на 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.