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

Фичи и состояние

Странице нужен список задач. Она показывает задачи и отправляет команды: добавить новую или очистить список. Правила обработки этих команд принадлежат фиче. Её можно использовать на другой странице, с другим GUI-бэкендом или в тесте без интерфейса.

Соберём TaskList: состояние для чтения, действия для страницы и обработчики. Весь код этой главы общий для всех бэкендов.

Страница читает Tasks. Редьюсер описывает, как каждое обновление меняет это состояние:

#[derive(Default, Clone, PartialEq, Debug)]
pub struct Tasks(pub Vec<String>);
#[derive(Clone)]
pub enum Change {
Added(String),
Cleared,
}
#[reducer]
fn tasks(this: &mut Tasks, change: Change) {
match change {
Change::Added(title) => this.0.push(title),
Change::Cleared => this.0.clear(),
}
}

Атрибут #[reducer] реализует для Tasks трейт Reducer с типом обновлений Change. Начальное состояние берётся из Default — здесь это пустой список. Added добавляет название задачи, Cleared очищает список.

Обновление — это уже выбранное фичей изменение. Действие, напротив, только просит фичу что-то сделать.

У страницы есть два действия:

pub struct AddTask(pub String);
pub struct ClearTasks;

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

Страница отправляет действия, а не формирует обновления Change. Чтение состояния не даёт ей Push: ручку записи фича получает при установке.

Актор обрабатывает действия и отправляет обновления через Push<Tasks>:

#[derive(Debug)]
struct Editing {
push: Push<Tasks>,
}
actor! {
Editing {
handlers { AddTask, ClearTasks }
}
}
#[handler]
fn add(In(this, AddTask(title), _): In<Editing, AddTask>) {
let title = title.trim();
if title.is_empty() {
return;
}
this.push.send(Change::Added(title.to_owned()));
}
#[handler]
fn clear(In(this, _, _): In<Editing, ClearTasks>) {
this.push.send(Change::Cleared);
}

В actor! перечислены сообщения, которые обрабатывает Editing. #[handler] реализует обработчик соответствующего сообщения. В In лежат актор, сообщение и контекст вызова. Контекст здесь не нужен, поэтому третье поле пропущено через _.

Перед добавлением актор обрезает пробелы по краям названия. Если ничего не осталось, обновления не будет. Иначе в редьюсер уйдёт Change::Added с готовым названием. Для очистки предварительной работы нет — достаточно отправить Change::Cleared.

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

Объявление фичи перечисляет её экспорты. Функция установки создаёт состояние и актор:

feature! {
pub TaskList {
exports { Tasks }
}
}
#[installs]
fn task_list(cx: &Install) -> anyhow::Result<TaskList> {
let (tasks, _) = cx.state::<Tasks>().driven_by(|push| Editing { push });
Ok(TaskList(tasks))
}

Вызов cx.state::<Tasks>() регистрирует состояние за этой фичей. driven_by создаёт актор, передавая его конструктору Push<Tasks>. Метод возвращает ручку Bound<Tasks> и адрес актора. Здесь адрес не используется: для экспорта состояния достаточно Bound.

В этом объявлении feature! создаёт кортежную структуру TaskList с одним полем — Bound<Tasks>. Функция возвращает эту ручку в TaskList(tasks), а установка фичи публикует объявленный экспорт. После этого Tasks доступен в скоупе установки и его потомках.

Сам экземпляр TaskList не владеет актором и ячейкой состояния: ими владеет скоуп. Поэтому страница может установить фичу и не хранить возвращённый экземпляр.

Страница устанавливает фичу через cx.install::<TaskList>(&())?, а затем читает cx.read::<Tasks>(). Вместе с состоянием она получает Dispatch, который отправляет действия фиче-владельцу. Через него и уходят AddTask и ClearTasks. Как связать чтение и события элементов интерфейса, показано в главе «Страницы и лейауты».

