На самом деле, не так много создается CLI приложений на OneScript. Грустно это или весело? Это печально. Тут есть много причин, вдаваться в которые я не буду, но заострю внимание на одной конкретно
Что мы привыкли видеть в приложениях для командной строки? С одной стороны, это гибкие и легко автоматизируемые решения, чем, собственно, и подкупают. Однако, если посмотреть с другой - это высокий порог вхождения, сложные описания действий, очень часто с большим количеством опций/аргументов и в целом RTFM, которым не каждый пользователь захочет заниматься. Можно ли сделать по-другому? Можно - и даже на OneScript!
На создание этой статьи меня вдохновил проект Charm. В него входит несколько репозиториев, которые представляют из себя инструменты для создания красивых и интерактивных CLI приложений на Go. Я сам на Go не пишу, но там были очень убедительные гифки
Мне в моих потугах такой красоты достичь пока не удалось, но основная идея схожа: приложение не на командах, но на интерактивных элементах - простота в ущерб универсальности
Как это работает?
Был главный вопрос. Я не могу точно сказать, так ли это работает в штуках от Charms, но где бы я не искал теорию, описывалось это примерно следующим образом:
- Мы выводим текстовые данные на экран и запоминаем, что вывели
- Начинаем слушать кнопки управления или ожидать какие-нибудь другие события
- При отлове нажатия/события перерисовываем все заново, но с изменением нужных нам элементов
Варианта перерисовки, которые мне удалось реализовать в OneScript, всего два: перерисовка всего экрана через полную очистку и перерисовка текущей строки через возврат каретки. Начнем с последнего
Основные действия и анимации
Определимся с тем, какие инструменты вообще нам доступны из OneScript. В первую очередь, это возможность работы с потоком вывода консоли. Т.е. со stdout
В глобальном контексте есть объект Консоль, а у того - функция ОткрытьСтандартныйПотокВывода(), которая возвращает собственно Поток (такой же как в 1С). Последний (именно как объект Поток) нельзя читать и в нем не доступен поиск, но зато доступна запись, чего нам пока будет достаточно
Создадим на основе потока вывода новый объект ЗаписьДанных (запись данных тоже как в 1С, если кто-то не знаком с OneScript)
Кодировка = Консоль.КодировкаВыходногоПотока;
ПотокВывода = Консоль.ОткрытьСтандартныйПотокВывода();
ЗаписьВывода = Новый ЗаписьДанных(ПотокВывода, Кодировка);
Им мы и будем манипулировать в дальнейшем. Так как это обычная ЗаписьДанных, то нам доступны такие две замечательные функции как ЗаписатьСимволы и ЗаписатьСтроку. Их различие для нас играет огромное значение: ЗаписатьСимволы оставляет каретку на той же строке консоли -> мы можем записывать в одну строку несколько сообщений. При использовании же функции ЗаписатьСтроку, следующее сообщение пойдет с новой строки
Для примера, попробуем вывести в одну строку текст разного цвета. Это невозможно сделать при помощи стандартного Сообщить(), который часто используется как самый простой способ вывода данных, но зато можно с использованием ЗаписатьСимволы
Кодировка = Консоль.КодировкаВыходногоПотока;
ПотокВывода = Консоль.ОткрытьСтандартныйПотокВывода();
ЗаписьВывода = Новый ЗаписьДанных(ПотокВывода, Кодировка);
Консоль.ЦветТекста = ЦветКонсоли.Желтый;
ЗаписьВывода.ЗаписатьСимволы("Это");
Консоль.ЦветТекста = ЦветКонсоли.Зеленый;
ЗаписьВывода.ЗаписатьСимволы(" разноцветный");
Консоль.ЦветТекста = ЦветКонсоли.Красный;
ЗаписьВывода.ЗаписатьСимволы(" текст");
Консоль.ЦветТекста = ЦветКонсоли.Белый;
Неплохо. Но при помощи одного лишь дописывания магии не получится - нужно заменять данные в строке на новые. С этим нам поможет символ Возврат каретки, он же Символы.ВК. Предлагаю при его использовании сразу создать полноценную функцию вывода символов, чтобы не возвращаться к этому каждый раз
Процедура ВывестиТекстВТекущуюСтроку(Знач Текст, Знач Цвет = "", Знач ВНачало = Ложь) Экспорт
Если НЕ ЗначениеЗаполнено(Цвет) Тогда
Цвет = ЦветКонсоли.Белый;
КонецЕсли;
Если ТипЗнч(Цвет) = Тип("Строка") Тогда
Консоль.ЦветТекста = ЦветКонсоли[Цвет];
Иначе
Консоль.ЦветТекста = Цвет;
КонецЕсли;
Если ВНачало Тогда
ЗаписьВывода.ЗаписатьСимволы(Символы.ВК);
КонецЕсли;
ЗаписьВывода.ЗаписатьСимволы(Текст);
Цвет = ЦветКонсоли.Белый;
КонецПроцедуры
Она довольно простая: на входе мы принимаем и устанавливаем цвет консоли, текст, а также признак необходимости перезаписать текущую строку - т.е. вернуть каретку в начало. После записи символов меняем цвет консоли к стандартному белому
Теперь что-нибудь с этим сделаем. Первое, что мне пришло на ум - прогресс-бар. Добавим новую функцию
Редактор статей Инфостарта блокирует псевдографику, так что если вы видите нечто вроде `12 или `16, то это коды всяких разных символов - на гифках они будут видны нормально
Процедура ВывестиПрогрессбар(Текущее, Максимальное, ДлинаСтроки = 30, Заголовок = "Прогресс") Экспорт
Префикс = Заголовок + " `12;";
Постфикс = "`16;";
ТекущийПоказатель = Цел(Текущее / Максимальное * ДлинаСтроки);
Буфер = "";
Счетчик = 0;
Пока Счетчик < ДлинаСтроки Цикл
Буфер = Буфер + ?(Счетчик < ТекущийПоказатель, ТаблицаСимволов["Блок"], " ");
Счетчик = Счетчик + 1;
КонецЦикла;
ВывестиТекстВТекущуюСтроку(Префикс, , Истина);
ВывестиТекстВТекущуюСтроку(Буфер, ЦветКонсоли.Зеленый);
ВывестиТекстВТекущуюСтроку(Постфикс);
КонецПроцедуры
Так как вывод вынесен отдельно, данный метод также выглядит просто и неугрожающе: получаем декоративный заголовок и длину прогресс-бара, определяем число делений, которые необходимо закрасить, после чего закрашиваем и добиваем строку пробелами до необходимой длины
В самом конце выводим по порядку:
- Заголовок с левой границей (с признаком того, что начинать надо с начала текущей строки - т.е. заместить старое значение)
- Сам прогресс-бар (буфер)
- Правую границу
Посмотрим, как оно работает. Напишем небольшой цикл для теста:
Н = 0;
Пока Н <= 400 Цикл
Заголовок = "Прогресс [" + Строка(Н) + "/" + "800" + "] ";
ВывестиПрогрессбар(Н, 400, 30, Заголовок);
Приостановить(1000);
Н = Н + 80;
КонецЦикла;
Таким же образом можно выводить разнообразные спинеры и зацикленные анимации, просто запоминая их положение между итерациями
Перем ПозицияСпиннера;
Функция Спиннер() Экспорт
МассивПоложений = Новый Массив;
МассивПоложений.Добавить("(o )");
МассивПоложений.Добавить("( o )");
МассивПоложений.Добавить("( o )");
МассивПоложений.Добавить("( o)");
МассивПоложений.Добавить("( o )");
МассивПоложений.Добавить("( o )");
ИзменитьПозициюСпиннера(МассивПоложений.ВГраница());
Возврат МассивПоложений[ПозицияСпиннера];
КонецФункции
Процедура ИзменитьПозициюСпиннера(Крайний)
Если ПозицияСпиннера >= Крайний Тогда
ПозицияСпиннера = 0;
Иначе
ПозицияСпиннера = ПозицияСпиннера + 1;
КонецЕсли;
КонецПроцедуры
ПозицияСпиннера = 0;
Для Н = 0 По 20 Цикл
ВывестиТекстВТекущуюСтроку(Спиннер(), ЦветКонсоли.Малиновый, Истина);
Приостановить(500);
КонецЦикла;
Получение данных
Но это все про анимации - пришло время чего-то более утилитарного. Сейчас мы попробуем организовать однострочное меню, в котором предложим пользователю выбрать один из доступных вариантов. Принцип работы схож с таковым у анимаций, с той лишь разницей, что вместо смены данных в цикле, мы будем ожидать ввода от пользователя
Заранее напишем метод для определения действий по различным кнопкам. У меня это будет так: стрелочка вверх и стрелочка вправо - следующий пункт меню, стрелочки влево и вниз - предыдущий. Enter, собственно, ввод - подтверждение выбора
Функция ОпределитьВвод(Счетчик, Максимальный, Минимальный = 0)
Влево = 37;
Вправо = 39;
Ввод = 13;
Вверх = 38;
Вниз = 40;
ЭтоВвод = Ложь;
Клавиша = Консоль.Прочитать();
Если Клавиша = Влево ИЛИ Клавиша = Вверх Тогда
Счетчик = ?(Счетчик = Минимальный, Счетчик, Счетчик - 1);
ИначеЕсли Клавиша = Вправо ИЛИ Клавиша = Вниз Тогда
Счетчик = ?(Счетчик = Максимальный, Счетчик, Счетчик + 1);
ИначеЕсли Клавиша = Ввод Тогда
ЭтоВвод = Истина;
Иначе
ЭтоВвод = Ложь;
КонецЕсли;
Возврат ЭтоВвод;
КонецФункции
Немного про входные параметры: Счетчик необходим для определения индекса выбранного варианта, после того как пользователь нажмет Enter. Максимальный и Минимальный (индексы) нужны для того, чтобы пользователь не мог при выборе выйти за границы нашего меню
Теперь определим саму функцию выбора значения
Функция ПоказатьВыборЗначения(МассивВариантов, Вопрос = "Выберите значение:", ТекущийИндекс = 0) Экспорт
Крайний = МассивВариантов.ВГраница();
Вопрос = СокрЛП(Вопрос) + " ";
ВывестиТекстВТекущуюСтроку(Вопрос, ЦветКонсоли.Бирюза, Истина);
Для Н = 0 По Крайний Цикл
ЭтотВыбран = Н = ТекущийИндекс;
Курсор = ?(ЭтотВыбран, "`58; ", " ");
Цвет = ?(ЭтотВыбран, ЦветКонсоли.Зеленый, ЦветКонсоли.Белый);
ВывестиТекстВТекущуюСтроку(Курсор, Цвет);
ВывестиТекстВТекущуюСтроку(МассивВариантов[Н] + " ");
КонецЦикла;
Если НЕ ОпределитьВвод(ТекущийИндекс, Крайний) Тогда
Ответ = ПоказатьВыборЗначения(МассивВариантов, Вопрос, ТекущийИндекс);
Иначе
Ответ = МассивВариантов[ТекущийИндекс];
КонецЕсли;
Возврат Символы.ПС + Ответ;
КонецФункции
Функция рекурсивная: если наш предыдущий метод ОпределитВвод() вернул Истина - значит пользователь нажал Enter и значение можно вернуть. В противном случае функция вызывает саму себя с изменившимся там же (в ОпределитьВвод) ТекущимИндексом. В цикле Н = 0 По Крайний определяется, какой элемент сейчас выбран - он помечается треугольником и другим цветом консоли (вспоминаем наш вывод слов разного цвета в одной строке)
Пробуем запустить
МассивВариантов = Новый Массив;
МассивВариантов.Добавить("Красный");
МассивВариантов.Добавить("Желтый");
МассивВариантов.Добавить("Зеленый");
Вопрос = "Какой ваш любимый цвет?";
Сообщить(ПоказатьВыборЗначения(МассивВариантов, Вопрос));
Перерисовка всего экрана
Способ с возвратом каретки хорош тем, что не влияет на предыдущие данные в выводе консоли. Однако в этом и его главное ограничение - нам недоступна информация в остальных строках. С учетом отсутствия возможности читать и изменять позицию в потоке вывода, я не смог найти способа это обойти. Но зато мы всегда можем просто очистить всю консоль целиком и записать все данные заново! С этим на поможет функция
Консоль.Очистить();
Последняя функция на сегодня - вывод таблицы значений в консоль с возможностью выбора строки. Начнем с простого: определим в отдельное соответствие символы псевдографики, при помощи которых будем отрисовывать нашу таблицу
ТаблицаСимволов = Новый Соответствие();
ТаблицаСимволов.Вставить("УголокЛевоВерх", "_56;");
ТаблицаСимволов.Вставить("УголокЛевоНиз", "_62;");
ТаблицаСимволов.Вставить("УголокПравоВерх", "_59;");
ТаблицаСимволов.Вставить("УголокПравоНиз", "_65;");
ТаблицаСимволов.Вставить("Перекрестье", "_80;");
ТаблицаСимволов.Вставить("ТВверх", "_77;");
ТаблицаСимволов.Вставить("ТВниз", "_74;");
ТаблицаСимволов.Вставить("ТВлево", "_71;");
ТаблицаСимволов.Вставить("ТВправо", "_68;");
ТаблицаСимволов.Вставить("ЛинияГоризонтальная", "_52;");
ТаблицаСимволов.Вставить("ЛинияВертикальная", "_53;");
ТаблицаСимволов.Вставить("Курсор", "`58; ");
Далее нам необходим метод, который будет определять ширину колонок таблицы в соответствии с их содержимым
Функция ОпределитьШиринуКолонокТаблицы(ТаблицаЗначений)
СоответствиеШириныКолонок = Новый Соответствие();
Запас = 2;
Для Каждого СтрокаТаблицы Из ТаблицаЗначений Цикл
Для Каждого Колонка Из ТаблицаЗначений.Колонки Цикл
Имя = Колонка.Имя;
Ширина = СоответствиеШириныКолонок[Имя];
Если НЕ ЗначениеЗаполнено(Ширина) Тогда
Ширина = СтрДлина(Имя) + Запас;
СоответствиеШириныКолонок.Вставить(Имя, Ширина);
КонецЕсли;
ТекущаяШирина = СтрДлина(СтрокаТаблицы[Имя] + Запас);
Если ТекущаяШирина > Ширина Тогда
СоответствиеШириныКолонок.Вставить(Имя, ТекущаяШирина);
КонецЕсли;
КонецЦикла;
КонецЦикла;
Возврат СоответствиеШириныКолонок;
КонецФункции
В данной функции мы обходим все строки таблицы, определяя и записывая в соответствие максимальную длину содержимого для каждой из колонок
Далее определим функцию, для вывода одной строки таблицы
Функция СформироватьСтрокуТаблицы(ТаблицаЗначений, СоответствиеШириныКолонок, СтрокаТЗ = "")
ТекущаяСтрока = "";
ШапкаВерх = "";
ШапкаНиз = "";
ШапкаСред = "";
Счетчик = 0;
Всего = ТаблицаЗначений.Колонки.Количество();
Для Каждого Колонка Из ТаблицаЗначений.Колонки Цикл
Если Счетчик = 0 Тогда
РазделительВерх = ТаблицаСимволов["УголокЛевоВерх"];
РазделительНиз = ТаблицаСимволов["УголокЛевоНиз"];
РазделительСред = ТаблицаСимволов["ТВправо"];
ИначеЕсли Счетчик = Всего Тогда
РазделительВерх = ТаблицаСимволов["УголокПравоВерх"];
РазделительНиз = ТаблицаСимволов["УголокПравоНиз"];
РазделительСред = ТаблицаСимволов["ТВлево"];
Иначе
РазделительВерх = ТаблицаСимволов["ТВниз"];
РазделительНиз = ТаблицаСимволов["ТВверх"];
РазделительСред = ТаблицаСимволов["Перекрестье"];
КонецЕсли;
Значение = ?(ЗначениеЗаполнено(СтрокаТЗ), СтрокаТЗ[Колонка.Имя], Колонка.Имя);
Лево = Истина;
Ширина = СоответствиеШириныКолонок[Колонка.Имя] + 2;
СекцияШапки = "";
Пока СтрДлина(Значение) > СтрДлина(СекцияШапки) Цикл
СекцияШапки = СекцияШапки + ТаблицаСимволов["ЛинияГоризонтальная"];
КонецЦикла;
Пока СтрДлина(Значение) < Ширина Цикл
Значение = ?(Лево, " " + Значение, Значение + " ");
СекцияШапки = СекцияШапки + ТаблицаСимволов["ЛинияГоризонтальная"];
Лево = НЕ Лево;
КонецЦикла;
ТекущаяСтрока = ТекущаяСтрока + ТаблицаСимволов["ЛинияВертикальная"] + Значение;
ШапкаВерх = ШапкаВерх + РазделительВерх + СекцияШапки;
ШапкаНиз = ШапкаНиз + РазделительНиз + СекцияШапки;
ШапкаСред = ШапкаСред + РазделительСред + СекцияШапки;
Счетчик = Счетчик + 1;
КонецЦикла;
ТекущаяСтрока = ТекущаяСтрока + ТаблицаСимволов["ЛинияВертикальная"];
ШапкаВерх = ШапкаВерх + ТаблицаСимволов["УголокПравоВерх"];
ШапкаНиз = ШапкаНиз + ТаблицаСимволов["УголокПравоНиз"];
ШапкаСред = ШапкаСред + ТаблицаСимволов["ТВлево"];
Возврат Новый Структура("ТекущаяСтрока,ШапкаВерх,ШапкаСред,ШапкаНиз", ТекущаяСтрока, ШапкаВерх, ШапкаСред, ШапкаНиз);
КонецФункции
Она достаточно объемная, но ничего сложного в ней нет: большая часть действий направлена на определение символов псевдографики, которыми нужно "рисовать" в данный момент
В объявлении можно заметить, что параметр СтрокаТЗ необязательный, хотя функция вроде бы должна формировать строку таблицы. Все просто - если СтрокаТЗ не заполнена, то будет сформирована шапка таблицы по названиям её колонок
Ну и наконец перейдем к основной функции вывода
Функция ПоказатьТаблицу(ТаблицаЗначений, ВыборСтроки = Ложь, ТекущийИндекс = 0) Экспорт
Консоль.ЦветТекста = ЦветКонсоли.Белый;
Консоль.Очистить();
СоответствиеШириныКолонок = ОпределитьШиринуКолонокТаблицы(ТаблицаЗначений);
СтруктураШапок = СформироватьСтрокуТаблицы(ТаблицаЗначений, СоответствиеШириныКолонок);
Сообщить(" " + СтруктураШапок["ШапкаВерх"]);
Сообщить(" " + СтруктураШапок["текущаяСтрока"]);
Сообщить(" " + СтруктураШапок["ШапкаСред"]);
Счетчик = 0;
Для Каждого СтрокаТаблицы Из ТаблицаЗначений Цикл
Выбрана = ТекущийИндекс = Счетчик;
Если ВыборСтроки Тогда
Указатель = ?(Выбрана, ТаблицаСимволов["Курсор"], " ");
Консоль.ЦветТекста = ?(Выбрана, ЦветКонсоли.Зеленый, ЦветКонсоли.Белый);
Иначе
Указатель = " ";
Конецесли;
СтруктураСтроки = СформироватьСтрокуТаблицы(ТаблицаЗначений, СоответствиеШириныКолонок, СтрокаТаблицы);
Сообщить(Указатель + СтруктураСтроки["ТекущаяСтрока"]);
Счетчик = Счетчик + 1;
КонецЦикла;
Консоль.ЦветТекста = ЦветКонсоли.Белый;
Сообщить(" " + СтруктураШапок["ШапкаНиз"]);
Ответ = ТаблицаЗначений[ТекущийИндекс];
Если ВыборСтроки Тогда
Крайний = ТаблицаЗначений.Количество() - 1;
Если НЕ ОпределитьВвод(ТекущийИндекс, Крайний) Тогда
Ответ = ПоказатьТаблицу(ТаблицаЗначений, ВыборСтроки, ТекущийИндекс);
КонецЕсли;
КонецЕсли;
Возврат Ответ;
КонецФункции
Пройдем её по шагам:
- Устанавливаем цвет и очищаем вывод консоли
- Определяем ширину колонок таблицы
- Вызываем СформироватьСтрокуТаблицы() без СтрокиТЗ -> получаем шапку
- Выводим на экран 3 элемента. Под "шапками" здесь подразумеваются горизонтальные линии таблицы
- Для каждой строки выводим либо строку таблицы, либо строку таблицы с указателем, если она сейчас выбрана - не забываем, что наша задача не просто отрисовка таблицы, но и возможность выбрать строку
- Выводим ШапкаНиз - это нижняя горизонтальная линии таблицы
- Делаем рекурсивыный вызов для выбора значения, как мы это делали ранее при создании меню
Попробуем теперь вызвать наш метод
ТЗ = Новый ТаблицаЗначений();
ТЗ.Колонки.Добавить("Имя");
ТЗ.Колонки.Добавить("Фамилия");
ТЗ.Колонки.Добавить("ЛюбимыйЦвет");
СтрокаТЗ = ТЗ.Добавить();
СтрокаТЗ.Имя = "Петр";
СтрокаТЗ.Фамилия = "Петров";
СтрокаТЗ.ЛюбимыйЦвет = "Красный";
СтрокаТЗ = ТЗ.Добавить();
СтрокаТЗ.Имя = "Иван";
СтрокаТЗ.Фамилия = "Иванов";
СтрокаТЗ.ЛюбимыйЦвет = "Желтый";
СтрокаТЗ = ТЗ.Добавить();
СтрокаТЗ.Имя = "Алексей";
СтрокаТЗ.Фамилия = "Алексеев";
СтрокаТЗ.ЛюбимыйЦвет = "Малиновый";
Ответ = ПоказатьТаблицу(ТЗ, Истина);
Сообщить("Любимый цвет пользователя " + Ответ.Имя + " - " + Ответ.ЛюбимыйЦвет);
Вот так вот незамысловато
В заключение
Возвращаясь к вопросу CLI приложений на OneScript:
Что имеем не храним, потерявши — плачем
У нас у всех, в сообществе 1С, есть возможность писать варез, не выходя, при этом, из зоны комфорта - делать это на знакомом нам языке и без вызывающей постоянные стенания привязки к вендеру. Не пренебрегайте данной возможностью.
Больше разработок - больше развитие технологии (речь про OneScript). И возможно, однажды, все мы окажемся в хорошем стэке технологий, а не будем пытаться сбежать из него
Спасибо за внимание!
Мой GitHub: https://gitub.com/Bayselonarrend Лицензия MIT: https://mit-license.org