Expand description
§6. Background work
Make deletion reversible for four seconds. A stored task removes the undo offer when its timer expires, while the event loop remains available for drawing and input.
§Remember what was deleted
Undo needs both the deleted todo and its former position. Store those together, then remove by position so the item can return to the same place:
struct Todos {
todos: Vec<Todo>,
/// The most recently deleted todo and its position, while it can be undone.
deleted: Option<(usize, Todo)>,
// ...the other fields, as before
}
impl Todos {
fn delete(&mut self, id: usize, cx: &mut Context<Self>) {
let Some(index) = self.todos.iter().position(|todo| todo.id == id) else {
return;
};
self.deleted = Some((index, self.todos.remove(index)));
cx.notify();
}
fn undo(&mut self, cx: &mut Context<Self>) {
if let Some((index, todo)) = self.deleted.take() {
self.todos.insert(index.min(self.todos.len()), todo);
cx.notify();
}
}
}index.min(self.todos.len()) keeps the insertion index valid if other changes
have shortened the list before the undo is clicked.
Show the offer below the list while there is something to undo:
div()
// ...the title, field, list and footer, as before
.when_some(todos.deleted.as_ref(), |column, (_, todo)| {
column.child(
div()
.id("undo")
.px_3()
.py_2()
.rounded_lg()
.bg(colors.container)
.text_sm()
.cursor_pointer()
.child(format!("Deleted “{}”. Click to undo.", todo.title))
.on_click(cx.listener(|todos, _, _, cx| todos.undo(cx))),
)
})when_some is like when, for an
Option: it adds the message only if there’s a deleted todo, and gives you the
value inside.
§Expire the offer with a task
Use a task for the timeout. A handler runs on the application’s event loop; a blocking sleep there would prevent drawing and input. An async task can await a timer and yield the loop until the timer is ready.
self.undo_timeout = cx.spawn(async move |todos, cx| {
cx.background_executor().timer(Duration::from_secs(4)).await;
todos
.update(cx, |todos, cx| {
todos.deleted = None;
cx.notify();
})
.ok();
});Put that in delete, after the todo is removed, and add an
undo_timeout: Task<()> field to Todos, starting as Task::ready(()), a task
that has already finished.
Three details determine the lifetime and cancellation behavior:
Context::spawnschedules the async block with the app. When the timer returnsPending, that poll ends and other app work can run; the task resumes when the timer is ready. An await whose input is already ready can continue in the same poll. Executors and tasks describes this polling contract.timercompletes after four seconds.- The block can’t hold
&mut Todosacross an.await, because the rest of the app needs to use the view in the meantime. Instead,spawngives ittodos, aWeakEntityhandle to the view, andcx, anAsyncApp.updateborrows the view just for the duration of the closure. - The handle is weak, so the task doesn’t keep the view alive. If the window
closes before the four seconds are up,
updatereturns an error, and.ok()ignores it: there’s nothing left to update.
§Owning the task
spawn returns a Task, and dropping a task cancels it. That’s why the
task is stored in a field rather than thrown away, and it makes cancelling easy.
Delete a second todo before the first offer expires, and the new task replaces
the old one in undo_timeout; dropping the old task cancels its timer. The
offer for the newest delete then gets its full four seconds.
If you forget to keep a task, it is dropped at the end of the statement and may
be dropped before its future is polled. When no caller needs the result, call
detach to remove that task handle’s cancellation path. This
does not guarantee completion during app or process shutdown.
This task only waits, so the background executor is enough; no worker thread is
needed for the four-second timer. For heavy work such as parsing a large file, use
background_spawn to run it on another thread
and await its result in a task like this one. Executors and
tasks explains both, and the async
work guide shows the patterns.
§The program so far
These are the focused additions to Todos: retain the deleted item and its
position, then replace the undo task whenever a new item is deleted. The first
block is a method-level reference; the complete source follows it.
use std::time::Duration;
use gpui::{Context, Entity, Focusable, SharedString, Task, Window, actions, prelude::*};
use gpui_ce_elements::editable_text::{EditableTextState, StringStorage};
struct Todo {
id: usize,
title: SharedString,
done: bool,
}
struct Todos {
todos: Vec<Todo>,
next_id: usize,
filter: Filter,
input: Entity<EditableTextState>,
/// The most recently deleted todo and its position, while it can be undone.
deleted: Option<(usize, Todo)>,
undo_timeout: Task<()>,
}
impl Todos {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
window.focus(&input.focus_handle(cx), cx);
Self {
todos: Vec::new(),
next_id: 0,
filter: Filter::All,
input,
deleted: None,
undo_timeout: Task::ready(()),
}
}
fn delete(&mut self, id: usize, cx: &mut Context<Self>) {
let Some(index) = self.todos.iter().position(|todo| todo.id == id) else {
return;
};
self.deleted = Some((index, self.todos.remove(index)));
// Offer to undo for a few seconds. Assigning a new task drops, and so
// cancels, the previous one.
self.undo_timeout = cx.spawn(async move |todos, cx| {
cx.background_executor().timer(Duration::from_secs(4)).await;
todos
.update(cx, |todos, cx| {
todos.deleted = None;
cx.notify();
})
.ok();
});
cx.notify();
}
fn undo(&mut self, cx: &mut Context<Self>) {
if let Some((index, todo)) = self.deleted.take() {
self.todos.insert(index.min(self.todos.len()), todo);
cx.notify();
}
}
}The complete src/main.rs at this point includes the input, stable todo ids,
filtering, the reusable checkbox component from Chapter 5, deletion, and the
cancellable undo timer. It stops before Chapter 7 adds animation and
accessibility semantics:
use std::time::Duration;
use gpui::{
App, ClickEvent, Context, ElementId, Entity, Focusable, FontWeight, KeyBinding, QuitMode,
SharedString, Task, Window, WindowOptions, actions, colors::Colors, div, prelude::*, px,
};
use gpui_ce_elements::editable_text::{
EditableTextState, StringStorage,
actions::{DEFAULT_INPUT_CONTEXT, default_bindings},
text_input,
};
actions!(todos, [AddTodo]);
struct Todo { id: usize, title: SharedString, done: bool }
#[derive(IntoElement)]
struct Checkbox {
id: ElementId,
checked: bool,
on_toggle: Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
}
impl RenderOnce for Checkbox {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let colors = Colors::for_appearance(window);
div().id(self.id).size_5().flex_shrink_0().rounded_full().border_1()
.border_color(colors.border).cursor_pointer()
.when(self.checked, |checkbox| checkbox.bg(colors.selected))
.on_click(self.on_toggle)
}
}
#[derive(Clone, Copy, PartialEq)]
enum Filter { All, Active, Done }
struct Todos {
todos: Vec<Todo>,
next_id: usize,
filter: Filter,
input: Entity<EditableTextState>,
deleted: Option<(usize, Todo)>,
undo_timeout: Task<()>,
}
impl Todos {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
window.focus(&input.focus_handle(cx), cx);
Self {
todos: Vec::new(), next_id: 0, filter: Filter::All, input,
deleted: None, undo_timeout: Task::ready(()),
}
}
fn add(&mut self, _: &AddTodo, _: &mut Window, cx: &mut Context<Self>) {
let title = self.input.read(cx).as_str().trim().to_string();
if title.is_empty() { return; }
self.todos.push(Todo { id: self.next_id, title: title.into(), done: false });
self.next_id += 1;
self.input.update(cx, |input, cx| input.emplace("", cx));
cx.notify();
}
fn toggle(&mut self, id: usize, cx: &mut Context<Self>) {
if let Some(todo) = self.todos.iter_mut().find(|todo| todo.id == id) {
todo.done = !todo.done;
cx.notify();
}
}
fn delete(&mut self, id: usize, cx: &mut Context<Self>) {
let Some(index) = self.todos.iter().position(|todo| todo.id == id) else { return; };
self.deleted = Some((index, self.todos.remove(index)));
self.undo_timeout = cx.spawn(async move |todos, cx| {
cx.background_executor().timer(Duration::from_secs(4)).await;
todos.update(cx, |todos, cx| { todos.deleted = None; cx.notify(); }).ok();
});
cx.notify();
}
fn undo(&mut self, cx: &mut Context<Self>) {
if let Some((index, todo)) = self.deleted.take() {
self.todos.insert(index.min(self.todos.len()), todo);
cx.notify();
}
}
fn visible(&self) -> impl Iterator<Item = &Todo> {
self.todos.iter().filter(|todo| match self.filter {
Filter::All => true, Filter::Active => !todo.done, Filter::Done => todo.done,
})
}
fn row(&self, todo: &Todo, colors: &Colors, cx: &mut Context<Self>) -> impl IntoElement + use<> {
let id = todo.id;
div().id(("todo", id)).group("row").flex().items_center().gap_3().px_2().py_2()
.rounded_md().cursor_pointer().hover(|style| style.bg(colors.container))
.child(Checkbox {
id: ("toggle", id).into(),
checked: todo.done,
on_toggle: Box::new(cx.listener(move |todos, _, _, cx| todos.toggle(id, cx))),
})
.child(div().flex_1().when(todo.done, |title| title.line_through()
.text_color(colors.disabled)).child(todo.title.clone()))
.child(div().id(("delete", id)).invisible().group_hover("row", |style| style.visible())
.text_color(colors.disabled).cursor_pointer().child("×")
.on_click(cx.listener(move |todos, _, _, cx| todos.delete(id, cx))))
}
fn filter_button(&self, filter: Filter, label: &'static str, cx: &mut Context<Self>) -> impl IntoElement {
div().id(label).px_2().cursor_pointer()
.when(self.filter == filter, |button| button.font_weight(FontWeight::SEMIBOLD))
.child(label)
.on_click(cx.listener(move |todos, _, _, cx| { todos.filter = filter; cx.notify(); }))
}
}
impl Render for Todos {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = Colors::for_appearance(window);
let left = self.todos.iter().filter(|todo| !todo.done).count();
div().key_context("Todos").on_action(cx.listener(Self::add))
.size_full().flex().justify_center().p_8().bg(colors.background).text_color(colors.text)
.child(div().w_full().max_w(px(480.)).flex().flex_col().gap_4()
.child(div().text_3xl().font_weight(FontWeight::SEMIBOLD).child("Todos"))
.child(text_input("new-todo").state(self.input.downgrade())
.placeholder("What needs doing?").px_3().py_2().rounded_lg()
.border_1().border_color(colors.border))
.child(div().flex().flex_col().children(
self.visible().map(|todo| self.row(todo, &colors, cx)),
))
.child(div().flex().justify_between().text_sm().text_color(colors.disabled)
.child(format!("{left} left"))
.child(div().flex().child(self.filter_button(Filter::All, "All", cx))
.child(self.filter_button(Filter::Active, "Active", cx))
.child(self.filter_button(Filter::Done, "Done", cx))))
.when_some(self.deleted.as_ref(), |column, (_, todo)| column.child(
div().id("undo").px_3().py_2().rounded_lg().bg(colors.container).text_sm()
.cursor_pointer().child(format!("Deleted “{}”. Click to undo.", todo.title))
.on_click(cx.listener(|todos, _, _, cx| todos.undo(cx))),
)))
}
}
fn main() {
gpui_platform::application().with_quit_mode(QuitMode::LastWindowClosed).run(|cx: &mut App| {
cx.bind_keys(default_bindings().as_keybindings(Some(DEFAULT_INPUT_CONTEXT)));
cx.bind_keys([KeyBinding::new("enter", AddTodo, Some("Todos"))]);
cx.open_window(WindowOptions::default(), |window, cx| cx.new(|cx| Todos::new(window, cx))).unwrap();
cx.activate(true);
});
}Deletion now has an explicit undo lifetime and does not block the event loop. The next chapter gives each row a transition and gives the checkbox focus and accessibility semantics.