CsConsoleFormat: formatting in the console in a new way (.NET)

    Everyone knows the rich formatting tools in the console: alignment with spaces, changing the current color of text and background. If you want to print a couple of lines, then this is completely enough, although the absence of hyphenation by spaces is sometimes annoying. If you need to display a table, you have to manually calculate the width of the columns, and often just hardcode the width. If you need to colorize the output, you have to streak the text output with endless switching and color recovery. If you need to display text with hyphenation or combine all of the above ...


    The code quickly turns into an unreadable mess, which does not make out, where is the logic, where is the text, where is the formatting. This is terrible! When we write a GUI, we have at our disposal all the charm of a modern design: MV * patterns, bindings and other cool stuff. After working with the GUI, writing console applications is akin to returning to the Stone Age.


    CsConsoleFormat to the rescue!


    Opportunities


    • Elements as in HTML : paragraphs, spans, tables, lists, borders, delimiters.
    • Layouts : tables (grid), piles (stack), Velcro (dock), hyphenation (wrap), absolute (canvas).
    • Text formatting : text and background color, hyphenation and word wrapping .
    • Unicode character formatting : hyphens, soft hyphens, non-breaking hyphens, spaces, non-breaking spaces, spaces with zero width.
    • Several syntaxes (see below):
      • Like WPF : XAML with obtaining values ​​of property chains (one-time binding), resources, converters, attached properties, loading documents from resources.
      • Like LINQ to XML : C # with object initializers, setting attached properties via extension methods or indexers, adding subelements by collapsing lists of elements and converting objects to strings.
    • Drawing : geometric primitives (lines, rectangles) based on pseudo-graphic symbols, color transformations (lighter, darker), text output.
    • Internationalization : cultures can be redefined at the level of any element.
    • Export to many formats: text with escape sequences, unformatted text, HTML; RTF, XPF, WPF FixedDocument, WPF FlowDocument (requires WPF).
    • JetBrains R # Annotations : CanBeNull, NotNull, ValueProvider, etc.
    • WPF : control for displaying a document, document converter, image import.


    Suppose we have the familiar and familiar classes Order, OrderItem, Customer. Let's create a document that displays the order in full detail. There are two syntax available, we can use any and even combine them.


    XAML (a la WPF):



    IdNameCount

    C # (a la 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),
                    })
                )
        );

    Syntax Selection


    XAML (a la WPF) forces a clear separation of models and views, which can be considered a virtue. However, XAML is not too strongly typed or compiled, so runtime errors are possible. The syntax is ambiguous: on the one hand, XML is verbose ( ), on the other hand, it allows saving on enumeration records ( Color="White") and using converters ( Stroke="Single Wide").


    Mono's XAML library is bazy and limited. If you need a cross-platform application, then using XAML can cause problems. However, if you are familiar with WPF and need only Windows support, then XAML should be natural. The version for .NET Standard uses the Portable.Xaml library, which should be slightly better, but so far it has not been sufficiently tested in combat conditions.


    XAML in general is only limitedly supported by Visual Studio and ReSharper: although syntax highlighting and code completion usually work, do not count on completion of binding paths, and errors are sometimes highlighted where they are not. However, for those familiar with XAML, this is nothing new.


    C # (a la LINQ to XML) allows you to perform a wide variety of transformations directly in the code due to LINQ and collapse lists when adding subelements. If you use C # 6, which is supported using static, then writing some enumerations can be shortened. The only place with loose typing is the extension method AddChildren(params object[])(its use is optional).


    Building documents in code is fully supported in any development environment, however, attempts to build huge documents with one expression on many pages can lead to brakes when using ReSharper (the 9th version sometimes almost hung the studio; probably now it is no longer relevant).


    Real example


    The Github repository has an example console application for displaying current processes in the system and launching new processes. It looks something like this:



    All formatting fits into one small and clear file. Here you can see message output, error output, formatting of the process table, and help output in all possible ways.


    To process the command line, the popular CommandLineParser library is used, the class BaseOptionAttributefrom there contains information about one command or parameter. Some features of C # 6 are used here. The rest of the code, I think, does not need special explanations.


    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 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 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 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 "";
        }
    }

    Magic


    How is all this piling up of elements built and turned into a document? Bird's-eye view:


    • Building a logical tree: using transformations a la LINQ to XML, collapse IEnumerablein parameters AddChidlren, etc.
    • Building a visual tree: each element is converted to a view convenient for the engine.
    • Calculation of the sizes of elements: each element decides how much space it needs and how much space to give to its children.
    • Calculation of the position of the elements: the elements decide on which coordinates to place the children inside themselves.
    • Rendering elements into a virtual buffer simulating a console.
    • Rendering a buffer to a real console or to any other format.

    And now more about each item.


    Logical tree


    Building a document in XAML is like WPF, only with {Get Foo}instead {Binding Foo, Mode=OneTime}and with {Res Bar}instead {StaticResource Bar}. The converters here are not classes, but single methods that can be accessed through {Call Baz}. Margin and Padding are set, as in WPF, using strings with 1, 2, or 4 numbers. Attached properties are specified through the class name and properties through the dot. In short, for those familiar with WPF, everything should be familiar and clear.


    Building a document in C # is done in the spirit of LINQ to XML (System.Xml.Linq), only the params object[]method AddChildren(as well AddColumns) is used instead of the constructors with an argument . Here are the available conversions:


    • Elements are nullcompletely ignored. Among other things, this allows you to conditionally include some elements using the ternary operator.
    • Sequences are IEnumerableexpanded, their elements are added instead. Sequences can be nested. For example, this allows you to AddChildrencreate both a table header and its contents using one call Select.
    • Objects that are not elements are converted to strings. If there is an opportunity (support IFormattable), then taking into account the locale of the element.

    For those unfamiliar with WPF, the concept of attached properties may not be familiar. The bottom line is that sometimes it is necessary to supplement the existing properties of elements, for example, Canvas has both its own properties and the coordinate properties for its elements. These are conveniently set using the Set: extension method new Div(...).Set(Canvas.LeftProperty, 10).


    Visual tree


    Elements are divided into two large groups: block and lowercase - just like in early HTML. There are also generator elements (well, that is, one element) that can serve as both, depending on the patterns.


    The original tree of elements is logical in terms of WPF, that is, it contains elements in the form in which the programmer created them. Then it irrevocably turns into a visual one , that is, it contains elements in the form that is convenient for the engine (roughly speaking, high-level abstractions are converted to elements that can actually represent themselves). This conversion includes:


    • Any element can completely replace its contents. For example, the Repeater generator clones its template and repeats several times, while excluding itself, and the List block prepares its subelements in the Grid.
    • If several line elements go one after another, then they are combined into one block (InlineContainer).
    • If several block elements go one after another, then they are combined into one stack (Stack).

    It should also be noted that some elements can do more implicit conversions than in WPF, for example, the Grid by default uses automatic sequential placement of cells, which makes creating table rows and specifying the coordinates of each cell unnecessary.


    Element size calculation


    For all elements, the Measure method is called recursively, in which the parent tells the child how much free space is assigned to him, and the child answers how much he wants. The child may ask for more than suggested, and less, but if you ask for more, the parent will show a fig. At infinity, the parent will also show a fig, even if offered.


    Elements with complex logic for placing children use this stage to do most of their work (for example, a container of lower-case elements formats text based on hyphenation and color).


    Calculation of the position of the elements


    For all elements, Arrange is recursively called, in which each parent is engaged in the placement of their children.


    Element rendering


    Render is recursively called on all elements, in which each element displays itself in the ConsoleBuffer virtual console buffer. The ConsoleBuffer class is something akin to HDC, System.Windows.Forms.Graphics, Graphics.TCanvas, and more. It contains methods for displaying text, drawing, and more.


    A buffer is supplied to each element in a convenient form with a limited available area so that you can draw yourself in the coordinates (0; 0) - (Width; Height) without bothering.


    Buffer rendering


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


    References



    Also popular now: