Skip to content

«Микрофреймворк» для создания псевдографических приложений.

License

Notifications You must be signed in to change notification settings

ik-hse-projects/Thuja

Repository files navigation

Не понимаете код в проекте, использующем Thuja? Тогда вам нужен раздел Как строить интерфейсы?

Thuja

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

Виджеты

Основа всего — интерфейсы IWidget и IFocusable. Каждый виджет умеет рисовать себя на экран, а некоторые к тому же могут обрабатывать нажатия клавиш. Все они находятся в директории Widgets/

  • Label — просто текст, самый простой виджет, но очень важный.
  • Button — кнопка, которая вызывает произвольную функцию, когда её нажимают. Соответственно она умеет обрабатывать нажатия клавиш.
  • InputField — виджет, в который можно вводить текст, удалять, двигаться стрелочками.
  • и др.

Контейнеры

Контейнеры — некоторый вид виджетов, которые в основном содержат несколько других виджетов, а сами почти ничего не делают. Часто наследуются от BaseContainer, но не обязательно. В какой-то степени это разделение условно, но весьма удобно.

  • BaseContainer – просто рисует все виджеты поверх друг-друга. Иногда бывает полезно.
  • StackLayout — рисует несколько виджетов рядом друг с другом. Основной строительный блок любого интерфейса и списков. Позволяет двигаться между IFocusable элементами, находящимися внутри.
  • Frame — как BaseContainer, но добавляет своему содержимому красивую рамку.
  • Tabs — гибрид виджета и контейнера: есть список заголовков сверху, но при этом большая часть содержимого отдаётся "внутренним" виджетам. Не наследуется от BaseContainer
  • и др.

Вспомогательные классы

Есть пара классов вроде Popup и RadioSetBuilder — они не являются виджетами, но позволяют их создавать. Например, Popup создаёт RelativeLayout, в котором находится Frame, в котором StackLayout, в котором наконец содержимое попапа. Писать это каждый раз было бы сложно и долго.

DelegateImplementation

Иногда бывает удобно построить новый виджет на базе какого-то другого, но не применяя наследование от него. Для этого можно поместить базовый виджет в поле и отнаследоваться от одного из классов Deleagte*. Они потребуют указать, какое поле содержит реализацию того или иного интерфейса, после чего перенаправят все функции этого интерфейса в это поле. При необходимости, можно использовать override.

Фокус и нажатия клавиш

Когда пользователь нажимает кнопку, а самый первый (корневой) виджет способен это обработать, то он получает это нажатие. Если это уважающий себя контейнер, то он следит за тем, какой элемент внутри него является сфокусированным на данный момент (см. BaseContainer.Focused). Если такой элемент есть, то обработка нажатия переходит к нему (представьте себе, что этот элемент — тоже контейнер). Такой процесс в коде называется BubbleDown: событие идёт от пользователя вглубь интерфейса.

Таким образом информация о нажатии проходит через все контейнеры и спускается до какого-то виджета вроде кнопки. Он проверяет: знает ли что делать с нажатой клавишей (например, Button не понимает нажатие Esc). Если да, то на этом обработка нажатия прекращается. Если нет, то всё раскручивается в обратную сторону.

Некоторые контейнеры обрабатывают нажатия по принципу "если виджет внутри что-то может, то пусть это сделает, а если нет, то я сделаю своё менее важное дело". Например, навигация по списку в StackLayout: если сфокусирован какой-нибудь InputField, то двигать курсор важнее, а если нет, то будем двигаться по списку. Для этого у BaseContainer есть метод BubbleUp, который вызывается только когда ни один виджет "глубже" не смог обработать событие.

Теперь, когда "публичная" сторона библиотеки немного описана, можно перейти к более практическому разделу:

Как строить интерфейсы?

Этот раздел идёт первым, потому что он важен для понимания кода, написанного при помощи Thuja.

Цель синтаксиса в том, чтобы приходилось придумывать как можно меньше названий для переменных. Получилось что-то отдалённо похожее на xml, но в синтаксисе C#.

Если очень кратко, то в контейнеры можно добавлять подэлементы таким образом:

var container = new Container()
    .Add(widget1)
    .Add(widget2)
    .Add(widget3);

Это совершенно идентично следующему:

var container = new Container();
container.Add(widget1);
container.Add(widget2);
container.Add(widget3);

Теперь попробую развить аналогию с xml на более сложном примере:

