wandres.dev
CONSTRUIR INTERFAZ · ventanas flotantes y UI

Entrada del usuario: vim.ui.select y vim.ui.input

Los dos puntos de extension que Neovim reserva para pedir datos al usuario. Contrato asincrono con callback, la convencion de cancelacion mediante nil, las claves prompt, format_item y kind, como se sustituye la implementacion por una flotante propia, y el argumento arquitectonico por el que un plugin nunca debe dibujar su propio selector.

⏱ 21 min

Tarde o temprano tu código necesita preguntar algo: elige una de estas acciones de código, escribe el nombre del nuevo fichero, confirma que quieres reemplazar. Neovim heredó de Vim varias formas de hacerlo, todas malas: bloquean el editor, no se pueden estilar, no se pueden probar y cada plugin las envuelve de una manera distinta. La respuesta moderna son dos funciones deliberadamente pequeñas, vim.ui.select y vim.ui.input, que no son una biblioteca de interfaz sino un punto de extensión: definen el contrato entre quien necesita un dato y quien decide cómo pedirlo, y permiten que el usuario final sustituya la segunda mitad sin que la primera se entere. Entender esa separación es entender por qué el ecosistema de Neovim tiene selectores intercambiables y el de casi cualquier otro editor no.

🎯 Al terminar esta lección sabrás
  • Usar vim.ui.select y vim.ui.input respetando su contrato asíncrono y la señal de cancelación.
  • Aprovechar format_item, prompt, kind y completion para dar contexto a la implementación.
  • Sustituir la implementación por defecto por una flotante propia sin romper a nadie.
  • Justificar por qué un plugin nunca debe dibujar su propio selector.

Dos funciones y un contrato asíncrono

Ambas reciben unas opciones y una función de continuación. No devuelven el valor elegido: lo entregan más tarde, cuando el usuario responde. Esa forma no es un capricho de estilo, es lo que permite que la implementación sea una ventana flotante que espera pulsaciones sin congelar el editor.

vim.ui.select({ "reiniciar", "recargar", "detener" }, {
  prompt = "Accion sobre el servidor:",
  kind = "servidor.accion",
  format_item = function(item) return "→ " .. item end,
}, function(eleccion, indice)
  if eleccion == nil then return end          -- el usuario cancelo
  vim.notify(("elegido %s en la posicion %d"):format(eleccion, indice))
end)

vim.ui.input({
  prompt = "Nombre del modulo: ",
  default = "nuevo",
  completion = "file",
}, function(texto)
  if texto == nil or texto == "" then return end
  crear_modulo(texto)
end)

La regla que hay que grabar es la convención de cancelación: la continuación se invoca siempre, y recibe nil cuando el usuario abandonó. No hay excepción, no hay segundo camino, no hay error. Un plugin que olvida esa comprobación produce el fallo clásico de crear un fichero llamado nil porque el usuario pulsó <Esc>.

La lista de vim.ui.select puede contener cualquier cosa, no solo cadenas: tablas con la información completa del elemento, nodos, objetos de un servidor de lenguaje. format_item es la función que traduce cada elemento a la cadena que se muestra, y esa separación entre dato y representación es lo que permite recuperar el objeto entero en la continuación en lugar de tener que buscarlo por su texto.

💡
La clave kind es una pista, no un requisito

La clave kind es una cadena libre que describe qué se está eligiendo. La implementación por defecto la ignora; las implementaciones ricas la usan para decidir el aspecto, el tamaño o incluso qué motor de selección emplear. Cuesta una línea y hace que tu plugin se integre mejor con configuraciones que no controlas, así que ponla siempre.

Sustituir la implementación

La implementación por defecto es deliberadamente sobria: un menú numerado en la línea de comandos para la selección y un mensaje de entrada para el texto. Su virtud es que funciona sin dependencias, incluso en modo sin interfaz. Su límite es evidente en cuanto la lista tiene treinta elementos.

Sustituirla consiste en reasignar la función. No hay registro, ni sistema de plugins, ni orden de carga que negociar: es una tabla de Lua y tú escribes en ella.

local original = vim.ui.select

vim.ui.select = function(items, opts, on_choice)
  -- Delegar en la implementacion previa cuando no aportamos nada
  if #items > 200 or opts.kind == "codeaction" then
    return original(items, opts, on_choice)
  end

  local format = opts.format_item or tostring
  local lineas = vim.tbl_map(format, items)
  local buf = vim.api.nvim_create_buf(false, true)
  vim.api.nvim_buf_set_lines(buf, 0, -1, false, lineas)
  vim.bo[buf].modifiable = false

  local ancho = 0
  for _, l in ipairs(lineas) do ancho = math.max(ancho, #l + 4) end
  local win = vim.api.nvim_open_win(buf, true, {
    relative = "cursor", row = 1, col = 0,
    width = math.min(ancho, vim.o.columns - 8),
    height = math.min(#lineas, 12),
    style = "minimal", border = "rounded",
    title = opts.prompt and (" " .. opts.prompt .. " ") or nil,
  })

  local respondido = false
  local function responder(idx)
    if respondido then return end
    respondido = true
    if vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_close(win, true) end
    on_choice(idx and items[idx] or nil, idx)
  end

  vim.keymap.set("n", "<CR>", function()
    responder(vim.api.nvim_win_get_cursor(win)[1])
  end, { buffer = buf, nowait = true })
  for _, tecla in ipairs({ "q", "<Esc>" }) do
    vim.keymap.set("n", tecla, function() responder(nil) end, { buffer = buf, nowait = true })
  end
  vim.api.nvim_create_autocmd("WinClosed", {
    pattern = tostring(win), once = true,
    callback = function() responder(nil) end,
  })
end

Tres decisiones de ese código son las que lo hacen correcto y no una demostración. La primera es delegar en la implementación previa cuando el caso no encaja: encadenar en vez de reemplazar deja convivir varias sustituciones. La segunda es la bandera de respondido, que garantiza que la continuación se invoca exactamente una vez aunque el cierre llegue por dos vías a la vez. La tercera es el autocomando de cierre, que convierte cualquier ruta de salida imprevista en una cancelación limpia en lugar de en una continuación que nunca se llama y un plugin que se queda esperando para siempre.

flowchart TB
a[Plugin necesita un dato] --> b[Llama a vim.ui.select con items y opciones]
b --> c{Implementacion activa}
c -->|por defecto| d[Menu numerado en la linea de comandos]
c -->|sustituida| e[Ventana flotante propia]
c -->|de un plugin| f[Motor de busqueda difusa]
d --> g[Continuacion con el elemento elegido]
e --> g
f --> g
g --> h[Cancelacion entrega nil por el mismo camino]
style b fill:#89b4fa,color:#11111b
style g fill:#a6e3a1,color:#11111b

Por qué no debes inventar el tuyo

La tentación es real: tu selector propio siempre encajará mejor con lo que estás haciendo. Los cuatro argumentos en contra son acumulativos y ninguno es estético.

El primero es la coherencia. Un usuario con veinte plugins que dibujan su propio menú tiene veinte interfaces con veinte teclas de confirmación distintas. Con el punto de extensión, cambia uno y cambian los veinte.

El segundo es la cesión de control. La decisión de cómo se ve un selector pertenece al usuario, no al autor del plugin. Al llamar al punto de extensión, aceptas que quien usa tu código elija un motor de búsqueda difusa, un menú clásico o una interfaz gráfica externa, y tu plugin funciona igual en los tres casos sin saberlo.

El tercero es la comprobabilidad. Una prueba automática puede sustituir la función por una que devuelve siempre el segundo elemento, y así ejercitar todo el flujo de tu plugin sin interfaz. Con un selector dibujado a mano, ese flujo solo se prueba a mano.

El cuarto es la accesibilidad y los entornos raros: modo sin interfaz, ejecución en un script, interfaces gráficas alternativas que implementan estas funciones de forma nativa. Tu flotante no existe en ninguno de esos contextos; el contrato sí.

La consecuencia práctica para quien escribe plugins es una regla de una línea: el código que necesita el dato nunca dibuja nada. Se limita a describir la pregunta y a esperar la respuesta.

-- Correcto: describe la pregunta, no la interfaz
local function renombrar_simbolo()
  local actual = vim.fn.expand("<cword>")
  vim.ui.input({ prompt = "Nuevo nombre: ", default = actual }, function(nombre)
    if not nombre or nombre == "" or nombre == actual then return end
    vim.lsp.buf.rename(nombre)
  end)
end

Hay dos límites del contrato que conviene conocer antes de tropezar con ellos. El primero es que la continuación puede ejecutarse en un contexto donde ciertas operaciones están prohibidas, así que las acciones que modifican buffers o mueven el cursor conviene aplazarlas con vim.schedule si observas errores de contexto rápido. El segundo es que no hay anidamiento garantizado: encadenar una selección dentro de la continuación de otra funciona con la implementación por defecto y puede comportarse de forma extraña con implementaciones que reutilizan una misma ventana, de modo que un asistente de varios pasos merece diseñarse como una máquina de estados explícita en vez de como llamadas anidadas.

Un punto de extensión es una frontera que separa la necesidad de la política

Mira el tamaño de lo que Neovim decidió estandarizar aquí, porque la elección es un ejemplo casi de manual de dónde poner una interfaz. No se estandarizó un componente, ni un sistema de menús, ni un lenguaje de descripción de diálogos: se estandarizó una pregunta. Un plugin declara necesito que el usuario elija uno de estos elementos y necesito que el usuario escriba un texto, y ahí se acaba su autoridad. Todo lo demás —el aspecto, las teclas, si hay búsqueda difusa, si aparece junto al cursor o en el centro, si hay vista previa— es política del usuario, y la política del usuario no debe estar hardcodeada en el código de nadie más. Esa frontera es la razón por la que el ecosistema de Neovim tiene una propiedad rara: puedes cambiar el motor de selección de todo tu editor con tres líneas y funcionan los plugins que se escribieron años antes de que ese motor existiera. La interfaz es tan estrecha que sobrevive a implementaciones que sus autores no imaginaron, y es tan asíncrona que admite implementaciones que tardan un minuto sin bloquear nada. Compáralo con la alternativa tentadora, que consistiría en publicar un componente de selección completo y bien hecho: habría sido más cómodo de usar el primer día y habría congelado el diseño para siempre, porque cada mejora del componente rompería a alguien y cada plugin acabaría dependiendo de detalles internos. La lección general es incómoda para el instinto de quien programa, porque el instinto empuja a dar más: la potencia de un punto de extensión es inversamente proporcional a lo que asume. Cuanto menos decide, más implementaciones admite; cuanto menos promete, más tiempo sobrevive. Cuando diseñes tus propias fronteras, dentro del editor o fuera de él, la pregunta útil no es qué funcionalidad quieres ofrecer, sino cuál es la mínima pregunta que ambos lados necesitan compartir para dejar de tener que ponerse de acuerdo en nada más.

⚔️ Cede el control y compruébalo
  1. Escribe un comando que pida una acción con vim.ui.select sobre tablas, usando format_item, y actúa sobre el objeto entero en la continuación.
  2. Añade la comprobación de cancelación y verifica que pulsar <Esc> no ejecuta nada ni deja mensajes.
  3. Sustituye vim.ui.select por una flotante propia que delegue en la implementación anterior para listas de más de doscientos elementos.
  4. Garantiza que tu implementación llama a la continuación exactamente una vez, cerrando la ventana por tres vías distintas y comprobándolo con un contador.
  5. Sustituye temporalmente la función por una que devuelve siempre el primer elemento y usa esa sustitución para probar tu comando sin interacción.