Харнес устанавливает ту же фичу и отправляет те же действия, что и страница:

#[guinea::test]
fn a_task_gets_a_title_before_it_is_added(h: &mut Harness) {
h.install::<TaskList>(&()).unwrap();
let dispatch = h.dispatch::<Tasks>();
dispatch.emit(AddTask(" Read the book ".into()));
dispatch.emit(AddTask(" ".into()));
h.settled();
assert_eq!(h.state::<Tasks>().0, vec!["Read the book"]);
dispatch.emit(ClearTasks);
h.settled();
assert!(h.state::<Tasks>().0.is_empty());
}

h.settled() выполняет работу, поставленную действиями в очередь. После этого можно проверять результат: пробелы по краям убраны, пустая задача не добавлена, очистка удалила все задачи.

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

#[guinea::test]
fn two_pages_can_have_separate_task_lists(h: &mut Harness) {
let work = h.child();
let home = h.child();
work.install::<TaskList>(&()).unwrap();
home.install::<TaskList>(&()).unwrap();
work.dispatch::<Tasks>()
.emit(AddTask("Review a change".into()));
home.dispatch::<Tasks>().emit(AddTask("Buy bread".into()));
h.settled();
assert_eq!(work.state::<Tasks>().0, vec!["Review a change"]);
assert_eq!(home.state::<Tasks>().0, vec!["Buy bread"]);
}

Если страницам нужен общий список, TaskList устанавливают в их общем лейауте. Тогда обе страницы читают один экземпляр и отправляют действия ему. Связь между местом установки, доступностью и временем жизни разобрана в главе «Скоупы».

Пустой список и список, который ещё не загрузился, — разные состояния. Если значение приходит асинхронно, используй Load<T> из guinea::prelude::*:

Вариант Значение
NotAsked Загрузка ещё не запрашивалась.
Loading Ожидание значения.
Ready(T) Значение получено, в том числе пустая коллекция.
Failed(Arc<anyhow::Error>) Загрузка завершилась ошибкой.

Load::default() — это Loading, а не NotAsked. Тип хранит статус, но не запускает задачу и не выполняет переходы автоматически. Фича, которая ждёт явного действия пользователя, может выбрать NotAsked в начальном состоянии:

#[derive(Clone, PartialEq, Debug)]
pub struct Listing {
pub rows: Load<Vec<String>>,
}
impl Default for Listing {
fn default() -> Self {
Self {
rows: Load::NotAsked,
}
}
}
#[reducer]
fn listing(this: &mut Listing, rows: Load<Vec<String>>) {
this.rows = rows;
}

Редьюсер заменяет состояние загрузки полученным обновлением. Актор сначала отправляет Loading, затем запускает работу и превращает её результат в Ready или Failed:

pub struct Refresh {
pub simulate_failure: bool,
}
async fn fetch(simulate_failure: bool) -> anyhow::Result<Vec<String>> {
tokio::time::sleep(Duration::from_secs(1)).await;
anyhow::ensure!(!simulate_failure, "service unavailable");
Ok(vec!["guinea".into(), "shown".into()])
}
#[derive(Debug)]
struct Loader {
push: Push<Listing>,
}
actor! {
Loader {
handlers { Refresh }
}
}
#[handler]
fn refresh(In(this, Refresh { simulate_failure }, cx): In<Loader, Refresh>) {
this.push.send(Load::Loading);
cx.task().latest().pushing(this.push).spawn(async move {
match fetch(simulate_failure).await {
Ok(rows) => Load::Ready(rows),
Err(error) => Load::failed(error),
}
});
}
feature! {
pub Catalogue {
exports { Listing }
}
}
#[installs]
fn catalogue(cx: &Install) -> anyhow::Result<Catalogue> {
let (listing, _) = cx.state::<Listing>().driven_by(|push| Loader { push });
Ok(Catalogue(listing))
}

