Всем хорошо известны богатые средства форматирования в консоли: выравнивание пробелами, изменение текущего цвета текста и фона. Если вы хотите вывести пару строчек, то этого полностью хватает, хотя отсутствие переносов по пробелам временами раздражает. Если нужно вывести таблицу, приходится вручную рассчитывать ширину колонок, а часто просто хардкодить ширину. Если нужно раскрасить вывод, приходится испещрять вывод текста бесконечными переключениями и восстановлениями цвета. Если нужно вывести текст с переносами по словам или совместить всё перечисленное выше...


Код быстро превращается в нечитаемое месиво, в котором не разобрать, где логика, где текст, где форматирование. Это ужасно! Когда мы пишем GUI, у нас в распоряжении все прелести современной разработки: паттерны MV*, байндинги и прочие крутые штуки. После работы с GUI написание консольных приложений сродни возвращению в каменный век.


CsConsoleFormat спешит на помощь!


Возможности


  • Элементы как в HTML: параграфы, спаны, таблицы, списки, границы, разделители.
  • Лейауты: таблицы (grid), стопки (stack), липучки (dock), переносы (wrap), абсолютные (canvas).
  • Форматирование текста: цвет текст и фона, переносы по буквам и словам.
  • Форматирование символов Unicode: дефисы, мягкие дефисы, неразрывные дефисы, пробелы, неразрывные пробелы, пробелы с нулевой шириной.
  • Несколько синтаксисов (см. ниже):
    • Как WPF: XAML с получением значений цепочек свойств (one-time binding), ресурсами, конвертерами, прикреплёнными свойствами, загрузкой документов из ресурсов.
    • Как LINQ to XML: C# с инициализаторами объектов, заданием прикреплённых свойств через методы расширения или индексаторы, добавление подэлементов через схлопывание списков элементов и конвертацию объектов к строкам.
  • Рисование: геометрические примитивы (линии, прямоугольники) на основе символов псевдо-графики, цветовые преобразования (светлее, темнее), вывод текста.
  • Интернационализация: культуры могут быть переопределены на уровне любого элемента.
  • Экспорт во множество форматов: текст с escape-последовательностями, неформатированный текст, HTML; RTF, XPF, WPF FixedDocument, WPF FlowDocument (требуется WPF).
  • Аннотации JetBrains R#: CanBeNull, NotNull, ValueProvider и др.
  • WPF: контрол для отображения документа, конвертер документа, импорт изображений.


Положим, у нас есть привычные и знакомые классы Order, OrderItem, Customer. Давайте создадим документ, который выведет заказ во всех подробностях. Доступных синтаксиса два, мы можете пользоваться любым и даже совмещать их.


XAML (а-ля WPF):


<Document xmlns="urn:alba:cs-console-format"
          xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Span Background="Yellow" Text="Order #"/>
    <Span Text="{Get OrderId}"/>
    <Br/>
    <Span Background="Yellow" Text="Customer: "/>
    <Span Text="{Get Customer.Name}"/>

    <Grid Color="Gray">
        <Grid.Columns>
            <Column Width="Auto"/>
            <Column Width="*"/>
            <Column Width="Auto"/>
        </Grid.Columns>
        <Cell Stroke="Single Wide" Color="White">Id</Cell>
        <Cell Stroke="Single Wide" Color="White">Name</Cell>
        <Cell Stroke="Single Wide" Color="White">Count</Cell>
        <Repeater Items="{Get OrderItems}">
            <Cell>
                <Span Text="{Get Id}"/>
            </Cell>
            <Cell>
                <Span Text="{Get Name}"/>
            </Cell>
            <Cell Align="Right">
                <Span Text="{Get Count}"/>
            </Cell>
        </Repeater>
    </Grid>
</Document>

C# (а-ля LINQ to XML):


using static System.ConsoleColor;

var headerThickness = new LineThickness(LineWidth.Single, LineWidth.Wide);

var doc = new Document()
    .AddChildren(
        new Span("Order #") { Color = Yellow },
        Order.Id,
        "\n",
        new Span("Customer: ") { Color = Yellow },
        Order.Customer.Name,

        new Grid { Color = Gray }
            .AddColumns(
                new Column { Width = GridLength.Auto },
                new Column { Width = GridLength.Star(1) },
                new Column { Width = GridLength.Auto }
            )
            .AddChildren(
                new Cell { Stroke = headerThickness }
                    .AddChildren("Id"),
                new Cell { Stroke = headerThickness }
                    .AddChildren("Name"),
                new Cell { Stroke = headerThickness }
                    .AddChildren("Count"),
                Order.OrderItems.Select(item => new[] {
                    new Cell()
                        .AddChildren(item.Id),
                    new Cell()
                        .AddChildren(item.Name),
                    new Cell { Align = HorizontalAlignment.Right }
                        .AddChildren(item.Count),
                })
            )
    );

Выбор синтаксиса


XAML (а-ля WPF) заставляет чётко разделять модели и представления, что можно считать достоинством. Однако XAML не слишком строго типизирован и не компилируется, поэтому возможны ошибки времени выполнения. Синтаксис неоднозначный: с одной стороны, XML отличается многословностью (<Grid><Grid.Columns><Column/></Grid.Columns></Grid>), с другой — позволяет экономить на записи перечислений (Color="White") и пользоваться конвертерами (Stroke="Single Wide").


Библиотека XAML в Mono бажная и ограниченная. Если вам нужно кросс-платформенное приложение, то использование XAML может вызвать проблемы. Однако если вы хорошо знакомы с WPF, и вам необходима поддержка только Windows, то XAML должен быть естественным. В версии для .NET Standard используется библиотека Portable.Xaml, которая должна быть чуть лучше, но пока оно недостаточно протестировано в боевых условиях.


XAML в общем виде лишь ограниченно поддерживается Visual Studio и ReSharper: хотя подсветка синтаксиса и автодополнение кода обычно работают, на автодополнение путей байндингов не рассчитывайте, да и ошибки иногда подсвечиваются там, где их нет. Впрочем, для знакомых с XAML в этом ничего нового.


C# (а-ля LINQ to XML) позволяет выполнять самые разнообразные преобразования прямо в коде за счёт LINQ и схлопывания списков при добавлении подэлементов. Если вы используете C# 6, в котором поддерживается using static, то запись некоторых перечислений можно сократить. Единственное место с нестрогой типизацией — это метод расширения AddChildren(params object[]) (его использование опционально).


Построение документов в коде полностью поддерживается в любой среде разработки, однако попытки соорудить огромные документы одним выражением на много страниц могут привести к тормозам при использовании ReSharper (9-ая версия временами чуть не подвешивала студию; вероятно, сейчас уже неактуально).


Реальный пример


В репозитрии на ГитХабе есть пример консольного приложения для отображения текущих процессов в системе и запуска новых процесов. Выглядит это примерно так:



Всё форматирование укладывается в одной небольшой и наглядный файлик. Здесь и вывод сообщений, и вывод ошибок, и форматирование таблицы процессов, и вывод справки всеми возможными способами.


Для обработки командной строки используется популярная библиотека CommandLineParser, класс BaseOptionAttribute оттуда и содержит информацию об одной команде или параметре. Здесь используются некоторые возможности C# 6. Остальной код, думаю, в особых объяснениях не нуждается.


using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using CommandLine;
using static System.ConsoleColor;

internal class View
{
    private static readonly LineThickness StrokeHeader = new LineThickness(LineWidth.None, LineWidth.Wide);
    private static readonly LineThickness StrokeRight = new LineThickness(LineWidth.None, LineWidth.None, LineWidth.Single, LineWidth.None);

    public Document Error (string message, string extra = null) =>
        new Document { Background = Black, Color = Gray }
            .AddChildren(
                new Span("Error\n") { Color = Red },
                new Span(message) { Color = White },
                extra != null ? $"\n\n{extra}" : null
            );

    public Document Info (string message) =>
        new Document { Background = Black, Color = Gray }
            .AddChildren(message);

