Skip to main content

Module ch6_background_work

Module ch6_background_work 

Source
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::spawn schedules the async block with the app. When the timer returns Pending, 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.
  • timer completes after four seconds.
  • The block can’t hold &mut Todos across an .await, because the rest of the app needs to use the view in the meantime. Instead, spawn gives it todos, a WeakEntity handle to the view, and cx, an AsyncApp. update borrows 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, update returns 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.