Caso real: un panel lateral que se actualiza solo
Integración de todo el nivel en un plugin funcional: estado idempotente con validación, superficie scratch en división lateral fija, render sin robar el foco ni ensuciar el editor, y actualización reactiva con autocomandos agrupados, un temporizador reutilizado como antirrebote y una función de cierre que libera todo lo que se creó.
Ya tienes las cuatro piezas: buffers por código, superficies sin archivo, ventanas configurables y consultas sin cambiar de contexto. Este capítulo las une en algo que se usa de verdad: un panel lateral que muestra información del buffer que estás editando y se actualiza solo mientras trabajas. El código completo cabe en cien líneas, pero cada decisión responde a un fallo concreto que verás en plugins reales: el buffer que se duplica, el panel que roba el foco, el render que dispara su propia actualización, el temporizador que nadie cierra. Escribe el código a mano, no lo pegues: el nivel entero está aquí dentro.
- Estructurar el estado de un plugin de forma idempotente, con validación en cada lectura.
- Construir la superficie combinando buffer scratch reutilizable y división lateral fija que no roba el foco.
- Renderizar con destino explícito, sin saltar de ventana ni mover el cursor del usuario.
- Reaccionar a eventos con un grupo limpio y un antirrebote bien construido, sin fugas de temporizadores.
- Escribir la función de cierre que libera exactamente lo que la de apertura creó.
Estado idempotente
El plugin guarda cuatro cosas y nada más: el buffer, la ventana, el grupo de autocomandos y el temporizador. Cualquier función pública debe poder llamarse dos veces seguidas sin duplicar nada, y toda lectura del estado pasa antes por una validación, porque —lección 10.2— el usuario puede haber cerrado la ventana o aniquilado el buffer a tus espaldas.
local api = vim.apilocal M = { buf = nil, win = nil, grupo = nil, timer = nil }local function buf_vivo() return M.buf and api.nvim_buf_is_valid(M.buf) endlocal function win_viva() return M.win and api.nvim_win_is_valid(M.win) endEsa pareja de predicados es el cimiento. Todo lo demás los llama antes de tocar nada, y con eso desaparece la clase entera de errores de identificador colgado que diagnosticaste en la lección 10.2.
La superficie
El buffer se crea una sola vez y se reutiliza —el patrón obtener de la lección 10.2—, con un filetype propio para que cualquiera, incluido tu yo futuro, pueda colgar autocomandos del panel sin tocar este módulo. La ventana es una división a la derecha con anchura fija, y el detalle crítico es winfixwidth: sin él, el primer :split del usuario reequilibra el árbol de la lección 10.3 y tu panel de treinta y cuatro columnas queda en la mitad.
local function crear_buf() if buf_vivo() then return M.buf end M.buf = api.nvim_create_buf(false, true) -- no listado, scratch vim.bo[M.buf].filetype = "panelinfo" vim.bo[M.buf].modifiable = false vim.keymap.set("n", "q", function() M.cerrar() end, { buffer = M.buf, nowait = true, desc = "cerrar el panel" }) return M.bufendlocal function crear_win() M.win = api.nvim_open_win(crear_buf(), false, { split = "right", win = 0, width = 34 }) local wo = vim.wo[M.win][0] -- indice doble: como :setlocal, sin plantilla wo.winfixwidth = true wo.number, wo.signcolumn = false, "no" wo.wrap, wo.cursorline = false, true wo.winhighlight = "Normal:NormalFloat" return M.winendTres decisiones de esas líneas merecen nombre. El segundo argumento de nvim_open_win va a false: la ventana se abre sin robarle el foco al usuario, porque un panel informativo que te arranca del código se desinstala el primer día. Las opciones de ventana van por vim.wo[M.win][0], el índice doble de la lección 8.2: equivale a :setlocal y no deja plantilla, así que si esa ventana algún día muestra otro buffer, no hereda tu estética de panel. Y el mapeo de q vive en el buffer, no en el ámbito global: nace y muere con él.
Render sin daños colaterales
La función de render recibe la ventana de origen —la que el usuario está mirando— y produce las líneas con las APIs de destino explícito de la lección 10.4, sin saltar a ninguna parte. La escritura usa el patrón de la 10.1: abrir modifiable, escribir, cerrar, limpiar modified. Y protege el cursor del propio panel: reemplazar todas las líneas puede recolocar el cursor de cualquier ventana que muestre ese buffer, y guardarlo evita que el panel dé saltos visuales.
local function recolectar(origen) if not api.nvim_win_is_valid(origen) then return {} end local b = api.nvim_win_get_buf(origen) local nombre = vim.fn.fnamemodify(api.nvim_buf_get_name(b), ":t") local tipo = vim.bo[b].filetype return { " PANEL", "", " buffer " .. b, " archivo " .. (nombre == "" and "sin nombre" or nombre), " tipo " .. (tipo == "" and "ninguno" or tipo), " lineas " .. api.nvim_buf_line_count(b), " cursor " .. api.nvim_win_get_cursor(origen)[1], " ventana " .. origen, }endlocal function render(origen) if not buf_vivo() or not win_viva() then return end local lineas = recolectar(origen) local cur = api.nvim_win_get_cursor(M.win) vim.bo[M.buf].modifiable = true api.nvim_buf_set_lines(M.buf, 0, -1, false, lineas) vim.bo[M.buf].modifiable = false vim.bo[M.buf].modified = false api.nvim_win_set_cursor(M.win, { math.min(cur[1], math.max(#lineas, 1)), 0 })endFíjate en que recolectar no usa ni una función de contexto implícito: todo recibe origen o b como destino. Si mañana quieres renderizar el panel respecto a una ventana que no es la activa, la función ya sabe. Sobre el árbol de deshacer: como el buffer es scratch y el usuario no lo edita, su historial no le importa a nadie; si tu panel fuese editable, aquí envolverías la escritura con el undojoin de la lección 10.1. Y cuando en el Nivel 14 tengas extmarks, las cabeceras de este render dejarán de ser texto con espacios y pasarán a ser resaltado de verdad.
Reactividad y limpieza
Los autocomandos viven en un grupo propio con clear activado —la disciplina del Nivel 9—, lo que garantiza que reinstalarlos no los duplique. El temporizador se crea una sola vez y se reutiliza: cada evento lo detiene y lo rearma, y esa es exactamente la definición de un antirrebote.
local function programar(origen) if not M.timer then M.timer = assert(vim.uv.new_timer()) end M.timer:stop() M.timer:start(60, 0, vim.schedule_wrap(function() render(origen) end))endlocal function instalar() M.grupo = api.nvim_create_augroup("PanelInfo", { clear = true }) api.nvim_create_autocmd( { "CursorMoved", "BufEnter", "WinEnter", "TextChanged" }, { group = M.grupo, callback = function() local w = api.nvim_get_current_win() if w ~= M.win then programar(w) end end }) api.nvim_create_autocmd("WinClosed", { group = M.grupo, pattern = tostring(M.win), -- WinClosed usa el winid como patron callback = function() M.cerrar() end, })endAquí hay tres ideas densas. La primera: el guardián w ~= M.win en el callback. Si el evento viene del propio panel no hay nada que actualizar, y sin esa comprobación tendrías realimentación: el render toca el buffer del panel, eso dispara eventos, los eventos programan otro render. La segunda: el antirrebote de sesenta milisegundos no es cosmético. CursorMoved se dispara en cada pulsación de j; con la tecla mantenida son decenas de eventos por segundo, y el temporizador colapsa la ráfaga en una sola actualización cuando el usuario descansa. La teoría completa del patrón —y cuándo preferir throttle— es la lección 12.4. Y la tercera: vim.schedule_wrap envuelve el callback porque el temporizador de vim.uv dispara en el bucle de eventos, donde la API de buffers no es segura; la regla de oro que fundamenta esto es la lección 12.3. De momento, quédate con la forma: callback de uv, luego schedule, luego API.
El antirrebote que crea un temporizador por evento es una fuga
Una versión que circula mucho crea vim.uv.new_timer() dentro de programar, en cada evento, y se limita a hacer stop() del anterior. Detenido no es liberado: cada pulsación deja un handle de libuv vivo, y una sesión de una tarde acumula miles. La forma correcta es una de dos: reutilizar un único temporizador rearmándolo, como aquí, o cerrar el viejo con close() antes de crear otro. Los handles de libuv y su ciclo de vida completo son la lección 12.2; el resumen operativo es que todo new_timer debe tener su close en alguna parte.
La limpieza cierra el círculo, y es deliberadamente el espejo de la apertura: el temporizador se para y se cierra, el grupo se borra —con él mueren todos los autocomandos de golpe—, la ventana se cierra si vive. El buffer no se toca: con el bufhidden en hide del preajuste scratch queda oculto y la próxima apertura lo reutiliza con su mapeo de q intacto.
function M.cerrar() if M.timer then M.timer:stop(); M.timer:close(); M.timer = nil end if M.grupo then pcall(api.nvim_del_augroup_by_id, M.grupo); M.grupo = nil end if win_viva() then api.nvim_win_close(M.win, true) end M.win = nilendfunction M.alternar() if win_viva() then return M.cerrar() end local origen = api.nvim_get_current_win() crear_win(); instalar(); render(origen)endapi.nvim_create_user_command("PanelInfo", M.alternar, { desc = "alternar el panel" })return MUn plugin es un ciclo de vida, no un conjunto de funciones
El código que acabas de escribir tiene tres capas y solo una es visible. La primera es lo que el usuario ve: un panel con texto. La segunda es la superficie: un buffer y una ventana con sus contratos declarados en opciones, como aprendiste en las lecciones 10.2 y 10.3. La tercera, la que decide si tu plugin es bueno o insoportable, es el ciclo de vida: qué se crea, qué se reutiliza, qué se destruye y, sobre todo, qué se desregistra. La mayoría de los plugins que la comunidad abandona no fallan por lógica incorrecta sino por esta capa: autocomandos que sobreviven a su ventana, temporizadores que nunca se cierran, buffers que se acumulan invisibles, identificadores guardados sin validar. Nada de eso da error jamás; solo hace el editor un poco más lento cada semana, y el usuario desinstala sin saber decir por qué. De ahí la disciplina que ordena este capítulo entero: escribe la función de cierre antes que la de apertura, y haz que sean espejos, línea por recurso. Si no sabes exactamente qué libera tu cerrar, todavía no sabes qué está creando tu abrir. En un programa que termina, el sistema operativo perdona esa ignorancia al salir; en un editor que el usuario deja abierto durante días, la deuda se cobra sin excepción, y con intereses que pagan los demás plugins.
Debe haber exactamente un buffer panelinfo en la lista, marcado con u por no listado. Si hay diez, tu crear_buf no valida antes de crear.
Con el panel abierto verás el grupo con sus eventos; tras cerrarlo, E216: el grupo ya no existe. Si sigue ahí, tu cerrar no borra el grupo y cada movimiento de cursor sigue pagando el callback.
Cerrar la ventana del panel con :q dispara el WinClosed con tu winid como patrón, que ejecuta M.cerrar. El siguiente :PanelInfo debe abrir limpio, no fallar con un id colgado.
Con 60 ms de antirrebote, mantener j cinco segundos produce un puñado de renders. Pon el retardo a 0 y repite: decenas por segundo. Esa diferencia es la que tu usuario percibe como fluidez.
Haz tuyo el panel
- Monta el módulo completo en tu configuración, escribiéndolo a mano, y comprueba que
:PanelInfoalterna sin duplicar buffers ni autocomandos. - Añade una línea con la posición del cursor en porcentaje del total y otra con el número de ventanas de la pestaña, todo con destino explícito.
- Añade la palabra bajo el cursor con
expand("<cword>")— necesitarás decidir si la lees en el callback del evento o en el render, y justificarlo con lo aprendido sobre el0implícito en la lección 10.4. - Convierte la división lateral en flotante y de vuelta con
nvim_win_set_config, sin reconstruir el buffer, con una tecla localf. - Elimina el guardián que ignora eventos del propio panel, entra en él y describe con precisión el bucle amortiguado que aparece; restáuralo.
- Sabotaje final: comenta el
M.timer:close()decerrary cambiaprogramarpara crear un temporizador nuevo en cada llamada. Cuenta los handles vivos con:lua local n = 0; vim.uv.walk(function() n = n + 1 end); print(n), muévete un rato por un fichero con el panel abierto, y vuelve a contar. Cada fuga tiene un número; restaura el código y comprueba que el número deja de crecer.