Ir al contenido

Un mini editor de código

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 Ctrl+S 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, Navegación
  • 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.

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:

    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:

    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:

    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:

    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 Cmd a Ctrl para que funcione igual en macOS:

    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 Ctrl+Shift+P, enfoca su campo y flota sobre el workspace con ui.layer:

    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))

    La paleta de comandos

examples/mini_editor.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 "."))
Windows, macOS y Linux
uv run --with pyzed-ui python examples/mini_editor.py

Para una versión más completa (buscador de archivos, panel de terminal, outline, temas, marcadores de git) mira examples/zed_editor.py y su página en el Showcase.

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…).
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