Terminales y millones de colores: una historia complicada

Los que más o menos me seguís por Twitter, quizá os habréis enterado de que estoy escribiendo una librería cross-platform (netstandard2) para desarrollar aplicaciones de consola. Evidentemente no es la única, es simplemente otra más y puedo asegurar que me lo paso genial desarrollándola.

Uno de los objetivos principales cuando empecé era permitir usar true color (es decir 16 millones de colores) en aquellos terminales que lo soportan y la verdad es que la historia del soporte de colores en terminales da para un post… y aquí estamos 😉

En la pasada netcoreconf de barcelona dí una charla titulada “Aplicaciones de consola fáciles? Más quisieramos” donde contaba las enormes diferencias entre el terminal de *NIX y la consola de Windows, así como algunas novedades interesantes que trae Windows 10 al respecto (aquí está la presentación).

De todo lo que comenté brevemente en la charla, me quiero centrar hoy en el soporte de colores en los terminales y en especial en el soporte de true color.

Estado del arte en *NIX

El mundo *NIX representa el escenario más complejo que pueda haber: si uno quiere adaptarse a todas las situaciones debe tener presente que su aplicación puede estar ejecutándose en terminales antiguos que no soporten todas las funcionalidades. En el mundo *NIX el terminal es un dispositivo que solo lee y envía carácteres. Eso significa que todo debe codificarse como una cadena de carácteres. Esta analogía es perfecta para texto (si recibo AB, pues sé que se ha pulsado la tecla A y luego la B) pero lo mismo ocurre para establecer comandos y opciones del terminal. Así para cambiar el color de un texto se manda una secuencia predeterminada de carácteres. Por razones históricas estas secuencias suelen empezar con el carácter ESC (código ASCII 27) y por eso se conocen generalmente con el nombre de “secuencias de escape“. La situación inicialmente es que cada terminal definía sus propias secuencias de escape, así que para poder lidiar con la variedad de terminales existentes, lo que se hizo fue distribuir junto al SO una “base de datos” (en forma de ficheros) llamada termcap que contenía una lista de terminales y para cada terminal que capacidades (opciones y acciones posibles) tenía y que secuencia de escape había que mandar en cada caso. Posteriormente termcap evolucionó a terminfo y eso es lo que se usa hoy en día. Los ficheros terminfo son binarios (se generan a partir de ficheros de texto con una herramienta llamada tic) y su formato binario no tiene por qué ser compatible entre versiones de sistemas *NIX (aunque suele serlo). Tampoco su ubicación está definida por POSIX (y p. ej. distintas distros Linux tienen los ficheros en distintos sitios).

Una cosa muy importante de terminfo, es que forma parte de tu SO. Eso significa que versiones distintas de SO pueden tener versiones distintas de los ficheros terminfo. Y, dado que esos ficheros no los han creado los creadores de terminales si no usuarios de *NIX, hay ficheros terminfo con errores, especialmente en terminales viejos que nadie tiene muy claro qué secuencias aceptan para qué casos. Con el tiempo se van arreglando esos errores, pero depende de qué versión de *NIX tengas tus ficheros terminfo estarán más o menos actualizados.

Terminal que muestra los ficheros terminfo ubicados (en mi caso) en /lib/terminfo

Si tienes un sistema *NIX a mano (un Linux, MacOS o, si estás en Windows, el WSL o cygwin) abre un terminal y teclea “infocmp”. Eso te imprimirá las capacidades de tu terminal y las secuencias de escape para cada una de ellas (ojo que esas secuencias de escape son “plantillas con parámetros” a partir de las cuales se crea la secuencia de escape real usando la API de terminfo).

Por lo tanto en *NIX debes usar la API de terminfo para leer esas cadenas de escape del terminal actual y enviarlas cuando desees hacer hacer “cosas” con el terminal (tales como borrar la pantalla, usar colores o mover el cursor). Para saber cual es el “terminal actual” la opción suele ser usar el valor de la variable de entorno “TERM”. Si pruebas de ejecutar “echo $TERM” verás cual es el terminal actual de tu sistema *NIX. Lo más probable es que el valor sea “xterm-256color” que es uno de los terminales más usados en los sistemas actuales, pero no tiene por qué.

Para usar colores las capacidades básicas de terminfo que debes usar son setf, setaf, setb, setab (si tecleas infocmp | grep <nombre-capacidad> verás la plantilla para generar la secuencia de escape a usar en cada caso).

