Construyendo un Selector de Color para la Terminal
O de cómo terminé dibujando el gamut OKLCH con puntos braille
Fecha:- pythontextualtuipixel artcoloroklch
Desde hace un tiempo he estado trabajando en amberglyf, un editor de pixel art que vive por completo dentro de la terminal, escrito en Python. Lo más parecido que ya existe es cmdpxl, y lo estudié bastante como referencia, pero mi meta es más ambiciosa: quiero un editor de pixel art completo en la terminal. Eso significa frames, una línea de tiempo para animación, importar y exportar GIF, rebanar spritesheets... muchas más funciones que un simple lienzo con un lápiz. Y una de esas funciones, la que ocupa este post, es un sistema de paletas en forma.
¿Por qué empezar por la paleta? Porque así funciona el pixel art de verdad. PICO-8 te da 16 colores, la mayoría de las paletas de Lospec andan entre 8 y 32. El flujo rápido es definir tus colores una vez y de ahí dibujar con las teclas numéricas. Pero si la paleta es el corazón del flujo de trabajo, entonces el editor de paleta necesita un buen selector de color, y la terminal no trae uno de fábrica precisamente. Así que tocó construirlo.
El contrato del widget
La primera decisión fue que el selector no debía saber nada del resto de la
aplicación. Lo colocas en un layout, enrutas tus teclas a cuatro métodos
públicos, y escuchas un mensaje Changed cuando el color cambia.
Ya, ese es todo el contrato.
python
class Picker(Widget):
"""Color picker, ready to drop into a host layout.
Host wires keys to: `cycle_focus()`, `next_mode()`, `prev_mode()`,
`adjust(dx, dy, coarse)`. Listen for `Picker.Changed` to react to edits.
"""
Suena a algo obvio, y lo es, pero me obligó a ser honesto con los límites. El selector no lee el teclado, ni sabe del lienzo o de la paleta. También significó que podía correrlo por separado con un pequeño arnés mientras lo desarrollaba11.
Cuatro formas de pensar el color
No quería forzar a nadie a un solo modelo mental del color, así que el selector tiene cuatro modos: HSV y HSL (un mapa 2D con una cruz de braille más una ruedita de matiz), RGB (tres rueditas apiladas), y OKLCH, que se lleva sus propias secciones más abajo porque fue por mucho la parte más compleja de todo el asunto.
Los primeros tres modos comparten un solo estado canónico: HSV. RGB y HSL se derivan de él, y ajustar un canal RGB simplemente recalcula el HSV tras bambalinas. Aquí hubo una trampa. Cuando la luminosidad llega a 0 o a 1, la saturación HSL matemáticamente colapsa a cero. Así que si arrastrabas la luminosidad hasta el negro y de regreso, aterrizabas en un gris en lugar de en tu color. El arreglo fue hacer la saturación "pegajosa": el estado recuerda lo que pediste, y solo confía en el valor derivado cuando de verdad significa algo.
python
@property
def hsl(self):
s_hsl_derived, l = hsv_to_hsl(self.s, self.v)
s_hsl = self._s_hsl if l in (0.0, 1.0) else s_hsl_derived
return self.hue, s_hsl, l
El patrón es recordar la intención, no solo el valor mostrable.
Entra OKLCH
OKLCH es el espacio de color perceptualmente uniforme que CSS adoptó hace un tiempo, y de verdad lo quería en el selector. La promesa es preciosa: mueve un color por el mismo paso numérico y obtienes el mismo cambio percibido, sin importar dónde estés. Cualquiera que haya peleado con HSV sabe que una rotación de matiz de 10° cerca del amarillo y una cerca del azul son dos experiencias muy distintas.
El precio de esa promesa es la geometría.
En HSV, toda combinación de valores es un color que puedes mostrar. En OKLCH... no. El gamut sRGB visto desde OKLCH es una masa irregular: para una luminosidad y matiz dados hay un croma máximo, y más allá de él el color simplemente no existe en tu pantalla. Puedes pedirlo, la matemática te dará coordenadas muy quitada de la pena, pero no hay nada que pintar.
Así que el selector necesita responder, constantemente, "¿este color existe?". Me fui por el camino flojo pero correcto: convertir a sRGB, y si algún canal cae fuera, no existe. La frontera del gamut se encuentra entonces por simple bisección22:
python
def max_chroma_at(L: float, H: float, iters: int = 22) -> float:
lo, hi = 0.0, 0.5
if oklch_to_srgb(L, hi, H) is not None:
return hi
for _ in range(iters):
mid = (lo + hi) / 2
if oklch_to_srgb(L, mid, H) is not None:
lo = mid
else:
hi = mid
return lo
Dibujando un disco de color con braille
Ahora, ¿cómo muestras una masa irregular de colores en una cuadrícula de celdas de texto? La respuesta a la que llegué: braille. Cada carácter braille es secretamente una retícula de 2×4 puntos, así que un lienzo de 24×12 celdas de terminal se convierte en una retícula de 48×48 puntos. La terminal tiene mucha más resolución de la que aparenta, solo hay que pedírsela con cariño.
La forma del disco no es algo que yo haya inventado. La idea completa de cortar el plano OKLCH en rebanadas de matiz y anillos de croma viene del diagrama OKLCH a todo color de Kyle Thayer. Es una representación del espacio de color por sí misma, hecha como parte de la investigación del Interactive Data Lab de la UW sobre cómo se nombran los colores en distintos idiomas33.
Cuando vi esas casillas, el selector entero hizo clic en mi cabeza: hacer de las casillas la primitiva de navegación, no solo decoración. Las casillas fuera del gamut simplemente no están, así que el cursor no puede caer en un color que no existe.
El gamut en sí se renderiza como un mosaico continuo: 12 rebanadas por 4 anillos de casillas de color que embonan unas con otras sin huecos, de modo que el disco se lee como un campo suave de color y no como un regadero de caracteres. Encima de él, el matiz actual es una línea braille color crema que corre del centro hacia la orilla, pero solo se dibuja en las celdas que no tienen casilla de color, así que nunca tapa el gamut y apenas se asoma más allá del borde, como la punta de la aguja de una brújula.
Lograr que esto se viera bien tomó más iteraciones de las que quiero admitir. Un regadero ingenuo de puntos se leía como ruido. Los huecos entre casillas se leían como algo roto. La versión de mosaico fue la primera en la que mis ojos dijeron "eso es una rueda de color" y no "eso es una terminal echándole ganas".
El croma como intención
Y aquí es donde la idea de la saturación pegajosa regresa, en su forma final. Supón que subes el croma hasta un rojo bien saturado. Ahora rota el matiz hacia el cian, donde el gamut sRGB es mucho más delgado. Tu croma ya no cabe. ¿Qué debería pasar?
La mayoría de los selectores recortan el valor en silencio y lo olvidan. Regresa el matiz al rojo y tu color saturado ya no está, se lo comió el recorte. Eso siempre me pareció injusto, como si la herramienta te castigara por explorar.
Así que en amberglyf, el croma se guarda como una intención: el valor que pediste vive separado del valor que se muestra. Cuando no coinciden, el disco muestra dos cursores: ◎ se planta en la posición recortada, pintado del color que sí se puede mostrar, y ◌ se planta en la posición de la intención, dibujado como un simple glifo sobre el tablero de ajedrez, porque ahí no hay ningún color que mostrarte.
Regresa el matiz, o sube la luminosidad hasta que tu croma quepa de nuevo, y el color rebota de vuelta a tu intención, como si te hubiera estado esperando. El código de ajuste lo dice mejor que yo:
python
elif self.focus_idx == 1: # C — adjust intent, not the clamped
# value, so the spring-back promised on H/L rotation has an
# intent to spring back to.
step = (oklch.C_COARSE if coarse else oklch.C_FINE) / 1000
self.oklch.set_C(self.oklch.C_intent + sign * step)
Esta semántica de intención/recorte también es la razón por la que OKLCH no pudo compartir el estado canónico HSV con los otros modos. HSV no tiene el concepto de un color que no existe — toda coordenada es mostrable, no hay nada que recordar. Así que OKLCH mantiene su propio objeto de estado, y los dos almacenes se puentean a través de RGB cada vez que cambias de modo:
python
# Bridge through RGB so the colour survives the mode switch.
if self.mode == "OKLCH":
self.state._from_rgb(*self.oklch.rgb)
elif new_mode == "OKLCH":
self.oklch.from_rgb(*self.state.rgb)
Con esto, un color hace el viaje redondo HSV → OKLCH → HSV y regresa siendo el mismo color. Lo cual suena a una vara muy baja, hasta que te pasas una tarde depurando por qué no lo hacía.
Lo que borré y lo que fijé
No todo fue matemática de color. Una versión anterior tenía un widget de
"tira de modos" hecho a mano que llevaba la cuenta de cuál modo era visible,
con su montoncito de estado de contabilidad. En algún punto me di cuenta de que
estaba reconstruyendo algo que Textual ya trae: TabbedContent.
Cada modo se volvió un TabPane, el cambio de pestaña activa
es el mostrar/ocultar, y una familia entera de bugs se borró sola. Mi
borrada más satisfactoria del proyecto.
Además, ambas columnas del selector están fijadas al tamaño del modo más alto, de forma que el widget nunca cambia de tamaño mientras ciclas entre modos. Los layouts de terminal se reacomodan de manera muy visible, y un selector que brinca de tamaño con cada cambio de pestaña se siente roto aunque no lo esté.
Sigue la animación
El selector está terminado y conectado al editor de paleta de amberglyf, a una tecla de distancia del lienzo. El siguiente frente es el flujo de animación: línea de tiempo de frames, vista previa, exportar GIF. El camino de construir un editor es largo, pero cada widget que queda terminado es uno en el que nunca tengo que volver a pensar. Esa es la mejor sensación de este pasatiempo.
[1] Lo que me salvó de lanzar el editor completo cada vez que rompía el renderizado del disco. Que fue seguido. ↩
[2] La conversión usa los coeficientes de Björn Ottosson directamente. Él literalmente publica las matrices en su blog; no hay premio por hacerse el listo aquí. ↩
[3] La investigación en sí trata sobre cómo distintos idiomas nombran los colores, que es fascinante por su cuenta. El diagrama al que doy crédito es solo la visualización del espacio de color, aislada del resto de ese trabajo. Lo que me atrapó fue la forma: cada casilla se lee como una región de color, y saltar entre regiones se sentía mucho más amigable que deslizarse sobre un gradiente continuo. Esa imagen fue la que me convenció de que valía la pena construir el modo OKLCH. ↩