Функция fetch заменяет вызов сервиса: через секунду она возвращает два имени или запрошенную имитацию ошибки. Настоящего обращения к серверу нет. В примере используются Duration из std::time и фича Tokio time, как в главе о фоновых задачах.

Load::failed(error) помещает ошибку в Arc, чтобы состояние с ней можно было дёшево клонировать. Пересечением запросов управляет latest: сам Load не знает, какой запрос новее. Без этой политики или другой явной проверки старая ошибка могла бы затереть актуальный результат.

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

fn status(listing: &Listing) -> String {
match &listing.rows {
Load::NotAsked => "Load the catalogue".into(),
Load::Loading => "Loading…".into(),
Load::Ready(rows) => format!("{} items", rows.len()),
Load::Failed(error) => format!("Could not load: {error}"),
}
}
#[guinea::test]
fn a_request_moves_from_not_asked_through_loading_to_ready(h: &mut Harness) {
h.install::<Catalogue>(&()).unwrap();
assert!(matches!(h.state::<Listing>().rows, Load::NotAsked));
assert_eq!(status(&h.state::<Listing>()), "Load the catalogue");
h.dispatch::<Listing>().emit(Refresh {
simulate_failure: false,
});
h.settled();
assert!(h.state::<Listing>().rows.is_loading());
assert_eq!(status(&h.state::<Listing>()), "Loading…");
h.advance(Duration::from_secs(1));
assert_eq!(
h.state::<Listing>().rows.ready().unwrap(),
&["guinea", "shown"]
);
assert_eq!(status(&h.state::<Listing>()), "2 items");
}

Метод ready() возвращает Some(&T) только для Ready. Метод is_loading() проверяет Loading, а error() заимствует ошибку только из Failed. Пустой Ready(Vec::new()) — всё ещё успешный результат, а не индикатор загрузки.

Повторная попытка — ещё один Refresh: актор снова отправляет Loading и может заменить ошибку успешным результатом.

#[guinea::test]
fn a_failed_load_can_be_retried(h: &mut Harness) {
h.install::<Catalogue>(&()).unwrap();
let dispatch = h.dispatch::<Listing>();
dispatch.emit(Refresh {
simulate_failure: true,
});
h.settled();
h.advance(Duration::from_secs(1));
let failed = h.state::<Listing>();
assert!(failed.rows.ready().is_none());
assert_eq!(
failed.rows.error().unwrap().to_string(),
"service unavailable"
);
assert_eq!(status(&failed), "Could not load: service unavailable");
dispatch.emit(Refresh {
simulate_failure: false,
});
h.settled();
assert!(h.state::<Listing>().rows.is_loading());
assert!(h.state::<Listing>().rows.error().is_none());
h.advance(Duration::from_secs(1));
assert_eq!(status(&h.state::<Listing>()), "2 items");
}

Варианты Failed сравниваются по идентичности Arc ошибки, а не по её тексту. Клонирование сохраняет равенство; новая ошибка с тем же текстом считается другим значением. В Ready сравниваются сами данные, если T поддерживает сравнение.

В этом примере обновление готового списка заменяет его на Loading, так что старых строк в состоянии уже нет. Если их нужно показывать во время обновления, опиши это отдельно — например, последним успешным значением и статусом запроса. Сам Load предыдущее значение не сохраняет.

Список принятых задач хранится в фиче. Текст, который пользователь ещё вводит, выбранная строка и локальный фильтр могут оставаться полями страницы. Если эти данные понадобятся другим экранам или должны будут пережить страницу, для них можно выбрать фичу с подходящим временем жизни.

Актор полезен, когда обработка действия требует решения, нескольким обработчикам нужны общие рабочие данные или фоновые задачи возвращают сообщения. Но состояние можно создать и без актора: цепочка cx.state::<Tasks>().plain() возвращает Bound<Tasks>. Её метод push отправляет обновления редьюсеру напрямую. Например, установка может зарегистрировать подписку, которая преобразует входящие значения и обновляет состояние через эту ручку.