    public Document ProcessList (IEnumerable<Process> processes) =>
        new Document { Background = Black, Color = Gray }
            .AddChildren(
                new Grid { Stroke = StrokeHeader, StrokeColor = DarkGray }
                    .AddColumns(
                        new Column { Width = GridLength.Auto },
                        new Column { Width = GridLength.Auto, MaxWidth = 20 },
                        new Column { Width = GridLength.Star(1) },
                        new Column { Width = GridLength.Auto }
                    )
                    .AddChildren(
                        new Cell { Stroke = StrokeHeader, Color = White }
                            .AddChildren("Id"),
                        new Cell { Stroke = StrokeHeader, Color = White }
                            .AddChildren("Name"),
                        new Cell { Stroke = StrokeHeader, Color = White }
                            .AddChildren("Main Window Title"),
                        new Cell { Stroke = StrokeHeader, Color = White }
                            .AddChildren("Private Memory"),
                        processes.Select(process => new[] {
                            new Cell { Stroke = StrokeRight }
                                .AddChildren(process.Id),
                            new Cell { Stroke = StrokeRight, Color = Yellow, TextWrap = TextWrapping.NoWrap }
                                .AddChildren(process.ProcessName),
                            new Cell { Stroke = StrokeRight, Color = White, TextWrap = TextWrapping.NoWrap }
                                .AddChildren(process.MainWindowTitle),
                            new Cell { Stroke = LineThickness.None, Align = HorizontalAlignment.Right }
                                .AddChildren(process.PrivateMemorySize64.ToString("n0")),
                        })
                    )
            );

    public Document HelpOptionsList (IEnumerable<BaseOptionAttribute> options, string instruction) =>
        new Document { Background = Black, Color = Gray }
            .AddChildren(
                new Div { Color = White }
                    .AddChildren(instruction),
                "",
                new Grid { Stroke = LineThickness.None }
                    .AddColumns(GridLength.Auto, GridLength.Star(1))
                    .AddChildren(options.Select(OptionNameAndHelp))
            );

    public Document HelpAllOptionsList (ILookup<BaseOptionAttribute, BaseOptionAttribute> verbsWithOptions, string instruction) =>
        new Document { Background = Black, Color = Gray }
            .AddChildren(
                new Span($"{instruction}\n") { Color = White },
                new Grid { Stroke = LineThickness.None }
                    .AddColumns(GridLength.Auto, GridLength.Star(1))
                    .AddChildren(
                        verbsWithOptions.Select(verbWithOptions => new object[] {
                            OptionNameAndHelp(verbWithOptions.Key),
                            new Grid { Stroke = LineThickness.None, Margin = new Thickness(4, 0, 0, 0) }
                                .Set(Grid.ColumnSpanProperty, 2)
                                .AddColumns(GridLength.Auto, GridLength.Star(1))
                                .AddChildren(verbWithOptions.Select(OptionNameAndHelp)),
                        })
                    )
            );

    private static object[] OptionNameAndHelp (BaseOptionAttribute option) => new[] {
        new Div { Margin = new Thickness(1, 0, 1, 1), Color = Yellow, MinWidth = 14 }
            .AddChildren(GetOptionSyntax(option)),
        new Div { Margin = new Thickness(1, 0, 1, 1) }
            .AddChildren(option.HelpText),
    };

    private static object GetOptionSyntax (BaseOptionAttribute option)
    {
        if (option is VerbOptionAttribute)
            return option.LongName;
        else if (option.ShortName != null) {
            if (option.LongName != null)
                return $"--{option.LongName}, -{option.ShortName}";
            else
                return $"-{option.ShortName}";
        }
        else if (option.LongName != null)
            return $"--{option.LongName}";
        else
            return "";
    }
}

Магия


Как всё это нагромождение элементов строится и превращается в документ? Взгляд с высоты птичьего полёта:


  • Построение логического дерева: с помощью преобразований а-ля LINQ to XML схлопываются IEnumerable в параметрах AddChidlren и др.
  • Построение визуального дерева: каждый элемент преобразуется к виду, удобному для движка.
  • Расчёт размеров элементов: каждый элемент решает, сколько места ему надо и сколько места дать своим детям.
  • Расчёт положения элементов: элементы решают, по каким координатам внутри себя разместить детей.
  • Рендеринг элементов в виртуальный буфер, имитирующий консоль.
  • Рендеринг буфера в реальную консоль или в любой другой формат.

А теперь подробнее про каждый пункт.


Логическое дерево


Построение документа в XAML напоминает WPF, только с {Get Foo} вместо {Binding Foo, Mode=OneTime} и с {Res Bar} вместо {StaticResource Bar}. Конвертеры здесь — не классы, а одиночные методы, к которым можно можно обратиться через {Call Baz}. Margin и Padding задаются, как и в WPF, с помощью строк c 1, 2 или 4 числами. Прикреплённые свойства задаются через имя класса и свойства через точку. Короче, для знакомых с WPF всё должно быть привычно и понятно.


