В этой статье я расскажу про возможности компактной кросс‑платформенной библиотеки с открытым исходным кодом для разработки приложений с графическим интерфейсом на языке C++ — Frenchie. Цель материала — познакомить читателей с возможностями библиотеки и сделать сравнение функционала с другими похожими проектами. Если кому‑то интересна техническая сторона проекта, то предлагаю заглянуть в исходный код, либо в материалы, описывающие архитектурную и техническую часть.
Зачем делать свою библиотеку и что не так с уже существующими популярными решениями?
Достаточно популярными современными C++ framework‑ами для разработки приложений с графическим интерфейсом являются Qt и WxWidgets. Данные framework‑и относительно просты в освоении, очень хорошо и подробно задокументированы, а также предоставляют широкие возможности для разработки приложений с графическим интерфейсом.
Основная проблема этих двух framework‑ов (и похожих на них) — это необходимость синхронизации состояния графического интерфейса и данных, по которым он рисуется (более подробно можно прочитать здесь). Именно по этой причине я долго искал решения, которые позволили бы рисовать интерфейс по любым данным и моделям «на лету».
Парадигма immediate mode user interface
Архитектурный подход к построению графического интерфейса, используемый в Qt и WxWidget, заключается в том, чтобы представить элементы графического интерфейса в виде дерева элементов, имеющих свое состояние, и раз в какое‑то время обходить это дерево, рисуя его элементы и отслеживая связанные с ними события. При этом, само дерево создается заранее и, как правило, сильно не изменяется по ходу работы программы. Такой подход к построению графического интерфейса называется retained mode user interface (интерфейс с хранением состояния).
Основной проблемой подхода retained mode user interface является синхронизация состояния интерфейса с состоянием данных, используемых для его рисовки. Отсюда вытекает логичный вопрос: а можно ли как‑то рисовать интерфейс «на лету» по абсолютно любой модели данных? Да, так делать можно, а сам подход называют immediate mode user interface (интерфейс с рисовкой «на лету»). Суть immediate mode user interface заключается в том, чтобы процедурно рисовать и перестраивать интерфейс по любой модели данных, а также хранить как можно меньше состояния, связанного с самим интерфейсом.
Библиотеки, реализующие парадигму immediate mode user interface
Библиотек, реализующих парадигму immediate mode user interface не очень много. Наверное, это связано с нишевостью их использования (в основном разработка игр). Но, пожалуй, самая популярная библиотека, реализующая парадигму immediate mode user interface, — это библиотека ImGUI. ImGUI — прекрасный инструмент, закрывающий примерно 99% всех возможных задач, т.к библиотека предоставляет API для процедурного рисования интерфейсов практически любой сложности. Так, почему бы не использовать эту библиотеку?
Основная проблема, с которой я столкнулся при работе с ImGUI — это отсутствие возможности полноценно рисовать 2D графику даже несмотря на то, что в библиотеке есть встроенный 2D renderer. Почему так? Дело в том что, 2D renderer ImGUI не поддерживает геометрические преобразования (поворот, масштабирование, перенос и.т.д), а также ручную сортировку рисуемых объектов в глубину для корректного отображения полупрозрачных объектов при наложении их друг на друга.
Также, у библиотеки ImGUI достаточно высокий порог входа, т.к для того, чтобы ее запустить, потребуется потратить какое‑то количество времени на изучение основ компьютерной графики и разобраться в каком‑то конкретном графическом API.
Еще одна проблема библиотеки ImGUI — это отсутствие средств автоматической компоновки элементов графического интерфейса. Что имеется в виду? В библиотеках типа того же Qt существуют стандартные средства компоновки элементов графического интерфейса, такие как горизонтальный/вертикальный стек, сетка и панель. В ImGUI такого, к сожалению нет, потому‑что библиотека не хранит иерархию элементов. Вместо этого, на поверхности каждого окна располагается виртуальный курсор, который двигается по мере добавления виджетов и прочих элементов. Управлять размерами и расположением элементов на поверхности окна можно, но, это все придется делать самостоятельно.
Цель проекта Frenchie и какие возможности в него закладывались
Основная цель проекта Frenchie — создать простой в использовании и мощный инструмент с открытым исходным кодом, позволяющий легко и быстро создавать приложения с графическим интерфейсом на языке C++ для множества платформ.
В проекте Frenchie я хотел совместить простоту и гибкость парадигмы immediate mode user interface с удобным 2D rendering‑ом, а также обеспечить минимальный порог входа в технологию, чтобы кому‑то, кто захочет делать интерфейсы, не пришлось разбираться с нуля с используемыми под капотом графическими API и сторонними библиотеками.
Также, при создании библиотеки хотелось сделать не просто immediate mode, хотелось добавить в него алгоритмов автоматической компоновки элементов графического интерфейса, чтобы при использовании библиотеки приходилось писать как можно меньше кода, связанного с тем, как правильно располагать и компоновать элементы внутри окна. На этом можно было бы закончить, но, мне хотелось добиться кое чего еще.
immediate mode user interface — это, по сути, процедурное рисование элементов графического интерфейса. Процедурное рисование — это, конечно, хорошо, т.к мы можем рисовать и перестраивать интерфейс по абсолютно любым данным. В чем тогда проблема?
Проблема в том, что C++ — это компилируемый язык программирования и до тех пор, пока мы программу не запустим, мы не увидим наш интерфейс. Т.е, каждый раз, когда мы захотим внести какие‑то изменения нам нужно будет закрыть программу, внести изменения, перекомпилировать программу и запустить ее заново. В принципе, когда проект небольшой, то никаких проблем с перекомпиляцией и запуском нет, но, вот если проект огромный, то время перекомпиляции даже куска программы может занимать время. Вопрос в том, а можно ли как‑то сделать так, чтобы хотя бы интерфейс можно было менять и сразу видеть результат без перезапуска программы?
Допустим, у нас с вами уже есть модуль процедурного рисования интерфейса «на лету», тогда, мы можем выбрать любой удобный для нас формат файла, например JSON или XML, в этом файле по каким‑то правилам верстать интерфейс, а программа будет подхватывать изменения файла, парсить его и рисовать интерфейс.
Далее, мы можем написать некую модель данных, которая будет хранить абсолютно любые данные, включая callback‑и, по принципу «ключ‑значение» и сделать к этой модели данных переопределяемый контроллер, который будет эту модель данных заполнять и обновлять.
В таком случае у нас получится не просто верстать интерфейс из файла, но и привязывать к элементам интерфейса какие‑то данные, что даст нам возможность отделить разработку интерфейса программы от разработки ее логики, а также менять верстку не перекомпилируя проект или отдельные его части. Вот такие интересные возможности я хотел реализовать. Теперь посмотрим, что из этого всего вышло на деле. Начнем, пожалуй, с базовой части Frenchie: с того, как он работает.
Как работает Frenchie?
В архитектурном плане, Frenchie содержит в себе статический класс приложения, у которого есть список слоев абстракции, обрабатываемых внутри бесконечного цикла, который выполняется пока пользователь не закроет приложение.
Цикл работы приложения разбит на несколько этапов, на каждом из которых приложение вызывает у всех слоев абстракции набор переопределяемых функций. Для запуска контекстного окна и рисования графики Frenchie использует системный и графический backend‑ы.
Системный backend — это статический класс, который оборачивает платформо‑зависимый функционал операционной системы, предназначенный для открытия контекстного окна и отслеживания связанных с ним событий. На момент написания этой статьи, Frenchie использует сторонние библиотеки в качестве системного backend‑а. Ниже представлен список сторонних библиотек, работа с которыми поддержана во Frenchie:
Библиотека |
SDL3 |
GLFW |
Графический backend — это статический класc, который оборачивает платформо‑зависимый функционал, отвечающий за загрузку геометрии и текстур на видеокарту для последующей рисовки, т.е по сути, графический backend является оберткой вокруг графического API.Ниже приведен список графических API, работа с которыми поддержана в актуальной на момент написания этой статьи версии Frenchie:
Графический API |
OpenGL3 |
DirectX9 |
MacOS Metal |
Добавление нового функционала в приложение Frenchie реализуется путем создания новых слоев абстракции и переопределения у них набора функций, запускаемых на разных этапах итерации цикла работы приложения. Собственно говоря, модули, отвечающие за рисовку и графический интерфейс являются слоями абстракции приложения. Более подробно про техническую часть проекта можно почитать в материалах по ссылке, либо посмотреть исходный код проекта.
2D Renderer
2D renderer во Frenchie представляет из себя очередь API независимых rendering команд, которые выполняются rendering backend‑ом в порядке сортировки рисуемых объектов в глубину. Сортировка rendering команд по глубине расположения рисуемых объектов обеспечивает корректный расчет цветов на их поверхностях при их наложении друг на друга.
При помощи 2D renderer‑а Frenchie мы можем менять clear color (цвет заливки контекстного окна), scissor box (ограничивающих прямоугольник, за пределами которого графика обрезается), а также рисовать текст и полигональные геометрические фигуры без отверстий. На любую геометрическую фигуру можно натягивать текстуры, либо заливать любым цветом, включая градиентный цвет.
Текстуры также как и ttf шрифты загружаются в память видеокарты и удаляются из нее функциями rendering backend‑а. Для полигональных геометрических фигур со скругленными краями предусмотрена настройка точности аппроксимации скругления — tessellation tolerance, которую можно изменять. В принципе, описанный функционал позволяет рисовать практически любую 2D графику. В качестве примера работы с 2D renderer‑ом Frenchie приведу следующий код:
#include <FrenchieApplication.hpp> #include <FrenchieApplicationLayerRenderingQueue2D.hpp> class ClockRenderingExample : public Frenchie::Application::Layer { public: ClockRenderingExample(){} virtual ~ClockRenderingExample(){} virtual bool awake() override { // загружаем слой 2D renderer-а if(m_Renderer == nullptr) m_Renderer = Frenchie::Application::App::push_layer<Frenchie::Application::RenderingQueue2D>(); return m_Renderer != nullptr; } virtual void frame_update() { int depth = 0; gs_2d_boxf clockBoundingBox = m_Renderer->current_viewport(); float clockFaceRadius = gs_min(clockBoundingBox.width(), clockBoundingBox.height()) * 0.5f; gs_color clockFrameColor = gs_color_rgb(128, 128, 128); gs_color clockBackgroundColor = gs_color_rgb(32, 32, 32); m_Renderer->push_clear_color(gs_color_rgba(32, 128, 128, 128)); // рисуем рамку m_Renderer->push_arc_filled( clockBoundingBox.center(), clockFaceRadius, clockFaceRadius, 0.f, 360.f, clockFrameColor, m_Renderer->calculate_transform_matrix((float)++depth)); // рисуем задний фон m_Renderer->push_arc_filled( clockBoundingBox.center(), clockFaceRadius - 8.f, clockFaceRadius - 8.f, 0.f, 360.f, clockBackgroundColor, m_Renderer->calculate_transform_matrix((float)++depth)); // рисуем панель с часами { const float deltaAngle = 360.f / 12.f; const float sourceAngle = 0.f; const float targetAngle = 360.f; for (float angle = gs_min(sourceAngle, targetAngle); angle < gs_max(sourceAngle, targetAngle); angle += deltaAngle) { // засечки m_Renderer->push_line( clockBoundingBox.center() + clockFaceRadius * gs_vec2f(cos(gs_to_radians(angle)), sin(gs_to_radians(angle))) * 0.9f, clockBoundingBox.center() + clockFaceRadius * gs_vec2f(cos(gs_to_radians(angle)), sin(gs_to_radians(angle))), 12.f, clockFrameColor, m_Renderer->calculate_transform_matrix((float)++depth)); // цифры std::string text = Frenchie::Core::String::to_string<int>((int)(13.f - (targetAngle - angle) / targetAngle * 12.f)); float fontSize = 64.f * clockFaceRadius / 512.f; m_Renderer->push_text( clockBoundingBox.center() + clockFaceRadius * gs_vec2f(cos(gs_to_radians(angle + 300.f)), sin(gs_to_radians(angle + 300.f))) * 0.8f - m_Renderer->calculate_bounding_box(text.begin(), text.end(), fontSize, Frenchie::Application::ApplicationRenderingBackendFont()).size() * 0.5f, text.begin(), text.end(), fontSize, clockFrameColor, m_Renderer->calculate_transform_matrix( (float)++depth)); } } // панель с минутами { const float deltaAngle = 360.f / 60.f; const float sourceAngle = 0.f; const float targetAngle = 360.f; const float radius = gs_min(clockBoundingBox.width(), clockBoundingBox.height()) * 0.5f; for (float angle = gs_min(sourceAngle, targetAngle); angle < gs_max(sourceAngle, targetAngle); angle += deltaAngle) { m_Renderer->push_line( clockBoundingBox.center() + radius * gs_vec2f(cos(gs_to_radians(angle)), sin(gs_to_radians(angle))) * 0.95f, clockBoundingBox.center() + radius * gs_vec2f(cos(gs_to_radians(angle)), sin(gs_to_radians(angle))), 12.f, clockFrameColor, m_Renderer->calculate_transform_matrix((float)++depth)); } } // стрелки часов { m_Renderer->push_arc_filled( clockBoundingBox.center(), 16.f, 16.f, 0.f, 360.f, clockFrameColor, m_Renderer->calculate_transform_matrix((float)++depth)); // часовая стрелка float hourAngle = (float)(Frenchie::Core::Clock::local_time_hour() - 13) / 12.f * 360.f + 300.f; m_Renderer->push_arrow( clockBoundingBox.center(), clockBoundingBox.center() + clockFaceRadius * gs_vec2f(cos(gs_to_radians(hourAngle)), sin(gs_to_radians(hourAngle))) * 0.5f, 12.f, 32.f, clockFrameColor, m_Renderer->calculate_transform_matrix((float)++depth)); // минутная стрелка float minuteAngle = (float)(Frenchie::Core::Clock::local_time_minute() - 65) / 60.f * 360.f + 300.f; m_Renderer->push_arrow( clockBoundingBox.center(), clockBoundingBox.center() + clockFaceRadius * gs_vec2f(cos(gs_to_radians(minuteAngle)), sin(gs_to_radians(minuteAngle))) * 0.6f, 12.f, 32.f, clockFrameColor, m_Renderer->calculate_transform_matrix((float)++depth)); // секундная стрелка float secondsAngle = (float)(Frenchie::Core::Clock::local_time_second() - 65) / 60.f * 360.f + 300.f; m_Renderer->push_arrow( clockBoundingBox.center(), clockBoundingBox.center() + clockFaceRadius * gs_vec2f(cos(gs_to_radians(secondsAngle)), sin(gs_to_radians(secondsAngle))) * 0.7f, 4.f, 32.f, clockFrameColor, m_Renderer->calculate_transform_matrix((float)++depth)); } } virtual void finish() override { if(m_Renderer != nullptr) m_Renderer->close(); } protected: std::shared_ptr<Frenchie::Application::RenderingQueue2D> m_Renderer {nullptr}; }; int main(int argc, char *argv[]) { (void)argc; (void)argv; Frenchie::Application::App::push_layer<ClockRenderingExample>(); return Frenchie::Application::App::execute(); }
Этот код нарисует нам вот такие вот аналоговые часы на мониторе:

