qt-items — новый фреймворк, или попытка найти Теорию Всего +14


Как известно, физики давно пытаются найти Теорию Всего, в рамках которой можно было бы объяснять все известные взаимодействия в природе. Склонность к обобщениям присуща не только физикам, но и математикам, и программистам. Способность меньшим количеством сущностей объяснять и предсказывать большой спектр явлений очень ценна. Для программистов в роли теорий выступают различные API и фреймворки. Некоторые из них решают узкоспециализированные проблемы, а какие-то претендуют на роль универсальных теорий. Примером последних может выступать Qt — универсальный фреймворк, предназначенный, в основном, для разработки GUI.

Далее я расскажу, что мне не нравится в Qt и как его можно сделать ещё более универсальным, мощным и удобным для работы.

Демо-видео (лучше смотреть в HD):



Qt, как и многие другие GUI фреймворки развивался от простого к сложному. Сначала создавались простые виджеты, потом более сложные и составные. Появился Model/View framework, для отображения данных в табличном или древовидном виде. Появился Graphics Items framework для отображения набора графических элементов. Все эти фреймворки имеют различные API и несовместимы друг с другом. По сути у нас есть три независимых и почти не пересекающихся теории в рамках одной большой. Когда мне нужно разработать какой-либо новый визуальный элемент, то я должен выбрать, в каком из трёх фреймворков я собираюсь его использовать и применять соответствующее API. Таким образом я не могу создать элемент, который можно было бы использовать и в качестве отдельного виджета, и внедрить в ячейки таблицы, и использовать в узлах графической сцены.

Qt развивается под лозунгом — Write once, run anythere. Для написания конечных приложений это может быть и правда, но для расширения и кастомизации самой библиотеки это не так.

Давайте подумаем, как должны быть устроены виджеты, что бы библиотека Qt стала по-настоящему единой и мощной.

Рассмотрим разные виджеты (чекбокс, таблица, дерево и графическая сцена) и постараемся найти в них что-то общее. Информация в них сгруппирована в ячейки (Items). Чекбокс состоит из одной ячейки, таблица — из рядов и столбцов ячеек, в сцене ячейками являются узлы. Таким образом можно сказать, что все виджеты отображают ячейки, только их количество и расположение в пространстве специфичны для разных типов виджетов. Давайте скажем, что виджет отображает некоторое пространство ячеек (Space). Для простых виджетов пространство ячеек тривиально SpaceItem, и состоит из единственной ячейки. Для таблицы можно придумать SpaceGrid, которое описывает, как ячейки организованы в строки и столбцы. Для графической сцены имеем SpaceScene, где ячейки могут располагаться как угодно.

Что есть общего у всех пространств, что можно выделить в базовый класс?
Пока что, можно выделить две вещи:

  1. Возвращать общий размер пространства (обычно это bounding box всех ячеек)
  2. Возвращать расположение ячейки по её координате ItemID


class Space {
    virtual QSize size() const = 0;
    virtual QRect itemRect(ItemID item) const = 0;
};

Давайте теперь внимательно рассмотрим сами ячейки. Для наглядности будем изучать такую таблицу:



Ячейки тоже имеют некоторую структуру. Например, чекбокс состоит из квадратика с галочкой и текста. В таблице ячейки могут быть очень сложными (содержать текст, картинки, ссылки, как в моём видео-примере). Заметим, что для таблицы у нас, как правило, ячейки в одном столбце имеют одинаковую структуру. Поэтому нам легче описывать не каждую ячейку, а целый набор. Наборы ячеек (Range) могут быть разными, например, все ячейки RangeAll, ячейки из колонки RangeColumn, ячейки из строки RangeRow, ячейки из четных строк RangeOddRow и т.п. Какой же интерфейс можно выделить для базового класса Range? Интерфейс простой и лаконичный — отвечать на вопрос, входит какая-то ячейка в Range или нет:

class Range {
    virtual bool hasItem(ItemID item) const = 0;
};

После того, как мы определились с подмножеством ячеек, нам надо указать, какой тип информации в этих ячейках мы хотим отобразить. За отображение самого маленького и неделимого кусочка информации будет отвечать класс View. Например, ViewCheck умеет отображать значок чекбокса, ViewText — отображает строку текста и т.п.

Пока что базовый класс View должен уметь лишь рисовать информацию в ячейке:

class View {
    virtual void draw(QPainter* painter, ItemID item, QRect rect) const = 0;
};

Возникает вопрос, откуда ViewCheck знает, что ему надо рисовать значок слева в ячейке, а ViewText знает, что ему нужно рисовать текст после значка чекбокса? Для этого заведем ещё один «карликовый» класс Layout. Этот класс умеет размещать View внутри ячейки. Например, LayoutLeft разместит View у левого края ячейки, LayoutRight — у правого, а LayoutClient — займёт всё пространство ячейки. Вот базовый интерфейс:

class Layout {
    virtual void doLayout(ItemID item, View view, QRect& itemRect, QRect& viewRect) const = 0;
};

Функция doLayout изменяет параметры itemRect и viewRect так, что бы расположить view внутри ячейки item. Например, LayoutLeft запрашивает размер, необходимый view для отображения информации в ячейке, и «откусывает» необходимое пространство от itemRect. Как видно, от интерфейса View требуется еще одна функция — size:

class View {
    virtual void draw(QPainter* painter, ItemID item, QRect rect) const = 0;
    virtual QSize size(ItemID item) const = 0;
};

В итоге, чтобы описать что и как мы хотим отображать в ячейках некоторого пространства, нам надо перечислять тройки объектов tuple<Range, View, Layout>. Такую тройку я назвал ItemSchema. Полностью наш класс Space выглядит примерно так:

