gpui-ce 0.3.0

A community fork of Zed's GPU-accelerated UI framework
Documentation
# 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:

```
# use gpui::{Context, SharedString};
# struct Todo { id: usize, title: SharedString, done: bool }
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:

```
# use gpui::{Context, IntoElement, SharedString, colors::Colors, div, prelude::*};
# struct Todo { title: SharedString }
# struct Todos { deleted: Option<(usize, Todo)> }
# impl Todos { fn undo(&mut self, _: &mut Context<Self>) {} }
# fn column(todos: &Todos, colors: &Colors, cx: &mut Context<Todos>) -> impl IntoElement {
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`](prelude::FluentBuilder::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.

```
# use std::time::Duration;
# use gpui::{Context, Task};
# struct Todo;
# struct Todos { deleted: Option<(usize, Todo)>, undo_timeout: Task<()> }
# impl Todos {
#     fn delete(&mut self, cx: &mut Context<Self>) {
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](crate::_documentation::concepts::executors)
  describes this polling contract.
* [`timer`](BackgroundExecutor::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`](Task::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`](AppContext::background_spawn) to run it on another thread
and await its result in a task like this one. [Executors and
tasks](crate::_documentation::concepts::executors) explains both, and the [async
work guide](crate::_documentation::guides::async_work) 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.

```standalone_crate,no_run
use std::time::Duration;

use gpui::{Context, Entity, Focusable, SharedString, Task, Window, actions, prelude::*};
use gpui_ce_elements::editable_text::{EditableTextState, StringStorage};

# actions!(todos, [AddTodo]);
struct Todo {
    id: usize,
    title: SharedString,
    done: bool,
}

# #[derive(Clone, Copy, PartialEq)]
# enum Filter { All, Active, Done }
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();
        }
    }
}
# fn main() {}
```

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:

```standalone_crate,no_run
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](crate::_documentation::tutorial::ch7_polish) gives each row a
transition and gives the checkbox focus and accessibility semantics.