Управляющие последовательности (ANSI)

от автора

В этой серии статей мы погружаемся в возможности языка AutoHotkey на примере вывода цветного текста в терминал для консольной версии Launcher.

Часть 1: Наивный алгоритм и его оптимизация
Часть 2: Управляющие последовательности (вы здесь)
Часть 3: Регулярные выражения
Часть 4: Цвет и стилизация текста (библиотека)

В прошлой части мы переписали алгоритм из рекурсивного в итеративный и ускорили его исполнение почти в 22 раза. Однако в получившемся коде пропала поддержка вложенных цветов. В этой части мы попробуем разобраться, почему это произошло, что такое ANSI коды и как они обрабатываются терминалом.

Управляющие последовательности

Терминалы

Сегодня терминал — это синоним эмулятора терминала (например, Cmder, Windows Terminal, Ghostty и многие другие), но когда-то терминалы были довольно простыми устройствами: клавиатура и экран для общения с удаленным сервером. Эти терминалы получали байты данных, но не имели возможности различать различные типы получаемых данных. Еще не существовало способа управлять поведением терминала.

Терминал DEC VT-100

Терминал DEC VT-100

Если вы введете в поиск или на Wikipedia “ANSI codes” из прошлой части, то вы первым вы увидите сайт ANSI (American National Standards Institute). Термина “ANSI codes” не существует, но есть институт ANSI. В нем создали стандарт для экранирования символов в терминалах; он был заменен стандартом ISO 6429. ANSI также оставил несколько байтов для кодов экранирования, специфичных для каждого производителя.

В наши дни эти коды различаются в разных ОС и эмуляторах терминала. За интерпретацию ANSI отвечает сам эмулятор. В том же Cmder можно изменить цвета и поведение, которое будет демонстрировать терминал, когда встретит ANSI.

В PowerShell 3.0+ (который эмулирует pwsh, Cmder и Windows Terminal) для обработки ANSI и интерактивного ввода используется модуль PSReadLine. С версии 5.0 он позволяет менять цвета текста с помощью $([char]0x1b)[91m (далее мы разберемся, что это значит), а с версии 6.0 доступен более удобный синтаксис через $PSStyle:

$PSStyle.Formatting.Verbose = $PSStyle.Foreground.Cyan$PSStyle.Formatting.Debug   = $PSStyle.Foreground.DarkGray

В Windows существует механизм Console Virtual Terminal Sequences на уровне консоли. Начиная с Windows 10, консоль поддерживает обработку VT100-последовательностей, что позволяет PowerShell корректно интерпретировать ANSI коды из прошлой части. Благодаря этому механизму мы можем выводить одинаково стилизованный текст из AutoHotkey в cmd.exe и pwsh.exe (или любой другой эмулятор).

Экранированная последовательность

Чтобы сообщить терминалу, что мы хотим использовать ANSI коды для стилизации текста, мы должны использовать экранированную последовательность (escaped sequence). Для этого существует специальный символ экранирования. В Unix-подобных системах символом экранирования обычно является \e или \033. В PowerShell 6.0+ и AutoHotkey 2.0+ символом экранирования служит `e. В AutoHotkey мы использовали esc := Chr(27) для надежности.

В PowerShell 5.1 вместо `e необходимо использовать $([char]0x1B) или [char]27.

В данной статье мы будем использовать распостраненное обозначение \e.

Экранированные последовательности смешаны с основным текстом когда вы их печатаете. Однако они исчезают при выводе, так как они сообщают информацию терминалу, а не читателю сообщения. Поэтому важно помнить, что длина сообщения с экранированной последовательностью всегда больше длины выведенного сообщения!

В сообщение добавляются экран. последов-ти, но его длина не меняется

В сообщение добавляются экран. последов-ти, но его длина не меняется

Управляющая последовательность

Управляющая последовательность начинается с комбинации символов \e и [ — эту последовательность (комбинацию) называют CSI (Control Sequence Introducer). За ней следует несколько байтов (помните, что 0 также является числом), и каждая последовательность имеет свой набор правил и конвенций о том, что она делает и какие аргументы ожидает. В рамках данной статьи мы рассмотрим только одно подмножество управляющих последовательностей — изменение цвета и стиля текста. Они имеют вид CSI n1 [;n2 [; ...]] m, где m— SGR функция (Select Graphic Rendition), а каждый n1n2, … является параметром SGR. Для однобайтового набора с которым мы работаем (\e[n1;n2m...) n1 — число от 0 до 7 (стиль текста или курсора); n1 — число от 0 до 107 (цвет).

В раннем стандарте ANSI было предусмотрено всего 8 цветов, которые можно было использовать для изменения цвета текста и фона. Значения n2 30-37 определяли цвет переднего плана, а 40-47 — цвет фона. Позднее появились коды для более яркого цвета: 90-97 для текста и 100-107 для фона. Этот набор называется “однобайтовый” и его мы использовали в прошлой части.

Управляющая последовательность также исчезает из выведенного сообщения, так как она является разновидностью экранированной последовательности.

256 цветов

Если 8 цветов окажется недостаточно (а это вполне возможно), мы можем использовать более длинную последовательность для палитры из 265 цветов. Последовательность ANSI для использования 8-битного цвета выглядит следующим образом: \e[<код текста | фона>;5;n, где первый код 38 (цвет текста) или 48 (цвет фона); n — цвет от 0 до 255. Например последовательность \e[38;5;220m меняет цвет текста на тёмно-жёлтый. Диапазон из 256 цветов делится на несколько секций: 0-7 — стандартные цвета, 8-15 — яркие цвета, 16-231 — цветовой куб 6x6x6, а 232-255 — оттенки серого.

Доступный спектр цветов

Доступный спектр цветов

Различные примеры последовательностей доступны здесь.

Завершение последовательности

Если не указать завершающую комбинацию последовательности, она будет применяться ко всему последующему тексту, выводимому нашим кодом на экран (будь то PowerShell или AutoHotkey).

При запуске приведённого ниже фрагмента кода вы заметите, что текст на новой строке по-прежнему выделен жирным шрифтом и инвертирован. Кроме того, изменился первый символ переноса строки в prompt.

Форматирование влияет на весь prompt и не обновляет его

Форматирование влияет на весь prompt и не обновляет его

Для завершения последовательности достаточно добавить в конец \e[0m. Эта комбинация сбрасывает ранее примененный стиль, цвет и т.д.

Полный сброс

Полный сброс

Альтернативный вариант — сбросить каждый код по отдельности. Например последовательность \e[1;7m сделает шрифт жирным и инвертирует цвета текста и фона. Чтобы сбросить только инверсию, следует добавить \e[27m. Текст останется жирным, но не инвертированным. Чтобы сбросить жирный текст, нужно добавить \e[22m. Текст больше не будет ни инвертированным, ни жирным.

Частичный сброс

Частичный сброс

Управляющие последовательности

Теперь, когда мы знаем, что для изменения цвета или стиля сообщения, необходимо в начало добавить \e[n1;n2m (например, \e[0;96m а в конец — \e[0m будет очевидно, что сообщение состоит из нескольких управляющих последовательностей:

\e[0;96m message \e[0m

Пробелы добавлены для читабельности. Далее в статье мы будем использовать использовать словосочетание управляющие последовательности, так как \e[0;96m — это одна последовательность; \e[0m — это тоже одна последовательность; а множественное число говорит о наличие нескольких таких последовательностей в сообщении.

Теперь, когда мы разобрались с тем, что вообще такое ANSI коды из предыдущей части, мы можем называть их правильно: управляющие последовательности.

Решение с вложенными цветами

State Machine

Давайте вернемся к нашему алгоритму вывода в консоль и разберемся, почему в нем не работают вложенные цвета, т.е. изменение цвета текста внутри уже раскрашенного текста.

Управляющие последовательности работают по принципу конечного автомата с единственным набором текущих атрибутов. Терминал хранит в памяти ровно один набор активных графических свойств: цвет текста, цвет фона и стиль (курсив, подчеркивание, и т.д.). Каждая новая последовательность перезаписывает соответствующие атрибуты, а не добавляет их в стек.

Предположим у нас есть сообщение, которое мы хотим раскрасить следующим образом:

<cyan>text<green>and</green>text</cyan>

Html теги приведены для читабельности и понимания цветов. Мы должны обернуть текст в управляющие последовательности:

"text and text".Color(".+", "cyan").Color("and", "green")

Метод Color() применит управляющие последовательности в следующем порядке:

\e[0;96m text \e[0;92m and \e[0m text \e[0m

Разберём по шагам, как это интерпретирует терминал:

Шаг

Последовательность

Состояние терминала после применения

1

\e[0;96m

Сброс всех атрибутов, затем установка цвета переднего плана в бирюзовый (96)

2

Вывод text

Бирюзовый цвет текста

3

\e[0;92m

Сброс всех атрибутов. Изменение цвета текста на зеленый (92)

4

Вывод and

Зеленый цвет текста

5

\e[0m

Сброс всех атрибутов

6

Вывод text

Стандартный цвет

7

\e[0m

Сброс всех атрибутов (избыточный, состояние уже сброшено)

На 3-ем шаге происходит полный сброс. Терминал не запоминает, что до этого был голубой цвет. Из-за отсутствия стека эта информация была потеряна. В отличие от HTML/CSS, где каждый элемент имеет собственный набор стилей и браузер вычисляет каскад, терминал работает с глобальным состоянием.

Таким образом, правильной является следующая последовательность:

\e[0;96m text \e[0;92m and \e[0;96m text \e[0m

Проверить работу управляющих последовательностей можно на сайтах ansi101 и ansi.tools.

Стек

Так как мы начали с разработки своего синтаксиса, который будет подсказывать Color(), куда добавить цвета, определимся с парами символов, который будут изменять цвет текста на бирюзовый и зеленый. Пусть это будут " " и * * соотв.:

"text *and* text"

Идея исправленного алгоритма следующая. Перед каждым символом необходимо добавить какую-то последов-ть (цвет). Если мы находимся внутри пары символов (например " ") и не нашли ни одной новой вложенной пары (* *) — просто добавляем открывающую и закрывающую последов-ть перед открывающей после закрывающей кавычки соотв.:

\e[96m"  ; уровень 1  text and text"\e[0m

Для каждой найденной вложенной пары * внутри " " переключаем цвет: открываем новый для открывающей и переключаем на предыдущий для ":

\e[96m  <- свой цвет"  ; уровень 1text     \e[92m   <- свой цвет    *         ; уровень 2      and     *    \e[96m    <- предыдущий цвет    text"\e[0m   <- нет предыдущего цвета

То есть получается, что закрывающий цвет каждой пары символов (проблема со вложенностью которую мы и пытаемся решить) зависит от уровня вложенности этой пары: каждый следующий уровень закрывается цветом уровня выше. И так до самой первой найденной пары верхнего уровня, которая не является вложенной:

Уровень

Символ

Цвет

1

«

Бирюзовый

2

*

Зеленый

2

*

Бирюзовый

1

«

0

Для пары верхнего уровня нет предыдущего уровня, то есть ее можно закрыть с помощью \e[0m или ее же цветом \e[96m. Всю эту таблицу можно упростить до простого стека, который хранит только найденный символы:

"**"

Из этого стека мы можем извлекать предыдущий символ и переключать цвет на предыдущий из chrColors — HashMap с парами «символ-код»:

" 96* 92

Получаем следующий алгоритм:

stack := []while (pos <= len) {    if !RegExMatch(msg, regex, &match, pos) {        ; Оставшийся текст        clrMsg .= msg.Slice(pos)        break    }        ; Обычный текст перед найденным символом    clrMsg .= msg.Slice(pos, match.pos - pos)    if (stack.Has(-1) && stack[-1] = match[1]) {        clrMsg .= match[1] . end        stack.Pop()    } else {        begin  := esc '[0;' chrColors[match[1]] 'm'        clrMsg .= begin . match[1]        stack.Push(match[1])    }        ; Движемся вперед    pos := match.pos + match.len}

Вы можете прочитать, что такое RegExMatch, здесь. Ниже представлена визуализация работы данного алгоритма, созданная с помощью staying.fun

На сайте ansi101 можно увидеть, как выглядит итоговое сообщение clrMsg.

Пары символ-цвет

Очевидно, что требовать от пользователя передать в функцию Color() HashMap с числовыми кодами неудобно. Мы хотим передавать пары «символ-цвет»:

msg.Color([   '"',  'cyan',  '*',  'green'])

В данном случае мы передаем массив, а не HashMap по двум причинам:

  1. Нам важен порядок, так как онопределяет приоритет каждого цвета.

  2. Это промежуточный контейнер, который нужен только для итерации.

Массив промежуточный, так как мы его преобразуем в HashMap chrColors с “символ-код”, с которым можно работать:

regex     := '' ; итоговое регулярное выражениеchars     := '' ; строка символовchrColors := Map() ; для HashMap нет сокращенияindex := 1loop (aRegexColor.length / 2) {; `aRegexColor` - входной массив пар "символ-цвет"    str   := aRegexColor[index++]    color := aRegexColor[index++]    chars .= str    chrColors[str] := colors[color]  ; `colors` - HashMap с парами "color-code"}if chars    regex .= '([' chars '])'else    regex := regex.RTrim('|')

Для оптимизации мы объединяем все переданные символы в RegEx set ["*], так парсинг пойдет чуть быстрее. А далее каждый символ будет служить ключом для извлечения цвета из chrColors.

Исходный код алгоритма вывода цветного текста доступен на GitHub. А здесь вы можете прочитать про некоторые дополнительные возможности этого алгоритма.

Заключение

Управляющие последовательности — это наследие тех лет, когда терминалы были физическими машинами, а не программами. Почти каждая библиотека форматирования текста, вроде {fmt} добавляет эти невидимые символы в сообщение. Поэтому для работы с цветами и стилями текста мы должны разобраться в них.

В данной статье мы увидели, что управляющие последовательности можно научиться читать. Теперь мы знаем, как их будет интерпретировать терминал, а значит — можем “попросить” его сделать ровно то, что нам требуется.

В следующей статье мы добавим поддержку целых выражений и рассмотрим некоторые интересные возможности регулярных выражений. Заглядывайте на мой GitHub, если вы хотите увидеть возможности AutoHotkey на практике.

ссылка на оригинал статьи https://habr.com/ru/articles/1054876/