# Un mini editor de código

> Tutorial: crea un editor de código en Python con Pyzed: workspace, árbol de archivos, pestañas, resaltado, guardado y paleta de comandos.

Source: https://albakore.github.io/pyzed/es/tutorials/advanced/code-editor/

Vamos a construir un editor de código en unas 150 líneas: abre una carpeta real, muestra el árbol de archivos,
abre pestañas, edita con resaltado de sintaxis, guarda con <kbd>Ctrl</kbd>+<kbd>S</kbd> y tiene paleta de comandos.
Las piezas del workspace (docks, pestañas, paleta) son las mismas con las que está hecho Pyzed Design; su aspecto
está inspirado en el editor Zed.

| Objetivo | Tiempo | Nivel | Necesitas |
| --- | --- | --- | --- |
| Un editor de código que funciona sobre una carpeta | 45 minutos | Avanzado · Python | [Listas y tablas](https://albakore.github.io/pyzed/es/tutorials/intermediate/lists-tables/), [Navegación](https://albakore.github.io/pyzed/es/tutorials/intermediate/navigation/) |

## Qué vas a aprender

- Convertir una carpeta en un árbol de archivos (`ui.TreeNode.from_paths`) y abrir archivos en pestañas.
- Editar con resaltado de sintaxis (`ui.editor`) y llevar la cuenta de los cambios sin guardar.
- Montar un workspace completo: barra de título, docks y barra de estado.
- Añadir una paleta de comandos con búsqueda difusa y atajos de teclado.

(Image: El mini editor)

1. **Leer la carpeta.** `TreeNode.from_paths` convierte una lista de rutas en el árbol del panel
   de proyecto. Saltamos carpetas pesadas como `.git` o `node_modules`:

```python
def scan(self):
    files = []
    for folder, dirs, names in os.walk(self.root):
        dirs[:] = [d for d in dirs if d not in IGNORED]
        rel = Path(folder).relative_to(self.root)
        files += [(rel / n).as_posix() for n in names]
    return files

self.nodes = ui.TreeNode.from_paths(self.scan())
self.expanded = set()          # carpetas abiertas
```

2. **Abrir y cerrar pestañas.** El estado son las rutas abiertas, la activa y el texto de cada
   una. El id de cada pestaña es su ruta:

```python
def open_file(self, path):
    if path not in self.texts:
        self.texts[path] = (self.root / path).read_text(encoding="utf-8")
    if path not in self.open_tabs:
        self.open_tabs.append(path)
    self.active = path
```

3. **El editor.** Usamos la ruta como `id` del editor: así cada archivo conserva su cursor y su
   historial de deshacer al cambiar de pestaña. `on_change` marca el archivo como modificado:

```python
ui.editor(self.active, self.texts[self.active], path=self.active,
          on_change=self.edited,
          on_cursor=lambda line, col: setattr(self, "cursor", (line, col)))

def edited(self, text):
    self.texts[self.active] = text
    self.dirty.add(self.active)       # la pestaña muestra un punto
```

4. **El workspace.** `ui.workspace` coloca la barra de título, el dock izquierdo, el centro y la
   barra de estado. Pasar `None` en `left` oculta el panel:

```python
ui.workspace(
    self.center(),
    title_bar=ui.title_bar([ui.title_button(self.root.name, chevron=True)]),
    left=project if self.show_left else None,
    status_bar=ui.status_bar([...], [ui.status_item(f"Ln {line}, Col {col}")]),
)
```

5. **Atajos.** Normalizamos <kbd>Cmd</kbd> a <kbd>Ctrl</kbd> para que funcione igual en macOS:

```python
def on_key(self, combo):
    combo = combo.replace("cmd-", "ctrl-")
    if combo == "ctrl-s":
        self.save()
    elif combo == "ctrl-b":
        self.toggle_left()
```

6. **Paleta de comandos.** Se abre con <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd>, enfoca su
   campo y flota sobre el workspace con `ui.layer`:

```python
if combo == "ctrl-shift-p":
    self.palette_open, self.query = True, ""
    return Command.focus("command-palette")

# al final de view():
palette = ui.command_palette(self.query, lambda q: setattr(self, "query", q), self.commands(),
                             on_submit=self.run_first_match) if self.palette_open else None
return ui.layer(layout, palette, on_dismiss=lambda: setattr(self, "palette_open", False))
```

   (Image: La paleta de comandos)

## Código completo

`examples/mini_editor.py`:

```py
"""A small Zed-like editor in ~150 lines: project tree, tabs, code editor, save, docks and command palette.

    python examples/mini_editor.py [folder]

Walkthrough: https://albakore.github.io/pyzed/tutorials/advanced/code-editor/
For a fuller version see examples/zed_editor.py.
"""

import os
import sys
from pathlib import Path

import pyzed as pz
from pyzed import Command, icons, ui

IGNORED = {".git", "target", "node_modules", "__pycache__", ".venv", "dist"}

class MiniEditor(pz.App):
    size = (1100, 700)

    def __init__(self, root="."):
        self.root = Path(root).resolve()
        self.nodes = ui.TreeNode.from_paths(self.scan()[:2000])
        self.expanded = set()
        self.open_tabs = []      # paths, in tab order
        self.active = None
        self.texts = {}          # path -> current text
        self.dirty = set()
        self.cursor = (1, 1)
        self.show_left = True
        self.palette_open = False
        self.query = ""

    def scan(self):
        files = []
        for folder, dirs, names in os.walk(self.root):
            dirs[:] = [d for d in dirs if d not in IGNORED]
            rel = Path(folder).relative_to(self.root)
            files += [(rel / n).as_posix() for n in names]
        return files

    def title(self):
        name = self.active or "sin archivos"
        return f"{name} — {self.root.name}"

    # -- files ------------------------------------------------------------------
    def open_file(self, path):
        if path not in self.texts:
            try:
                self.texts[path] = (self.root / path).read_text(encoding="utf-8")
            except (UnicodeDecodeError, OSError):
                return
        if path not in self.open_tabs:
            self.open_tabs.append(path)
        self.active = path

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

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

    def save(self):
        if self.active in self.dirty:
            (self.root / self.active).write_text(self.texts[self.active], encoding="utf-8")
            self.dirty.discard(self.active)

    def toggle_folder(self, folder):
        self.expanded ^= {folder}

    # -- commands & keys ----------------------------------------------------------
    def toggle_left(self):
        self.show_left = not self.show_left

    def toggle_theme(self):
        self.theme = "One Light" if self.resolved_theme().is_dark else "One Dark"

    def commands(self):
        return [
            ui.PaletteItem("workspace: save", self.save, keys="ctrl-s", icon="save"),
            ui.PaletteItem("workspace: toggle left dock", self.toggle_left, keys="ctrl-b", icon="panel-left"),
            ui.PaletteItem("theme: toggle light/dark", self.toggle_theme, icon="sun-moon"),
            ui.PaletteItem("pane: close active item", lambda: self.active and self.close_tab(self.active),
                           keys="ctrl-w", icon="x"),
        ]

    def run_first_match(self):
        matches = ui.fuzzy_filter(self.query, self.commands(), key=lambda c: c.label)
        self.palette_open = False
        if matches:
            return matches[0][0].on_press()

    def on_key(self, combo):
        combo = combo.replace("cmd-", "ctrl-")
        if combo == "ctrl-shift-p":
            self.palette_open, self.query = True, ""
            return Command.focus("command-palette")
        if combo == "escape":
            self.palette_open = False
        elif combo == "ctrl-s":
            self.save()
        elif combo == "ctrl-b":
            self.toggle_left()
        elif combo == "ctrl-w" and self.active:
            self.close_tab(self.active)

    # -- view -----------------------------------------------------------------------
    def center(self):
        if not self.active:
            return pz.center(pz.column([ui.heading("Mini editor", level=2),
                                        ui.muted("Abre un archivo del panel de proyecto."),
                                        ui.kbd("ctrl-shift-p")], spacing=8, align="center"))
        tabs = [ui.Tab.for_file(p, dirty=p in self.dirty) for p in self.open_tabs]
        return pz.column([
            ui.tab_bar(tabs, self.active, on_select=lambda p: setattr(self, "active", p), on_close=self.close_tab),
            ui.toolbar([ui.breadcrumbs(self.active.split("/"))]),
            ui.editor(self.active, self.texts[self.active], path=self.active, on_change=self.edited,
                      on_cursor=lambda line, col: setattr(self, "cursor", (line, col))),
        ])

    def view(self):
        project = pz.column([
            ui.panel_header(self.root.name, [ui.icon_button("chevrons-down-up", lambda: self.expanded.clear(),
                                                            size="xs", tooltip="Plegar todo")]),
            pz.scrollable(ui.tree(self.nodes, expanded=self.expanded, selected=self.active,
                                  on_toggle=self.toggle_folder, on_select=self.open_file), height="fill"),
        ])
        line, col = self.cursor
        layout = ui.workspace(
            self.center(),
            title_bar=ui.title_bar([ui.title_button(self.root.name, chevron=True)],
                                   [ui.icon_button("sun-moon", self.toggle_theme, size="sm", tooltip="Tema")]),
            left=project if self.show_left else None,
            status_bar=ui.status_bar(
                [ui.status_item(icon="panel-left", on_press=self.toggle_left, selected=self.show_left,
                                tooltip="Panel de proyecto", keys="ctrl-b")],
                [ui.status_item(f"Ln {line}, Col {col}") if self.active else None,
                 ui.status_item(icons.language_for(self.active)) if self.active else None],
            ),
        )
        palette = None
        if self.palette_open:
            palette = ui.command_palette(self.query, lambda q: setattr(self, "query", q), self.commands(),
                                         on_submit=self.run_first_match)
        return ui.layer(layout, palette, on_dismiss=lambda: setattr(self, "palette_open", False))

if __name__ == "__main__":
    pz.run(MiniEditor(sys.argv[1] if len(sys.argv) > 1 else "."))
```

**Tip:**

Pasa una carpeta para abrirla: `python examples/mini_editor.py ruta/a/una/carpeta`. Sin ella abre la carpeta actual.

Para una versión más completa (buscador de archivos, panel de terminal, outline, temas, marcadores
de git) mira [`examples/zed_editor.py`](https://github.com/albakore/pyzed/blob/main/examples/zed_editor.py) y su
página en el [Showcase](https://albakore.github.io/pyzed/es/showcase/apps/#editor-de-proyectos).

## Eventos de este tutorial

| Widget | Evento | El handler recibe |
| --- | --- | --- |
| `ui.tab_bar` | `on_select`, `on_close` | El id de la pestaña (su ruta). |
| `ui.editor` | `on_change`, `on_cursor` | El texto; la línea y la columna. |
| `ui.tree` | `on_toggle`, `on_select` | La ruta de la carpeta o del archivo. |
| `ui.status_item` | `on_press` | Nada. |
| `ui.command_palette` | `on_query`, `on_submit` | La búsqueda; nada. |
| `ui.layer` | `on_dismiss` | Nada (un clic fuera de la paleta). |
| La app | `on_key` | La combinación (`ctrl-shift-p`, `ctrl-b`…). |

### Prueba más eventos

```python
ui.editor(self.active, self.texts[self.active], path=self.active, font_size=self.font_size,
          on_change=self.edited,
          on_cursor=lambda line, col: setattr(self, "cursor", (line, col)),
          on_zoom=lambda step: setattr(self, "font_size", 14 if step == 0 else self.font_size + step))

ui.tree(self.nodes, expanded=self.expanded, selected=self.active,
        on_toggle=self.toggle_folder, on_select=self.open_file,
        on_open=self.open_file,                                     # doble clic
        on_context=lambda path: setattr(self, "menu_for", path))    # clic derecho: un menú

ui.tab_bar(tabs, self.active, on_select=lambda p: setattr(self, "active", p), on_close=self.close_tab,
           on_new=self.new_file)                                    # doble clic después de las pestañas
```

## Siguiente

- [Un bucle de juego](https://albakore.github.io/pyzed/es/tutorials/advanced/game-loop/) con `pyzed.game`.
- [Workspace](https://albakore.github.io/pyzed/es/library/workspace/), [Tree](https://albakore.github.io/pyzed/es/library/tree/) y
  [Command palette](https://albakore.github.io/pyzed/es/library/command-palette/) en la biblioteca de componentes.
