Консольні програми існують з самих початків комп'ютерних моніторів, що робить їх одними з найстаріших технологічних інструментів. Але замість того, щоб зникати, вони переживають своєрідне відродження у світі розробки з кількох причин:
- Простота: немає потреби боротися з вередливими фреймворками UI.
- Універсальність: ідеально підходять для автоматизації в CI/CD пайплайнах.
- Швидкість: блискавичне виконання команд.
Консольні програми доводять, що класика ніколи не виходить з моди. Але чому вони повертаються саме зараз у світі розробки? Головна проблема полягає в зручності використання — ці інструменти часто вимагають від користувачів читання великих посібників або навігації через вбудовані довідкові повідомлення, щоб зрозуміти, як ними користуватися. Хоча це не є перешкодою для досвідчених професіоналів, це може стати серйозною проблемою для звичайного користувача.
Протягом останнього року я розробляв внутрішні інструменти на C#, щоб оптимізувати свої щоденні завдання та підвищити ефективність. У цій статті я поділюся уроками, які я засвоїв, і поясню, чому консольні програми заслуговують на новий погляд.
Технічний фон та терміни
Термінал
Комп'ютерний термінал — це пристрій, який дозволяє користувачам взаємодіяти з комп'ютерною системою, часто через текстовий ввід та вивід. Перші термінали були фізичними апаратними пристроями, що підключалися до комп'ютера через мережу. Найвідомішим з таких терміналів був DEC VT-100 (VT100 — Wikipedia). Він підтримував ANSI escape коди для форматування текстового виводу.
З розвитком технологій фізичні термінали стали застарілими, але їхній спадок живе в емуляторах терміналів. Емулятор термінала — це програмне забезпечення, яке забезпечує підтримку запуску консольних програм через підтримку ANSI escape кодів. Для емулятора термінала є обов'язковим надання хоча б сумісності з VT100. Таким емулятором є Windows Terminal, вбудований в Windows 11.
Консоль
З виходом Windows 95 Microsoft назвала свій вбудований текстовий інтерфейс на рівні API "консоллю", а не "терміналом". Це розрізнення виникло через необхідність зберегти сумісність з DOS-додатками, які кардинально відрізнялися від Unix-термінальних додатків. DOS не підтримував ANSI escape коди, в основному через обмеження пам'яті перших IBM PC — комп'ютерів, які мали всього лише 640 КБ доступної пам'яті для програм користувачів, що сьогодні виглядає смішно мало.
Хоча API консолі Windows дозволяло текстову функціональність, воно не було настільки досконалим і універсальним, як ANSI escape коди. Ця особливість ускладнювала перенесення багатьох чудових текстових додатків з Linux на Windows, і цей процес часто був неприємним і малопродуктивним. Через це Windows не мала доступу до багатьох потужних термінальних інструментів, які процвітали в світі Unix.
Це змінилося в 2016 році, з випуском Windows 10 Version 1607. З того часу Windows підтримує ANSI escape коди і зберігає сумісність зі старим API консолі. У 2019 році було запущено проект Windows Terminal, який став основною термінальною програмою в Windows 11 і надає значно більш розширені можливості для термінала.
Shell
Shell — це програма командного інтерфейсу (CLI), яка дозволяє користувачам взаємодіяти з операційною системою, вводячи текстові команди. Вона працює як інтерпретатор команд, перетворюючи введення користувача на інструкції, які операційна система може виконати.
Shell дозволяє виконувати завдання, такі як управління файлами, запуск програм і налаштування системи. Вони підтримують сценарії, що дозволяє користувачам писати та виконувати скрипти для автоматизації повторюваних завдань.
Windows пропонує два Shell за замовчуванням. "Старий" cmd.exe, який зберігає сумісність з командами DOS, і значно більш сучасний Powershell, який був написаний на C# з використанням .NET. Powerhsell має дві версії.
Powershell і Powershell Core. Оригінальний Powershell був створений для використання .NET Framework і підтримує лише Windows, тоді як Powershell Core був розроблений для роботи з сучасним .NET і є багатоплатформним. Його можна використовувати на Windows, Linux та Mac.
На системах на базі Linux де-факто оболонкою є Bash (Bourne Again Shell), який також є багатоплатформним.
Програмування
У C# для взаємодії з користувачем через CLI інтерфейс використовується клас Console. Це дозволяє виконувати базові функції, як позиціонування, читання та запис. Форматування тексту досягається через запис спеціальних рядків виводу, які інтерпретуються терміналом або емулятором термінала. Ці спеціальні рядки називаються ANSI escape послідовностями.
Давайте розглянемо приклад. Припустімо, я хочу вивести текст курсивом зеленим кольором, що говорить "CLI це круто". Я можу досягти цього за допомогою наступного коду в .NET 9 та C# 13:
Console.WriteLine("\e[3;32mCLI is awesome\e[0m");
.NET 9 та C# 13 важливі в цьому контексті через символ \e
. Цей символ, що позначає початок послідовності escape, сигналізує, що наступні символи керують форматуванням.
У старіших версіях .NET та C# символ \e
не розпізнається. Однак це не означає, що послідовності escape не підтримуються — вам потрібно використовувати Unicode еквівалент, \u001b
(U+001B), щоб позначити початок коду escape.
[3;32m
увімкне курсивний текст (3) та встановить колір на зелений (32). Після тексту \e[0m
скидає форматування до стандартного. Без цього термінал відобразить весь текст зеленим курсивом.
Існує сторінка Microsoft Learn, яка описує всі базові escape послідовності, що підтримуються: Console Virtual Terminal Sequences — Windows Console | Microsoft Learn. Однак цей список не є вичерпним, оскільки Windows Terminal підтримує набагато більше кодів escape. Добре початкове джерело для вивчення послідовностей escape — наступна сторінка в Wikipedia: ANSI escape code — Wikipedia
Підтримка UTF
UTF також підтримується на Windows, але з огляду на питання сумісності, консольні програми C# за замовчуванням не запускаються з увімкнутим UTF, вони використовують кодові сторінки для представлення тексту. Тому кожна сучасна консольна програма C# повинна починатися з наступної інструкції:
Console.OutputEncoding = System.Text.Encoding.UTF8;
Увімкнення виводу UTF також дає нам підтримку емодзі.
Інтерактивний додаток чи складний додаток з командами?
Під час проектування вашого додатку перше, що потрібно вирішити — як ви хочете, щоб користувачі взаємодіяли з вашим додатком? Ви хочете надати інтерфейс, подібний до оболонки, чи хочете створити складний додаток з аргументами, подібний до git? Обидва підходи мають свої переваги і недоліки.
Інтерактивний CLI, схожий на оболонку, — це зазвичай командний додаток, що дозволяє користувачам взаємодіяти з програмою в розмовному або інтерактивному режимі, схожому на REPL C# або Python.
Це надає більш гнучкий і зручний досвід, оскільки користувачі можуть взаємодіяти з програмою в реальному часі та вводити команди без необхідності все вказувати заздалегідь. Програма може зберігати стан між командами, що дозволяє надавати контекстно-залежну допомогу чи відповіді. З іншого боку, реалізація інтерактивного інтерфейсу оболонки може бути складною, оскільки потрібно керувати парсингом вводу, станами сеансів користувачів та можливими перервами. Це може бути непідходящим для автоматизованих завдань чи скриптів, оскільки користувачам потрібно буде бути присутніми для введення даних під час взаємодії.
CLI додаток, орієнтований на аргументи, очікує, що користувачі надаватимуть командні аргументи та прапорці, що визначають поведінку програми, як це робиться, наприклад, в git при фіксації змін:
git commit -m "message"
Додатки з аргументами ідеально підходять для автоматизованих процесів, оскільки дозволяють повністю контролювати додаток через скрипти та пакетні процеси, і користувачі можуть точно вказати, що вони хочуть, в одній команді. З іншого боку, ці інструменти мають круту криву навчання.
Для початківців може бути складніше зрозуміти необхідний синтаксис, особливо якщо додаток має багато команд чи опцій і немає можливості запитати користувачів про введення під час виконання процесу; все повинно бути зазначено заздалегідь.
Мінімалістичний додаток, подібний до оболонки, можна реалізувати таким чином:
using System.Text;
namespace SimpleShell;
//Базовий інтерфейс, який повинні реалізовувати команди оболонки.
internal interface ICommand
{
string CommandName { get; }
void Execute(IReadOnlyList args);
}
//Проста команда, яка завершує роботу оболонки.
internal class ExitCommand : ICommand
{
public string CommandName { get; } = "exit";
public void Execute(IReadOnlyList args)
{
Environment.Exit(0);
}
}
//Проста команда, яка виводить "Hello, World!" та аргументи.
internal class HelloCommand : ICommand
{
public string CommandName { get; } = "hello";
public void Execute(IReadOnlyList args)
{
Console.WriteLine("Hello, World!");
Console.WriteLine("Args: " + string.Join(", ", args));
}
}
internal static class Program
{
private static void Main(string[] args)
{
const string prompt = "> ";
Dictionary commands = LoadCommandsWithReflection();
while (true)
{
Console.Write(prompt);
string input = Console.ReadLine() ?? string.Empty;
(string commandName, IReadOnlyList commandArgs) = Parse(input);
if (commands.TryGetValue(commandName, out ICommand? command))
{
command.Execute(commandArgs);
}
else
{
Console.WriteLine($"Command not found: {commandName}");
}
}
}
//Отримує ім'я команди та аргументи з рядка введення.
//Аргументи розділяються пробілами. Ті, що в лапках, вважаються одним аргументом.
private static (string commandName, IReadOnlyList commandArgs) Parse(string input)
{
var command = string.Empty;
var argumentList = new List();
bool inQuotes = false;
StringBuilder currentArg = new(input.Length);
static void Store(ref string command, List argumentList, StringBuilder currentArg)
{
if (currentArg.Length > 0)
{
if (string.IsNullOrEmpty(command))
command = currentArg.ToString();
else
argumentList.Add(currentArg.ToString());
currentArg.Clear();
}
}
foreach (char currentChar in input)
{
if (currentChar == ' ' && !inQuotes)
Store(ref command, argumentList, currentArg);
else if (currentChar == '\"')
inQuotes = !inQuotes;
else
currentArg.Append(currentChar);
}
if (currentArg.Length > 0)
Store(ref command, argumentList, currentArg);
return (command, argumentList);
}
//Завантажує команди через рефлексію. Це не є готовим рішенням для продакшн середовища
//оскільки не має жодних перевірок безпеки та обробки помилок.
private static Dictionary LoadCommandsWithReflection()
{
return typeof(ICommand).Assembly
.GetTypes()
.Where(t => !t.IsAbstract && !t.IsInterface)
.Where(t => t.IsAssignableTo(typeof(ICommand)))
.Select(t => (ICommand)Activator.CreateInstance(t)!)
.ToDictionary(c => c.CommandName, c => c);
}
}
Наведений вище код є прикладом концепції, він лише демонструє ідеї і не повинен використовуватися без належної обробки винятків.
Обробка аргументів — це фактично мистецтво керування машиною станів, і у вас є кілька варіантів: створити власний парсер з нуля або скористатися бібліотекою, спеціально призначеною для цієї задачі. Чому винаходити колесо, коли бібліотеки вже надають додаткові переваги, такі як перетворення типів і дотримання встановлених стандартів?
Говорячи про стандарти, давайте розглянемоsomething.mp3
```
Ось анатомія цього введення:
- Команда: Перша частина,
program
, це виконуваний файл, який викликається. - Опції:
— verbose
— це довга опція, яка супроводжується значеннямtrue
.
Довгі опції є читабельними та самодокументованими. - Флаги:
-n
— це короткий флаг. - Аргумент:
d:\something.mp3
— це позиційний аргумент, додаткові дані, передані команді.
Як опції, так і флаги можуть бути подані у довгій або короткій формі. Те, що відрізняє їх, — це наявність чи відсутність значення. Опції вимагають значення, а флаги — це просто присутність або відсутність. Для зручності використання рекомендується мати як довгу, так і коротку версію для кожної опції та флагу. Наприклад, для перемикання рівня детальності логування слід вважати –verbose
та -v
флагами однаковими.
Добрий парсер має гарно обробляти такі варіанти і уникати залежності від позицій аргументів. Це означає, що такі введення мають трактуватися як однакові:
foo --bar c:\
foo c:\ --bar
foo -b c:\
foo c:\ -b
Реалізувати простий парсер можна так:
public class ArgumentParser
{
public List Arguments = new List();
public HashSet Switches = new HashSet();
public Dictionary Options = new Dictionary();
public void Parse(string[] args)
{
for (int i = 0; i < args.Length; i++)
{
string arg = args[i];
if (arg.StartsWith("--"))
{
string option = arg[2..];
if (i + 1 < args.Length && !args[i + 1].StartsWith('-'))
{
Options[option] = args[i + 1];
i++;
}
else
{
Options[option] = string.Empty;
}
}
else if (arg.StartsWith('-'))
{
foreach (char c in arg[1..])
{
Switches.Add(c.ToString());
}
}
else
{
Arguments.Add(arg);
}
}
}
}
Цей клас поділяє введення на три категорії: опції, аргументи та флаги. Для аргументів дозволяється дублювання, але для опцій і флагів — ні. Розділивши введення на ці категорії, стає "простим" завданням перевірити наявність певного аргументу, опції або значення.
Нам не потрібно працювати з ім'ям команди чи обробкою вводу в лапках, оскільки ці завдання вже виконуються за нас. Масив args
нашого головного методу не містить ім'я програми, а ті значення, що були подані в лапках, є окремими значеннями в масиві.
Щоб уникнути зайвих проблем, я б рекомендував скористатися бібліотекою, а саме Spectre.Console.Cli. У розділі бібліотек можна знайти приклад використання.
Проєктування для сумісності
Сумісність (interoperability) є ключовою проблемою в сучасній розробці додатків, особливо коли ваш додаток повинен працювати безперешкодно на Windows, Linux та macOS. .NET підтримує всі ці платформи "з коробки", але є один нюанс: не всі API сумісні з кожною платформою. Це означає, що певні виклики API можуть викликати PlatformNotSupportedException. На щастя, інструменти розробки .NET включають аналізатори, які попереджають вас, коли ви намагаєтеся викликати такі несумісні API, спрощуючи життя.
Ще краще, .NET дозволяє вам версіювати ваші API за допомогою атрибута SupportedOSPlatform, тому ви можете вказати, які платформи підтримуються для методу або властивості. Крім того, .NET надає API, які дозволяють перевірити поточну операційну систему та платформу, що робить легким налаштування поведінки вашого додатку, особливо під час роботи з рідними бібліотеками.
Але почекайте — сумісність не закінчується на цьому! Є ще одна підступна пастка, яку можна не помітити: шляхи до файлів. Windows використовує зворотні слеші (‘\’), а системи на базі Unix — прямі слеші (‘/’) як роздільники шляхів. Жорстко прописані шляхи, як “c:\users\file.txt”, призведуть до помилок на інших платформах. Рішення? Використовувати метод Path.Combine, який враховує ці відмінності.
А як щодо зберігання конфігурацій? Властивість AppContext.BaseDirectory вказує на директорію, де виконується ваш додаток, але вона може бути тільки для читання. Не завжди можна покладатися на права на запис у цій директорії. Найбезпечнішим варіантом є зберігання конфігураційних файлів у домашній директорії користувача.
Наприклад, щоб зберегти файл під назвою config.txt у домашній папці користувача, ви можете використати:
string userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string configFile = Path.Combine(userHome, "file.txt");
Нарешті, давайте поговоримо про файлові системи. Вони можуть поводитися дуже по-різному, і це важливо при запису на диск, особливо під час непередбачуваних подій, таких як відключення електроенергії. Щоб уникнути втрати даних, не переписуйте файли безпосередньо. Замість цього створюйте новий файл, записуйте в нього, а потім перейменовуйте його, щоб замінити старий. Таким чином, якщо під час процесу запису станеться відключення електроенергії, оригінальний файл залишиться цілим. Звісно, ви можете втратити вміст нового файлу, але це безпечніший підхід, ніж ризикувати повною втратою даних. Хоча це правда, що електрика може відключитись під час операції перейменування, ймовірність цього низька, оскільки перейменування — це набагато швидша операція, ніж запис на диск.
string userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string configFile = Path.Combine(userHome, "file.txt");
var newFile = Path.Combine(userHome, "newFile.txt.new");
//запишіть ваш файл тут
//і коли все готово:
File.Move(configFile, newFile, overwrite: true);
Налаштування та конфігурації
Тема конфігурацій не обов'язково пов'язана з консольними додатками, однак може бути важливою для вашого додатку, якщо він має надавати користувачу можливість налаштовувати параметри. Як зазвичай, є багато варіантів на вибір, коли мова йде про формат.
По-перше, потрібно вирішити, як користувачі повинні взаємодіяти з конфігурацією додатку. Чи хочемо ми реалізувати підкоманди для зміни налаштувань, чи дозволимо редагувати ці налаштування за допомогою текстового редактора?
Якщо обраний шлях — текстовий редактор, то можна вибрати текстовий серіалізований формат, наприклад XML, JSON або YAML. XML і JSON мають вбудовану підтримку в екосистемі .NET, але коли йдеться про конфігурації, потрібно врахувати наступні сценарії: користувач може захотіти вимкнути налаштування, закоментувавши його, і може бути корисно пояснити налаштування та можливі значення в коментарі для користувача.
Стандарт JSON офіційно не підтримує коментарі. Система серіалізації System.Text.Json може бути налаштована на ігнорування коментарів при читанні файлу, але вона не зберігатиме коментарі при записі об'єкта у формат JSON.
XML підтримує коментарі, але вбудований XML-серіалізатор має API, яке існує вже понад 20 років і має ту ж проблему: при записі XML коментарі не зберігаються.
Щоб вирішити ці проблеми, я рекомендую використовувати YAML як формат конфігурації. Це людочитний формат, створений спеціально для конфігураційних файлів. У .NET ми не маємо вбудованої підтримки YAML, але бібліотека YamlDotNet надає чудову підтримку для читання та запису таких файлів.
Яким би форматом ви не обрали, він не звільняє вас від проблем міграції та оновлення конфігурацій. Оновлення — це процес, коли ви додаєте нове налаштування до конфігурації, і якщо воно відсутнє в старій конфігурації, то ви повинні створити це налаштування з за замовчуванням. Міграція — це процес, коли ви перейменовуєте чи видаляєте налаштування, і потрібно зробити щось з оригінальним файлом конфігурації, щоб бути сумісним з новою версією. Цей процес може бути складним, тому я рекомендую приділити більше часу при проєктуванні ваших конфігураційних файлів.
Текстові конфігурації не завжди можуть бути найкращим варіантом. Припустимо, що складність вашого додатку можна порівняти з git. У такому випадку рано чи пізно вам доведеться зберігати стан, конфігурацію та інші дані на файловій системі. У цьому випадку використання бази даних SQLite, керованої через Entity Framework, може бути хорошим рішенням для ваших потреб.
Вона підтримує Міграції та оновлення, індекси та все, що ви очікуєте від бази даних, у єдиному файлі бази даних SQLite. Вона кросплатформна, і вам не доведеться турбуватися про продуктивність.
Єдиний недолік полягає в тому, що це бінарний формат, тому вам доведеться реалізувати команди для зміни налаштувань у вашому додатку.
Бібліотеки
Особисто я не люблю вигадувати велосипед, тому більшість часу я використовую бібліотеки для взаємодії з терміналом і створення додатків. Одна з бібліотек, яку я найчастіше використовую, називається Spectre.Console, що є бібліотекою .NET, яка спрощує створення красивих консольних додатків.
Spectre.Console
Вона пропонує форматування за допомогою простого для читання мови розмітки, а також багато інтерактивних віджетів (таблиці, дерева тощо…) і запитів (ввід тексту, вибір одного елемента, вибір кількох елементів). Найкраще те, що вона розроблена з урахуванням юніт-тестування. Вона має абстракції інтерфейсів і окремий пакет для тестування, який дозволяє правильно проводити юніт-тестування.
Ця бібліотека також дозволяє створювати складні консольні додатки, подібні до git, gh або dotnet, завдяки своєму розширеному пакету Spectre.Console.Cli. Цю бібліотеку добре задокументовано на її офіційному сайті: https://spectreconsole.net/
Демо-додаток Spectre.Console
Розглянемо простий приклад, який демонструє парсинг аргументів за допомогою цієї бібліотеки. Наступна програма реалізує простий консольний додаток для привітання, який має опцію для вказівки кількості разів, коли потрібно привітати людину, та обов'язковий аргумент, який вказує, кого привітати:
using Spectre.Console;
using Spectre.Console.Cli;
using System.ComponentModel;
namespace SpectreCliDemo;
internal static class Program
{
private static int Main(string[] args)
{
// Створюємо додаток команд з тільки однією командою
var app = new CommandApp();
// Запускаємо його
return app.Run(args);
}
}
internal sealed class HelloCommand : Command
{
// Налаштування команди
public sealed class Settings : CommandSettings
{
// Опис використовуються для генерації повідомлення допомоги
[Description("Ім’я для привітання.")]
// вказує, що це обов'язкове. Якщо б воно було необов'язковим
// [Name] використовувалося б для синтаксису
[CommandArgument(0, "")]
public string Name { get; set; } = "";
[Description("Кількість разів, коли потрібно привітати ім’я.")]
[CommandOption("-t|--times")]
[DefaultValue(1)]
public int Times { get; set; }
// Додаткова, кастомна логіка перевірки, якщо потрібно
public override ValidationResult Validate()
{
if (string.IsNullOrWhiteSpace(Name))
return ValidationResult.Error("Ім’я повинно бути вказано");
if (Times < 1 || Times > 100)
return ValidationResult.Error("Кількість повинна бути між 1 і 100");
return ValidationResult.Success();
}
}
// Основна точка входу команди
public override int Execute(CommandContext context, Settings settings)
{
for (int i=0; i [OPTIONS]
ARGUMENTS:
Ім’я для привітання
OPTIONS:
DEFAULT
-h, --help Виводить інформацію про допомогу
-t, --times 1 Кількість разів, коли потрібно привітати ім’я
System.CommandLine
Пакет System.CommandLine був створений спеціально для інструмента .NET Cli. Він пропонує подібну функціональність до пакету Spectre.Console.Cli, але цей пакет ще знаходиться в бета-версії. Він фокусується лише на парсингу аргументів, як і вказує його назва.
Приклад попереднього застосування Spectre.Console.Cli, переписаний за допомогою цього парсера, виглядає ось так:
using System.CommandLine;
namespace CommandLineDemo;
internal static class Program
{
private static int Main(string[] args)
{
// визначаємо аргументи та опції
var timesOption = new Option("-t", "Кількість разів, коли потрібно привітати ім’я.");
var nameArgument = new Argument("", "Ім’я для привітання.");
// додаємо валідатори
nameArgument.AddValidator(nameArgument =>
{
if (string.IsNullOrWhiteSpace(nameArgument.GetValueOrDefault()))
{
nameArgument.ErrorMessage = "Ім’я не може бути порожнім.";
}
});
timesOption.AddValidator(timesOption =>
{
if (timesOption.GetValueOrDefault() < 1)
{
timesOption.ErrorMessage = "Кількість повинна бути більшою за 0.";
}
});
// визначаємо команду
var helloCommand = new RootCommand("Привітання кілька разів");
helloCommand.AddOption(timesOption);
helloCommand.Add(nameArgument);
helloCommand.SetHandler(OnHello, nameArgument, timesOption);
// виконуємо команду
return helloCommand.Invoke(args);
}
// Логіка команди
private static void OnHello(string name, int times)
{
for (int i=0; i < times; i++)
{
Console.WriteLine($"Привіт, {name}");
}
}
}
Як ви можете бачити, вона пропонує подібну функціональність, але з іншою синтаксичною структурою. Також вона генерує повідомлення про допомогу, якщо я запускаю програму без аргументів:
Відсутній обов'язковий аргумент для команди: 'Example'.
Опис:
Привітання кілька разів
Використання:
Example <> [опції]
Аргументи:
<> Ім’я для привітання.
Опції:
-t Кількість разів, коли потрібно привітати ім’я.
--version Показує інформацію про версію
-?, -h, --help Показує допомогу та інформацію про використання
Документацію до бібліотеки можна знайти на Microsoft.Learn: https://learn.microsoft.com/en-us/dotnet/standard/commandline/
Prettyprompt
Prettyprompt — це корисна бібліотека для програм, схожих на оболонку. Вона забезпечує підсвічування синтаксису вводу, автозаповнення та багато іншого. Її можна налаштовувати, а поведінку можна легко переоприділити.
Вона не має великої документації, але, чесно кажучи, цього і не потрібно. Вона має дуже добре написаний приклад FruitPrompt
, який дозволяє зрозуміти, як інтегрувати її у ваш проект. У бібліотеці є деякі помилки, але нічого серйозного. Ви можете знайти бібліотеку на GitHub: https://github.com/waf/PrettyPrompt
Демо FruitPrompt
бібліотеки PrettyPrompt
Terminal.Gui
До цього часу я розповідав про основи, але що робити, якщо ви хочете побудувати GUI, точніше, додаток TUI? TUI (Text User Interface) — це текстовий інтерфейс користувача, що базується на текстових взаємодіях, структурованих макетах і навігації за допомогою клавіатури.
Існує кілька бібліотек C# для цього завдання, але найбільш вдосконаленою є Terminal.Gui. Вона кросплатформена, має широке документування та інструменти для шаблонів, а також пропонує безліч віджетів для створення чудових інтерфейсів для вашого термінального додатку.
Демо-додаток Terminal.Gui
Відображення зображень
Поширене непорозуміння, що термінали можуть відображати тільки текст, але це не так. У 80-х роках люди, що працювали в DEC, зрозуміли, що було б круто, якби термінали могли відображати зображення разом з текстом. Для цього вони вигадали кодування зображень під назвою Sixel, що є скороченням від "шість пікселів". Воно кодує зображення у вигляді послідовностей символів ASCII, які можуть бути відображені сумісними терміналами.
Технічно пікселі згруповані у вертикальні зрізи по 6 пікселів, а потім кожен зріз кодується в символ ASCII, що робить дані компактними та зручними для відображення в текстових потоках.
На жаль, недолік цього формату в тому, що не всі емулятори терміналів підтримують його. Саме тому був створений сайт Are We Sixel Yet?. Він узагальнює, які емулятори терміналів його підтримують. Стабільний реліз Windows Terminal наразі не підтримує його, але версія 1.22 в режимі попереднього перегляду вже має підтримку.
Без підтримки sixel ви “можете” відображати зображення тільки в низькій роздільній здатності, якщо ваш термінал підтримує 24-бітні кольори та має символи для малювання блоків у UTF. З цими двома можливостями можна представляти пікселі на наднизькій роздільній здатності.
Для кодування sixel де-факто стандартом є бібліотека libsixel, яку можна завантажити за наступним посиланням: https://github.com/saitoha/libsixel. Вона написана на C і не має прямих прив’язок до C#, але з використанням Platform Invoke її можна застосовувати. Однак є альтернатива.
Проект https://github.com/trackd/Sixel пропонує модуль для PowerShell для відображення зображень у форматі Sixel, основна частина якого написана на C#, і її можна повторно використовувати. Ось фрагмент коду, який був взятий з цього проекту:
Цей код використовує SixLabors.ImageSharp для декодування та ресемплінгу зображень.
Ресемплінг необхідний, оскільки формат sixel підтримує максимум 256 кольорів.
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
using System.Text;
namespace Sixel;
// Згідно з: https://github.com/trackd/Sixel
public static class SixelEncoder
{
private const char SIXELEMPTY = '?';
private const char SIXELCOLORSTART = '#';
private const char SIXELREPEAT = '!';
private const char SIXELDECGCR = '$';
private const char SIXELDECGNL = '-';
private const string SIXELSTART = $"\eP0;1q";
private const string SIXELEND = $"\e\\";
private const string SIXELTRANSPARENTCOLOR = "#0;2;0;0;0";
private const string SIXELRASTERATTRIBUTES = "\"1;1;";
private static (int width, int height) _cellSize = GetCellSize();
private static string GetControlSequenceResponse(string controlSequence)
{
char? c;
var response = string.Empty;
Console.Write($"\e{controlSequence}");
do
{
c = Console.ReadKey(true).KeyChar;
response += c;
} while (c != 'c' && Console.KeyAvailable);
return response;
}
private static (int width, int height) GetCellSize()
{
var response = GetControlSequenceResponse("[16t");
try
{
var parts = response.Split(';', 't');
return (width: int.Parse(parts[2]), height: int.Parse(parts[1]));
}
catch
{
// Повертаємо розмір за замовчуванням для Windows Terminal,
// якщо не вдалося отримати розмір з термінала.
return (width: 10, height: 20);
}
}
public static string ImageToSixel(Image image)
{
int cellWidth = Console.WindowWidth;
image.Mutate(ctx =>
{
if (cellWidth > 0)
{
// Трохи математики для отримання цільового розміру в пікселях
// та перетворення його на висоту клітинки, яку вона споживатиме.
var pixelWidth = cellWidth * _cellSize.width;
var pixelHeight = (int)Math.Round((double)image.Height / image.Width * pixelWidth);
// Змінюємо розмір зображення до цільового
ctx.Resize(new ResizeOptions()
{
Sampler = KnownResamplers.Bicubic,
Size = new(pixelWidth, pixelHeight),
PremultiplyAlpha = false,
});
}
// Sixel підтримує максимум 256 кольорів
ctx.Quantize(new OctreeQuantizer(new()
{
MaxColors = 256,
}));
});
var targetFrame = image.Frames[0];
return FrameToSixelString(targetFrame);
}
private static string FrameToSixelString(ImageFrame frame)
{
var sixelBuilder = new StringBuilder();
var palette = new Dictionary();
var colorCounter = 1;
sixelBuilder.StartSixel(frame.Width, frame.Height);
frame.ProcessPixelRows(accessor =>
{
for (var y = 0; y < accessor.Height; y++)
{
var pixelRow = accessor.GetRowSpan(y);
// Як працює sixel, цей зсув бітів, починаючи від константи SIXELEMPTY
// дасть нам правильний символ для поточного ряду.
// Кожні шість рядків ми повертаємось до "порожнього символу + 1" після додавання символу нового рядка
// до рядка.
var c = (char)(SIXELEMPTY + (1 << (y % 6)));
var lastColor = -1;
var repeatCounter = 0;
foreach (ref var pixel in pixelRow)
{
if (!palette.TryGetValue(pixel, out var colorIndex))
{
colorIndex = colorCounter++;
palette[pixel] = colorIndex;
sixelBuilder.AddColorToPalette(pixel, colorIndex);
}
var colorId = pixel.A == 0 ? 0 : colorIndex;
if (colorId == lastColor || repeatCounter == 0)
{
lastColor = colorId;
repeatCounter++;
continue;
}
if (repeatCounter > 1)
{
sixelBuilder.AppendRepeatEntry(lastColor, repeatCounter, c);
}
else
{
sixelBuilder.AppendSixelEntry(lastColor, c);
}
lastColor = colorId;
repeatCounter = 1;
}
if (repeatCounter > 1)
{
sixelBuilder.AppendRepeatEntry(lastColor, repeatCounter, c);
}
else
{
sixelBuilder.AppendSixelEntry(lastColor, c);
}
sixelBuilder.Append(SIXELDECGCR);
if (y % 6 == 5)
{
sixelBuilder.Append(SIXELDECGNL);
}
}
});
sixelBuilder.Append(SIXELEND);
return sixelBuilder.ToString();
}
private static void AddColorToPalette(this StringBuilder sixelBuilder,
Rgba32 pixel,
int colorIndex)
{
var r = (int)Math.Round(pixel.R / 255.0 * 100);
var g = (int)Math.Round(pixel.G / 255.0 * 100);
var b = (int)Math.Round(pixel.B / 255.0 * 100);
sixelBuilder.Append(SIXELCOLORSTART)
.Append(colorIndex)
.Append(";2;")
.Append(r)
.Append(';')
.Append(g)
.Append(';')
.Append(b);
}
private static void AppendRepeatEntry(this StringBuilder sixelBuilder,
int color,
int repeatCounter,
char e)
{
sixelBuilder.Append(SIXELCOLORSTART)
.Append(color)
.Append(SIXELREPEAT)
.Append(repeatCounter)
.Append(color != 0 ? e : SIXELEMPTY);
}
private static void AppendSixelEntry(this StringBuilder sixelBuilder, int color, char e)
{
sixelBuilder.Append(SIXELCOLORSTART)
.Append(color)
.Append(color != 0 ? e : SIXELEMPTY);
}
private static void StartSixel(this StringBuilder sixelBuilder, int width, int height)
{
sixelBuilder.Append(SIXELSTART)
.Append(SIXELRASTERATTRIBUTES)
.Append(width)
.Append(';')
.Append(height)
.Append(SIXELTRANSPARENTCOLOR);
}
}
internal static class Program
{
private static void Main(string[] args)
{
var imagePath = Path.Combine(AppContext.BaseDirectory, "test.png");
var img = Image.Load(imagePath);
Console.Write(SixelEncoder.ImageToSixel(img));
}
}
Запуск цього коду у Windows Terminal 1.22 або старішій версії виведе наступне зображення:
Зображення, закодоване за допомогою Sixel, що відображається в Windows Terminal 1.22 Джерело зображення: https://www.pexels.com/photo/photo-of-a-road-with-a-view-of-a-mountain-27101105/
Розповсюдження вашого інструменту
Ви створили свій додаток — тепер настає справжнє випробування: доставити його користувачам. У Windows варіантів безліч: можна створити інсталятор, упакувати його для winget або Chocolatey, опублікувати на Windows Store або просто запропонувати у вигляді архіву Zip.
У Linux можливості не менші: можна розповсюджувати його як tarball з інсталяційним скриптом, упакувати для реєстру пакетів вашої дистрибуції або поділитися вихідним кодом з інструкціями по встановленню.
Для Mac є варіанти — можна створити Zip файл, опублікувати в Homebrew або створити готовий DMG пакет.
Кожна платформа має свої переваги та виклики, і правильний вибір залежить від потреб вашого додатку. Можливо, я пропустив деякі варіанти, які ідеально підходять для вашого проєкту! Моя порада? Знайдіть час, щоб дослідити свої варіанти.
Хоча це може здатися складним завданням через величезну кількість варіантів, це варто зусиль — особливо якщо врахувати додаткову складність автоматичних оновлень.
.NET надає потужне рішення для CLI інструментів: ви можете упакувати свій додаток як інструмент, який легко встановлюється за допомогою команди dotnet tool. Коли ви публікуєте свій додаток таким чином, він фактично стає пакетом NuGet, який ви завантажуєте в реєстр NuGet. Після цього будь-хто з установленим .NET може просто виконати команду dotnet tool install yourappname, щоб отримати його.
Що ще краще, .NET дозволяє автоматично створювати цей пакет NuGet під час процесу збірки. Все, що вам потрібно зробити, це налаштувати кілька параметрів у файлі проекту. До того ж, інфраструктура .NET для інструментів підтримує безшовні оновлення, тому користувачі завжди можуть залишатися в курсі останньої версії вашого додатку. Щоб розповсюджувати ваш C# додаток як інструмент .NET, просто додайте ці основні налаштування у файл вашого проекту:
True
True
1.0.1
Program author name
Короткий опис пакету, який буде відображатися в реєстрі NuGet
Інформація про авторські права
http://your.project.website
Package-icon-128x128.png
readme.md.path
http://source.repository.url
Тип репозиторію. Наприклад, Git
semicolon;delimited;list;of;tags;for;search
Текст нотаток про випуск
license.file
True
Семантичне версіонування (Semantic Versioning) — це система версіонування, розроблена для того, щоб передавати значення змін у релізі програмного забезпечення. Вона використовує триступеневий номер версії: MAJOR.MINOR.PATCH.
Основна версія (MAJOR) збільшується, коли ви робите несумісні зміни в API або порушуєте зворотну сумісність. Наприклад: якщо ви змінюєте спосіб роботи функції або видаляєте функцію, основна версія повинна збільшити версію з 1.0.0 до 2.0.0.
Мінорна версія (MINOR) збільшується, коли ви додаєте нову функціональність у зворотно-сумісний спосіб. Це означає, що нові функції вводяться, але існуюча функціональність не порушується. Наприклад: додавання нових опційних функцій без порушення старого коду має збільшити версію з 1.1.0 до 1.2.0.
Патч-версія (PATCH) збільшується, коли ви робите зворотно-сумісні виправлення помилок або незначні покращення, які не вводять нові функції або нічого не ламають. Наприклад: виправлення помилки або патч без зміни функціональності має збільшити версію з 1.0.1 до 1.0.2.
Іконка пакету є необов'язковою, але якщо ви вирішите її використовувати, вона повинна бути іконкою розміром 128x128 пікселів у форматі PNG або JPEG.
Файл readme.md повинен містити опис вашого інструменту у форматі markdown. Його вміст буде відображатися на сторінці реєстру NuGet для вашого пакету. Для інструментів корисно включати кілька прикладів використання, щоб користувачі швидко зрозуміли, як його використовувати.
Вибір правильної ліцензії може бути складним завданням і цілком може стати темою для окремої статті. Рекомендується обирати ліцензію з переліку відкритих ліцензій OSI. Кожна з них має свої переваги та недоліки. Найкращий вибір залежить від вашого додатку та ваших конкретних потреб. Особисто я зазвичай обираю ліцензію MIT через її гнучкість. Ви можете порівняти різні ліцензії на сайті OSI: https://opensource.org/licenses
Перекладено з: Console Apps in C#: Old-School Tech Making a Modern Comeback