# Apps and utilities

> Small, complete Python desktop apps made with Pyzed: a to-do list, Markdown and image viewers, a clock, a stopwatch, a QR generator, live data, an AI chat, panes, several windows and a code editor workspace.

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

Everyday programs, each a single Python file in
[`examples/`](https://github.com/albakore/pyzed/tree/main/examples) that runs as it is. They are short on purpose:
read one from top to bottom and you will have seen state, a view, events and a subscription working together.
Several are Pyzed versions of the classic [Iced](https://iced.rs) demos, the Rust GUI library Pyzed draws with.

**How to read these apps:**

Every app is a class with **state** (attributes set in `__init__`), a **`view()`** that builds the screen from the
state, and **methods** that change the state when something happens. Pyzed redraws after each change. New to that
idea? Start with the [To-do list tutorial](https://albakore.github.io/pyzed/tutorials/basic/todo-app/).

## Todos

Add, edit, complete, filter and delete tasks; they are saved as JSON in your user folder, so they are still there
the next time you open the app.

- A dataclass per task and a list in the state; the filter is a `ui.segmented` control.
- Double-click a task to edit it in place; tasks are written to disk on every change.
- A simpler version is built step by step in the [To-do list tutorial](https://albakore.github.io/pyzed/tutorials/basic/todo-app/).

<details>
<summary>Full code: <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>

## Markdown viewer

A live Markdown editor: write on the left, see the formatted result on the right, and drag the divider between
them.

- `ui.editor` with `on_change` keeps the text in the state; `ui.markdown` renders it.
- `ui.split` makes the two panes resizable; links in the preview open in your browser.

<details>
<summary>Full code: <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>

## Image viewer

Shows a picture with `pz.image` and in a zoomable `pz.image_viewer`: scroll to zoom, drag to pan. Pass a path to
open your own image; without one it draws a picture from raw pixels made in pure Python.

<details>
<summary>Full code: <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>

## Clock

An analog clock drawn on a `pz.canvas`. A subscription, `pz.every(1, ...)`, redraws it every second, and the hands
are paths rotated around the center.

<details>
<summary>Full code: <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>

## Stopwatch

A stopwatch with laps. <kbd>Space</kbd> starts and stops it, <kbd>L</kbd> records a lap and <kbd>R</kbd> resets it.

- The timer subscription only exists **while the watch runs**: `subscriptions()` returns an empty list otherwise,
  so an idle app uses no CPU.

<details>
<summary>Full code: <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>

## QR code

Type a text or a link and get its QR code, in the theme's colors or in black and white. The whole app is about 30
lines: `pz.qr_code(data, cell_size=7)` does the work.

<details>
<summary>Full code: <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

A focus timer with work and break periods: the app that the [Prompting](https://albakore.github.io/pyzed/ai/prompting/) guide builds with an
AI assistant, one request at a time.

<details>
<summary>Full code: <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>

## Color picker

`ui.ColorPicker` in its three modes: a color with transparency, a linear gradient, and a fill with an opacity mask.
It reports a `"#rrggbbaa"` string, a `pz.Gradient` or a `pz.Paint`, and all three work as a `background`.

<details>
<summary>Full code: <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>

## Game of Life

Conway's Game of Life: click or drag to paint cells, <kbd>Space</kbd> to play or pause, <kbd>N</kbd> to advance one
generation. Thousands of cells are drawn as a single shape on a `pz.canvas`, and `pz.every` drives the simulation.

<details>
<summary>Full code: <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>

## Downloads with progress

Several downloads at once, each with its own progress bar, and a window that never freezes. Each download is an
`async def` generator: every value it `yield`s goes to `update` while it keeps running on Pyzed's event loop. Swap
the simulated transfer for streaming with `httpx` to download real files.

<details>
<summary>Full code: <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>

The same idea with threads (for blocking code) is explained in the
[Background tasks](https://albakore.github.io/pyzed/tutorials/intermediate/background-tasks/) tutorial.

## Live data

A price ticker fed by `pz.listen`: a subscription backed by an async generator. It runs while `subscriptions()`
returns it and sends every value it yields to `update`.

```python
async def feed():                       # a real app would read a websocket here
    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]    # each value goes to update()

class Ticker(pz.App):
    def subscriptions(self):
        return [pz.listen(feed)] if self.connected else []   # the switch pauses the feed

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

<details>
<summary>Full code: <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>

## AI chat

A chat whose answers appear word by word. It talks to any OpenAI-compatible server (Ollama, LM Studio, OpenAI...)
through `httpx`, and has a built-in demo mode with canned answers, so it runs offline.

To use a real model, set three environment variables before running it, for example with
[Ollama](https://ollama.com) on your machine: `CHAT_BASE_URL=http://localhost:11434/v1` and `CHAT_MODEL=llama3.2`
(plus `CHAT_API_KEY` for a hosted service). The [AI section](https://albakore.github.io/pyzed/ai/) explains the rest.

<details>
<summary>Full code: <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>

## Several windows

Open as many windows as you like: each `pz.Window` has its own view, title, size and theme, but all of them share
the app's state, so the click counter is the same in every window. Closing the main window quits the app.

<details>
<summary>Full code: <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>

## Panes

Resizable panes like an editor's: split any pane to the right or down, drag the dividers, close panes. The layout
is a small tree of lists kept in the state and drawn with nested `ui.split`.

<details>
<summary>Full code: <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>

## Context menu

A right-click menu built from `ui.popover` and `ui.menu`. `mouse_area(on_move=...)` remembers where the pointer
is, so the menu opens right there; clicking elsewhere or pressing <kbd>Esc</kbd> closes it.

<details>
<summary>Full code: <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>

## Custom components

Three reusable pieces written in code: a star rating and a tag editor (`pz.Component` classes with properties,
their own state and their own events), and a note you can drag, double-click and type on, which logs the common
events every widget has.

<details>
<summary>Full code: <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>

To draw components with the mouse instead, follow [A custom component](https://albakore.github.io/pyzed/tutorials/intermediate/custom-component/).

## Component gallery

A tour of the ready-made components in `pyzed.ui`: buttons, inputs, tabs, a code editor, menus, trees, notifications,
forms, feedback and data, with a theme picker to see them all in any theme.

<details>
<summary>Full code: <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>

## Counter

The smallest Pyzed app: one number, two buttons. It is the best file to start from when you make something new.

<details>
<summary>Full code: <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>

## Workspace editor

A full code editor workspace in about 450 lines: a project panel with the files of a folder, tabs, a
syntax-highlighted editor (edit, undo, save with <kbd>Ctrl</kbd>+<kbd>S</kbd>), git status colors, an outline
panel, theme switching, a command palette (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd>) and a file finder
(<kbd>Ctrl</kbd>+<kbd>P</kbd>). Its look recreates the [Zed](https://zed.dev) editor, whose interface inspired
Pyzed's default theme. The terminal panel shows sample output; it does not run a shell.

Pass a folder to open it: `python examples/zed_editor.py path/to/project`. A smaller version is built step by step
in the [mini code editor](https://albakore.github.io/pyzed/tutorials/advanced/code-editor/) tutorial.

<details>
<summary>Full code: <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>

**About this page:**

Every app here is drawn off-screen by the test suite, so they run with the current version of Pyzed. Short
recordings are being added for the apps that move (Game of Life, downloads, several windows).