Помимо рисования на мониторе, Frenchie может рисовать в текстуру, которую можно показывать в каком‑нибудь виджете. Следующий пример показывает, как нарисовать какую‑то графику в текстуру и потом отобразить ее в окне:
#include <FrenchieApplication.hpp> #include <FrenchieApplicationLayerRenderingQueue2D.hpp> class FrameBufferRenderingExample : public Frenchie::Application::Layer { public: FrameBufferRenderingExample(){} virtual ~FrameBufferRenderingExample(){} virtual bool awake() override { // создаем два renderer-а и добавляем слой UI в приложение if(m_Renderer1 == nullptr) m_Renderer1 = Frenchie::Application::App::push_layer<Frenchie::Application::RenderingQueue2D>(); if(m_Renderer2 == nullptr) m_Renderer2 = Frenchie::Application::App::push_layer<Frenchie::Application::RenderingQueue2D>(); if(m_UI == nullptr) m_UI = Frenchie::Application::App::push_layer<Frenchie::Application::ImmediateUserInterfaceContextLayer>(); return m_UI != nullptr && m_Renderer1 != nullptr && m_Renderer2 != nullptr; } virtual void frame_update() override { // создаем окно и показываем в нем две нарисованные в текстуру сцены if(m_UI->begin_window( m_UI->next_id("Framebuffers test window", "Framebuffers test window"), Frenchie::Application::ImmediateUserInterfaceNodeSettings_::ImmediateUserInterfaceNodeSettings_Defaults, &m_Opened)) { if(m_UI->begin_horizontal_stack(m_UI->next_id("Framebuffers"))) { m_UI->image(m_UI->next_id("Scene-1"), gs_color_rgb(255, 255, 255), m_Renderer1->get_framebuffer_texture()); m_UI->image(m_UI->next_id("Scene-2"), gs_color_rgb(255, 255, 255), m_Renderer2->get_framebuffer_texture()); m_UI->end_horizontal_stack(); } m_UI->end_window(); } // рисуем сцену номер 1 { m_Renderer1->render_to_texture(); int depth = 0; m_Renderer1->push_rectangle_filled( m_Renderer1->current_viewport().Min, m_Renderer1->current_viewport().Max, gs_color_rgb(32, 16, 16), m_Renderer1->calculate_transform_matrix((float)depth++)); gs_2d_boxf box = m_Renderer1->current_viewport(); float ballRadius = gs_max(m_Ball.MajorRadius, m_Ball.MinorRadius); gs_vec2f ballToCenterDirection = gs_vector_normalize(box.center() - m_Ball.Center); if(!box.contains(m_Ball.Center + ballRadius * ballToCenterDirection * -1.f) || !m_Direction.has_value()) m_Direction = gs_vector_normalize(ballToCenterDirection + gs_vec2f(gs_pseudo_random(-1.f, +1.f), gs_pseudo_random(-1.f, +1.f))); m_Ball.Center += m_Direction.value() * 8.f; m_Renderer1->push_arc_filled( m_Ball.Center, m_Ball.MinorRadius, m_Ball.MajorRadius, 0.f, 360.f, gs_color_rgb(128, 128, 128), m_Renderer1->calculate_transform_matrix((float)depth++)); } // рисуем сцену номер 2 { m_Renderer2->render_to_texture(); int depth = 0; m_Renderer2->push_arc_filled( m_Renderer2->current_viewport().center(), gs_min(m_Renderer2->current_viewport().width(), m_Renderer2->current_viewport().height()) * 0.5f, gs_min(m_Renderer2->current_viewport().width(), m_Renderer2->current_viewport().height()) * 0.5f, 0.f, 360.f, gs_color_rgb(32, 16, 16), m_Renderer2->calculate_transform_matrix((float)depth++)); } } virtual void finish() override { if(m_Renderer1 != nullptr) m_Renderer1->close(); if(m_Renderer2 != nullptr) m_Renderer2->close(); } private: std::shared_ptr<Frenchie::Application::ImmediateUserInterfaceContextLayer> m_UI {nullptr}; std::shared_ptr<Frenchie::Application::RenderingQueue2D> m_Renderer1 {nullptr}; std::shared_ptr<Frenchie::Application::RenderingQueue2D> m_Renderer2 {nullptr}; std::optional<gs_vec2f> m_Direction {std::optional<gs_vec2f>()}; gs_2d_ellipsef m_Ball {gs_2d_ellipsef(gs_vec2f(0.f, 0.f), 64.f)}; }; int main(int argc, char *argv[]) { (void)argc; (void)argv; Frenchie::Application::App::push_layer<FrameBufferRenderingExample>(); return Frenchie::Application::App::execute(); }
В результате выполнения кода выше, получает такое вот окно с изображением двух графических сцен:

