wandres.dev
CONSTRUIR INTERFAZ · ventanas flotantes y UI

Un popup usable: cerrar, limpiar y no estorbar

De la flotante que abre a la flotante que se comporta. Cerrar con q y con Escape sin romper el modo normal, cerrar al perder el foco con autocomandos de ventana, evitar buffers colgando y ventanas huerfanas, recalcular el tamano cuando cambia el terminal, y encapsular todo en un modulo reutilizable con limpieza idempotente.

⏱ 23 min

Abrir una ventana flotante cuesta ocho líneas. Cerrarla bien cuesta el resto de la lección. La diferencia entre una demostración y un componente que puedes poner en tu configuración durante un año está enteramente en el ciclo de vida: qué pasa cuando el usuario pulsa q, qué pasa cuando se va a otra ventana sin cerrarla, qué pasa cuando redimensiona el terminal con el panel abierto, qué pasa cuando abre el mismo panel dos veces seguidas. Cada una de esas preguntas tiene una respuesta correcta y varias que producen buffers huérfanos, ventanas invisibles que retienen memoria o errores rojos al invocar una función sobre una ventana que ya no existe. Esta lección construye un popup completo y defiende cada decisión.

🎯 Al terminar esta lección sabrás
  • Asociar q y <Esc> al cierre mediante asignaciones locales al buffer, sin tocar el resto del editor.
  • Cerrar al perder el foco con los autocomandos adecuados y sin dispararlos de más.
  • Garantizar que ni el buffer ni la ventana sobreviven al cierre, en todas las rutas posibles.
  • Recalcular la geometría ante VimResized y respetar los límites reales del terminal.

Cerrar es una decisión del buffer, no del editor

La forma incorrecta de hacer que q cierre el panel es una asignación global. La correcta es una asignación local al buffer, que existe solo mientras existe ese buffer y desaparece con él. Como el buffer es efímero y no lo comparte nadie, no hay riesgo de pisar la tecla de grabación de macros en ningún otro sitio.

local function cerrar(win, buf)
  if win and vim.api.nvim_win_is_valid(win) then
    vim.api.nvim_win_close(win, true)          -- force = true: cierra aunque este modificado
  end
  if buf and vim.api.nvim_buf_is_valid(buf) then
    vim.api.nvim_buf_delete(buf, { force = true })
  end
end

local function asignar_cierres(win, buf)
  local opts = { buffer = buf, nowait = true, silent = true, desc = "cerrar el panel" }
  for _, tecla in ipairs({ "q", "<Esc>" }) do
    vim.keymap.set("n", tecla, function() cerrar(win, buf) end, opts)
  end
end

Tres detalles de esas opciones merecen justificación. nowait evita que Neovim se quede esperando por si el usuario está tecleando una secuencia más larga que empiece por q, lo que produce una demora perceptible. silent impide que la asignación se haga eco en la línea de comandos. Y el orden de la comprobación en cerrar importa: cerrar la ventana antes de borrar el buffer evita que Neovim tenga que decidir qué mostrar en una ventana cuyo buffer acaba de desaparecer.

⚠️
Cuidado con secuestrar la tecla de escape en modo normal

En modo normal, <Esc> no está libre: cancela cuentas pendientes y limpia el resaltado de la búsqueda en muchas configuraciones. Dentro de un buffer efímero de solo lectura eso no importa, porque no hay nada que cancelar. Pero si tu panel es editable, o si el usuario puede entrar en modo inserción dentro de él, asignar <Esc> a cerrar convierte salir del modo inserción en cerrar la ventana. En ese caso, deja q y añade una combinación explícita.

Merece la pena hacer el buffer inmune a la edición accidental. Poner modifiable a falso y readonly a verdadero tras escribir el contenido evita el caso, muy real, del usuario que pulsa una tecla cualquiera esperando que cierre y en su lugar inserta un carácter en un panel que se suponía informativo.

Perder el foco es la señal más honesta

Una flotante que sigue ahí cuando ya estás escribiendo en otra ventana es basura visual. El editor emite dos eventos útiles para detectarlo, y elegir mal entre ellos produce fallos difíciles de diagnosticar.

WinLeave se dispara al abandonar una ventana, incluida la tuya, pero también en travesías intermedias que no significan que el usuario haya terminado. BufLeave es análogo para buffers. El evento robusto es WinClosed para reaccionar al cierre, y para el foco la práctica sólida es escuchar WinEnter y comprobar si la ventana que acaba de recibir el foco es la nuestra.

local function cerrar_al_perder_foco(win, buf)
  local grupo = vim.api.nvim_create_augroup("panel_foco_" .. buf, { clear = true })

  vim.api.nvim_create_autocmd({ "WinEnter", "BufEnter" }, {
    group = grupo,
    callback = function()
      if vim.api.nvim_get_current_win() ~= win then cerrar(win, buf) end
    end,
  })

  -- Si la ventana muere por otra via, el grupo debe irse con ella
  vim.api.nvim_create_autocmd("WinClosed", {
    group = grupo,
    pattern = tostring(win),
    callback = function() pcall(vim.api.nvim_del_augroup_by_id, grupo) end,
  })
end

El patrón que sostiene todo esto es el grupo de autocomandos con nombre único por instancia. Sin él, abrir el panel dos veces registra dos manejadores y el segundo actúa sobre una ventana ya cerrada. Con clear a verdadero, la segunda apertura borra los manejadores de la primera antes de registrar los suyos, lo que hace que la operación sea idempotente por construcción.

El segundo autocomando es igual de importante y suele faltar. Los manejadores registrados globalmente sobreviven a la ventana que los motivó, y un editor que acumula grupos de autocomandos muertos ejecuta trabajo inútil en cada movimiento del cursor. Escuchar WinClosed filtrado por el identificador de la ventana y desmontar el grupo es lo que cierra el círculo.

flowchart TB
a[Abrir panel] --> b[Buffer scratch mas ventana flotante]
b --> c[Asignaciones locales al buffer]
b --> d[Grupo de autocomandos unico por instancia]
c --> e{Motivo del cierre}
d --> e
e -->|tecla q o Escape| f[cerrar]
e -->|WinEnter en otra ventana| f
e -->|WinClosed externo| f
f --> g[Cerrar ventana si es valida]
g --> h[Borrar buffer si es valido]
h --> i[Eliminar el grupo de autocomandos]
style b fill:#89b4fa,color:#11111b
style i fill:#a6e3a1,color:#11111b

No dejar nada colgando

El fallo silencioso más común de los paneles caseros es la fuga de buffers. Se produce porque hay más rutas de cierre de las que uno imagina: la tecla asignada, el evento de foco, un :q escrito a mano, el cierre de la pestaña entera, :qa durante la salida del editor, o un error de Lua a mitad de la construcción que deja el buffer creado y la ventana no.

La defensa es una función de limpieza idempotente —invocarla dos veces no debe fallar— y llamarla desde todas las rutas. Las comprobaciones de validez que aparecen en cerrar no son paranoia: nvim_win_close sobre una ventana ya cerrada lanza un error, y ese error, si ocurre dentro de un autocomando, produce un mensaje rojo que el usuario asocia a tu plugin sin saber por qué.

-- Escribir contenido y blindar el buffer contra edicion accidental
local function llenar(buf, lineas)
  vim.bo[buf].modifiable = true
  vim.api.nvim_buf_set_lines(buf, 0, -1, false, lineas)
  vim.bo[buf].modifiable = false
  vim.bo[buf].readonly = true
  vim.bo[buf].bufhidden = "wipe"     -- red de seguridad: sin ventana, el buffer se borra
end

Poner bufhidden a wipe es la red de seguridad definitiva: si por cualquier motivo la ventana desaparece sin pasar por tu función de limpieza, el buffer se elimina solo al quedarse sin vista. No sustituye a la limpieza explícita, pero convierte una fuga en un no evento.

Respetar el terminal

Un panel de 100 columnas de ancho es razonable en una pantalla amplia y un desastre en un terminal de 80. Las dimensiones no deben ser constantes, sino funciones del espacio disponible, con un mínimo por debajo del cual el panel simplemente no se abre.

local function geometria(prop_ancho, prop_alto)
  local ancho = math.max(20, math.min(math.floor(vim.o.columns * prop_ancho), vim.o.columns - 4))
  local alto  = math.max(3,  math.min(math.floor(vim.o.lines   * prop_alto),  vim.o.lines - 6))
  return {
    relative = "editor", width = ancho, height = alto,
    row = math.floor((vim.o.lines - alto) / 2) - 1,
    col = math.floor((vim.o.columns - ancho) / 2),
  }
end

vim.api.nvim_create_autocmd("VimResized", {
  group = grupo,
  callback = function()
    if vim.api.nvim_win_is_valid(win) then
      vim.api.nvim_win_set_config(win, geometria(0.8, 0.8))
    end
  end,
})

El descuento de filas no es arbitrario: la línea de comandos, la línea de estado global y el borde de la flotante consumen espacio que no puedes usar. Calcular la posición vertical sin restarlos produce un panel cuyo borde inferior queda tapado por la línea de comandos, síntoma inconfundible de una interfaz escrita sin medir.

Un componente de interfaz es una promesa sobre lo que ocurrirá después

La asimetría de esfuerzo entre abrir y cerrar no es un accidente de esta API: es una propiedad general de todo lo que gestiona recursos, y merece que la reconozcas aquí porque el patrón se repite en cada capa de software que vas a tocar. Abrir es un camino: un buffer, una ventana, unas teclas, un manejador. Cerrar es un conjunto de caminos, y su cardinalidad crece con cada vía que el sistema ofrece para llegar al mismo estado final. El usuario puede pulsar tu tecla, irse a otra ventana, escribir :q, cerrar la pestaña, salir del editor, o tu propio código puede fallar a mitad de camino y dejar la construcción incompleta. Cada una de esas rutas debe converger en la misma limpieza, y la única forma manejable de garantizarlo no es enumerar los casos —siempre falta uno— sino diseñar la limpieza para que sea idempotente y defensiva: que comprobar antes de actuar sea la norma, que llamarla dos veces sea inofensivo, y que exista una red pasiva que actúe cuando todas las activas fallen. Esa es exactamente la diferencia entre un recolector de basura y un free manual, entre un bloque con garantía de ejecución y un descriptor de fichero cerrado a mano, entre un contenedor con límites y un proceso suelto. Y hay una consecuencia práctica que separa a quien escribe herramientas de quien escribe demostraciones: el usuario de un editor no percibe la calidad de tu interfaz cuando funciona, porque funcionar es lo mínimo. La percibe cuando deja de funcionar de una forma que no le cuesta nada. Un panel que se retira sin dejar un buffer en la lista, sin robar el foco, sin un mensaje de error al salir y sin ocupar memoria diez minutos después es un panel en el que se puede confiar, y la confianza es lo único que hace que una herramienta sobreviva a la novedad. La ventana bonita se agradece un día; la que no deja rastro se usa un año.

⚔️ Un panel que sobrevive al maltrato
  1. Construye un módulo con una función que abra el panel centrado al 80 por ciento y devuelva los identificadores de ventana y buffer.
  2. Añade el cierre con q y <Esc> local al buffer, y verifica que q sigue grabando macros en el resto de ventanas.
  3. Registra el cierre por pérdida de foco y comprueba que abrir el panel dos veces seguidas no deja manejadores duplicados.
  4. Cierra el panel escribiendo :q dentro de él y confirma con :ls! que no queda ningún buffer huérfano.
  5. Redimensiona el terminal con el panel abierto, comprueba que se recoloca, y después reduce el terminal hasta un tamaño donde tu mínimo impida abrirlo: decide y justifica qué debe ocurrir entonces.