class Space {
    virtual QSize size() const = 0;
    virtual QRect itemRect(ItemID item) const = 0;

    QVector<ItemSchema> schemas;
};

Вот наглядный пример (подписи немного устарели, но основная идея, думаю, понятна):



Создавая разных наследников классов Range, View и Layout, и комбинируя их различным образом, мы имеем богатые возможности по кастомизации любого пространства ячеек и, таким образом, любого виджета. Например, создав класс ViewRating, который отображает оценку в виде звёздочек, я могу использовать его и как отдельный виджет, и в ячейках таблицы, и в элементах графической сцены.

Данная архитектура располагает к сотрудничеству программистов. Кто-то может написать свой тип пространства ячеек, который укладывает ячейки каким-то специальным образом. Кто-то напишет View, который отображает специфичные данные. И эти программисты могу воспользоваться результатом работы друг друга. Вот не полный список моих реализаций класса View, их легко создавать и использовать (реализация буквально несколько строк кода):
  1. ViewButton — рисует кнопку;
  2. ViewCheck — рисует значок чекбокса;
  3. ViewColor — заливает область определенным цветом;
  4. ViewEnumText — рисует текст из ограниченного списка;
  5. ViewImage, ViewPixmap, ViewStyleStandardPixmap — рисуют изображения;
  6. ViewLink — рисует текстовые ссылки;
  7. ViewAlternateBackground — рисует через-полосицу;
  8. ViewProgressLabel, ViewProgressBox — рисуют прогрессбар или проценты;
  9. ViewRadio — рисует значок радиобаттона;
  10. ViewRating — рисует значки оценки;
  11. ViewSelection — рисует выделенные ячейки;
  12. ViewText — рисует текст;
  13. ViewTextFont — меняет шрифт последующего текста;
  14. ViewVisible — показывает или скрывает другой View;


Идём дальше. Как правило, виджет отображает не всё пространство ячеек, а только видимую часть. Класс Space удобен для описания пространства ячеек, но плох для отрисовки ячеек в некоторой ограниченной видимой области. Давайте определим специальный класс для отображения под-области пространства CacheSpace:

class CacheSpace {
    // reference to items space
    Space space;
    // visible area
    QRect window;
    // draw cached items
    void draw(QPainter* painter) const;
    // visit all cached items
    virtual void visit(Visitor visitor) = 0;
};


Каждый конкретный наследник от CacheSpace (CacheGrid, CacheScene и др.) хранит набор кешированных ячеек CacheItem по-разному (но оптимально для данного типа пространства). Поэтому мы выделим в базовом классе функцию visit, которая посещает все кешированные ячейки. С помощью неё легко реализовать функцию draw — просто нужно посетить все кешированные ячейки и вызвать у них свою функцию draw.

Как понятно из названия, CacheItem хранит всю информацию, нужную для отображения конкретной ячейки:

class CacheItem {
    ItemID item;
    QRect itemRect;
    QVector<CacheView> views;

    void draw(QPainter* painter) const;
};

Здесь функция draw устроена тоже очень просто — в цикле вызвать draw у класса CacheView, который отвечает за отрисовку самого маленького и неделимого кусочка информации внутри ячейки.

class CacheView {
    View view;
    QRect viewRect;

    void draw(QPainter* painter, ItemID item) const;
};


Таким образом, виджету необходимо иметь CacheSpace и с помощью него рисовать содержимое своего пространства ячеек:

class Widget {
    // space of items
    Space space;
    // cache of visible area of space
    CacheSpace cacheSpace;

    void paintEvent(QPaintEvent *event) override;
    void resizeEvent(QResizeEvent *event) override;
};

В обработчике resizeEvent мы меняем видимую область объекта cacheSpace.window, а в обработчике paintEvent — рисуем его содержимое cacheSpace.draw().

Как видно, иерархия объектов CacheSpace->CacheItem->CacheView позволяет нам «видеть» всю визуальную структуру виджета с максимальными подробностями. Мы можем доступиться к любому самому маленькому и неделимому кусочку информации, спускаясь с уровня CacheSpace на уровень отдельной ячейки CacheItem и, далее, внутри ячейки перебирая отдельные CacheView.

Эта возможность, представить любой виджет, как иерархию CacheSpace->CacheItem->CacheView, даёт нам большие возможности по управлению и интроспекции виджета.

Например, мы можем реализовать единый интерфейс доступа к любому нашему виджету из системы автоматического тестирования. Система автоматического тестирования GUI обычно запрашивает необходимую область в виджете и потом воздействует на эту область мышью, имитируя действия пользователя. Мы можем предоставить такой системе самую подробную «карту» областей, на которые можно воздействовать.

Другой пример — анимации, которые представлены в видео-примере. Мы можем не только смотреть, из чего состоит наш виджет, но и воздействовать на его составные части. Для примера, можно менять расположения любых объектов в иерархии (CacheSpace->CacheItem->CacheView) во времени или отрисовывать их с полупрозрачностью. Таким образом, можно собирать целую библиотеку анимаций, которые могут быть применены на любой виджет и на любое пространство ячеек.

В итоге, хочу еще раз перечислить, в каких направлениях можно кастомизировать данную библиотеку:
  1. Space — можно создавать свои типы пространства ячеек
  2. CacheSpace — можно создавать новые типы отображения пространств, например, реализовать CacheSpaceCourusel — отображать список ячеек в виде карусельки
  3. View — создавать новые виды визуализаций для ячеек
  4. Animation — создавать новые анимации


Данная заметка является продолжением предыдущих двух: здесь и здесь. Проект qt-items является реализацией идей из этих заметок.

Идей и задач по дальнейшему развитию еще много, так что оставайтесь на связи.




К сожалению, не доступен сервер mySQL