Модуль процедурного рисования интерфейса
Итак, в прошлом разделе мы вкратце ознакомились с тем, как работает 2D renderer Frenchie. Теперь посмотрим, что интересного есть в библиотеке Frenchie для рисования интерфейсов. Для рисования интерфейса, Frenchie имеет два программных модуля: модуль процедурного рисования интерфейса и модуль декларативного рисования интерфейса.
Модуль процедурного рисования интерфейса — это слой абстракции приложения, предоставляющий API, которым можно рисовать различные виджеты, а также иерархические элементы интерфейса типа окон, диалогов, меню и.т.д. Модуль также дает возможность автоматически компоновать элементы графического интерфейса в горизонтальный и вертикальный стеки, а также в сетку, панель, либо размещать их нужным образом внутри прокручиваемой области. Ниже приведены основные возможности модуля:
1. Окна с возможностью крепления вкладками или дочерними элементами:

2. Модальные диалоговые окна с возможностью наложения blur‑а на блокируемые элементы интерфейса:

3. Виджеты: кнопки, label‑ы, а также виджеты для скалярного и текстового ввода, progress bar‑ы, color picker‑ы и.т.д:

4. Деревья и таблицы. При этом, таблицы имеют возможность выделения видимой части по вертикали и горизонтали, что позволяет эффективно рисовать таблицы по несколько десятков тысяч строк и столбцов без потери производительности. Таблицы также имеют возможность полной кастомизации ячеек с данными, ячеек с названиями строк и столбцов, а также угловой ячейки, т.е мы можем верстать абсолютно любой content внутри ячеек таблицы:

