# To-do list

> Build your first complete Python desktop app with Pyzed: a to-do list with state, forms, lists, filters and keyboard shortcuts.

Source: https://albakore.github.io/pyzed/tutorials/basic/todo-app/

In this tutorial we build a to-do list: the classic first app, and a good one, because it has everything a real
app has: data, a form to add to it, a list to show it and ways to change it.

| Goal | Time | Level | You need |
| --- | --- | --- | --- |
| A to-do list with filters and shortcuts | 30 minutes | Basic · Python | [5-step tutorial](https://albakore.github.io/pyzed/tutorials/basic/step-by-step/) (steps 1 and 2) |

## What you'll learn

- Keep the app's data in plain Python objects (a dataclass).
- Read a text field as the user types, and submit it with <kbd>Enter</kbd>.
- Draw a list of rows with a checkbox and a delete button each.
- Filter what is shown, and add keyboard shortcuts.

(Image: The finished app)

1. **The model.** A task is a dataclass. The app state is the list of tasks, the text being
   typed and the active filter:

```python
@dataclass
class Task:
    title: str
    done: bool = False

class Todo(pz.App):
    title = "Tasks"
    size = (520, 560)

    def __init__(self):
        self.tasks = [Task("Build my first app")]
        self.draft = ""
        self.filter = "All"
```

2. **Adding tasks.** The field is *controlled*: it shows `self.draft` and every keystroke
   updates it. Enter (`on_submit`) and the button call the same method:

```python
def add(self):
    if self.draft.strip():
        self.tasks.append(Task(self.draft.strip()))
        self.draft = ""
    return Command.focus("new-task")      # give focus back to the field

# in view():
pz.row([
    ui.input(self.draft, lambda text: setattr(self, "draft", text), id="new-task",
             placeholder="What needs to be done?", on_submit=self.add, icon="plus"),
    ui.button("Add", self.add, variant="solid"),
], spacing=8)
```

3. **Drawing the list.** Each task is a `ui.list_item` with a checkbox at the start and a delete
   button at the end. The lambdas capture `task` because `task_row` receives the task as an
   argument:

```python
def task_row(self, task):
    return ui.list_item(
        ui.label(task.title, color="text_muted" if task.done else "text"),
        lambda: self.toggle(task),
        start=ui.checkbox(task.done, lambda _: self.toggle(task)),
        end=ui.icon_button("trash-2", lambda: self.remove(task), size="sm", tooltip="Delete"),
        height=34,
        inset=True,
    )
```

4. **Filtering.** `ui.segmented` changes `self.filter`, and `view()` only shows the visible
   tasks. Since `view()` is called again after every change, there is nothing to "refresh":

```python
ui.segmented(["All", "Pending", "Done"], self.filter, lambda f: setattr(self, "filter", f))
```

5. **Shortcuts.** `on_key` receives each key as text:

```python
def on_key(self, combo):
    if combo == "ctrl-l":
        return self.clear_done()
    if combo == "ctrl-t":
        self.theme = "One Light" if self.resolved_theme().is_dark else "One Dark"
```

6. **Initial focus.** `on_start` runs once when the window opens:

```python
def on_start(self):
    return Command.focus("new-task")
```

**Tip:**

Try switching the theme with <kbd>Ctrl</kbd>+<kbd>T</kbd>: every component restyles itself
because it reads its colors from the active theme.

## Full code

`examples/todo.py`:

```py
"""A to-do list: state, text input, list items, filters and keyboard shortcuts.

    python examples/todo.py

Walkthrough: https://albakore.github.io/pyzed/tutorials/basic/todo-app/
"""

from dataclasses import dataclass
from itertools import count

import pyzed as pz
from pyzed import Command, ui

_ids = count(1)

@dataclass
class Task:
    title: str
    done: bool = False
    id: int = 0

    def __post_init__(self):
        self.id = self.id or next(_ids)

class Todo(pz.App):
    title = "Tareas"
    size = (520, 560)
    min_size = (400, 360)

    def __init__(self):
        self.tasks = [Task("Leer la guía de Pyzed", done=True), Task("Crear mi primera app"), Task("Probar los temas")]
        self.draft = ""
        self.filter = "Todas"

    # -- actions -----------------------------------------------------------
    def add(self):
        if self.draft.strip():
            self.tasks.append(Task(self.draft.strip()))
            self.draft = ""
        return Command.focus("new-task")

    def toggle(self, task):
        task.done = not task.done

    def remove(self, task):
        self.tasks.remove(task)

    def clear_done(self):
        self.tasks = [t for t in self.tasks if not t.done]

    def on_start(self):
        return Command.focus("new-task")

    def on_key(self, combo):
        if combo == "ctrl-l":
            return self.clear_done()
        if combo == "ctrl-t":
            self.theme = "One Light" if self.resolved_theme().is_dark else "One Dark"

    # -- view ----------------------------------------------------------------
    def visible(self):
        if self.filter == "Pendientes":
            return [t for t in self.tasks if not t.done]
        if self.filter == "Hechas":
            return [t for t in self.tasks if t.done]
        return self.tasks

    def task_row(self, task):
        return ui.list_item(
            ui.label(task.title, color="text_muted" if task.done else "text"),
            lambda: self.toggle(task),
            start=ui.checkbox(task.done, lambda _: self.toggle(task)),
            end=ui.icon_button("trash-2", lambda: self.remove(task), size="sm", tooltip="Eliminar"),
            height=34,
            inset=True,
        )

    def view(self):
        pending = sum(not t.done for t in self.tasks)
        tasks = self.visible()
        return pz.container(
            pz.column(
                [
                    pz.row([ui.heading("Tareas", level=2), pz.hspace(), ui.badge(f"{pending} pendientes")],
                           align="center"),
                    pz.row(
                        [
                            ui.input(self.draft, lambda text: setattr(self, "draft", text), id="new-task",
                                     placeholder="¿Qué hay que hacer?", on_submit=self.add, icon="plus"),
                            ui.button("Añadir", self.add, variant="solid"),
                        ],
                        spacing=8,
                    ),
                    pz.row(
                        [
                            ui.segmented(["Todas", "Pendientes", "Hechas"], self.filter,
                                         lambda f: setattr(self, "filter", f)),
                            pz.hspace(),
                            ui.button("Limpiar hechas", self.clear_done, variant="ghost", size="sm", keys="ctrl-l"),
                        ],
                        align="center",
                    ),
                    ui.card(
                        pz.scrollable(pz.column([self.task_row(t) for t in tasks], spacing=2), height="fill")
                        if tasks else pz.center(ui.muted("No hay tareas aquí."), height=120),
                        padding=6,
                    ),
                ],
                spacing=14,
                max_width=640,
            ),
            padding=24,
            width="fill",
            height="fill",
            align_x="center",
            bg=self.resolved_theme().editor,
        )

if __name__ == "__main__":
    pz.run(Todo)
```

## Events in this tutorial

| Widget | Event | Handler | Gets |
| --- | --- | --- | --- |
| `ui.input` | `on_input` | `lambda text: setattr(self, "draft", text)` | The text. |
| `ui.input`, **Add** | `on_submit`, `on_press` | `self.add` | Nothing. |
| `ui.segmented` | `on_select` | `lambda f: setattr(self, "filter", f)` | The filter. |
| `ui.list_item` | `on_press` | `lambda: self.toggle(task)` | Nothing. |
| `ui.checkbox` | `on_toggle` | `lambda _: self.toggle(task)` | `True`/`False` (ignored: `_`). |
| `ui.icon_button` | `on_press` | `lambda: self.remove(task)` | Nothing. |
| The app | `on_start`, `on_key` | focus the field; `ctrl-l`, `ctrl-t` | —, the combo. |

### Try more events

Rename a task with a double click, and open a menu with a right click:

```python
def __init__(self):
    ...
    self.renaming = None                    # the task being renamed
    self.menu_for = None                    # the task whose menu is open

def task_row(self, task):
    return ui.list_item(
        ui.label(task.title), lambda: self.toggle(task),
        start=ui.checkbox(task.done, lambda _: self.toggle(task)),
        on_double_press=lambda: setattr(self, "renaming", task),     # double click
        on_right_press=lambda: setattr(self, "menu_for", task),      # right click
        height=34, inset=True,
    )

def view(self):
    page = ...                              # the page of the tutorial
    dialog = None
    if self.renaming:
        task = self.renaming
        dialog = ui.prompt("Rename", task.title, lambda t: setattr(task, "title", t),
                           lambda: setattr(self, "renaming", None), lambda: setattr(self, "renaming", None))
    return ui.dialog_layer(page, dialog)
```

## Next

- [Data binding and validation](https://albakore.github.io/pyzed/tutorials/intermediate/data-binding/) in the designer.
- [Lists and tables](https://albakore.github.io/pyzed/tutorials/intermediate/lists-tables/): search, sort and select rows.
- The [Todos showcase](https://albakore.github.io/pyzed/showcase/apps/#todos), which saves the list to disk.
