wandres.dev
HIGHLIGHTS · el sistema de color

Escribir un esquema de color desde cero

El esqueleto de un archivo en colors, cómo diseñar una paleta con criterio perceptual, cuál es el conjunto mínimo de grupos que no puedes omitir y cómo sostener variante clara y oscura en un solo tema.

⏱ 18 min

Un esquema de color es, técnicamente, un archivo Lua que llama a nvim_set_hl muchas veces. Escribirlo es fácil; escribirlo bien exige tres decisiones que no son de programación: cuántos colores puede sostener la visión periférica, qué distinciones semánticas merecen gastarlos y cómo sobrevivir al hecho de que el mismo tema debe funcionar sobre fondo negro a medianoche y sobre fondo blanco al mediodía.

🎯 Al terminar esta lección sabrás
  • Montar el esqueleto correcto de un archivo en el directorio colors.
  • Diseñar una paleta con roles semánticos en vez de nombres de color.
  • Cubrir el conjunto mínimo de grupos sin el cual un tema se ve roto.
  • Soportar fondo claro y oscuro desde un único punto de entrada.

El esqueleto obligatorio

Neovim busca los esquemas en un directorio colors dentro de cualquier ruta del runtimepath. El nombre del archivo es el nombre del tema, y hay un preámbulo que no es opcional:

-- colors/mitema.lua
vim.cmd.highlight("clear")
if vim.fn.exists("syntax_on") == 1 then
  vim.cmd.syntax("reset")
end
vim.g.colors_name = "mitema"

local claro = vim.o.background == "light"
local p = require("mitema.paleta")[claro and "claro" or "oscuro"]

Cada línea justifica su existencia. La limpieza inicial devuelve la tabla de grupos a su estado por defecto, sin lo cual heredarías restos del tema anterior y tendrías fallos irreproducibles que solo aparecen al cambiar de esquema en caliente. El reinicio de syntax es necesario porque los archivos clásicos declaran enlaces que la limpieza acaba de borrar. Y asignar colors_name no es cosmético: es lo que permite a Neovim volver a cargar el tema por su cuenta cuando algo cambia.

La lógica de negocio empieza en la última línea, leyendo background. Guárdala en una variable y no vuelvas a consultarla: el resto del archivo debe hablar de la paleta, nunca de si el fondo es claro.

La paleta: roles, no colores

El error clásico es definir una tabla con nombres de color —azul, rojo, verde1— y repartirlos por gusto. El resultado es imposible de mantener: cuando quieres oscurecer todos los acentos, no sabes cuáles son acentos.

La alternativa es una paleta de dos niveles. En el primero, los pigmentos crudos. En el segundo, y es el que usa el resto del tema, roles:

local crudo = {
  tinta   = "#1e1e2e", nieve  = "#cdd6f4", humo = "#6c7086",
  lavanda = "#cba6f7", cielo  = "#89b4fa", hoja = "#a6e3a1",
  ambar   = "#f9e2af", brasa  = "#f38ba8", turquesa = "#94e2d5",
}

local rol = {
  fondo = crudo.tinta,     texto     = crudo.nieve,
  tenue = crudo.humo,      estructura = crudo.lavanda,
  llamada = crudo.cielo,   literal   = crudo.hoja,
  tipo = crudo.ambar,      alarma    = crudo.brasa,
}

Tres criterios gobiernan el primer nivel. El contraste entre texto y fondo debe alcanzar la proporción de cuatro y medio a uno que exigen las pautas de accesibilidad; por debajo de eso el tema cansa aunque parezca elegante en una captura. La cantidad debe quedarse en torno a media docena de acentos, que es donde se agota la capacidad de discriminar color fuera de la zona de visión nítida. Y la separación de tono importa más que la belleza individual: dos acentos preciosos pero vecinos en el círculo cromático son, en la práctica, un solo acento.

💡
Reserva un color y no lo gastes

Elige un tono que no aparezca en ningún grupo sintáctico y guárdalo para los errores. Si el rojo ya colorea palabras clave, un error subrayado en rojo deja de ser una señal y pasa a ser ruido. Un color que solo aparece cuando algo va mal es la información más barata que puede darte un tema.

Los grupos mínimos

No hace falta definir cientos de grupos, pero sí una lista concreta por debajo de la cual el editor se ve averiado. Se divide en dos bloques.

El bloque sintáctico son las raíces canónicas de la lección 15.2: Comment, Constant, String, Identifier, Function, Statement, Keyword, Operator, PreProc, Type, Special, Underlined, Error y Todo. Con esas catorce entradas cualquier lenguaje queda coloreado, tenga o no parser, porque todo lo demás desemboca ahí.

El bloque de interfaz es el que casi todo el mundo olvida y el que de verdad decide si un tema se siente terminado:

local set = function(g, o) vim.api.nvim_set_hl(0, g, o) end