5. 2D графики, векторные и круговые диаграммы:

6. Механика Drag and drop:

7. Выпадающие меню, менюбары, а также всплывающие подсказки:

8. Алгоритмы автоматической компоновки элементов интерфейсов. На момент написания этой статьи реализован горизонтальный и вертикальный стек, а также сетка, панель и прокручиваемая область:

Для более подробного изучения возможностей модуля процедурной рисовки интерфейса предлагаю поизучать примеры работы с библиотекой в ее репозитории.
Модуль декларативного рисования интерфейса
Модуль декларативного рисования интерфейса позволяет разделить разработку графического интерфейса программы и ее логики, а также быстро макетировать верстку без необходимости перекомпиляции проекта или его отдельных частей. Приведу небольшой пример.
Допустим, нам нужно нарисовать круговую диаграмму с несколькими секторами и возможностью включать/выключать легенду. С использованием модуля декларативного рисования интерфейса задача разбивается на два этапа: верстка и разработка логики. Начнем с верстки. Набросаем вот такой вот JSON файл с версткой:
{ "VerticalStack":{ "Name":"View", "ScrollArea":{ "Name":"LegendEditor", "Settings":{ "ResizeToContentsVertically":true }, "SliderButton":{ "Name":"ShowLegendButton", "Checked":{ "Source":"ShowLegend" } }, "Label":{ "Name":"ShowLegendLabel", "Text":"Show legend", "SameLine":true } }, "HorizontalStack":{ "Name":"Stack", "Plot":{ "Name":"PieChart", "PlotPie":{ "Name":"Pie", "Names":{ "Source":"PieNames" }, "Values":{ "Source":"PieValues" }, "Colors":{ "Source":"PieColors" }, "Count":{ "Source":"PieCount" } } }, "PlotLegend":{ "Name":"Legend", "Enabled":{ "Source":"ShowLegend" }, "Plot":{ "Source":"PieChart" } } } } }
В файле выше задана иерархия элементов графического интерфейса и для некоторых из низ указаны источники данных из модели данных. В результате, передав в модуль декларативного рисования интерфейса путь к созданному файлу и добавив этот модуль в список слоев абстракции приложения:
int main(int argc, char *argv[]) { (void)argc; (void)argv; Frenchie::Application::App::push_layer<Frenchie::Application::ImmediateUserInterfaceModelViewControllerLayer>( "assets/views/FrenchieImmediateUserInterfaceMainWindowView.json", nullptr); return Frenchie::Application::App::execute(); }
Получим следующее:

На картинке выше, слева у нас область с графиком, справа — область под легенду, сверху — радио‑кнопка для включения/выключения легенды. При этом видно, что легенда и графики пустые. Они пустые, потому‑что мы указали источники данных, по пути к которым в модели данных ничего нет. Чтобы что‑то появилось, нужно написать логику. Чтобы, написать логику, нам нужно отнаследоваться от интерфейса контроллера и переопределить его функции:
#include <FrenchieImmediateUserInterfaceModelViewControllerLayer.hpp> class FrenchieImmediateUserInterfacePlotPieController : public Frenchie::Application::ImmediateUserInterfaceViewController { public: virtual ~FrenchieImmediateUserInterfacePlotPieController(){} virtual bool setup(std::shared_ptr<Frenchie::Application::ImmediateUserInterfaceViewModel>& _Model) override { for (int i = 0; i < 5; i++) { m_PieNames.push_back(Frenchie::Core::String::format("Sector-%d", i)); m_PieValues.push_back(5.f * (i + 1)); m_PieColors.push_back(gs_color_rgba(gs_pseudo_random<int>(0, 255), gs_pseudo_random<int>(0, 255), gs_pseudo_random<int>(0, 255), 255)); } _Model->request<std::string*>("PieNames") = m_PieNames.data(); _Model->request<float*>("PieValues") = m_PieValues.data(); _Model->request<gs_color*>("PieColors") = m_PieColors.data(); _Model->request<int>("PieCount") = m_PieNames.size(); _Model->request<bool>("ShowLegend") = true; return true; } virtual void update(std::shared_ptr<Frenchie::Application::ImmediateUserInterfaceViewModel>& _Model) override { } virtual void destroy(std::shared_ptr<Frenchie::Application::ImmediateUserInterfaceViewModel>& _Model) override { } std::vector<std::string> m_PieNames {std::vector<std::string>()}; std::vector<float> m_PieValues {std::vector<float>() }; std::vector<gs_color> m_PieColors {std::vector<gs_color>() }; };
Теперь, отдав модулю декларативного рисования интерфейса файл верстки + контроллер, получим такую картину:

А теперь, самое интересное: мы можем менять файл верстки, а программа будет подхватывать наши изменения, т.к модуль декларативного рисования интерфейса следит за состоянием файла, что мы ему передали:

Выше приведен пример верстки из JSON формата, но, модуль декларативной рисовки интерфейса использует универсальный DOM‑парсер, который умеет работать еще и с XML, поэтому, если кому‑то удобно, можно верстать по аналогичным правилам в XML.
Чем Frenchie лучше, а чем хуже того же ImGUI?
Достаточно сложно, на самом деле, быть объективным по отношению к своей программе в части ее сравнения с другими похожими программами, но, я все‑таки постараюсь.
Как было сказано ранее, одна из основных целей Frenchie — быть простым. Поэтому, количество знаний, которое требуется для работы с Frenchie, значительно меньше, чем то, что нужно для работы с ImGUI. При этом, ImGUI, не смотря на относительную сложность запуска, обладает очень высокой гибкостью в части интеграции в уже готовые rendering pipeline‑ы. Что имеется в виду? Допустим, вы эксперт в компьютерной графике, у вас уже есть готовый 2D/3D renderer и вам нужен инструмент для визуализации того, что он рисует. В таком случае, ImGUI — это то, что вам нужно. Можно ли для таких же целей использовать Frenchie?
Технически, интеграция Frenchie с уже готовым rendering pipe line‑ом возможна, но, в таком случае, порог входа в библиотеку резко повысится, т.к вам придется писать свою кастомную реализацию platform и rendering backend‑а для интеграции с вашим конкретным rendering pipe line‑ом. Хотя, я лично, чисто для себя пока не упел понять, что быстрее: написать кастомные backend‑ы для Frenchie или попытаться интегрировать уже готовые backend‑ы ImGUI в свой renderer. Пожалуй, лично для меня, этот вопрос пока открытый. С этим, наверное, понятно, а что в плане функционала?
В части функционала по рисовке графического интерфейса, Frenchie имеет практически все тоже самое, что и ImGUI, за исключением небольшого количества элементов. Например, на данный момент у Frenchie нет tab widget‑ов. Почему? Дело в том, что я эти виджеты хотел сделать на базе окон, которые уже умеют крепиться друг к другу вкладками, но, руки пока до этого не дошли. А что с 2D renderning‑ом, ради которого, собственно говоря, все и делалось?
В части кросс‑платформенного 2D rendering‑а Frenchie однозначно обгоняет ImGUI, т.к в нем есть возможность рисовать практически любые графические примитивы, натягивать на них текстуры, заливать абсолютно любым цветом (включая градиенты), а также вручную управлять глубиной расположения рисуемых объектов. Однако, как, наверное, заметили читатели набор поддерживаемых графических API во Frenchie довольно скромный по сравнению с ImGUI, но, я постараюсь это все наверстать со временем.
Таким образом, делаем следующий вывод: если, вы эксперт в компьютерной графике и вам требуются инструменты визуализации для вашего 2D/3D renderer‑а, то, наверное, лучше взять ImGUI. Если вам нужен компактный кросс‑платформенный инструмент с низким порогом вхождения, который умеет рисовать 2D графику и графический интерфейс, то Frenchie — это то, что вам нужно. Также, хотелось бы отметить, что проект Frenchie очень молодой, он активно дорабатывается и поэтому, надеюсь, со временем получится доделать весь недостающий функционал и исправить в нем все ошибки.
Что дальше?
Как я уже написал проект Frenchie очень молодой, поэтому, впереди предстоит огромная работа по исправлению ошибок, доработке же существующего функционала и разработке нового. На данный момент, ведется работа по оптимизации 2D rendering‑а, а также исправлению ошибок. Но, в будущем хотелось бы поддержать больше графических API, а также реализовать модули эффективного рисования 2D и 3D сцен. Надеюсь, было интересно, постараюсь подготовить еще больше интересных материалов.
Комментарии (5)