Построение документа в C# сделано в духе LINQ to XML (System.Xml.Linq), только вместо конструкторов с аргументом params object[] используется метод AddChildren (а также AddColumns). Вот доступные преобразования:


  • Элементы null полностью игнорируются. Кроме прочего, это позволяет условно включать некоторые элементы с помощью тернарного оператора.
  • Последовательности IEnumerable разворачиваются, вместо них добавляются их элементы. Последовательности могут быть вложенными. Например, это позволяет в одном вызове AddChildren создать и шапку таблицы, и её содержимое с помощью Select.
  • Объекты, которые не являются элементами, преобразуются к строкам. Если есть возможность (поддержка IFormattable), то с учётом локали элемента.

Для незнакомых с WPF концепция прикреплённых свойств может быть непривычной. Суть в том, что иногда требуется дополнять уже имеющиеся свойства элементов, например, Canvas имеет как свои свойства, так и свойства координат для своих элементов. Такие удобно задаются с помощью метода-расширения Set: new Div(...).Set(Canvas.LeftProperty, 10).


Визуальное дерево


Элементы делятся на две большие группы: блочные и строчные — прямо как в раннем HTML. Также есть элементы-генераторы (ну то есть один элемент), которые могут служить и теми, и другими в зависимости от шаблонов.


Исходное дерево элементов — логическое в терминах WPF, то есть содержит элементы в том виде, в котором их создал программист. Потом оно же безвозвратно превращается в визуальное, то есть содержит элементы в том виде, который удобен для движка (грубо говоря, высокоуровневые абстракции преобразуются к элементам, которые реально могут себя отобразить). Это преобразование включает:


  • Любой элемент может полностью заменить своё содержимое. Например, генератор Repeater клонирует свой шаблон и повторяет несколько раз, при этом исключая самого себя, а блок List расфасовывает свои подэлементы в Grid.
  • Если несколько строчных элементов идут друг за другом, то они объединяются в один блок (InlineContainer).
  • Если несколько блочных элементов идут друг за другом, то они объединяются в одну стопку (Stack).

Также надо отметить, что некоторые элементы умеют делать больше неявных преобразований, чем в WPF, например, Grid по умолчанию использует автоматическое последовательное размещение ячеек, что делает создание строк таблицы и указание координат каждой ячейки ненужным.


Расчёт размера элементов


У всех элементов рекурсивно вызывается метод Measure, в котором родитель сообщает ребёнку, сколько ему положено свободного места, а ребёнок отвечает, сколько ему самому хочется. Ребёнок может попросить и больше, чем предложено, и меньше, но если попросить больше, то родитель покажет фигу. На бесконечность родитель тоже покажет фигу, даже если её предложил.


Элементы со сложной логикой размещения детей используют эту стадию, чтобы выполнить большую часть своей работы (например, контейнер строчных элементов форматирует текст, учитывая переносы и цвета).


Расчёт положения элментов


У всех элементов рекурсивно вызывается Arrange, в котором каждый родитель занимается размещением своих детей.


Рендеринг элементов


У всех элементов рекурсивно вызывается Render, в котором каждый элемент отображает себя в виртуальном консольном буфере ConsoleBuffer. Класс ConsoleBuffer — это что-то сродни HDC, System.Windows.Forms.Graphics, Graphics.TCanvas и прочего такого. Он содержит методы для отображения текста, рисования и прочего.


Подаётся буфер каждому элементу в удобном виде с ограниченной доступной областью, чтобы можно было рисовать себя по координатам (0; 0)–(Width; Height), не заморачиваясь.


Рендеринг буфера


На данной стадии с буфере находится прямоугольная область с текстом и цветом. Её можно или отобразить в консоль, как и было задумано, или сотворить с ним что-то другое, например, конвертировать в HTML или отобразить в окошке WPF. За это отвечают классы, реализующие IRenderTarget.


А что как сложно-то?


Это просто. Сложно — это ConsoleFramework и уж тем более Avalonia. Тут ни инвалидаций, ни интерактива. Всё сделано ради простоты: и просто писать документы, и просто писать элементы. Все деревья одноразовые.


Реально нужно знать только то, как использовать AddChildren (и то по вкусу), а также паттерны использования базовых элементов, в частности Grid. Всё остальное понадобится, только если захотите создавать свои элементы.


И даже если вы захотите создавать свои элементы, то можно ограничиться простым преобразованием в уже умеющие себя отображать элементы, например, List реализован через Grid, вся логика умещается в один тривиальный метод на 16 строк.


