VOOZH about

URL: https://habr.com/ru/articles/1053046/

⇱ Цветной текст в консоли в AutoHotkey / Хабр


Rafaell0

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

Средний
10 мин
5.7K
Кейс

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

👁 Image

Задача стояла написать алгоритм, который будет применять менять разные цвета к фрагментам выводимого текста. И в этой статьей мы детально рассмотрим, как можно написать такой алгоритм на моем любимом языке 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, если вы хотите увидеть возможности языка на практике.

0
8
Карма
Рафаэль@Rafaell0

3Д художник, который вынужден писать скрипты

Поток Бэкенд доступен 24/7 благодаря поддержке друзей Хабра
👁 Хабр Карьера Курсы
Хабр Курсы для бэкендеров
РЕКЛАМА
Практикум, Хекслет, SkyPro, авторские курсы — собрали всех и попросили скидки. Осталось выбрать!