Strijar
10.09.2026 04:22Насколько просто добавить работу через другие render? Например через Linux FrameBuffer. Для embedded систем без X11 и проч. Сейчас для этого использую LVGL и в принципе доволен, но варианты это всегда хорошо (;

AlexeyTykvinsky Автор
10.09.2026 04:22Frenchie также, как dear ImGUI выдает список API независимых rendering-комманд, в которых есть списки вершин рисуемых mesh-ей. У этих вершин есть свойства типа цвета, UV-координат и XYZ координат в пространстве. Если нужно использовать Linux FrameBuffer, то вам придется писать свой программный растерайзер, который будет конвертировать rendering команды Frenchie в пиксельный буфер, который вы потом сможете загружать в Linux Framebuffer. Также, вам каким-то образом нужно будет отлавливать события ввода/вывода.
Иными словами, задача интеграции Frenchie с Linux Framebuffer-ом заключается в написании кастомного rendering и platform backend-а. То, насколько это сложно зависит от того, насколько хорошо вы разбираетесь в том, как работает Linux FrameBuffer и ваша конкретная embedded платформа.
Также, при работе с Frenchie на embedded остается открытым вопрос к производительности, т.к на embedded платформах, использующих Linux FrameBuffer, как правило нет аппаратного ускорения, поэтому, неясно, насколько быстро будет работать растеризация на CPU (я так делать, по крайней мере не пробовал, но тема для меня очень и очень интересная). В общем, нужно экспериментировать. Для того же ImGUI, сделанном по похожему принципу, что и Frenchie, все нюансы касательно embedded, что я здесь описал, также актуальны.

Strijar
10.09.2026 04:22Спасибо, понятно. Пока остаюсь на LVGL. У него нет проблем с производительностью даже на STM32F4 (;
eao197
Прошу прощения за занудство, но официальное название упоминаемой вами библиотеки Dear ImGui (не смотря на то, что в качестве префикса имен там используется просто ImGui).
Одним из следствий того, что immediate mode GUI библиотеки работают сильно иначе является то, что многие вещи, привычные в “традиционных” GUI тулкитах, здесь отсутствуют. Например, диалоговое окно для выбора файла (для реализации пунктов меню Open или Save As). Для Dear ImGui пришлось искать стороннюю библиотеку с такой функциональностью. А что у вас в библиотеке с такими вещами?
AlexeyTykvinsky Автор
На данный момент, у Frenchie нет средств для открытия нативных диалогов ОС для выбора файла, но, этот функционал можно реализовать, используя std::filesystem::path + Frenchie. Аналогичным образом можно сделать и в Dear ImGUI.
Ограничением такого подхода будет то, что std::filesystem::path не видит подключенных переферийных и сетевых дисков. Поэтому, если вам нужен файловый диалог с доступом к вашим жестким дискам на компьютере, то можно использовать std::filesystem::path + Frenchie либо ImGUI, если нужен полноценный нативный файловый диалог, то его нужно будет открывать самостоятельно используя api ОС, либо искать стороннюю библиотеку.