new Frame()
    .Add(new StackLayout(Orientation.Horizontal, 1)
        .Add(new Label("1"))
        .Add(new Button("Click me")
            .OnClick(() => ButtonPressedHandler())
        .Add(new StackLayout()
            .Add(new Label("Row 1"))
            .Add(new Label("Row 2")) ))
<Frame>
   <StackLayout orientation="Orientation.Horizontal">
       <Label text="1" />
       <Button text="Click me">
           <OnClick>() => ButtonPressedHandler()</OnClick>
       </Button>
       <StackLayout> <!-- по-умолчанию orientation="Vertical" -->
           <Label text="Row 1"/>
           <Label text="Row 2"/>
       </StackLayout>
   </StackLayout> 
</Frame>

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

IKeyHandler

Многие виджеты и интерфейсы реализуют IKeyHandler. Цель этого интерфейса — позволить навешивать нажатия кнопок на произвольные IFocusable. Разберу пример кода:

var foo =
    new StackLayout()
        .Add(new Label("Hello!"))
        .Add(new Button("World!"))
    .AsIKeyHandler()
        .Add(new KeySelector(ConsoleKey.Escape), () => EscapePressed())
        .Add(new KeySelector(ConsoleKey.Enter), () => EnterPressed());
// Здесь foo имеет тип IKeyHandler, а не StackLayout!
// Но гарантируется, что следующий код не упадет:
var bar = (StackLayout) foo;

В "обычном" c#:

// Создаём контейнер, как обычно:
var stackLayout = new StackLayout();

// Добавляем содержимое:
stackLayout.Add(new Label("Hello!"));
stackLayout.Add(new Button("World!"));

// Затем хотим, чтобы когда через этот контейнер проходит нажатие Enter,
// то он бы его перехватил и вместо того, чтобы спускать вниз, вызывал бы EnterPressed()

// Для этого преобразуем его в IKeyHandler. Оба способа делают одно и то же.
var keyHandler = (IKeyHandler) stackLayout;
var keyHandler = stackLayout.AsIKeyHandler();
// И добавим желаемое:
keyHandler.Add(new KeySelector(ConsoleKey.Enter), () => EnterPressed());

// Аналогично с Enter:
keyHandler.Add(new KeySelector(ConsoleKey.Escape), () => EscapePressed());

Как это могло бы выглядеть на xml (часть с Escape опущена):

<StackLayout>
    <!-- Обычное содержимое, обычные виджеты -->
    <Label Text="Hello!" />
    <Button Text="World!" />

    <!-- StackLayout реализует IKeyHandler, поэтому мы можем поместить что-то в его Actions -->
    <IKeyHandler.Actions>
        <Entry> <!-- элемент списка -->
            <KeySelector>
                <!-- Какие клавиши мы хотим ловить?  -->
                ConsoleKey.Enter
            </KeySelector>
            <Action>
                <!-- Что произойдет, когда будет нажата клавиша? -->
                () => EnterPressed()
            </Action>
        </Entry>
        <Entry> <!-- здесь должен быть Escape -->
            ...
        </Entry>
    </IKeyHandler.Actions>
</StackLayout>

Как можно было догадаться, теперь при нажатии Enter, будет вызвана функция EnterPressed(). Важный момент, что в этот момент считается, что нажатие было обработано, а значит глубже в иерархию оно не спустится. Таким образом, на кнопку "World!" нельзя будет нажать при помощи Enter: до неё это событие просто-напросто не дойдёт.

Как оно реализовано?

MainLoop

MainLoop — место, где происходит вся работа (см. MainLoop.Tick())

  1. Обработка нажатий;
  2. Отрисовка всех виджетов;
  3. (Display) Перенос нарисованного на реальный экран консольки.
  4. goto 1

Когда использующий библиотеку программист вызывает mainLoop.Start(), его программа полностью замирает в этот момент: всё управление переходит в руки Thuja. Далее можно либо работать с потоками, либо использовать всевозможные коллбэки кнопок или IKeyHandler, чтобы реагировать на действия пользователя.

У MainLoop есть корневой виджет: только он будет отрисовываться и получать события клавиатуры. Поэтому чаще всего это один из контейнеров, который и отрисует всё как надо, и передаст нажатие клавиш сфокусированному элементу.

Отрисовка

RenderContext

Каждый виджет может рисовать своё содержимое при помощи RenderContext. Этот класс обеспечивает разные полезные вещи:

  • Например, каждый элемент контейнера StackLayout (списка) должен отображаться в определённом месте: например, под предыдущим виджетом. Для этого контейнер создаёт новый Context на основе своего (см. RenderContext.Derive()), устанавливает ему правильное положение и передаёт уже его в виджеты. Всё нарисованное в таком контексте будет сдвинуто на какое-то значение.
  • Также контекст контролирует размер отрисованного внутри. Всё содержимое виджета можно вписать в прямоугольник, у которого потом нужно получить ширину и высоту. Например, Frame использует это для того, чтобы отслеживать размер своего содержимого и рисовать рамочку правильного размера
  • У контекста имеется ряд вспомогательных методов, вроде "нарисовать строку с указанным стилем".
  • И ещё через него же происходит позиционирование курсора.

Canvas

Контекст рассчитывает абсолютные координаты всех символов, с учётом всех смещений, и передаёт их в Canvas. Этот класс построен вокруг двумерного массива символов, которые отображаются на экран.

  • Canvas следит за тем, чтобы отрисовка за пределами экрана не приводила к падению.
  • И он же обрабатывает ситуацию, когда один символ отрисовывается поверх другого. См. Canvas.TrySet(), там не всё так просто.

Display

Этот класс занимается тем, чтобы эффективно перенести всё нарисовааное на экран консоли. Он хранит два Canvas: текущий и предыдущий. Чтобы обновить картинку, нужно вычислить отличия между этими двумя, что и происходит в весьма сложной функции Canvas.FindDifferences, которая возвращает список действий, которые нужно применить к экрану консоли, чтобы получить желаемую картинку. Класс Display перебирает все эти действия, правильно настраивает стили консоли и всё такое, чтобы наконец отобразить виджеты человеку.

VideoPlayer

Одна из интересных деталей: проигрывание анимаций. Анимации хранятся в специальном простом формате, который позволяет пятью байтами закодировать:

  • Цвет символа (5 бит)
  • Цвет фона (5 бит)
  • Немного флагов на будущее (6 бит)
  • Любой символ юникода (3 байта)

Поскольку закодированное таким образом видео получается огромным, после оно сжимается при помощи Brotli, что очень значительно уменьшает его размер.

Цвета перечислены в MyColor: их там 16 штук, плюс ещё прозрачный и цвет по-умолчанию (тот, который использует консоль, если не применять стилей). Числа соответствуют тем, которые использует libcaca

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

Спасибо за внимание, на этом краткий обзор библиотеки завершается.

About

«Микрофреймворк» для создания псевдографических приложений.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published