Цветной текст в консоли в AutoHotkey

от автора

Не так давно я начал обращать внимание, что многие консольные утилиты выводят цветной текст. Меня заинтересовало, смогу ли я тоже добавить цвета в вывод моей консольной версии Launcher.

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

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

Наивное решение

Виды функций

Начнем с идеи раннего алгоритма. Наша цель — менять цвет сообщения при выводе в консоль (далее — терминал, так как я работаю в эмуляторе Cmder с PowerShell). Для этого сначала в самом сообщении должны быть какие-то маркеры, которые будут указывать цвет для каждой части текста. Например, маркерами могут служить html теги: <color>text</color>. Далее необходимо найти все такие теги в тексте, извлечь имя цвета и применить его в консоль.

Сырой код

Сырой код
Текст в консоли

Текст в консоли

Благо Microsoft позволяет нам обращаться к Windows и просить ее что-нибудь сделать. Для этого существует Windows API (Windows Application Programming Interface, он же WinApi или просто функции API). Каждая функция имеет свою документацию на сайте Microsoft, и располагается в своих dynamic-link libraries (DLL), которые загружаются при запуске интерпретатора AutoHotkey. В результате мы можем вызывать любые функции из этих библиотек от Microsot из AutoHotkey с помощью встроенной функции языка DllCall. Все встроенные функции AutoHotkey вроде MsgBox или FileAppend вызывают те или иные функции WinApi, но при большом желании мы можем “спуститься на уровень ниже” и вызвать их самостоятельно.

Не стоит путать: пользовательские функции мы объявляем сами; функции языка предоставляет интерпретатор; функции WinApi можно вызвать только через DllCall;

Для изменения цвета в консоли существует функция WinApi SetConsoleTextAttribute. Она принимает handle консоли (ее уникальный номер/id) и число, которое “характеризует” цвет. Это не RGB и не HEX представление, а специальный внктренний флаг, на котором мы не будем заострять внимание.

Для дальнейшего вывода в консоль существует функция языка FileAppend которая умеет выводить сообщение в текст или в консоль (специальное имя файла — CONOUT$).

В самой ранней версии алгоритма использовалась рекурсивная обработка html тегов цвета и вызов SetConsoleTextAttribute() для каждого с последующим вызовом FileAppend():

Print(text, color := 'white') {    ; Словарь/HashMap который сопоставляет цвету спец. код    ; статич.: инициализируется один раз при загрузке скрипта,    ; уменьшает время исполнения    static colors := Map(        'black',    0,        'blue',     1,        'green',    2,        'cyan',     3,        'red',      4,        'magenta',  5,        'yellow',   6,        'white',    7,        'gray',     8,    )        ; Цвет по-умолчанию, который будет использоваться    ; для частей текста    normalColor := colors.Get(color, 7)        _Print(msg, _color := normalColor) {        ; Замыкание: пользовательская функция внутри функции        ; Весьма удобно использовать как макрос для повторяющегося текста        ; статич.: инициализируется один раз при загрузке скрипта,        ; уменьшает время исполнения        static hConsole := GetOutputHandle()        DllCall(            'SetConsoleTextAttribute',             'ptr', hConsole,             'uint', _color        )                ; Выводим цветной текст        FileAppend(msg, 'CONOUT$')    }    pos := 1    while (pos <= text.length) {        ; Ищем все цветовые теги начиная с `pos`,         ; сохраняем результат в переменной `match`        if (RegExMatch(text, 's)<(\w+)>(.*?)</\1>', &match, pos)) {            ; Выводим текст до цвтного участка            normalText := text.Slice(pos, match.pos - pos)            if (normalText)                _Print(normalText)    ; замыкание (макрос) сверху                        ; Обрабатываем вложенные теги            ; Рекурсивный вызов:            ; группа 2 = сообщение,             ; группа 1 = цвет.            Print(match[2], match[1])                        ; Движемся вперед            pos := match.pos + match.len        } else {            ; Выводим оставшийся текст            _Print(text.Slice(pos))            break        }    }}Message(msg, icon := '', normalColor := 'white') {    if IsConsole        return Print(msg '`n', normalColor)         msg := RegExReplace(msg, 's)<(\w+)>(.*?)</\1>', '$2')    return MsgBox(msg, A_ScriptName, icon)}

RegExMatch это функция языка, которая позволяет быстро парсить текст без промедлений со стороны высокоуровневых языков

Как видите, выглядит довольно просто: найти цвет, применить цвет, вывести в консоль, повторить. И такой алгоритм обрабатывает и вложенные теги: <yellow>my colorful<cyan>message</cyan></yellow>

Так как функция Print() только обрабатывает и выводит сообщение, нам нужна дополнительная функция Color() которая добавит html теги в текст:

Color(str, regex, colorTag) {    return RegExReplace(        str,         regex,        Format('<{1}>$0</{1}>', colorTag)    )}

Еще одна функция языка, которая ищет кусок текста и обрачивает его в теги. Минималистично, удобно.

Новые методы строк

Для удобства мы можем объявить дополнительные методы строк как в Python. Только в AutoHotkey в отличие от Python мы можем создать какие угодно методы для наших строк, например добавить метод Color() который будет раскрашивать строку:

'My colorful message'.Color('message', 'cyan'); or'-help, -h or -? displays help message'.Color('-\w+', 'green')

Как вы, наверное, догадались, метод Color() будет вызывать нашу функцию Color() и передавать первым аргументом msg ту строку, к которой он применяется. Т.е. 'My colorful message'.Color('message', 'cyan') будет эквивалентно Color('My colorful message', 'message', 'cyan').

Такая конструкция объявляет новый метод Color() для примитивов типа String:

({}.DefineProp)(String.prototype, 'Color', {call: Color})

В алгоритме выше вы могли заметить методы Slice(), атрибут length. На самом деле это тоже “добавленные методы”, которые являются обертками над функциями языка, но делают код чуть-чуть читабельнее.

Давайте посмотрим, как можно применить наш алгоритм. В коде есть функция PrintHelp(). Внутри нее сообщения со справкой: с цветовыми тегами и без них. Ниже приведен ее небольшой фрагмент (так как сообщения достаточно большие):

PrintHelp() {    usage :=     (    "<gray>Launch saved scripts and applications.    Copyright (c) 2026 Rafaello    https://github.com/JoyHak/Launcher</gray>        Usage:      launcher --param=script1<gray>[;script2;script3...]</gray>      launcher --param=<yellow>@file</yellow>      launcher -switch      launcher <cyan>variable</cyan>=value"    )    synopsis :=     (    "Parameters:      --run     run script(s)      --close   close script(s)      --add     add script(s)      --remove  remove script(s)      --sep     set separator between scripts"    )    synopsis := synopsis      .Color('[\-]+[\-\w]+', 'cyan')        ; Конкатенация строк: a . b    Message(usage . synopsis)

Так как сообщение usage уже содержит цветовые теги, Color() применятся только к собщению synopsis и раскрашивает --switches.

Красиво. Но что лежит у этих функций под капотом и сколько операций они совершают при каждом вызове? Функция FileAppend() снова и снова открывает/закрывает доступ в консоль. Функция SetConsoleTextAttribute выполняет ряд операций, которые к смене цвета не имеют отношения: опрос режима консоли, проверка свободного буфера вывода и т.д. И наконец есть риск что рекурсия в Print() может обвалиться при многократно вложенных тегах и вызвать stack overflow.

Время выполнения

Давайте попробуем измерить время выполнения алгоритма. Для этого используем обертку над функцией WinApi QueryPerformanceCounter:

Timer(_start := false) {    static previous  := 0,            frequency := 0,            current   := DllCall("QueryPerformanceFrequency", "Int64*", &frequency)        _result := !DllCall("QueryPerformanceCounter", "Int64*", &current)    if _start {        previous := current        _result += current / frequency    } else {        _result += (current - previous) / frequency    }        return _result * 1000  ; seconds to milliseconds}

Приницип прост: Timer(1) запускает таймер, а Timer(0) — останаливает и возвращает время в миллисекундах. Измерим время обработки всей справки и сохраним в файл:

PrintHelp() {; ...Timer(1)    synopsis := synopsis      .Color('[\-]+[\-\w]+', 'cyan'); switches          examples := examples      .Color('(mainDir|AhkDir)(?=\=)', 'cyan') ; variables names      .Color('%[^%]+%',                'blue') ; variables values      .Color('@(file|list\.ini)',      'yellow') ; list        Message(usage . synopsis . examples)    time := Timer(0)        FileAppend(        Format('Color tags, multiple SetConsoleTextAttribute() calls: {:.4f}ms`n', time),         'C:\Temp\launcher_bench.log'    )

Так как вывод в консоль и обработка аргументов возможна только в исполнямых файлах, сначала соберем наш launcher.ahk в launcher.exe и сохраним его в директорию v1. Выполним &"C:\Temp\v1\launcher.exe" -help в терминале и увидим в файле время 77.6859ms.

Довольно неплохо, хотя и видно как в терминале текст появляется постепенно, по частям. Можем ли мы оптимизировать наш код?

Производительное решение

ANSI коды

Как я писал выше, самое медленное здесь — связка SetConsoleTextAttribute и FileAppend. Было бы здорово оставить только один вызов FileAppend, но тогда мы должны выполнить пре-процессинг сообщения самостоятельно, без вызова SetConsoleTextAttribute.

Мне удалось выяснить, что SetConsoleTextAttribute добавляет специальные коды в сообщение при выводе в терминал. И хотя исторически за ними сохранилось название ANSI codes, на самом деле они представляют собой расширение для текущей кодировки (обычно Windows-1252).

Причем сама функция довольно ограничена, ведь число этих кодов (и способов кодирования) гораздо больше. Более того, мой эмулятор Cmder, как и многие другие хорошие эмуляторы терминала способны обрабатывать эти коды выводить 256+ цветов. Не уверен, что Windows Terminal на это способен.

Таким образом, теперь наш алгоритм должен сам добавлять ANSI коды в полученный на вход текст. Для простоты мы ограничимся однобайтовым набором символов. Они имеют следующий вид: \e[0;31m — красный, \e[0;34m — синий, и т.д. И хотя есть также “стилизация” текста, например \e[1;31m — жирный красный или \e[0;41m — красный фон, для простоты мы будем рассматривать только цвета текста без стилизации.

Для окрашивания сообщения нам достаточно “обернуть” найденные фрагменты текста в соответствующе коды согласно найденным цветовым тегам: \e[1;31mmessage\e[0m где \e[0m — “завершающий” код, после которого текст приобретает цвет по-умолчанию (зависит от настроек эмулятора, но обычно — белый).

В таком случае перепишем наш алгоритм так, чтобы он добавлял необходимые коды. Каждому коду дадим понятное имя, которое и будем ожидать на входе в качестве параметра color:

Color(text, color := 'white') {    ; Словарь/HashMap который сопоставляет цвету ANSI код.    ; статич.: инициализируется один раз при загрузке скрипта,    ; уменьшает время исполнения    static colors := Map(        'black',    30,        'red',      31,        'orange',   33,        'magenta',  35,        'gray',     90,        'crimson',  91,        'green',    92,        'yellow',   93,        'blue',     94,        'purple',   95,        'cyan',     96,    )    ; Default color for text    normalColor := colors.Get(color, 37)        static esc := Chr(27)     ; ASCI escape character \e    static end := esc '[0m'   ; Прекратить обработку цвета: \e[0m     begin  := esc '[0;' normalColor 'm'  ; Начать обработку цвета, код вроде \e[0;37m                 clrText := ''  ; итоговое цветное сообщение    pos := 1       ; начало сообщения        while (pos <= text.length) {        ; Ищем все цветовые теги начиная с `pos`,         ; сохраняем результат в переменной `match`        if (RegExMatch(text, 's)<(\w+)>(.*?)</\1>', &match, pos)) {            ; Нормальный текст до цветового участка            ; (цвет зависит от уровня рекурсии)            clrText .= begin . text.Slice(pos, match.pos - pos) . end                        ; Обрабатываем вложенные теги            ; Рекурсивный вызов:            ; группа 2 = сообщение,             ; группа 1 = цвет.            clrText .= Output(match[2], match[1])                        ; Движемся вперед            pos := match.pos + match.len        } else {            ; Выводим оставшийся текст            clrText .= begin . text.Slice(pos) . end            break        }    }        return clrText}Print(msg, icon := '') {    if IsConsole        return FileAppend(msg '`n', 'CONOUT$')       msg := RegExReplace(msg, 's)<(\w+)>(.*?)</\1>', '$2')    return MsgBox(msg, A_ScriptName, icon)}

Код стал значительно проще. Вместо двух независимых алгоритмов подсветки и парсинга текста мы получили один небольшой алгоритм в функции Color() и маленький алгоритм в функции Print(), который только выводит готовое сообщение одним вызовом FileAppend().

Выполним &"C:\Temp\v2\launcher.exe" -help и увидим время 3.5338ms. Ускорение в 21.9836 раз! Наглядный пример, почему много вызовов функций — это плохо.

Решение без тегов

Несмотря на удобный алгоритм у нас осталось несколько недостатков:

  1. Html теги неудобно писать руками в некоторых текстовых редакторах вроде Notepad++.

  2. Html теги удлинняют сообщение и замедляют парсинг.

Попробуем уменьшить количество символов в справке. Если вы знакомы с Markdown, то вы знаете символы форматирования текста: # __ `

При рендере .md документа они исчезают, чтобы сфокусировать внимание на тексте с помощью форматирования, а не с помощью доп. символов.

Вместо html тегов мы можем создать похожий синтаксис вроде #header#, magenta etc. который удобно парсить:

PrintHelp(*) {; ...    msg := usage . synopsis . examples        Timer(1)    msg :=       msg        .Color('(@(file|list\.ini))',    'yellow')   ; list        .Color('(mainDir|AhkDir)(?=\=)', 'purple')   ; variables names        .Color('(%[^%]+%)',              'blue')     ; variables values        .Color('(\-+[\-\w]+)(?=[ =])',   'cyan')     ; switches        .Color('\*\*([^\*]+)\*\*',       'crimson')          .Color('__([^_]+)__',            'magenta')          .Color('(~)',                    'gray')        .Color('(``)',                   'green')         .Color('(#)',                    'orange')      .Print()          time := Timer(0)        FileAppend(        Format('ANSI codes, multiple .Color() calls: {:.4f}ms`n', time),         'C:\Temp\launcher_bench.log'    )}

В таком коде мы ожидаем, что можно раскрасить текст внутри символов передав соответствующее выражение и ожидаемый цвет. Например, для синего текста, окруженного символами процента %, мы передаем выражение вроде (%[^%]+%).

Для поддержки таких ожиданий необходимо переписать Color():

Color(msg, regex, color) {       ; Словарь/HashMap который сопоставляет цвету спец. код    ; статич.: инициализируется один раз при загрузке скрипта,    ; уменьшает время исполнения     static colors := Map(        'black',    30,        'red',      31,        'orange',   33,        'magenta',  35,        'gray',     90,        'crimson',  91,        'green',    92,        'yellow',   93,        'blue',     94,        'purple',   95,        'cyan',     96,    )        static esc := Chr(27)     ; ASCI escape character \e    static end := esc '[0m'   ; Прекратить обработку цвета: \e[0m     begin := esc '[0;' colors.Get(color, 37) 'm'    ; Код текущего участка текста    pos := 1    len := msg.length    clrMsg := ''        while (pos <= len) {        if !RegExMatch(msg, regex, &match, pos) {            ; Remaining text            clrMsg .= msg.Slice(pos)            break        }                ; Текст перед цветным участком        clrMsg .= msg.Slice(pos, match.pos - pos)        ; Добавляем ANSI код        clrMsg .= begin . match[1] . end        ; Движемся дальше        pos    := match.pos + match.len       }        return clrMsg}Print(msg, icon := '') {    if IsConsole        return FileAppend(msg '`n', 'CONOUT$')       msg := RegExReplace(str, 'U)' esc '\[\d+(;\d+)?m')    return MsgBox(msg, A_ScriptName, icon)}; дополнительные методы для строк({}.DefineProp)(String.prototype, 'Color',  {call: Color})({}.DefineProp)(String.prototype, 'Print',  {call: Print})

Теперь мы последовательно ищем каждый шаблон с помощью цикла, и благодаря добавленному методу Print() можем создавать цепочки вызовов: "My colorful message".Color("message", "cyan").Print()

Выполним &"C:\Temp\v3\launcher.exe" -help в терминале и увидим время 2.5428ms. Весьма неплохо! Алгоритм из рекурсивного стал итеративный, и читабельность чуть-чуть улучшилась.

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

Вывод

Первое решение далеко не всегда самое правильное. Конечно, если вы пишите маленький AutoHotkey скрипт, скорость написания зачастую имеет значение. А вот если вы пишите полноценные проекты, которые выполняют большие и сложные задачи, разумно попытаться “притормозить” и попытаться сделать кодовую базу более читабельной, а используемые алгоритмы — более производительными.

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

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

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