set("Normal",       { fg = rol.texto, bg = rol.fondo })
set("NormalFloat",  { fg = rol.texto, bg = crudo.tinta })
set("FloatBorder",  { fg = rol.tenue, bg = crudo.tinta })
set("CursorLine",   { bg = "#313244" })
set("LineNr",       { fg = rol.tenue })
set("CursorLineNr", { fg = rol.tipo, bold = true })
set("Visual",       { bg = "#45475a" })
set("Search",       { fg = rol.fondo, bg = rol.tipo })
set("IncSearch",    { fg = rol.fondo, bg = rol.alarma })
set("MatchParen",   { fg = rol.alarma, bold = true })
set("Pmenu",        { fg = rol.texto, bg = "#313244" })
set("PmenuSel",     { fg = rol.fondo, bg = rol.llamada, bold = true })
set("WinSeparator", { fg = "#45475a" })
set("StatusLine",   { fg = rol.texto, bg = "#313244" })
set("Comment",      { fg = rol.tenue, italic = true })
set("DiagnosticError", { fg = rol.alarma })
set("DiagnosticWarn",  { fg = rol.tipo })
set("DiagnosticHint",  { fg = crudo.turquesa })

A eso hay que sumar la familia de diferencias, DiffAdd, DiffChange, DiffDelete y DiffText, cuya ausencia solo se nota el día que abres un conflicto de fusión y no distingues nada. Y conviene declarar de forma explícita Visual sin color de frente, para que el texto seleccionado conserve su color semántico en vez de aplanarse.

flowchart TD
A[Punto de entrada en colors] --> B[Limpiar grupos y reiniciar syntax]
B --> C[Fijar colors_name]
C --> D[Leer la opcion background]
D -->|oscuro| E[Paleta oscura]
D -->|claro| F[Paleta clara]
E --> G[Mismo codigo de asignacion de roles]
F --> G
G --> H[Bloque sintactico y bloque de interfaz]
style D fill:#f9e2af,color:#11111b
style G fill:#a6e3a1,color:#11111b

Claro y oscuro sin duplicar nada

La tentación es escribir dos temas. Es un error: acabarás corrigiendo un grupo en uno y olvidándolo en el otro, y la divergencia crece con cada versión.

La estructura correcta separa datos de lógica. Existen dos tablas de paleta con exactamente las mismas claves de rol y un único bloque de asignaciones que jamás menciona si el fondo es claro. Añadir un grupo nuevo se hace una sola vez y funciona en ambas variantes por construcción.

Falta cerrar el ciclo de vida. Cuando el usuario cambia background, Neovim recarga el esquema activo, así que tu archivo se ejecuta de nuevo y lee el valor nuevo: no hay nada que hacer. El caso que sí requiere trabajo es el contrario, el de los retoques personales del usuario, que la recarga acaba de borrar. Se resuelve reaccionando al evento de cambio de esquema:

vim.api.nvim_create_autocmd("ColorScheme", {
  pattern = "mitema",
  callback = function()
    vim.api.nvim_set_hl(0, "Comment", { fg = "#7f849c", italic = false })
  end,
})

Diseñar la variante clara merece una advertencia final. No basta con invertir los valores de luminosidad: sobre fondo claro los tonos saturados vibran y los pasteles desaparecen, de modo que la paleta clara necesita acentos más oscuros y menos saturados que sus equivalentes oscuros. Son dos diseños distintos que comparten estructura, no una función y su inversa.

Para publicar el tema quedan dos detalles de higiene. El primero es que el nombre del archivo y el valor de colors_name deben coincidir, porque la recarga automática busca por ese nombre y una discrepancia produce fallos que solo aparecen al alternar el fondo. El segundo es la verificación sistemática: recorre tus grupos comprobando que ninguno quedó sin definición efectiva antes de dar el trabajo por terminado.

for _, g in ipairs({ "Normal", "Comment", "Visual", "Pmenu", "DiffText" }) do
  local hl = vim.api.nvim_get_hl(0, { name = g, link = false })
  if vim.tbl_isempty(hl) then vim.notify("sin definir: " .. g) end
end
Un tema es un presupuesto, no una colección

La pregunta que decide la calidad de un esquema no es cuántos grupos define sino cuántos tonos distintos gasta, porque la atención es el recurso escaso y el color es la única forma de sintaxis que se percibe sin leer. Si todo está coloreado, nada está resaltado: un tema de doce acentos no transmite doce veces más información que uno de cinco, transmite menos, porque destruye la señal que hace saltar la vista hacia lo importante. De ahí que los esquemas que sobreviven años compartan una misma disciplina —pocas raíces, mucho enlace, un acento reservado para el error— mientras los que deslumbran en una captura de pantalla se abandonan en dos semanas. Escribir un tema es, en el fondo, un ejercicio de renuncia: decides qué distinciones no vas a mostrar para que las tres o cuatro que de verdad importan lleguen a tu cerebro sin que tengas que buscarlas. El código que produces es trivial; la economía de atención que codifica, no.

⚔️ Tu primer esquema propio
  1. Crea colors/mitema.lua con el preámbulo completo y comprueba que aparece al listar los esquemas disponibles.
  2. Define una paleta de dos niveles con seis acentos como máximo y roles con nombres semánticos.
  3. Cubre las catorce raíces sintácticas y el bloque de interfaz completo, incluida la familia de diferencias.
  4. Añade la variante clara en una segunda tabla con idénticas claves, sin duplicar ni una línea de asignación.
  5. Alterna el valor de background en caliente y verifica que ningún grupo queda ilegible en la variante que menos usas.