La utilidad tput recibe como parámetro una capacidad de terminfo, sus parámetros y envía al terminal la secuencia de escape correspondiente. Es un mecanismo rápido para probar las distintas capacidades. Abre tu terminal y teclea “echo $(tput setaf 0)$(tput setab 5) hello world“. Lo más probable es que veas “hello world” en negro y con fondo morado. Bienvenido al mundo de los colores.

Si quieres ver las secuencias de escape enviadas por tput puedes usar xxd. Así en mi terminal si uso “echo -n $(tput setaf 0)$(tput setab 5) | xxd” lo que veo es:

00000000: 1b5b 3330 6d1b 5b34 356d .[30m.[45m

Donde puedo ver <ESC>[30m y <ESC>[45m que son las dos secuencias de escape mandadas.

Divertido ¿verdad? Pues aún hay más. Para empezar la dualidad setb/setab para establecer el color fondo y setf/setaf para establecer el de texto. ¿Por qué hay dos? Pues setf/setb son las primeras versiones y luego cuando salió el estándard ANSI de colores se añadió la capacidad setaf y setab en aquellos terminales que los soportaban. La primera versión de ANSI usaba 3 bits (8 colores) que se expandieron luego a 4 bits (un bit para intensidad). Así en ANSI el color 5 (0101) es el morado, mientras que el color 13 (1101) es “morado intenso”. Lo puedes verificar tu mismo jugando con tput:

Terminal *NIX donde se muestra texto con "tput setaf 5" en morado y luego con "tput setaf 13" con morado intenso

Así esos 4 bits nos dan 8 * 2 colores distintos, que no son realmente 8 * 2, ya que el 0000 es el negro y no tiene sentido que el 1000 fuese el “negro intenso”. Por ello el 1000 se reservó para un gris. Así que realmente tenemos 7 colores en su variante normal y brillante, el negro y un gris. Este es el estándard ANSI de 4 bits.

Luego está la otra gran dualidad: los “terminales tipo Tektronix” y los “terminales tipo HP”. En los terminales tipo Tektronix tenemos un máximo de N colores y podemos combinarlos como queramos como fondo y texto. Eso nos da un máximo de N * N combinaciones. Por otro lado en los terminales de tipo HP, tenemos un número N de colores y podemos combinarlos solo de M maneras distintas (donde M es menor que N * N). Estos terminales no permiten establecer el color de fondo y de texto de forma independiente, si no que debemos crear “un par (fondo, texto)” y establecer este par como color del carácter. Y podemos tener hasta “M pares” de forma simultanea. La mayoría de terminales actuales son “tipo Tektronix” (y son los que usan setf/setaf y setb/setab). Para los terminales “tipo HP” (mayoría en la antigüedad) debe usarse la capacidad scp (set color pair).

La capacidad “colors” te da el valor de colores soportados (en mi terminal xterm+256color “tput colors” me devuelve 256). Además si existe la capacidad “ccc” (can change color) eso significa que se pueden redefinir los colores: es decir puedes modificar la paleta de colores. Si “infocmp | grep ccc” te devuelve “ccc” significa que puedes redefinir los colores. En este caso puedes usar la capacidad initc para inicializar un valor x (0…colors-1) con los valores RGB deseados. Así echo $(tput initc 1 500 500 500)$(tput setaf 1)Hello imprimirá “Hello” con el color número 1 que antes hemos definido como RGB (500,500,500) (los rangos suelen ir de 1 a 1000), es decir un gris. Con initc cambias la paleta actual, por lo que si tienes algo en el terminal impreso con el color x y lo modificas con intc, los carácteres existentes de este color se verán afectados también.

Posteriormente la gama de colores ANSI se amplió a 256 colores (8 bits). En este caso setaf/setab soportan valores de 0 a 255 y hay paleta pre-establecida de colores. De todos modos, por lo general, los terminales que soportan 256 colores suelen soportar también ccc por lo que puedes modificar la paleta a tu gusto. La mayoría de terminales actuales permiten usar paletas de 256 colores simultáneos a elegir entre una gama de 16 millones.

Hasta aquí lo básico de colores y terminfo. Pero no se puede hablar del desarrollo de terminal en *NIX sin hablar de ncurses. Esta es una librería que es un standard de facto en el mundo *NIX que nos ofrece una API unificada sea cual sea el tipo de terminal que tengamos debajo. Eso significa que en ncurses tengo un método para borrar la pantalla y este método usará la secuencia de escape correcta. Por supuesto ncurses por debajo usa terminfo (e incluso termcap en versiones *NIX realmente antiguas) pero nos abstrae de él. Cuando crearon ncurses, para dar soporte a la dualidad HP/Tektronix optaron por usar una API orientada en pares de colores. Es decir, en ncurses no estableces el color de fondo y texto por separado, si no que creas un par de colores (fondo, texto) y estableces un par para el carácter. Vamos, que ncurses expone el modelo de HP. Si el terminal es tipo Tektronix, es ncurses quien traslada el “par x” a los colores de fondo y texto correspondientes. La razón de que funcione así es que ncurses es una evolución de curses y cuando curses apareció lo normal es que los terminales fuesen “tipo HP”.

Claro, el modelo de pares no escala muy bien. En 256 colores tenemos 65536 pares que bueno… aún podemos meter en memoria. Pero… si queremos 16 millones de colores, la cosa se dispara.

Vale, vayamos ahora al soporte de true color. Lo primero a saber es que no hay un mecanismo estandarizado para soportar true color, pero bueno. Lo primero es saber si tu terminal soporta true color. No hay entrada en terminfo que te lo diga, así que actualmente se suele usar la variable de entorno COLORTERM. Si existe y su valor es “truecolor” o “RGB” es que el terminal que usas soporta true color. En mi Ubuntu 18.04 el valor de COLORTERM es “truecolor” para indicar precisamente este soporte. Pero el fichero terminfo que viene por defecto (xterm-256color) no tiene las definiciones de las secuencias de escape correctas… simplemente porque no se ha definido qué entradas de terminfo deben usarse para true color.

El modo true color se diferencia del modo de 256 colores en algo fundamental: el primero funciona, como ya hemos visto, con paleta de colores y el segundo es de acceso directo. Con los modos de 256 o bien tenemos una paleta fija (los colores ANSI) o (si se soporta ccc) podemos modificarla (con llamadas a initc) y luego mediante setaf/setab seleccionamos cual de esos 256 colores usamos en cada caso. Con un modo de acceso directo no nos interesa esa aproximación: lo que queremos es establecer los valores (R,G,B) de cada carácter de forma independiente. Pero, como hemos visto, terminfo no define ninguna capacidad que podamos usar. Cual es la aproximación que puede seguir? La primera es tener hardcodeadas en tu código las secuencias de escape a usar para usar un color (RGB) concreto. Todos los terminales parece ser que usan las mismas, así que esa aproximación funcionaría.

Otra aproximación es reutilizar las capacidades setaf/setab y tener un fichero terminfo modificado que genere las nuevas secuencias de escape. Por ejemplo ncurses, en su última versión la 6.1, ha optado por esa aproximación. Así ncurses 6.1:

  • Mira si terminfo tiene una capacidad llamada RGB (sin valor, solo debe existir)
  • Si existe asume que el terminal “puede usarse” en modo directo (sin paletas) y asume que setaf/setab mandan las secuencias de escape correctas.

Si tu terminal es “xterm-256color” puedes encontrar un fichero terminfo con soporte de color directo en https://invisible-island.net/xterm/terminfo-contents.html#tic-xterm-direct. Este es un fichero terminfo que reutiliza xterm-256color pero añade la capacidad RGB y redefine setab/setaf. Debes usar tic para instalar este fichero en tu sistema.

De todos modos, hasta donde yo sé ncurses todavía no está adaptada al modo de color directo. El problema es que su API sigue estando basada en pares de colores y eso es imposible para 16 millones de colores. Aunque reconozco que no he investigado mucho más, creo que hoy por hoy, si quieres soportar true color debes abandonar ncurses y usar terminfo a mano (o si la compatibilidad con terminales antiguos no te importa puedes tener hardcodeadas las secuencias de escape que es lo que hace mucha gente).

Estado del arte en Windows

Las cosas son mucho más sencillas en Windows… o al menos lo parecen. A diferencia de *NIX, Windows no se preocupa de terminales antiguos y a diferencia de *NIX, Windows no ve el terminal solo como un emisor/receptor de carácteres, si no como un “objeto del sistema”. Y por lo tanto, Windows ofrece una API de consola, que desde el punto de vista de desarrollador es una maravilla, aunque, como veremos luego viene con un precio enorme que pagar.

La API de consola de Windows, entiende de terminales modernos por lo que, tenemos métodos para mover el cursor, para obtener datos del teclado, del ratón y por supuesto para gestionar la pantalla y los colores. Nada de secuencias de escape: métodos de la API de Win32. Y por supuesto, como forma parte de Windows, entiende que la consola es una ventana, de forma que puedes abrir tantas consolas como necesites (cada proceso puede tener una).

Ya te digo: como desarrollador es imposible no enamorarse de la API de consola de Windows. No solo es potentísima, si no que está bien diseñada y da soluciones para casi todo. En un mundo Windows, donde la gente suele estar físicamente delante del ordenador todo funciona de mil maravillas. Pero hay dos grandes limitaciones con la API de Windows actual.

La primera limitación es que todo aquello que la API no soporte, no vas a poder hacerlo. En *NIX no hay esa limitación porque siempre puedes mandar secuencias de escape que te hagas tú y oye, si el terminal las entiende todo funcionará. Pero en Windows no y la API tiene una gran limitación actualmente: solo soporta colores ANSI de 4 bits. Sí, eso significa que estás limitado a 16 colores. Game over (bueno, no del todo… sigue leyendo xD).

La segunda limitación tiene que ver con un escenario muy típico en el mundo *NIX pero muy poco frecuente en el mundo Windows hasta hace relativamente poco: el acceso desde un terminal remoto. En *NIX es típico usar ssh, conectarse a un ordenador remoto y desde esa terminal ejecutar comandos que pueden incluír programas de consola “a pantalla completa” como vi o emacs. Y si el programa está “bien hecho” (es decir usa terminfo) el programa se adaptará al terminal remoto (el tuyo). Eso mismo en Windows es, con perdón de la expresión, un puto dolor. Me dirás que hay clientes de ssh en Windows como Putty, pero es que el dolor al que me refiero no es tuyo si no de los pobres desgraciados que han tenido que crear un servidor ssh para Windows. De verdad, que programas como OpenSSH o emuladores de terminal como ConEmu funcionen en Windows es casi un milagro. ¿Conoces los emuladores de terminal? En *NIX son muy comunes (p. ej. xterm que es el que se usa por defecto en la mayoría de distros de Linux), de hecho son tan comunes que (casi) siempre usas uno. La razón es que un terminal es un dispositivo hardware (un monitor y un teclado p. ej.). Un terminal no es una ventana flotante. Cada vez que abres una ventana flotante, en un Linux o un MacOs usas un emulador de terminal. Un emulador de terminal lee carácteres, intepreta las secuencias de escape y muestra los resultados.

Imagina que quieres crear un emulador de terminal. En *NIX crearías un programa que básicamente leería carácteres. Carácter que recibe lo muestra en su ventana, a no ser que forma parte de una secuencia de escape que en cuyo caso procesa adecuadamente. Por lo tanto, en *NIX, abres tu emulador de terminal (como xterm) y ejecutas un programa de consola (como emacs o midnight commander). Este programa empezará a mandar carácteres y secuencias de escape al emulador de terminal (para este programa el emulador de terminal es el terminal) y éste los procesará para mostrar los resultados de forma correcta. Pero en Windows….. ¡uy en Windows! El problema es que en Windows tu abres el emulador de terminal y ejecutas un programa. Este programa usará la API de consola de Windows para pintar la pantalla o mover el cursor. Pero la API de consola de Windows ¡afectará a la consola real de Windows, no a la ventana del emulador de terminal. Por lo tanto, lo que hacen los emuladores de terminal en Windows es tener la ventana de consola real del sistema en una posición no visible y es en esta ventana en la que se ejecuta realmente el programa. Luego, con un temporizador, realizan scrapping del contenido de dicha ventana y refrescan la ventana real que tu estás viendo. En el caso de un servidor ssh para windows ocurre lo mismo (de hecho un servidor ssh es un emulador de terminal). El programa remoto se ejecuta en una ventana de la consola de Windows, el servidor ssh hace scrapping de dicha ventana y manda por red el resultado hacia el cliente. ¡Qué diferencia con *NIX donde el servidor ssh solo debe mandar los carácteres que recibe!

En fin… ¿a qué ahora miras a ConEmu con otros ojos?

Bueno, tenemos pues las dos limitaciones en Windows: colores ANSI de 4 bits y remoting de aplicaciones muy complejo. Por suerte Windows 10 viene a cambiar las reglas del juego.

Windows 10 incorpora novedades en el sistema de consola de Windows. Es importante porque, a grandes rasgos, el sistema de consola de Windows 7 es el mismo que el de Windows 2000. Así que el hecho de que Microsoft esté haciendo cambios importantes en él, es algo muy destacable.

El primer cambio que Microsoft ha implementado ya en Windows 10, es… ¡soporte para secuencias de escape! Efectivamente: ahora la consola de Windows 10 entiende de secuencias de escape. Además, como no se pretende soportar un porrón de terminales del pleistoceno, no hay terminfo ni nada que se le parezca: el SO define unas secuencias y son esas las que puedes usar. En este caso son secuencias definidas por el estándard ANSI (sí, hay un estándard de secuencias de escape al cual todos los terminales modernos, incluídos los emuladores modernos en *NIX) están adheridos). P. ej. en *NIX, el emulador xterm-256colors usa secuencias de escape ANSI. Un programa que emita esas secuencias de escape se verá correctamente en el terminal de Windows 10… En el de Windows 7 solo verás horror y destrucción. Eso viene acompañado del soporte de colores ANSI de 8 bits e incluso true color pero solo a través de secuencias de escape. Es decir, la API de consola no se ha adaptado para soportar más de 16 colores, pero mediante secuencias de escape, ¡tenemos los que queramos! De hecho la recomendación es empezar a usar secuencias de escape en lugar de la API de consola.

Si te preguntas por qué le ha dado a Microsoft por incorporar secuencias de escape en su consola después de tantos años de pasar de ellas, la razón es muy simple y tiene tres letras: WSL. Recuerda: WSL ejecuta programas Linux nativos, pero se ejecutan en la consola de Windows… por lo tanto la consola de Windows debe actuar como emulador de terminal y entender las secuencias de escape.

La segunda novedad es incluso de mayor calado y aparece en Windows 1809 y se trata de la API ConPTY. Y, amigos, eso es la bomba: ConPTY dota a Windows de un modelo de PTY completo como el de *NIX. Una descripción detallada de ConPTY queda fuera del alcance de este post, pero, para resumir, diremos que básicamente lo que ConPTY hace es traducir las llamadas de la API de Consola a secuencias de carácteres. Y eso, está pensado para simplificar el remoting de aplicaciones y los emuladores de terminal. ¿Recuerdas todo lo del scrapping que comentaba antes? Pues con ConPTY ahora todo es mucho más sencillo. La idea es que el emulador de terminal se conectará con ConPTY que a su vez se conectará con la consola real. Cuando una aplicación que se ejecuta en la consola real usa la API de consola para hacer algo, ConPTY traducirá ese algo a una secuencia de carácteres y mandará esa secuencia de carácteres a quien esté conectado a él. Por lo tanto nuestro emulador de terminal ya no tiene que hacer scrapping ni nada: envía carácteres a ConPTY y esos carácteres llegan a la consola (y al programa real). Cuando el programa usa la API de Consola, ConPTY se encarga de generar una secuencia de escape correcta y es esa secuencia de escape la que lee el emulador de consola. De hecho el emulador de consola desconoce cual es la consola real asociada: él solo conoce a su ConPTY asociado. En resúmen, con ConPTY Windows está adoptando el modelo que tiene *NIX, al tiempo que mantiene la compatibilidad de la API de consola para no romper todas las aplicaciones actuales.

Por lo tanto si quieres hacer una aplicacion de consola para Windows y aprovechar al máximo las capacidades debes:

  1. Usar la API de consola de Windows si estás en Windows 7 o anteriores
  2. Usar las secuencias de escape si estás en Windows 10

Y tener presente que tu aplicación en Windows 7 solo podrá usar 16 colores.

Estado del arte en .NET

En .NET las cosas son muy sencillas: la API de consola de .NET (System.Console vamos) se creó a imagen y semejanza de la API de consola de Windows, así que ha heredado sus limitaciones (p. ej. solo 16 colores) y ha añadido algunas de cosecha propia (no se soporta el ratón).

Con .net core, actualmente, tenemos las mismas limitaciones y hay una implementación de System.Console por cada plataforma:

  • En Windows, se usa la API de Windows via P/Invoke
  • En Linux y MacOS se usan secuencias de escape. La implementación para Linux usa terminfo para obtener las secuencias de escape. Eso sí, la gestión es simplificada (p. ej. asumen que el terminal es tipo Tektronix y siempre usan setaf/setab) pero lo realmente interesante es que no usan la API de terminfo (las funciones tigetstr y putp básicamente) via P/Invoke si no que se han currado su propio parser del fichero terminfo. Entiendo que es por rendimiento. Lamentablemente esta clase no es pública 🙁

¡Y eso es todo! Ya ves… y parecía que crear una aplicación de consola era sencillo, ¿verdad? Pues solo hemos hablado de colores… cualquier día hablaré de la gestión del teclado y… ¡del soporte de ratón! Otro mundo…

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *