English version.
Не так давно я начал обращать внимание, что многие консольные утилиты выводят цветной текст. Меня заинтересовало, смогу ли я тоже добавить цвета в вывод моей консольной версии 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*", ¤t) 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 раз! Наглядный пример, почему много вызовов функций - это плохо.
Решение без тегов
Несмотря на удобный алгоритм у нас осталось несколько недостатков:
Html теги неудобно писать руками в некоторых текстовых редакторах вроде Notepad++.
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, если вы хотите увидеть возможности языка на практике.
