Цветной текст в консоли в AutoHotkey
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, если вы хотите увидеть возможности языка на практике.
