# Apps y utilidades

> Apps de escritorio en Python pequeñas y completas hechas con Pyzed: una lista de tareas, visores de Markdown e imágenes, un reloj, un cronómetro, un generador de QR, datos en vivo, un chat con IA, paneles, varias ventanas y un editor de proyectos.

Source: https://albakore.github.io/pyzed/es/showcase/apps/

Programas de todos los días, cada uno un único archivo de Python en
[`examples/`](https://github.com/albakore/pyzed/tree/main/examples) que funciona tal cual. Son cortos a propósito:
lee uno de arriba abajo y habrás visto el estado, una vista, eventos y una suscripción trabajando juntos. Varios son
versiones en Pyzed de las demos clásicas de [Iced](https://iced.rs), la biblioteca de interfaces de Rust con la que
dibuja Pyzed.

**Cómo leer estas apps:**

Cada app es una clase con **estado** (atributos que se crean en `__init__`), un **`view()`** que construye la
pantalla a partir del estado y **métodos** que cambian el estado cuando pasa algo. Pyzed redibuja después de cada
cambio. ¿Es nueva para ti esta idea? Empieza por el tutorial de la
[Lista de tareas](https://albakore.github.io/pyzed/es/tutorials/basic/todo-app/).

## Todos

Añade, edita, completa, filtra y borra tareas; se guardan como JSON en tu carpeta de usuario, así que siguen ahí la
próxima vez que abras la app.

- Un dataclass por tarea y una lista en el estado; el filtro es un control `ui.segmented`.
- Haz doble clic en una tarea para editarla en el sitio; las tareas se escriben en disco en cada cambio.
- Una versión más simple se construye paso a paso en el tutorial de la
  [Lista de tareas](https://albakore.github.io/pyzed/es/tutorials/basic/todo-app/).

<details>
<summary>Código completo: <code>examples/todos.py</code></summary>
`examples/todos.py`:

```py
"""Todos, after Iced's ``todos`` demo: add, edit, complete, filter and persist tasks.

    python examples/todos.py

Tasks are saved as JSON next to your user config, so they survive restarts.
"""

import json
from dataclasses import asdict, dataclass, field
from itertools import count
from pathlib import Path

import pyzed as pz
from pyzed import ui

STORE = Path.home() / ".config" / "pyzed-todos.json"
_ids = count()

@dataclass
class Task:
    text: str
    done: bool = False
    id: int = field(default_factory=lambda: next(_ids))

class Todos(pz.App):
    title = "Todos"
    size = (560, 680)

    def __init__(self, store=STORE):
        self.store = store
        self.draft = ""
        self.filter = "All"
        self.editing = None  # (task id, edited text)
        self.tasks = self.load()

    # -- persistence --------------------------------------------------------
    def load(self):
        try:
            return [Task(t["text"], t["done"]) for t in json.loads(self.store.read_text())]
        except (OSError, ValueError, KeyError, TypeError):
            return [Task("Read the Pyzed examples"), Task("Build something nice", done=True)]

    def save(self):
        try:
            self.store.parent.mkdir(parents=True, exist_ok=True)
            self.store.write_text(json.dumps([{"text": t.text, "done": t.done} for t in self.tasks], indent=2))
        except OSError:
            pass

    # -- actions --------------------------------------------------------------
    def add(self):
        if self.draft.strip():
            self.tasks.append(Task(self.draft.strip()))
            self.draft = ""
            self.save()

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

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

    def start_edit(self, task):
        self.editing = (task.id, task.text)
        return pz.Command.focus(f"edit-{task.id}")

    def finish_edit(self):
        task_id, text = self.editing
        for task in self.tasks:
            if task.id == task_id and text.strip():
                task.text = text.strip()
        self.editing = None
        self.save()

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

    def on_key(self, combo):
        if combo == "escape":
            self.editing = None

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

    def task_row(self, task):
        t = self.resolved_theme()
        if self.editing and self.editing[0] == task.id:
            return pz.row([
                ui.input(self.editing[1], lambda text: setattr(self, "editing", (task.id, text)),
                         on_submit=self.finish_edit, id=f"edit-{task.id}"),
                ui.icon_button("check", self.finish_edit, tooltip="Save"),
                ui.icon_button("trash-2", lambda: self.remove(task), tooltip="Delete", color=t.error),
            ], spacing=6, align="center")
        return pz.row([
            ui.checkbox(task.done, lambda _: self.toggle(task)),
            pz.mouse_area(
                ui.label(task.text, color="text_muted" if task.done else "text", width="fill"),
                on_double_click=lambda: self.start_edit(task),
            ),
            ui.icon_button("pencil", lambda: self.start_edit(task), tooltip="Edit"),
        ], spacing=10, align="center")

    def view(self):
        t = self.resolved_theme()
        left = sum(not task.done for task in self.tasks)
        tasks = self.visible()
        body = (pz.column([self.task_row(task) for task in tasks], spacing=6)
                if tasks else ui.empty_state("Nothing here", f"No {self.filter.lower()} tasks."))
        return pz.container(
            pz.column([
                ui.heading("todos"),
                ui.input(self.draft, lambda text: setattr(self, "draft", text), on_submit=self.add,
                         placeholder="What needs to be done?", icon="plus"),
                pz.row([
                    ui.muted(f"{left} {'task' if left == 1 else 'tasks'} left"),
                    pz.hspace(),
                    ui.segmented(["All", "Active", "Completed"], self.filter, lambda f: setattr(self, "filter", f)),
                ], align="center"),
                pz.scrollable(body, height="fill"),
                pz.row([pz.hspace(), ui.button("Clear completed", self.clear_done, variant="ghost", size="sm")]),
            ], spacing=14, max_width=520),
            padding=24, width="fill", height="fill", align_x="center", bg=t.editor,
        )

if __name__ == "__main__":
    pz.run(Todos)
```
</details>

## Visor de Markdown

Un editor de Markdown en vivo: escribe a la izquierda, mira el resultado con formato a la derecha y arrastra el
divisor entre los dos.

- `ui.editor` con `on_change` guarda el texto en el estado; `ui.markdown` lo muestra con formato.
- `ui.split` hace que los dos paneles se puedan redimensionar; los enlaces de la vista previa se abren en tu
  navegador.

<details>
<summary>Código completo: <code>examples/markdown_viewer.py</code></summary>
`examples/markdown_viewer.py`:

```py
"""A live Markdown editor: code editor on the left, rendered preview on the right.

    python examples/markdown_viewer.py

Shows ``ui.editor`` with ``on_change``, ``ui.markdown`` and a draggable ``ui.split``.
Links in the preview open in your browser.
"""

import webbrowser

import pyzed as pz
from pyzed import ui

SAMPLE = """# Pyzed

Build **fast** desktop apps in *Python*, rendered by [Iced](https://iced.rs).

## Features

- Zed-style components in `pyzed.ui`
- Themes, including the ones from [Zed](https://zed.dev)
- `async def` everywhere

```python
import pyzed as pz

class Hello(pz.App):
    def view(self):
        return pz.text("Hello!")
```

> Tip: drag the divider to resize the panes.
"""

class MarkdownViewer(pz.App):
    title = "Markdown"
    size = (1100, 720)

    def __init__(self):
        self.source = SAMPLE
        self.ratio = 0.5
        self.wrap = True

    def view(self):
        t = self.resolved_theme()
        editor = pz.column([
            pz.container(
                pz.row([ui.label("Markdown", size="sm", color="text_muted"), pz.hspace(),
                        ui.checkbox(self.wrap, lambda v: setattr(self, "wrap", v), label="Wrap")], align="center"),
                padding=(6, 12), bg=t.tab_bar, width="fill",
            ),
            ui.editor("source", self.source, language="md", wrap=self.wrap,
                      on_change=lambda text: setattr(self, "source", text)),
        ])
        preview = pz.container(
            pz.scrollable(pz.container(ui.markdown(self.source, on_link=webbrowser.open), padding=24, max_width=760),
                          height="fill"),
            width="fill", height="fill", bg=t.background,
        )
        return ui.split(editor, preview, ratio=self.ratio, on_resize=lambda r: setattr(self, "ratio", r))

if __name__ == "__main__":
    pz.run(MarkdownViewer)
```
</details>

## Visor de imágenes

Muestra una imagen con `pz.image` y en un `pz.image_viewer` con zoom: la rueda hace zoom y arrastrar la mueve. Pasa
una ruta para abrir tu propia imagen; sin ella dibuja una imagen a partir de píxeles generados en Python puro.

<details>
<summary>Código completo: <code>examples/image_viewer.py</code></summary>
`examples/image_viewer.py`:

```py
"""Bitmap images: ``pz.image`` and the zoomable ``pz.image_viewer``.

    python examples/image_viewer.py [picture.png]

Without an argument it shows a picture generated in pure Python (raw RGBA pixels
passed with ``size=(w, h)``). Scroll to zoom the viewer and drag to pan.
"""

import math
import sys

import pyzed as pz
from pyzed import ui

W, H = 480, 320

def generated_pixels():
    """A colorful plasma pattern as raw RGBA bytes."""
    out = bytearray()
    for y in range(H):
        for x in range(W):
            v = math.sin(x / 23) + math.sin(y / 17) + math.sin((x + y) / 31) + math.sin(math.hypot(x - W / 2, y - H / 2) / 19)
            out += bytes((int(128 + 127 * math.sin(v * math.pi)), int(128 + 127 * math.sin(v * math.pi + 2)),
                          int(128 + 127 * math.sin(v * math.pi + 4)), 255))
    return bytes(out)

class Viewer(pz.App):
    title = "Image viewer"
    size = (1000, 640)

    def __init__(self, path=None):
        self.path = path
        self.pixels = None if path else generated_pixels()
        self.fit = "contain"
        self.radius = 12.0
        self.opacity = 1.0

    def source(self, **kwargs):
        if self.path:
            return dict(source=self.path, **kwargs)
        return dict(source=self.pixels, size=(W, H), **kwargs)

    def view(self):
        t = self.resolved_theme()
        thumbs = pz.column([
            ui.label("pz.image", weight="semibold"),
            ui.field("Fit", ui.select(["contain", "cover", "fill", "none", "scale_down"], self.fit,
                                      lambda v: setattr(self, "fit", v), width=130)),
            ui.field("Radius", pz.container(ui.slider(self.radius, lambda v: setattr(self, "radius", v), min=0, max=60),
                                            width=130)),
            ui.field("Opacity", pz.container(ui.slider(self.opacity, lambda v: setattr(self, "opacity", v)),
                                             width=130)),
            pz.container(pz.image(**self.source(width="fill", height=200, content_fit=self.fit, radius=self.radius,
                                                opacity=self.opacity)),
                         width="fill", height=200, bg=t.surface, radius=self.radius),
            pz.row([pz.image(**self.source(width=64, height=64, content_fit="cover", radius=32)),
                    pz.image(**self.source(width=64, height=64, content_fit="cover", radius=8, rotation=15)),
                    pz.image(**self.source(width=64, height=64, content_fit="cover", filter="nearest", scale=2))],
                   spacing=12),
        ], spacing=12, width=320)
        viewer = pz.column([
            ui.label("pz.image_viewer (scroll to zoom, drag to pan)", weight="semibold"),
            pz.container(pz.image_viewer(**self.source(width="fill", height="fill")), width="fill", height="fill",
                         bg=t.surface, radius=t.radius, clip=True),
        ], spacing=12, width="fill")
        return pz.container(pz.row([thumbs, viewer], spacing=20), padding=20, bg=t.background,
                            width="fill", height="fill")

if __name__ == "__main__":
    pz.run(Viewer(sys.argv[1] if len(sys.argv) > 1 else None))
```
</details>

## Reloj

Un reloj analógico dibujado en un `pz.canvas`. Una suscripción, `pz.every(1, ...)`, lo redibuja cada segundo, y las
agujas son trazos girados alrededor del centro.

<details>
<summary>Código completo: <code>examples/clock.py</code></summary>
`examples/clock.py`:

```py
"""An analog clock drawn on a canvas, after Iced's ``clock`` demo.

    python examples/clock.py

``pz.every(1, ...)`` redraws it every second; the hands are rotated paths.
"""

import math
import time

import pyzed as pz
from pyzed import draw

SIZE = 320

def hand(cx, cy, angle, length, width, color):
    a = math.radians(angle - 90)
    return draw.line(cx, cy, cx + length * math.cos(a), cy + length * math.sin(a), stroke=color, width=width,
                     cap="round")

class Clock(pz.App):
    title = "Clock"
    size = (SIZE + 40, SIZE + 80)

    def __init__(self):
        self.now = time.localtime()

    def subscriptions(self):
        return [pz.every(1.0, lambda: setattr(self, "now", time.localtime()))]

    def view(self):
        t = self.resolved_theme()
        c = SIZE / 2
        r = c - 8
        shapes = [draw.circle(c, c, r, fill=t.surface, stroke=t.border, width=2)]
        for i in range(60):
            a = math.radians(i * 6)
            outer, inner = r - 6, r - (18 if i % 5 == 0 else 11)
            shapes.append(draw.line(c + inner * math.sin(a), c - inner * math.cos(a),
                                    c + outer * math.sin(a), c - outer * math.cos(a),
                                    stroke=t.text if i % 5 == 0 else t.text_muted, width=2.5 if i % 5 == 0 else 1))
        for h in range(1, 13):
            a = math.radians(h * 30)
            shapes.append(draw.text(c + (r - 38) * math.sin(a), c - (r - 38) * math.cos(a), h, color=t.text,
                                    size=16, align_x="center", align_y="center"))
        hours, minutes, seconds = self.now.tm_hour % 12, self.now.tm_min, self.now.tm_sec
        shapes += [
            hand(c, c, hours * 30 + minutes / 2, r * 0.5, 6, t.text),
            hand(c, c, minutes * 6 + seconds / 10, r * 0.75, 4, t.text),
            hand(c, c, seconds * 6, r * 0.82, 2, t.error),
            draw.circle(c, c, 5, fill=t.error),
        ]
        return pz.container(
            pz.column([pz.canvas(shapes, width=SIZE, height=SIZE),
                       pz.text(time.strftime("%H:%M:%S", self.now), size=20, font="mono", color=t.text)],
                      spacing=12, align="center"),
            center=True, bg=t.background,
        )

if __name__ == "__main__":
    pz.run(Clock)
```
</details>

## Cronómetro

Un cronómetro con vueltas. <kbd>Espacio</kbd> lo arranca y lo para, <kbd>L</kbd> guarda una vuelta y <kbd>R</kbd>
lo reinicia.

- La suscripción del temporizador solo existe **mientras el cronómetro corre**: si no, `subscriptions()` devuelve
  una lista vacía, así que una app en reposo no usa CPU.

<details>
<summary>Código completo: <code>examples/stopwatch.py</code></summary>
`examples/stopwatch.py`:

```py
"""A stopwatch with laps, after Iced's ``stopwatch`` demo.

    python examples/stopwatch.py

The timer subscription only exists while the watch runs, so an idle app uses no CPU.
Space starts/stops, ``l`` records a lap, ``r`` resets.
"""

import time

import pyzed as pz
from pyzed import ui

def fmt(seconds):
    minutes, rest = divmod(seconds, 60)
    return f"{int(minutes):02d}:{rest:05.2f}"

class Stopwatch(pz.App):
    title = "Stopwatch"
    size = (420, 520)

    def __init__(self):
        self.elapsed = 0.0
        self.started = None
        self.laps = []

    @property
    def running(self):
        return self.started is not None

    def now(self):
        return self.elapsed + (time.monotonic() - self.started if self.running else 0.0)

    def toggle(self):
        if self.running:
            self.elapsed = self.now()
            self.started = None
        else:
            self.started = time.monotonic()

    def reset(self):
        self.elapsed, self.started, self.laps = 0.0, None, []

    def lap(self):
        if self.running:
            self.laps.insert(0, self.now())

    def subscriptions(self):
        # An empty message still triggers a redraw; ``now()`` does the rest.
        return [pz.every(0.03, lambda: None)] if self.running else []

    def on_key(self, combo):
        {"space": self.toggle, "l": self.lap, "r": self.reset}.get(combo, lambda: None)()

    def view(self):
        t = self.resolved_theme()
        laps = [
            pz.row([ui.muted(f"Lap {len(self.laps) - i}"), pz.hspace(), pz.text(fmt(v), font="mono", color=t.text)])
            for i, v in enumerate(self.laps)
        ]
        return pz.container(
            pz.column([
                pz.text(fmt(self.now()), size=56, font="mono", color=t.text),
                pz.row([
                    ui.button("Stop" if self.running else "Start", self.toggle, variant="solid",
                              tint="error" if self.running else "success", keys="space"),
                    ui.button("Lap", self.lap, disabled=not self.running, keys="l"),
                    ui.button("Reset", self.reset, variant="ghost", keys="r"),
                ], spacing=8),
                pz.scrollable(pz.column(laps, spacing=6, width="fill"), height="fill"),
            ], spacing=20, align="center", max_width=340),
            padding=28, width="fill", height="fill", align_x="center", bg=t.editor,
        )

if __name__ == "__main__":
    pz.run(Stopwatch)
```
</details>

## Generador de QR

Escribe un texto o un enlace y obtén su código QR, con los colores del tema o en blanco y negro. Toda la app son
unas 30 líneas: `pz.qr_code(data, cell_size=7)` hace el trabajo.

<details>
<summary>Código completo: <code>examples/qr_code.py</code></summary>
`examples/qr_code.py`:

```py
"""Type something and get its QR code, after Iced's ``qr_code`` demo."""

import pyzed as pz
from pyzed import ui

class QR(pz.App):
    title = "QR code"
    size = (420, 520)

    def __init__(self):
        self.data = "https://github.com/albakore/pyzed"
        self.dark = True

    def view(self):
        t = self.resolved_theme()
        fg, bg = (t.text, t.surface) if self.dark else ("#000000", "#ffffff")
        return pz.container(
            pz.column([
                ui.heading("QR code", level=2),
                ui.input(self.data, lambda v: setattr(self, "data", v), placeholder="Text or URL"),
                ui.checkbox(self.dark, lambda v: setattr(self, "dark", v), label="Use theme colors"),
                pz.container(pz.qr_code(self.data, cell_size=7, color=fg, bg=bg) if self.data else ui.muted("Empty"),
                             padding=16, bg=bg, radius=t.radius),
            ], spacing=14, align="center"),
            padding=24, width="fill", height="fill", align_x="center", bg=t.background,
        )

if __name__ == "__main__":
    pz.run(QR)
```
</details>

## Pomodoro

Un temporizador de concentración con periodos de trabajo y descanso: la app que la guía
[Cómo pedir](https://albakore.github.io/pyzed/es/ai/prompting/) construye con un asistente de IA, un pedido cada vez.

<details>
<summary>Código completo: <code>examples/pomodoro.py</code></summary>
`examples/pomodoro.py`:

```py
"""A Pomodoro timer: the app built step by step in the AI prompting guide."""

import pyzed as pz
from pyzed import ui

WORK, BREAK = 25 * 60, 5 * 60

class Pomodoro(pz.App):
    title = "Pomodoro"
    size = (420, 380)

    def __init__(self):
        self.mode = "Focus"
        self.left = WORK
        self.running = False
        self.done = 0

    def subscriptions(self):
        return [pz.every(1, self.tick, key="timer")] if self.running else []

    def tick(self):
        self.left -= 1
        if self.left <= 0:
            if self.mode == "Focus":
                self.done += 1
            self.mode = "Break" if self.mode == "Focus" else "Focus"
            self.left = BREAK if self.mode == "Break" else WORK

    def switch(self, mode):
        self.mode, self.running = mode, False
        self.left = WORK if mode == "Focus" else BREAK

    def toggle(self):
        self.running = not self.running

    def reset(self):
        self.running, self.left = False, WORK if self.mode == "Focus" else BREAK

    def view(self):
        total = WORK if self.mode == "Focus" else BREAK
        return pz.center(pz.column([
            ui.segmented(["Focus", "Break"], self.mode, self.switch),
            pz.text(f"{self.left // 60:02d}:{self.left % 60:02d}", size=64, weight="bold"),
            ui.progress(1 - self.left / total, height=8, width=280),
            pz.row([
                ui.button("Pause" if self.running else "Start", self.toggle,
                          icon="pause" if self.running else "play", variant="solid", size="lg"),
                ui.icon_button("rotate-ccw", self.reset, tooltip="Reset"),
            ], spacing=8, align="center"),
            ui.muted(f"{self.done} sessions today", size="sm"),
        ], spacing=18, align="center")).style(background="editor")

if __name__ == "__main__":
    pz.run(Pomodoro)
```
</details>

## Selector de color

`ui.ColorPicker` en sus tres modos: un color con transparencia, un degradado lineal y un relleno con máscara de
opacidad. Devuelve un texto `"#rrggbbaa"`, un `pz.Gradient` o un `pz.Paint`, y los tres sirven como `background`.

<details>
<summary>Código completo: <code>examples/color_picker.py</code></summary>
`examples/color_picker.py`:

```py
"""Color picker: a color with alpha, a linear gradient, or a fill with an opacity mask.

    python examples/color_picker.py

``ui.ColorPicker`` reports a ``"#rrggbbaa"`` string, a ``pz.Gradient`` or a ``pz.Paint``;
all of them work as a ``background`` in ``widget.style``.
"""

import pyzed as pz
from pyzed import ui

class ColorPickerDemo(pz.App):
    title = "Color picker"
    size = (640, 560)

    def __init__(self):
        self.fill = pz.Gradient(["#6366f1", "#ec4899"], angle=135)

    def pick(self, event):
        self.fill = event.value

    def view(self):
        preview = pz.container(None, width="fill", height="fill").style(background=self.fill)
        return pz.row([
            ui.ColorPicker(value=self.fill, on_change=self.pick),
            pz.column([
                pz.stack([ui.checkerboard(320, 360, 12), preview], width=320, height=360).style(border=(1, "border")),
                pz.text(repr(self.fill)).style(font="mono", size=12, color="text_muted"),
            ], spacing=10, width="fill"),
        ], spacing=20).style(padding=24, width="fill", height="fill")

if __name__ == "__main__":
    pz.run(ColorPickerDemo)
```
</details>

## Juego de la vida

El Juego de la vida de Conway: haz clic o arrastra para pintar celdas, <kbd>Espacio</kbd> para reproducir o pausar,
<kbd>N</kbd> para avanzar una generación. Miles de celdas se dibujan como una sola figura en un `pz.canvas`, y
`pz.every` mueve la simulación.

<details>
<summary>Código completo: <code>examples/game_of_life.py</code></summary>
`examples/game_of_life.py`:

```py
"""Conway's Game of Life, after Iced's ``game_of_life`` demo.

    python examples/game_of_life.py

Click or drag on the grid to paint cells, Space to play/pause, ``n`` to step once.
Shows ``pz.canvas`` drawing thousands of cells in one shape and ``pz.every`` driving
the simulation.
"""

import random

import pyzed as pz
from pyzed import draw, ui

CELL = 10
COLS, ROWS = 80, 56

def step(alive):
    counts = {}
    for x, y in alive:
        for dx in (-1, 0, 1):
            for dy in (-1, 0, 1):
                if dx or dy:
                    key = ((x + dx) % COLS, (y + dy) % ROWS)
                    counts[key] = counts.get(key, 0) + 1
    return {cell for cell, n in counts.items() if n == 3 or (n == 2 and cell in alive)}

class Life(pz.App):
    title = "Game of Life"
    size = (COLS * CELL + 48, ROWS * CELL + 120)

    def __init__(self):
        self.alive = set()
        self.playing = False
        self.speed = 10
        self.generation = 0
        self.painting = None  # True paints cells, False erases them
        self.randomize()

    def randomize(self):
        self.alive = {(x, y) for x in range(COLS) for y in range(ROWS) if random.random() < 0.18}
        self.generation = 0

    def clear(self):
        self.alive.clear()
        self.generation = 0

    def tick(self):
        self.alive = step(self.alive)
        self.generation += 1

    def subscriptions(self):
        return [pz.every(1 / self.speed, self.tick, key="life")] if self.playing else []

    def on_key(self, combo):
        if combo == "space":
            self.playing = not self.playing
        elif combo == "n":
            self.tick()

    def cell_at(self, x, y):
        return int(x // CELL) % COLS, int(y // CELL) % ROWS

    def press(self, x, y):
        cell = self.cell_at(x, y)
        self.painting = cell not in self.alive
        self.paint(x, y)

    def paint(self, x, y):
        if self.painting is None:
            return
        cell = self.cell_at(x, y)
        (self.alive.add if self.painting else self.alive.discard)(cell)

    def view(self):
        t = self.resolved_theme()
        grid = [draw.background(t.editor)]
        grid.append(draw.rects(((x * CELL, y * CELL, CELL - 1, CELL - 1) for x, y in self.alive), fill=t.accent))
        board = pz.canvas(
            grid,
            width=COLS * CELL,
            height=ROWS * CELL,
            on_press=self.press,
            on_move=self.paint,
            on_release=lambda: setattr(self, "painting", None),
            cursor="crosshair",
        )
        controls = pz.row([
            ui.button("Pause" if self.playing else "Play", lambda: setattr(self, "playing", not self.playing),
                      icon="pause" if self.playing else "play", variant="solid", keys="space"),
            ui.button("Step", self.tick, icon="step-forward", disabled=self.playing),
            ui.button("Random", self.randomize, icon="shuffle"),
            ui.button("Clear", self.clear, icon="eraser"),
            pz.hspace(),
            ui.muted(f"Speed {self.speed}/s"),
            pz.container(ui.slider(self.speed, lambda v: setattr(self, "speed", int(v)), min=1, max=60, step=1),
                         width=160),
            ui.muted(f"Generation {self.generation} · {len(self.alive)} alive"),
        ], spacing=8, align="center")
        return pz.container(pz.column([controls, board], spacing=12), padding=16, bg=t.background,
                            width="fill", height="fill")

if __name__ == "__main__":
    pz.run(Life)
```
</details>

## Descargas con progreso

Varias descargas a la vez, cada una con su barra de progreso, y una ventana que nunca se congela. Cada descarga es
un generador `async def`: cada valor que hace `yield` va a `update` mientras sigue corriendo en el loop de eventos
de Pyzed. Cambia la transferencia simulada por streaming con `httpx` para descargar archivos reales.

<details>
<summary>Código completo: <code>examples/download_progress.py</code></summary>
`examples/download_progress.py`:

```py
"""Concurrent downloads with live progress, after Iced's ``download_progress`` demo.

    python examples/download_progress.py

Each download is an ``async def`` generator: every value it yields goes to ``update``
while it keeps running on Pyzed's asyncio loop, so the UI never blocks. Swap the
simulated transfer for ``aiohttp``/``httpx`` streaming to download real files.
"""

import asyncio
import random

import pyzed as pz
from pyzed import ui

async def download(index, size):
    """Simulates a transfer that sometimes fails; yields ``(index, event, value)``."""
    received = 0
    while received < size:
        await asyncio.sleep(random.uniform(0.02, 0.08))
        received = min(size, received + random.randint(20_000, 120_000))
        if random.random() < 0.004:
            yield index, "failed", "connection reset"
            return
        yield index, "progress", received / size
    yield index, "finished", size

class Download:
    def __init__(self, index):
        self.index = index
        self.size = random.randint(2, 12) * 1_000_000
        self.state = "idle"  # idle | running | finished | failed
        self.progress = 0.0
        self.error = ""

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

    def __init__(self):
        self.downloads = [Download(0)]

    def start(self, item):
        item.state, item.progress = "running", 0.0
        # Returning an async generator runs it in the background.
        return download(item.index, item.size)

    def add(self):
        self.downloads.append(Download(len(self.downloads)))

    def start_all(self):
        return [pz.Command.perform(self.start(d)) for d in self.downloads if d.state != "running"]

    def update(self, message):
        index, event, value = message
        item = self.downloads[index]
        if event == "progress":
            item.progress = value
        elif event == "finished":
            item.state, item.progress = "finished", 1.0
        elif event == "failed":
            item.state, item.error = "failed", value

    def row(self, item):
        t = self.resolved_theme()
        mb = item.size / 1_000_000
        if item.state == "idle":
            status, action = ui.muted(f"{mb:.0f} MB"), ui.button("Start", lambda: self.start(item), size="sm")
        elif item.state == "running":
            status, action = ui.muted(f"{item.progress * mb:.1f} / {mb:.0f} MB"), ui.spinner(size=14)
        elif item.state == "finished":
            status = ui.label("Done", color="success", size="sm")
            action = ui.icon_button("rotate-ccw", lambda: self.start(item), tooltip="Again")
        else:
            status = ui.label(f"Failed: {item.error}", color="error", size="sm")
            action = ui.button("Retry", lambda: self.start(item), size="sm", tint="error")
        return ui.card(pz.column([
            pz.row([ui.label(f"file-{item.index + 1}.bin", weight="medium"), pz.hspace(), status, action],
                   spacing=10, align="center"),
            ui.progress(item.progress, tint="error" if item.state == "failed" else "accent",
                        theme=t),
        ], spacing=8), padding=12)

    def subscriptions(self):
        # Keep the spinners turning while something is downloading.
        busy = any(d.state == "running" for d in self.downloads)
        return [pz.every(1 / 30, lambda: None)] if busy else []

    def view(self):
        t = self.resolved_theme()
        return pz.container(
            pz.column([
                pz.row([ui.heading("Downloads", level=2), pz.hspace(),
                        ui.button("Add", self.add, icon="plus"),
                        ui.button("Start all", self.start_all, icon="download", variant="solid")], spacing=8,
                       align="center"),
                pz.scrollable(pz.column([self.row(d) for d in self.downloads], spacing=8), height="fill"),
            ], spacing=16),
            padding=24, width="fill", height="fill", bg=t.background,
        )

if __name__ == "__main__":
    pz.run(Downloads)
```
</details>

La misma idea con hilos (para código bloqueante) se explica en el tutorial
[Tareas en segundo plano](https://albakore.github.io/pyzed/es/tutorials/intermediate/background-tasks/).

## Datos en vivo

Un panel de cotizaciones alimentado por `pz.listen`: una suscripción respaldada por un generador async. Corre
mientras `subscriptions()` la devuelva y envía cada valor que produce a `update`.

```python
async def feed():                       # una app real leería aquí un websocket
    prices = {s: random.uniform(50, 150) for s in SYMBOLS}
    while True:
        await asyncio.sleep(0.25)
        symbol = random.choice(SYMBOLS)
        prices[symbol] *= 1 + random.gauss(0, 0.01)
        yield symbol, prices[symbol]    # cada valor va a update()

class Ticker(pz.App):
    def subscriptions(self):
        return [pz.listen(feed)] if self.connected else []   # el interruptor pausa el flujo

    def update(self, message):
        symbol, price = message
        self.history[symbol] = (self.history[symbol] + [price])[-60:]
```

<details>
<summary>Código completo: <code>examples/websocket_stream.py</code></summary>
`examples/websocket_stream.py`:

```py
"""Live data with ``pz.listen``: a subscription backed by an async generator.

    python examples/websocket_stream.py

``listen`` runs the generator for as long as it is returned from ``subscriptions()``
and sends every value it yields to ``update``. Here it simulates a price feed; replace
the body with ``async for message in websocket:`` to consume a real socket.
"""

import asyncio
import random

import pyzed as pz
from pyzed import ui

SYMBOLS = ["PYZ", "ICE", "ZED", "RST"]

async def feed():
    prices = {s: random.uniform(50, 150) for s in SYMBOLS}
    while True:
        await asyncio.sleep(0.25)
        symbol = random.choice(SYMBOLS)
        prices[symbol] *= 1 + random.gauss(0, 0.01)
        yield symbol, prices[symbol]

class Ticker(pz.App):
    title = "Live feed"
    size = (720, 460)

    def __init__(self):
        self.connected = True
        self.history = {s: [] for s in SYMBOLS}

    def subscriptions(self):
        return [pz.listen(feed)] if self.connected else []

    def update(self, message):
        symbol, price = message
        self.history[symbol] = (self.history[symbol] + [price])[-60:]

    def view(self):
        t = self.resolved_theme()
        cards = []
        for symbol, values in self.history.items():
            if len(values) < 2:
                cards.append(ui.stat(symbol, "…", width="fill"))
                continue
            delta = (values[-1] / values[0] - 1) * 100
            cards.append(ui.stat(symbol, f"{values[-1]:.2f}", delta=delta, trend=values, width="fill"))
        return pz.container(
            pz.column([
                pz.row([ui.heading("Live feed", level=2), pz.hspace(),
                        ui.switch(self.connected, lambda v: setattr(self, "connected", v)),
                        ui.muted("connected" if self.connected else "paused")], spacing=8, align="center"),
                pz.row(cards[:2], spacing=12),
                pz.row(cards[2:], spacing=12),
            ], spacing=16),
            padding=24, width="fill", height="fill", bg=t.background,
        )

if __name__ == "__main__":
    pz.run(Ticker)
```
</details>

## Chat con IA

Un chat cuyas respuestas aparecen palabra a palabra. Habla con cualquier servidor compatible con OpenAI (Ollama,
LM Studio, OpenAI...) a través de `httpx`, y tiene un modo demo con respuestas predefinidas, así que funciona sin
conexión.

Para usar un modelo real, define tres variables de entorno antes de ejecutarlo, por ejemplo con
[Ollama](https://ollama.com) en tu máquina: `CHAT_BASE_URL=http://localhost:11434/v1` y `CHAT_MODEL=llama3.2` (más
`CHAT_API_KEY` para un servicio en la nube). La [sección de IA](https://albakore.github.io/pyzed/es/ai/) explica el resto.

<details>
<summary>Código completo: <code>examples/ai_chat.py</code></summary>
`examples/ai_chat.py`:

```py
"""A streaming AI chat: any OpenAI-compatible server through httpx, with a built-in demo stream.

    python examples/ai_chat.py                                    # demo: canned answers, no network
    CHAT_BASE_URL=http://localhost:11434/v1 CHAT_MODEL=llama3.2 python examples/ai_chat.py     # Ollama
    CHAT_BASE_URL=https://api.openai.com/v1 CHAT_API_KEY=sk-... CHAT_MODEL=<model> python examples/ai_chat.py

The answer streams token by token: ``send`` returns an async generator, Pyzed runs it on its asyncio
loop and every value it yields goes to ``update``, so the window keeps responding while the model writes.
"""

import asyncio
import json
import os
import time

import pyzed as pz
from pyzed import ui

BASE_URL = os.environ.get("CHAT_BASE_URL", "")  # empty: demo mode
API_KEY = os.environ.get("CHAT_API_KEY", "")
MODEL = os.environ.get("CHAT_MODEL", "demo" if not BASE_URL else "llama3.2")
SYSTEM = "You are a concise assistant inside a desktop app. Answer in Markdown."

SUGGESTIONS = [
    ("code", "Write a Python function that removes duplicates but keeps the order"),
    ("lightbulb", "Explain async/await like I'm new to Python"),
    ("table", "Compare SQLite, PostgreSQL and DuckDB in a table"),
]

DEMO_ANSWER = """Sure! A `dict` keeps insertion order, so it does the work for you:

```python
def unique(items):
    \"\"\"Removes duplicates and keeps the first occurrence of each item.\"\"\"
    return list(dict.fromkeys(items))

unique([3, 1, 3, 2, 1])   # [3, 1, 2]
```

- **O(n)**: one pass, no nested loops.
- Works with any *hashable* item (numbers, strings, tuples).
- For unhashable items such as lists, keep a `seen` list instead.
"""

# -- the model ---------------------------------------------------------------------------------
async def openai_stream(messages, *, model=MODEL, base_url=BASE_URL, api_key=API_KEY):
    """Yields the answer's text pieces from an OpenAI-compatible ``/chat/completions`` endpoint."""
    import httpx  # only needed when talking to a real server

    headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
    body = {"model": model, "messages": messages, "stream": True}
    async with httpx.AsyncClient(timeout=httpx.Timeout(60, connect=10)) as client:
        async with client.stream("POST", f"{base_url.rstrip('/')}/chat/completions", json=body,
                                 headers=headers) as response:
            if response.status_code >= 400:
                detail = (await response.aread()).decode(errors="replace")[:300]
                raise RuntimeError(f"HTTP {response.status_code}: {detail}")
            async for line in response.aiter_lines():  # server-sent events: "data: {...}"
                if not line.startswith("data:"):
                    continue
                data = line[5:].strip()
                if data == "[DONE]":
                    break
                delta = json.loads(data)["choices"][0].get("delta", {}).get("content")
                if delta:
                    yield delta

async def demo_stream(messages, *, delay=0.015):
    """A stand-in for the model: streams a canned answer a few characters at a time."""
    text = DEMO_ANSWER
    for i in range(0, len(text), 4):
        await asyncio.sleep(delay)
        yield text[i:i + 4]

def model_stream(messages):
    return demo_stream(messages) if not BASE_URL else openai_stream(messages)

# -- the app -----------------------------------------------------------------------------------
class AIChat(pz.App):
    title = "AI chat"
    size = (980, 720)

    def __init__(self, stream=model_stream):
        self.stream = stream            # a function: messages -> async iterator of text
        self.messages = []              # [{"role": "user" | "assistant", "content": str}]
        self.draft = ""
        self.busy = False
        self.error = ""
        self.turn = 0                   # tags each reply, so pieces of a stopped one are ignored
        self.started = 0.0
        self.tokens = 0

    # -- actions --------------------------------------------------------------------------------
    def send(self, text=None):
        text = (text if text is not None else self.draft).strip()
        if not text or self.busy:
            return None
        self.messages += [{"role": "user", "content": text}, {"role": "assistant", "content": ""}]
        self.draft, self.error, self.busy = "", "", True
        self.turn += 1
        self.started, self.tokens = time.monotonic(), 0
        history = [{"role": "system", "content": SYSTEM}] + self.messages[:-1]
        return [self.reply(self.turn, history), pz.Command.scroll_to_end("thread")]

    async def reply(self, turn, history):
        """Runs on Pyzed's asyncio loop; each yield goes to update() on the UI thread."""
        try:
            async for piece in self.stream(history):
                if turn != self.turn:   # stopped or superseded
                    return
                yield ("delta", turn, piece)
        except Exception as error:      # network errors, bad keys... shown in the thread
            yield ("error", turn, str(error) or type(error).__name__)
        yield ("done", turn, None)

    def stop(self):
        self.turn += 1                  # the running reply sees a new turn and ends
        self.busy = False

    def clear(self):
        self.stop()
        self.messages, self.error = [], ""

    def update(self, message):
        kind, turn, value = message
        if turn != self.turn:
            return None
        if kind == "delta":
            self.messages[-1]["content"] += value
            self.tokens += 1
            return pz.Command.scroll_to_end("thread")
        if kind == "error":
            self.error = value
        elif kind == "done":
            self.busy = False
        return None

    # -- view -----------------------------------------------------------------------------------
    def bubble(self, index, message):
        last = index == len(self.messages) - 1
        if message["role"] == "user":
            return pz.row([pz.hspace(), pz.container(ui.label(message["content"]), padding=(10, 14),
                                                     max_width=560).style(background="element", radius=14)])
        content = message["content"]
        if last and self.busy:
            content += " ▍"
        body = [ui.markdown(content) if content.strip() else
                pz.row([ui.spinner(size=14), ui.muted("Thinking…")], spacing=8, align="center")]
        if not (last and self.busy) and content:
            body.append(pz.row([
                ui.icon_button("copy", pz.Command.copy(message["content"]), size="sm", tooltip="Copy"),
                ui.icon_button("refresh-cw", self.retry, size="sm", tooltip="Regenerate") if last else None,
                ui.icon_button("thumbs-up", None, size="sm"), ui.icon_button("thumbs-down", None, size="sm"),
            ], spacing=2))
        avatar = pz.container(pz.icon("sparkles", size=15, color="#ffffff"), width=30, height=30, center=True).style(
            background=pz.Gradient(["#8b5cf6", "#ec4899"], angle=135), radius=15)
        return pz.row([avatar, pz.column(body, spacing=6, width="fill")], spacing=12)

    def retry(self):
        if self.messages and not self.busy:
            question = self.messages[-2]["content"]
            self.messages = self.messages[:-2]
            return self.send(question)
        return None

    def empty(self):
        cards = [pz.button(pz.row([pz.icon(icon, size=16), ui.label(text, size="sm")], spacing=10, align="center"),
                           lambda text=text: self.send(text), width="fill", padding=(12, 14), radius=10,
                           bg=self.resolved_theme().surface, hover_bg=self.resolved_theme().element_hover,
                           border_color=self.resolved_theme().border_variant, border_width=1)
                 for icon, text in SUGGESTIONS]
        return pz.container(pz.column([
            pz.container(pz.icon("sparkles", size=26, color="#ffffff"), width=56, height=56, center=True).style(
                background=pz.Gradient(["#8b5cf6", "#ec4899"], angle=135), radius=16, glow=("#8b5cf6", 18)),
            ui.heading("How can I help?"),
            ui.muted(f"Model: {MODEL}" + ("  ·  demo stream, no network" if not BASE_URL else "")),
            pz.space(height=8),
            pz.column(cards, spacing=8, width=480),
        ], spacing=10, align="center"), width="fill", height="fill", center=True)

    def composer(self):
        action = (ui.icon_button("square", self.stop, variant="solid", tooltip="Stop") if self.busy else
                  ui.icon_button("arrow-up", self.send, variant="solid" if self.draft.strip() else "filled",
                                 tooltip="Send", disabled=not self.draft.strip()))
        return pz.container(pz.row([
            ui.input(self.draft, lambda v: setattr(self, "draft", v), placeholder="Ask anything…",
                     on_submit=self.send, borderless=True, id="prompt", size="lg"),
            action,
        ], spacing=8, align="center"), padding=(6, 8, 6, 12), max_width=760).style(
            background="surface", border=(1, "border"), radius=16, shadow=("#00000026", (0, 4), 16))

    def view(self):
        speed = ""
        if self.tokens and self.busy:
            speed = f"{self.tokens / max(time.monotonic() - self.started, 0.001):.0f} chunks/s"
        header = ui.toolbar(
            [pz.icon("sparkles", size=14),
             ui.label("Assistant", weight="semibold"), ui.badge(MODEL, tint="info", variant="soft")],
            [ui.muted(speed, size="sm") if speed else None,
             ui.icon_button("trash-2", self.clear, size="sm", tooltip="New chat")], height=44)
        if self.messages:
            thread = pz.column([self.bubble(i, m) for i, m in enumerate(self.messages)], spacing=22, max_width=760,
                               padding=(24, 24, 8, 24))
            if self.error:
                thread = pz.column([thread, pz.container(ui.callout("The model failed", self.error, tint="error",
                                                                    icon="circle-alert"), max_width=760,
                                                         padding=(0, 24))])
            middle = pz.scrollable(pz.container(thread, width="fill", align_x="center"), height="fill", id="thread",
                                   anchor_bottom=True)
        else:
            middle = self.empty()
        return pz.column([
            header, ui.divider(), middle,
            pz.container(self.composer(), width="fill", align_x="center", padding=(8, 24, 20, 24)),
        ], height="fill").style(background="editor")

if __name__ == "__main__":
    pz.run(AIChat)
```
</details>

## Varias ventanas

Abre tantas ventanas como quieras: cada `pz.Window` tiene su propia vista, título, tamaño y tema, pero todas
comparten el estado de la app, así que el contador de clics es el mismo en todas. Cerrar la ventana principal cierra
la app.

<details>
<summary>Código completo: <code>examples/multi_window.py</code></summary>
`examples/multi_window.py`:

```py
"""Several windows in one app, after Iced's ``multi_window`` demo.

    python examples/multi_window.py

Every ``pz.Window`` has its own view, title, size and theme, but they all share the
app's state: the counter below is the same in every window. Closing the main window
quits the app.
"""

import pyzed as pz
from pyzed import ui

class Note(pz.Window):
    size = (360, 260)

    def __init__(self, number, theme):
        self.number = number
        self.theme = theme
        self.title = f"Window {number}"
        self.text = ""

    def on_close(self):
        # The user clicked the window's close button.
        self.app.windows.remove(self)

    def dismiss(self):
        self.app.windows.remove(self)
        return self.close()

    def view(self):
        t = self.resolved_theme()
        return pz.container(
            pz.column([
                ui.heading(self.title, level=3),
                ui.muted(f"Theme: {t.name}"),
                ui.input(self.text, lambda v: setattr(self, "text", v), placeholder="Type something…"),
                pz.row([
                    ui.button(f"Shared clicks: {self.app.clicks}", self.app.click, icon="mouse-pointer-click"),
                    pz.hspace(),
                    ui.button("Close", self.dismiss, variant="ghost"),
                ], align="center"),
            ], spacing=12),
            padding=20, width="fill", height="fill", bg=t.background,
        )

class Main(pz.App):
    title = "Multi window"
    size = (480, 360)

    def __init__(self):
        self.clicks = 0
        self.windows = []
        self.opened = 0

    def click(self):
        self.clicks += 1

    def open_window(self):
        self.opened += 1
        themes = pz.themes.all()
        window = Note(self.opened, themes[self.opened % len(themes)])
        self.windows.append(window)
        return window.open()

    def close_all(self):
        commands = [w.close() for w in self.windows]
        self.windows = []
        return commands

    def view(self):
        t = self.resolved_theme()
        items = [ui.list_item(w.title, None, icon="app-window", end=ui.muted(w.text or "empty")) for w in self.windows]
        return pz.container(
            pz.column([
                ui.heading("Windows", level=2),
                pz.row([ui.button("Open window", self.open_window, icon="plus", variant="solid"),
                        ui.button("Close all", self.close_all, disabled=not self.windows),
                        ui.button(f"Clicks: {self.clicks}", self.click)], spacing=8),
                pz.column(items) if items else ui.muted("No extra windows yet."),
            ], spacing=14),
            padding=24, width="fill", height="fill", bg=t.background,
        )

if __name__ == "__main__":
    pz.run(Main)
```
</details>

## Paneles

Paneles redimensionables como los de un editor: divide cualquier panel a la derecha o hacia abajo, arrastra los
divisores y cierra paneles. La disposición es un pequeño árbol de listas guardado en el estado y dibujado con
`ui.split` anidados.

<details>
<summary>Código completo: <code>examples/panes.py</code></summary>
`examples/panes.py`:

```py
"""Resizable panes, after Iced's ``pane_grid`` demo, using nested ``ui.split``.

    python examples/panes.py

Drag the dividers; each pane can be split again or closed. The layout is a small
tree of ``("h" | "v", ratio, first, second)`` tuples kept in your state.
"""

from itertools import count

import pyzed as pz
from pyzed import ui

_ids = count(1)

class Panes(pz.App):
    title = "Panes"
    size = (1000, 680)

    def __init__(self):
        self.layout = next(_ids)  # a leaf is a pane number
        self.focus = self.layout

    def replace(self, node, target, new):
        if node == target:
            return new
        if isinstance(node, list):
            node[2] = self.replace(node[2], target, new)
            node[3] = self.replace(node[3], target, new)
        return node

    def remove(self, node, target):
        if isinstance(node, list):
            if node[2] == target:
                return node[3]
            if node[3] == target:
                return node[2]
            node[2] = self.remove(node[2], target)
            node[3] = self.remove(node[3], target)
        return node

    def split(self, pane, vertical):
        new = next(_ids)
        self.layout = self.replace(self.layout, pane, ["v" if vertical else "h", 0.5, pane, new])
        self.focus = new

    def close(self, pane):
        if self.layout != pane:
            self.layout = self.remove(self.layout, pane)
            self.focus = None

    def pane(self, number):
        t = self.resolved_theme()
        focused = number == self.focus
        header = pz.container(
            pz.row([
                ui.label(f"Pane {number}", weight="medium", color="text" if focused else "text_muted"),
                pz.hspace(),
                ui.icon_button("columns-2", lambda: self.split(number, False), size="sm", tooltip="Split right"),
                ui.icon_button("rows-2", lambda: self.split(number, True), size="sm", tooltip="Split down"),
                ui.icon_button("x", lambda: self.close(number), size="sm", tooltip="Close",
                               disabled=self.layout == number),
            ], spacing=2, align="center"),
            padding=(4, 8), width="fill", bg=t.tab_active if focused else t.tab_inactive,
        )
        body = pz.mouse_area(pz.center(ui.muted("Click to focus")), on_press=lambda: setattr(self, "focus", number))
        return pz.container(pz.column([header, body]), width="fill", height="fill", bg=t.editor,
                            border_color=t.border_focused if focused else t.border_variant, border_width=1)

    def render(self, node):
        if not isinstance(node, list):
            return self.pane(node)
        direction, ratio, first, second = node

        def resize(r, node=node):
            node[1] = r

        return ui.split(self.render(first), self.render(second), ratio=ratio, on_resize=resize,
                        vertical=direction == "v", min_size=120)

    def view(self):
        return pz.container(self.render(self.layout), padding=8, width="fill", height="fill",
                            bg=self.resolved_theme().background)

if __name__ == "__main__":
    pz.run(Panes)
```
</details>

## Menú contextual

Un menú de clic derecho hecho con `ui.popover` y `ui.menu`. `mouse_area(on_move=...)` recuerda dónde está el
puntero, así que el menú se abre justo ahí; hacer clic en otro sitio o pulsar <kbd>Esc</kbd> lo cierra.

<details>
<summary>Código completo: <code>examples/context_menu.py</code></summary>
`examples/context_menu.py`:

```py
"""A right-click context menu built from ``ui.popover`` and ``ui.menu``.

    python examples/context_menu.py

The pointer position is remembered with ``mouse_area(on_move=...)`` so the menu opens
where you clicked. Clicking anywhere else (or pressing Escape) closes it.
"""

import pyzed as pz
from pyzed import ui

class ContextMenu(pz.App):
    title = "Context menu"
    size = (640, 420)

    def __init__(self):
        self.pointer = (0, 0)
        self.menu_at = None
        self.log = ["Right-click anywhere in the box."]
        self.soft_wrap = True

    def act(self, what):
        def run():
            self.log.append(what)
            self.menu_at = None
        return run

    def toggle_wrap(self):
        self.soft_wrap = not self.soft_wrap
        self.menu_at = None

    def on_key(self, combo):
        if combo == "escape":
            self.menu_at = None

    def view(self):
        t = self.resolved_theme()
        area = pz.mouse_area(
            pz.container(pz.column([ui.muted(line) for line in self.log[-12:]], spacing=4), padding=16,
                         width="fill", height="fill", bg=t.editor, border_color=t.border_variant, border_width=1,
                         radius=t.radius),
            on_move=lambda x, y: setattr(self, "pointer", (x, y)),
            on_right_press=lambda: setattr(self, "menu_at", self.pointer),
        )
        menu = None
        if self.menu_at:
            menu = ui.menu([
                ui.MenuItem("Cut", self.act("cut"), icon="scissors", keys="ctrl-x"),
                ui.MenuItem("Copy", self.act("copy"), icon="copy", keys="ctrl-c"),
                ui.MenuItem("Paste", self.act("paste"), icon="clipboard", keys="ctrl-v"),
                ui.SEPARATOR,
                ui.MenuItem("Soft wrap", self.toggle_wrap, checked=self.soft_wrap),
                ui.SEPARATOR,
                ui.MenuItem("Delete", self.act("delete"), icon="trash-2", danger=True),
            ], width=220)
        x, y = self.menu_at or (0, 0)
        return pz.container(
            ui.popover(area, menu, x=x + 16, y=y + 16, on_dismiss=lambda: setattr(self, "menu_at", None)),
            padding=16, width="fill", height="fill", bg=t.background,
        )

if __name__ == "__main__":
    pz.run(ContextMenu)
```
</details>

## Componentes propios

Tres piezas reutilizables escritas en código: una valoración con estrellas y un editor de etiquetas (clases
`pz.Component` con propiedades, su propio estado y sus propios eventos), y una nota que puedes arrastrar, a la que
puedes hacer doble clic y escribirle, que registra los eventos comunes de todos los widgets.

<details>
<summary>Código completo: <code>examples/components.py</code></summary>
`examples/components.py`:

```py
"""Custom components and events: a star rating, a tag editor and a draggable note.

    python examples/components.py

- ``Rating`` and ``TagInput`` are ``pz.Component`` classes: props, their own state,
  their own events (``change``, ``add``, ``remove``), reusable anywhere.
- The note uses the common events every widget has: drag it around, double-click to
  pin it, hover it, click it and press keys (they show up in the log).
"""

import pyzed as pz
from pyzed import ui

class Rating(pz.Component):
    """Stars the user can click; shows a preview while hovering."""

    value: int = 0
    max: int = 5
    events = {"change": "A star was picked (``event.value`` is the new rating)."}
    icon = "star"
    size = (140, 28)

    def setup(self):
        self.hover = None  # state: which star is under the pointer

    def render(self):
        t = pz.theme.current()
        shown = self.hover or self.value
        stars = []
        for i in range(1, self.max + 1):
            color = t.warning if i <= shown else t.text_disabled
            stars.append(
                pz.icon("star", size=20, color=color).on(
                    click=lambda i=i: self.emit("change", i),
                    mouse_enter=lambda i=i: setattr(self, "hover", i),
                    mouse_leave=lambda: setattr(self, "hover", None),
                )
            )
        return pz.row(stars, spacing=2)

class TagInput(pz.Component):
    """A list of tags with a box to add more."""

    tags: list = ()
    placeholder: str = "Add a tag…"
    events = {"add": "Enter was pressed (``event.value`` is the text).",
              "remove": "A tag's x was clicked (``event.value`` is the tag)."}
    icon = "tags"
    size = (280, 64)

    def setup(self):
        self.draft = ""

    def submit(self):
        text, self.draft = self.draft.strip(), ""
        return self.emit("add", text) if text else None

    def render(self):
        chips = [ui.chip(tag, on_remove=lambda tag=tag: self.emit("remove", tag)) for tag in self.tags]
        return pz.column([
            pz.row(chips, spacing=6, wrap=True, width="fill") if chips
            else ui.label("No tags yet", color="text_muted", size="sm"),
            ui.input(self.draft, lambda text: setattr(self, "draft", text), placeholder=self.placeholder,
                     on_submit=self.submit, icon="tag"),
        ], spacing=8)

class Demo(pz.App):
    title = "Components and events"
    size = (720, 460)

    def __init__(self):
        self.rating = 3
        self.tags = ["python", "rust"]
        self.note = (20, 40)
        self.pinned = False
        self.log = ["Drag the note, double-click it, click it and type."]

    def record(self, event):
        detail = event.key or (f"{event.dx:+.0f}, {event.dy:+.0f}" if event.type == "drag" else "")
        self.log = [f"{event.type} {detail}".strip(), *self.log][:9]

    def move(self, event):
        if not self.pinned:
            x, y = self.note
            self.note = (x + event.dx, y + event.dy)

    def view(self):
        t = self.resolved_theme()
        form = ui.card(pz.column([
            ui.field("Rating", Rating(value=self.rating, on_change=lambda e: setattr(self, "rating", e.value))),
            ui.label(f"{self.rating} of 5", color="text_muted", size="sm"),
            ui.label("Tags"),
            TagInput(
                tags=self.tags,
                on_add=lambda e: setattr(self, "tags", [*self.tags, e.value]),
                on_remove=lambda e: setattr(self, "tags", [x for x in self.tags if x != e.value]),
            ),
        ], spacing=14), title="Components", width=320)

        note = pz.container(
            pz.column([
                pz.row([pz.icon("pin" if self.pinned else "grip", size=14, color=t.text_muted),
                        ui.label("Pinned" if self.pinned else "Drag me", weight="semibold")], spacing=6, align="center"),
                ui.label("Double-click to pin", size="sm", color="text_muted"),
            ], spacing=4),
            width=180, padding=12, bg=t.elevated, border_color=t.accent if self.pinned else t.border, border_width=1,
            radius=t.radius,
        ).on(
            drag=self.move, drag_end=self.record, double_click=lambda: setattr(self, "pinned", not self.pinned),
            click=self.record, key_down=self.record, focus=self.record, blur=self.record,
            cursor="pointer" if self.pinned else "grab",
        )

        log = ui.card(pz.column([ui.label(line, mono=True, size="sm") for line in self.log], spacing=2),
                      title="Event log", width=320)
        x, y = self.note
        canvas = pz.stack([
            pz.container(width="fill", height="fill"),
            pz.pin(note, x=x, y=y),
        ], width="fill", height="fill")
        return pz.row([pz.column([form, log], spacing=16), canvas], spacing=24, padding=24)

if __name__ == "__main__":
    pz.run(Demo)
```
</details>

Para dibujar componentes con el mouse, sigue [Un componente propio](https://albakore.github.io/pyzed/es/tutorials/intermediate/custom-component/).

## Todos los componentes

Un recorrido por los componentes listos de `pyzed.ui`: botones, campos, pestañas, un editor de código, menús,
árboles, notificaciones, formularios, avisos y datos, con un selector de tema para verlos en cualquier tema.

<details>
<summary>Código completo: <code>examples/gallery.py</code></summary>
`examples/gallery.py`:

```py
"""A tour of the ready-made components in ``pyzed.ui``.

    python examples/gallery.py
"""

import pyzed as pz
from pyzed import ui

SNIPPET = '''from pyzed import ui

def view(self):
    return ui.button("Deploy", self.deploy)
'''

class Gallery(pz.App):
    title = "Pyzed components"
    size = (1440, 900)

    def __init__(self, theme=pz.themes.ONE_DARK):
        self.theme = theme
        self.name = "Pyzed"
        self.query = ""
        self.format_on_save = True
        self.vim_mode = False
        self.telemetry = True
        self.font_size = 0.55
        self.view_mode = "Split"
        self.active_tab = "main.py"
        self.selected_file = "src/app.py"
        self.expanded = {"src"}
        self.language = "Python"
        self.size_choice = "Medium"
        self.tab_size = 4
        self.tags = {"rust", "python", "ui"}
        self.advanced_open = True
        self.sort_by = "Size"
        self.descending = True
        self.selected_row = None

    def set(self, field):
        return lambda value: setattr(self, field, value)

    def section(self, title, *children, spacing=10):
        return ui.card(pz.column(list(children), spacing=spacing), title=title, width="fill")

    def view(self):
        t = self.theme
        buttons = self.section(
            "Buttons",
            pz.row([
                ui.button("Filled", lambda: None),
                ui.button("Ghost", lambda: None, variant="ghost"),
                ui.button("Outline", lambda: None, variant="outline"),
                ui.button("Solid", lambda: None, variant="solid"),
                ui.button("Disabled", disabled=True),
            ], spacing=8),
            pz.row([
                ui.button("Error", lambda: None, variant="error", icon="circle-x"),
                ui.button("Warning", lambda: None, variant="warning", icon="triangle-alert"),
                ui.button("Success", lambda: None, variant="success", icon="circle-check"),
                ui.button("Save", lambda: None, icon="save", keys="ctrl-s"),
            ], spacing=8),
            pz.row([
                ui.button("XS", lambda: None, size="xs"),
                ui.button("Small", lambda: None, size="sm"),
                ui.button("Medium", lambda: None, size="md"),
                ui.button("Large", lambda: None, size="lg", icon="rocket", variant="solid"),
            ], spacing=8, align="center"),
            pz.row([
                ui.icon_button("files", lambda: None, tooltip="Project"),
                ui.icon_button("search", lambda: None, selected=True),
                ui.icon_button("git-branch", lambda: None),
                ui.icon_button("bug", lambda: None),
                ui.icon_button("settings", lambda: None, variant="filled"),
                pz.space(width=12),
                ui.avatar("KV", index=0), ui.avatar("AL", index=3), ui.avatar("MZ", index=7),
                pz.space(width=12),
                ui.indicator("success"), ui.indicator("warning"), ui.indicator("error"),
            ], spacing=6, align="center"),
        )

        badges = self.section(
            "Badges & key bindings",
            pz.row([
                ui.badge("New"),
                ui.badge("Beta", tint="warning"),
                ui.badge("Stable", tint="success", icon="check"),
                ui.badge("Deprecated", tint="error", variant="outline"),
                ui.badge("v0.1.0", tint="accent", variant="solid"),
            ], spacing=6),
            pz.row([ui.kbd("ctrl-shift-p"), ui.kbd("ctrl-k ctrl-s"), ui.kbd("alt-enter"), ui.kbd("escape")],
                   spacing=12, align="center"),
        )

        inputs = self.section(
            "Inputs",
            ui.input(self.name, self.set("name"), placeholder="Project name"),
            ui.search_input(self.query, self.set("query"), placeholder="Search symbols…"),
            ui.field("Format on save", ui.switch(self.format_on_save, self.set("format_on_save")),
                     description="Run the formatter whenever a buffer is saved"),
            ui.field("Vim mode", ui.switch(self.vim_mode, self.set("vim_mode"))),
            ui.checkbox(self.telemetry, self.set("telemetry"), label="Send anonymous usage data"),
            pz.row([ui.label("Font size", size="sm", color="text_muted"),
                    ui.slider(self.font_size, self.set("font_size"))], spacing=12, align="center"),
            pz.row([ui.label("Indexing", size="sm", color="text_muted"), ui.progress(0.68)],
                   spacing=12, align="center"),
            ui.segmented(["Unified", "Split", "Inline"], self.view_mode, self.set("view_mode")),
            spacing=12,
        )

        menu = self.section(
            "Context menu",
            ui.menu([
                ui.MenuItem("Cut", lambda: None, icon="scissors", keys="ctrl-x"),
                ui.MenuItem("Copy", lambda: None, icon="copy", keys="ctrl-c"),
                ui.MenuItem("Paste", lambda: None, icon="clipboard", keys="ctrl-v"),
                ui.SEPARATOR,
                ui.MenuItem("Soft wrap", lambda: None, checked=True),
                ui.MenuItem("Show minimap", lambda: None, checked=False),
                ui.SEPARATOR,
                ui.MenuItem("Delete file", lambda: None, icon="trash-2", danger=True),
            ], width=280),
        )

        files = ui.TreeNode.from_paths(
            ["src/app.py", "src/views/editor.py", "src/views/panel.py", "tests/test_app.py", "pyproject.toml", "README.md"],
            {"src/app.py": "modified", "tests/test_app.py": "created"},
        )
        tree = self.section(
            "Tree & list",
            ui.tree(files, expanded=self.expanded, selected=self.selected_file,
                    on_toggle=lambda f: self.expanded.symmetric_difference_update({f}),
                    on_select=self.set("selected_file")),
            ui.divider(),
            ui.list_item("Recent projects", icon="history", end=ui.badge("3"), on_press=lambda: None),
            ui.list_item("Extensions", icon="blocks", end=ui.kbd("ctrl-shift-x"), on_press=lambda: None),
            spacing=6,
        )

        tabs = self.section(
            "Tabs, breadcrumbs & editor",
            pz.container(
                pz.column([
                    ui.tab_bar(
                        [ui.Tab.for_file("main.py"), ui.Tab.for_file("theme.rs", dirty=True),
                         ui.Tab.for_file("README.md", preview=True)],
                        self.active_tab,
                        on_select=self.set("active_tab"),
                        on_close=lambda _: None,
                        end=[ui.icon_button("plus", lambda: None, size="sm")],
                    ),
                    ui.toolbar([ui.breadcrumbs(["examples", self.active_tab, "def view"])]),
                    pz.container(ui.editor("gallery-snippet", SNIPPET, language="py"), height=150),
                ]),
                border_color=t.border, border_width=1, radius=t.radius, clip=True,
            ),
        )

        toasts = self.section(
            "Notifications",
            ui.toast("Update available", "Pyzed 0.2 is ready to install.", kind="info",
                     actions=[ui.button("Restart", lambda: None, size="sm", variant="solid"),
                              ui.button("Later", lambda: None, size="sm", variant="ghost")], width=None),
            ui.toast("Saved 3 files", kind="success", on_dismiss=lambda: None, width=None),
            ui.toast("Language server crashed", "rust-analyzer exited with code 101.", kind="error",
                     on_dismiss=lambda: None, width=None),
        )

        forms = self.section(
            "Forms",
            ui.field("Language", ui.select(["Python", "Rust", "TypeScript", "Go"], self.language,
                                           self.set("language"), width=160)),
            ui.field("Tab size", ui.number_field(self.tab_size, self.set("tab_size"), min=1, max=8)),
            ui.radio_group(["Small", "Medium", "Large"], self.size_choice, self.set("size_choice"), horizontal=True),
            pz.row([ui.chip(tag, on_remove=lambda tag=tag: self.tags.discard(tag)) for tag in sorted(self.tags)],
                   spacing=6),
            ui.disclosure("Advanced", ui.muted("Settings that most people never need."), open=self.advanced_open,
                          on_toggle=lambda: setattr(self, "advanced_open", not self.advanced_open)),
            pz.row([ui.split_button("Run", lambda: None, lambda: None, icon="play"),
                    ui.toggle_group([("list", "list"), ("grid", "layout-grid")], "list", lambda v: None)],
                   spacing=8),
        )
        feedback = self.section(
            "Feedback",
            ui.callout("Update available", "Pyzed 0.2 is ready.", tint="info",
                       actions=[ui.button("Update", lambda: None, size="sm", variant="solid")]),
            ui.callout("Build failed", "3 errors in src/main.rs", tint="error"),
            pz.row([ui.count_badge(3), ui.count_badge(128, tint="error"), ui.facepile(["KV", "AL", "MZ", "JD", "RS", "TT"]),
                    ui.diff_stat(42, 7)], spacing=12, align="center"),
            ui.keybinding_hint("Open file", "ctrl-p"),
        )
        files = [dict(name="main.rs", size=1200, lang="Rust"), dict(name="app.py", size=840, lang="Python"),
                 dict(name="README.md", size=310, lang="Markdown"), dict(name="theme.json", size=96, lang="JSON")]

        def sort(column):
            self.descending = not self.descending if self.sort_by == column else False
            self.sort_by = column

        data = self.section(
            "Data",
            ui.table([ui.Column("Name", "name"), ui.Column("Size", "size", width=70, align="end"),
                      ui.Column("Language", "lang")], files, sort_by=self.sort_by, descending=self.descending,
                     on_sort=sort, on_row_press=self.set("selected_row"), selected=self.selected_row, height=156),
            pz.row([ui.stat("Requests", "12.4k", delta=4.2, trend=[3, 5, 4, 6, 8, 7, 9]),
                    ui.donut([5, 3, 2], label="62%", size=88)], spacing=12, align="center"),
            ui.bar_chart([4, 8, 6, 9, 3], labels=["Mon", "Tue", "Wed", "Thu", "Fri"], width=300, height=120),
        )

        theme_picker = ui.select(pz.themes.names(), t.name, lambda name: setattr(self, "theme", pz.themes.get(name)),
                                 width=200)
        header = pz.row([
            pz.icon("layers", size=22, color=t.accent),
            ui.heading("Pyzed components", level=2),
            pz.hspace(),
            theme_picker,
        ], spacing=10, align="center")

        grid = pz.row([
            pz.column([buttons, badges, menu], spacing=16, width="fill"),
            pz.column([inputs, tree], spacing=16, width="fill"),
            pz.column([tabs, toasts], spacing=16, width="fill"),
        ], spacing=16)
        more = pz.row([
            pz.column([forms], width="fill"),
            pz.column([feedback], width="fill"),
            pz.column([data], width="fill"),
        ], spacing=16)

        return pz.container(
            pz.scrollable(pz.column([header, grid, more], spacing=20, padding=24), height="fill"),
            width="fill",
            height="fill",
            bg=t.background if not t.is_dark else t.editor,
            color=t.text,
        )

if __name__ == "__main__":
    pz.run(Gallery)
```
</details>

## Contador

La app de Pyzed más pequeña: un número y dos botones. Es el mejor archivo para empezar cuando haces algo nuevo.

<details>
<summary>Código completo: <code>examples/counter.py</code></summary>
`examples/counter.py`:

```py
"""The smallest Pyzed app: state + update + view."""

import pyzed as pz
from pyzed import ui

class Counter(pz.App):
    title = "Counter"
    size = (360, 220)

    def __init__(self):
        self.count = 0

    def update(self, message):
        if message == "increment":
            self.count += 1
        elif message == "decrement":
            self.count -= 1

    def view(self):
        return pz.center(
            pz.column(
                [
                    ui.heading(str(self.count)),
                    pz.row(
                        [
                            ui.button("1", "decrement", icon="minus"),
                            ui.button("1", "increment", icon="plus", variant="solid"),
                            ui.icon_button("rotate-ccw", lambda: setattr(self, "count", 0), tooltip="Reset"),
                        ],
                        spacing=8,
                    ),
                ],
                spacing=16,
                align="center",
            ),
            bg=self.theme.editor,
        )

if __name__ == "__main__":
    pz.run(Counter)
```
</details>

## Editor de proyectos

Un workspace de editor de código completo en unas 450 líneas: un panel de proyecto con los archivos de una
carpeta, pestañas, un editor con resaltado de sintaxis (editar, deshacer, guardar con <kbd>Ctrl</kbd>+<kbd>S</kbd>),
colores de estado de git, un panel de estructura, cambio de tema, una paleta de comandos
(<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd>) y un buscador de archivos (<kbd>Ctrl</kbd>+<kbd>P</kbd>). Su
aspecto recrea el del editor [Zed](https://zed.dev), cuya interfaz inspiró el tema por defecto de Pyzed. El panel de
terminal muestra una salida de ejemplo; no ejecuta una shell.

Pasa una carpeta para abrirla: `python examples/zed_editor.py ruta/al/proyecto`. Una versión más pequeña se
construye paso a paso en el tutorial del [mini editor de código](https://albakore.github.io/pyzed/es/tutorials/advanced/code-editor/).

<details>
<summary>Código completo: <code>examples/zed_editor.py</code></summary>
`examples/zed_editor.py`:

```py
"""A recreation of the Zed editor written with Pyzed.

    python examples/zed_editor.py [folder]

Real features: project panel over the folder's files, tabs, a syntax-highlighted
editor (edit, undo, save with ctrl-s), git status colors, an outline panel, theme
switching, the command palette (ctrl-shift-p) and the file finder (ctrl-p).
The terminal panel shows static sample output; it does not run a shell.
"""

from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import pyzed as pz
from pyzed import Command, ui

IGNORED_SUFFIXES = (".so", ".pyd", ".dll", ".dylib", ".pyc")
IGNORED = {".git", "target", ".venv", "venv", "__pycache__", "node_modules", ".pytest_cache", "dist", ".mypy_cache"}
MAX_FILES = 3000

TERMINAL_SAMPLE = (
    "\x1b[32m~/pyzed\x1b[0m \x1b[34mmain\x1b[0m $ maturin develop --release\n"
    "🔗 Found pyo3 bindings with abi3 support\n"
    "\x1b[32m   Compiling\x1b[0m pyzed v0.1.0 (~/pyzed)\n"
    "\x1b[32m    Finished\x1b[0m `release` profile [optimized] target(s) in 41.20s\n"
    "📦 Built wheel for abi3 Python ≥ 3.9\n"
    "🛠 Installed pyzed-0.1.0\n"
    "\x1b[32m~/pyzed\x1b[0m \x1b[34mmain\x1b[0m $ python examples/zed_editor.py\n"
)

# ---------------------------------------------------------------------------
# Project helpers
# ---------------------------------------------------------------------------

def scan(root: Path) -> List[str]:
    files: List[str] = []
    for path in sorted(root.rglob("*")):
        rel = path.relative_to(root)
        if any(part in IGNORED for part in rel.parts) or path.suffix in IGNORED_SUFFIXES or not path.is_file():
            continue
        files.append(rel.as_posix())
        if len(files) >= MAX_FILES:
            break
    return files

def git(root: Path, *args: str) -> str:
    try:
        return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, timeout=5).stdout
    except (OSError, subprocess.SubprocessError):
        return ""

def git_status(root: Path) -> Dict[str, str]:
    status = {}
    for line in git(root, "status", "--porcelain").splitlines():
        code, path = line[:2], line[3:].strip()
        if "D" in code:
            status[path] = "deleted"
        elif "?" in code or "A" in code:
            status[path] = "created"
        elif code.strip():
            status[path] = "modified"
    return status

def git_markers(root: Path, path: str) -> Dict[int, str]:
    markers: Dict[int, str] = {}
    for hunk in re.finditer(r"^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", git(root, "diff", "-U0", "--", path), re.M):
        removed, start, added = int(hunk.group(1) or 1), int(hunk.group(2)), int(hunk.group(3) or 1)
        if added == 0:
            markers[max(start, 1)] = "deleted"
        for line in range(start, start + added):
            markers[line] = "created" if removed == 0 else "modified"
    return markers

SYMBOLS = re.compile(
    r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?P<kind>fn|struct|enum|trait|impl|mod|def|class)\s+(?P<name>[A-Za-z_]\w*)",
)

def outline(text: str) -> List[Tuple[int, str, str]]:
    out = []
    for number, line in enumerate(text.splitlines(), start=1):
        match = SYMBOLS.match(line)
        if match:
            out.append((number, match["kind"], match["name"].strip()))
    return out

# ---------------------------------------------------------------------------
# The app
# ---------------------------------------------------------------------------

class Zed(pz.App):
    title = "Pyzed — a Zed-like editor in Python"
    size = (1440, 900)
    min_size = (800, 500)

    def __init__(self, root: str = ".", *, status: Optional[Dict[str, str]] = None):
        self.root = Path(root).resolve()
        self.files = scan(self.root)
        self.status = git_status(self.root) if status is None else status
        self.nodes = [ui.TreeNode(self.root.name, children=ui.TreeNode.from_paths(self.files, self.status), id="")]
        self.expanded = {""}
        self.selected: Optional[str] = None
        self.tabs: List[str] = []
        self.active: Optional[str] = None
        self.texts: Dict[str, str] = {}
        self.markers: Dict[str, Dict[int, str]] = {}
        self.dirty: set = set()
        self.cursor = (1, 1)
        self.show_left, self.show_bottom, self.show_right = True, False, False
        self.palette: Optional[str] = None  # "commands" | "files"
        self.query, self.choice = "", 0
        self.notices: List[Tuple[str, str]] = []
        self.theme = pz.themes.ONE_DARK

    def on_start(self):
        commands = []
        if self.active and self.cursor != (1, 1):
            commands.append(Command.set_editor_cursor(self.active, *self.cursor))
        if self.palette:
            commands.append(Command.focus("palette"))
        return commands

    # -- files -------------------------------------------------------------
    def open_file(self, path: str):
        if path not in self.texts:
            try:
                self.texts[path] = (self.root / path).read_text(encoding="utf-8")
            except (UnicodeDecodeError, OSError):
                self.notify("Can't open binary file", "warning")
                return
            self.markers.setdefault(path, git_markers(self.root, path))
        if path not in self.tabs:
            self.tabs.append(path)
        self.active = self.selected = path
        parts = path.split("/")
        self.expanded.update("/".join(parts[:i]) for i in range(1, len(parts)))
        self.cursor = (1, 1)

    def close_tab(self, path: str):
        if path in self.tabs:
            index = self.tabs.index(path)
            self.tabs.remove(path)
            if self.active == path:
                self.active = self.tabs[min(index, len(self.tabs) - 1)] if self.tabs else None

    def edited(self, path: str, text: str):
        self.texts[path] = text
        self.dirty.add(path)

    def save(self):
        if self.active:
            (self.root / self.active).write_text(self.texts[self.active], encoding="utf-8")
            self.dirty.discard(self.active)
            self.notify(f"Saved {self.active}", "success")

    def toggle_folder(self, folder: str):
        self.expanded.symmetric_difference_update({folder})
        self.selected = folder

    def notify(self, text: str, kind: str = "info"):
        self.notices = (self.notices + [(text, kind)])[-3:]

    # -- palette -----------------------------------------------------------
    def commands(self) -> List[ui.PaletteItem]:
        def run(action):
            def handler():
                self.palette = None
                return action()
            return handler

        def set_theme(theme):
            return run(lambda: setattr(self, "theme", theme))

        return [
            ui.PaletteItem("file finder: toggle", run(self.open_finder), keys="ctrl-p"),
            ui.PaletteItem("workspace: toggle left dock", run(self.toggle_left), keys="ctrl-b"),
            ui.PaletteItem("workspace: toggle bottom dock", run(self.toggle_bottom), keys="ctrl-j"),
            ui.PaletteItem("workspace: toggle right dock", run(self.toggle_right), keys="ctrl-alt-b"),
            ui.PaletteItem("workspace: save", run(self.save), keys="ctrl-s"),
            ui.PaletteItem("pane: close active item", run(lambda: self.active and self.close_tab(self.active)), keys="ctrl-w"),
            ui.PaletteItem("theme selector: one dark", set_theme(pz.themes.ONE_DARK)),
            ui.PaletteItem("theme selector: one light", set_theme(pz.themes.ONE_LIGHT)),
            ui.PaletteItem("theme selector: gruvbox dark", set_theme(pz.themes.GRUVBOX_DARK)),
            ui.PaletteItem("terminal panel: toggle focus", run(self.toggle_bottom), keys="ctrl-`"),
            ui.PaletteItem("outline panel: toggle", run(self.toggle_right)),
            ui.PaletteItem("zed: about", run(lambda: self.notify("Pyzed 0.1 · Iced 0.14 · Python " + sys.version.split()[0]))),
            ui.PaletteItem("zed: quit", run(Command.exit), keys="ctrl-q"),
        ]

    def palette_matches(self):
        if self.palette == "files":
            items = [
                ui.PaletteItem(path, (lambda p=path: self.pick_file(p)), icon=pz.icons.for_file(path)[0])
                for path in self.files
            ]
            return ui.fuzzy_filter(self.query, items, key=lambda i: i.label)[:200]
        return ui.fuzzy_filter(self.query, self.commands(), key=lambda i: i.label)

    def pick_file(self, path: str):
        self.palette = None
        self.open_file(path)

    def open_palette(self, kind: str = "commands"):
        self.palette, self.query, self.choice = kind, "", 0
        return Command.focus("palette")

    def open_finder(self):
        return self.open_palette("files")

    def set_query(self, query: str):
        self.query, self.choice = query, 0

    def submit(self):
        matches = self.palette_matches()
        if matches:
            return matches[min(self.choice, len(matches) - 1)][0].on_press()

    def toggle_left(self):
        self.show_left = not self.show_left

    def toggle_bottom(self):
        self.show_bottom = not self.show_bottom

    def toggle_right(self):
        self.show_right = not self.show_right

    def on_key(self, combo: str):
        combo = combo.replace("cmd-", "ctrl-")
        if combo == "ctrl-shift-p":
            if self.palette == "commands":
                self.palette = None
                return None
            return self.open_palette()
        if combo == "ctrl-p":
            return self.open_finder()
        if self.palette:
            if combo == "escape":
                self.palette = None
            elif combo in ("down", "ctrl-n"):
                self.choice += 1
            elif combo in ("up", "ctrl-p"):
                self.choice = max(0, self.choice - 1)
            return None
        actions = {
            "ctrl-b": self.toggle_left,
            "ctrl-j": self.toggle_bottom,
            "ctrl-`": self.toggle_bottom,
            "ctrl-alt-b": self.toggle_right,
            "ctrl-s": self.save,
            "ctrl-w": lambda: self.active and self.close_tab(self.active),
            "ctrl-q": Command.exit,
        }
        if combo in actions:
            return actions[combo]()

    # -- view --------------------------------------------------------------
    def view(self):
        t = self.theme
        layout = ui.workspace(
            self.center(),
            title_bar=ui.title_bar(
                [
                    ui.title_button(self.root.name, chevron=True),
                    ui.title_button("main", icon="git-branch", muted=True),
                ],
                [
                    ui.icon_button("bell", tooltip="Notifications"),
                    ui.icon_button("sparkles", tooltip="Agent Panel"),
                    pz.space(width=4),
                    ui.avatar("KV", index=0),
                    pz.space(width=4),
                ],
            ),
            left=self.project_panel() if self.show_left else None,
            right=self.outline_panel() if self.show_right else None,
            bottom=self.terminal_panel() if self.show_bottom else None,
            status_bar=self.status_bar(),
            left_width=250,
            right_width=260,
        )
        if self.notices:
            layout = ui.toasts(layout, [
                ui.toast(text, kind=kind, on_dismiss=(lambda i=i: self.notices.pop(i)))
                for i, (text, kind) in enumerate(self.notices)
            ])
        if self.palette:
            matches = self.palette_matches()
            self.choice = min(self.choice, max(len(matches) - 1, 0))
            overlay = ui.picker(
                self.query,
                self.set_query,
                matches,
                selected=self.choice,
                input_id="palette",
                on_submit=self.submit,
                placeholder="Execute a command…" if self.palette == "commands" else "Search project files…",
            )
            layout = ui.layer(layout, overlay, on_dismiss=lambda: setattr(self, "palette", None),
                              backdrop=pz.theme.alpha("#000000", 0.12 if t.is_dark else 0.04))
        return layout

    def project_panel(self):
        return pz.scrollable(
            pz.container(
                ui.tree(self.nodes, expanded=self.expanded, selected=self.selected,
                        on_toggle=self.toggle_folder, on_select=self.open_file),
                padding=(4, 0),
            ),
            height="fill",
            scroller=self.theme.scrollbar,
            scrollbar_width=6,
        )

    def outline_panel(self):
        symbols = outline(self.texts.get(self.active or "", ""))
        rows = [
            ui.list_item(
                pz.row([ui.label(kind, color=self.theme.syntax["keyword"], size="sm", mono=True),
                        ui.label(name, mono=True, size="sm")], spacing=6),
                lambda: None,
                height=24,
                indent=8,
            )
            for _, kind, name in symbols
        ]
        return pz.column([
            ui.panel_header("Outline", [ui.icon_button("x", self.toggle_right, size="sm")], icon="list-tree"),
            pz.scrollable(pz.column(rows or [ui.muted("No symbols", size="sm")], padding=(0, 0, 8, 0)), height="fill"),
        ])

    def terminal_panel(self):
        tabs = pz.row([
            pz.container(
                pz.row([pz.icon("terminal", size=14), ui.label("zsh", size="sm")], spacing=6, align="center"),
                padding=(3, 10), bg=self.theme.editor, radius=self.theme.radius_sm,
            ),
            pz.hspace(),
            ui.icon_button("plus", size="sm"),
            ui.icon_button("maximize-2", size="sm"),
            ui.icon_button("x", self.toggle_bottom, size="sm"),
        ], align="center", padding=(4, 6))
        return pz.column([tabs, ui.terminal(TERMINAL_SAMPLE)])

    def center(self):
        if not self.active:
            return self.welcome()
        symbols = outline(self.texts.get(self.active, ""))
        scope = [f"{kind} {name}" for line, kind, name in symbols if line <= self.cursor[0]][-1:]
        tabs = ui.tab_bar(
            [ui.Tab.for_file(p, dirty=p in self.dirty, status=self.status.get(p)) for p in self.tabs],
            self.active,
            on_select=self.select_tab,
            on_close=self.close_tab,
            start=[ui.icon_button("arrow-left", size="sm"), ui.icon_button("arrow-right", size="sm")],
            end=[ui.icon_button("plus", size="sm"), ui.icon_button("columns-2", size="sm")],
        )
        toolbar = ui.toolbar(
            [ui.breadcrumbs([self.active, *scope])],
            [
                ui.icon_button("sparkles", size="sm", tooltip="Inline Assist"),
                ui.icon_button("text-search", size="sm", tooltip="Buffer Search"),
                ui.icon_button("ellipsis", size="sm"),
            ],
        )
        editor = ui.editor(
            self.active,
            self.texts[self.active],
            path=self.active,
            markers=self.markers.get(self.active),
            on_change=lambda text, p=self.active: self.edited(p, text),
            on_cursor=self.move_cursor,
        )
        return pz.column([tabs, toolbar, editor])

    def select_tab(self, path: str):
        self.active = self.selected = path

    def move_cursor(self, line: int, column: int):
        self.cursor = (line, column)

    def welcome(self):
        rows = [
            ("Open a File", "file", "ctrl-p", self.open_finder),
            ("Command Palette", "command", "ctrl-shift-p", self.open_palette),
            ("Toggle Project Panel", "panel-left", "ctrl-b", self.toggle_left),
            ("Toggle Terminal", "terminal", "ctrl-j", self.toggle_bottom),
        ]
        return pz.center(
            pz.column(
                [
                    pz.row([pz.icon("zap", size=30, color=self.theme.accent), ui.heading("Pyzed")], spacing=10, align="center"),
                    ui.muted("Code at the speed of thought — now in Python"),
                    pz.space(height=18),
                    *[
                        ui.button(label, action, icon=icon, variant="ghost", full_width=True, keys=keys)
                        for label, icon, keys, action in rows
                    ],
                ],
                spacing=4,
                width=340,
                align="center",
            )
        )

    def status_bar(self):
        language = pz.icons.language_for(self.active) if self.active else None
        return ui.status_bar(
            [
                ui.status_item(icon="panel-left", on_press=self.toggle_left, selected=self.show_left,
                               tooltip="Project Panel", keys="ctrl-b"),
                ui.status_item(icon="git-branch", on_press=lambda: None),
                ui.status_item(icon="search", on_press=self.open_finder, tooltip="File Finder", keys="ctrl-p"),
                pz.space(width=6),
                ui.status_item("0", icon="circle-x", on_press=lambda: None),
                ui.status_item("0", icon="triangle-alert", on_press=lambda: None),
            ],
            [
                ui.status_item(f"{self.cursor[0]}:{self.cursor[1]}", on_press=lambda: None) if self.active else None,
                ui.status_item(language, on_press=lambda: None) if language else None,
                ui.status_item(icon="sparkles", on_press=lambda: None),
                ui.status_item(icon="bell", on_press=lambda: None),
                ui.status_item(icon="panel-bottom", on_press=self.toggle_bottom, selected=self.show_bottom,
                               tooltip="Terminal", keys="ctrl-j"),
                ui.status_item(icon="panel-right", on_press=self.toggle_right, selected=self.show_right,
                               tooltip="Outline", keys="ctrl-alt-b"),
            ],
        )

if __name__ == "__main__":
    Zed(sys.argv[1] if len(sys.argv) > 1 else ".").run()
```
</details>

**Sobre esta página:**

Cada app de aquí se dibuja fuera de pantalla en la suite de tests, así que funcionan con la versión actual de
Pyzed. Se están añadiendo grabaciones cortas de las apps que se mueven (Juego de la vida, descargas, varias
ventanas).