А также


Всё это работает в .NET 4.0+, .NET Standard 1.3+. Для пуристов есть версия без поддержки XAML. Она же консерваторов, потому что включает поддержку .NET 3.5.


Есть пакетик для поддержки шрифтов FIGlet из Colorful.Console, но эта зависимость была ошибкой, потому что, как выяснилось, Colorful.Console не умеет FIGlet по-нормальному. Позднее будет или поддержка Figgle, или своя реализация.


Лицензия


Apache 2.0. Часть кода позаимствована из библиотеки ConsoleFramework, написанной Игорем Костоминым под лицензией MIT.


Ссылки


Комментарии (18)


  1. rbobot
    28.02.2018 22:47

    Очень круто, спасибо!


  1. kovserg
    28.02.2018 23:16

    Прикольно, только зачем консольным утилитам такое оформление? Скриптам оформление фиолетово. Чем вас gui не устраиват? Зачем вам понадобилось раскрашивать консоль? Следующий шаг видимо будет добавление анимации и javascript-а.


    1. Xandrmoro
      01.03.2018 03:53
      +1

      Например, чтобы логи почитабельнее были?


      1. kovserg
        02.03.2018 15:41

        Логи обычно идут через grep, awk и том подобные преобразования, зачем там цвет? Да и сотни мегабайт логов читать глазами это не весело.


        1. Athari Автор
          02.03.2018 20:50

          Во-первых, логи не всегда стомегабайтовые. Во-вторых, если не раскрашивать каждую букву по отдельности, то поиску расцветка нисколько не вредит.


        1. mayorovp
          02.03.2018 21:37

          А всю сотню мегабайт читать глазами и не нужно. Красивые логи в консоли делают чтобы можно было посмотреть чем программа занята в данный момент.


    1. Athari Автор
      01.03.2018 04:05

      Я не согласен, что консоль должна выглядеть уныло. Если бы цвет не был нужен, никто бы не добавлял поддержку аж 16 цветов.


    1. Doomsday_nxt
      02.03.2018 22:28

      Ну тот же mc тоже использует цветовое оформление… Пусть и не в таком объёме…


  1. Pro-invader
    01.03.2018 10:59

    Кто-нибудь знает, возможно ли в консоли Windows XP,7,10 Java-приложения считывать нажатие клавиш без нажатия Enter?


    1. Athari Автор
      01.03.2018 14:06

      Разработчики Java почему-то решили, что чтение клавиши без нажатия Enter — редкая и не портируемая на разные платформы функция, поэтому просто её не включили. Пути решения можно найти в вопросе "How to read a single char from the console in Java (as the user types it)?".


      P. S. Не очень понимаю, при чём здесь Java...


      1. Pro-invader
        01.03.2018 14:20

        Благодарю.

        Не очень понимаю, при чём здесь Java...

        Не причем, смотрю спецы по консолям собрались, дай думаю спрошу, не прогадал.


        1. Athari Автор
          01.03.2018 14:39

          Я скорее не спец по консолям, а спец по использованию Stack Overflow, потому что ни Java, ни его стандартной билиотеки, ни тем более кроссплатформенных нюансов я не знаю.


  1. Busla
    01.03.2018 13:12
    +1

    Спасибо, интересно!

    Позанудствую:

    Элементы как в HTML: параграфы
    По-русски это называется «абзацы».


    1. Athari Автор
      01.03.2018 14:10

      Обычно я бы возразил, что программисту привычнее слово "параграф", потому что нет необходимости вспоминать перевод, но, учитывая, что я развёл конченое славянофильство со стопками и липучками, придётся исправлять. X)


      1. Busla
        01.03.2018 14:52

        На одной из прошлых работ делали мультимедиа-курсы по школьной программе — там была пара конфузов из-за путаницы абзац/парагаф :-(


  1. FillCT
    01.03.2018 13:50

    В репозитрии на ГитХабе есть пример консольного приложения для отображения текущих процессов

    Можете указать ссылку? А то поиск выдает слишком много результатов.


    1. Athari Автор
      01.03.2018 13:53

      Зачем поиск? В конце статьи ссылка на репозиторий. Вот там, среди остальных, и лежит проект под названием "Alba.CsConsoleFormat.Sample.ProcessManager".


      1. FillCT
        01.03.2018 17:23

        О, спасибо большое. Думал там только библиотека