Pull to refresh

Опыт создания UI библиотеки на C++

Level of difficultyMedium
Reading time21 min
Views14K

Началось все почти два года назад в декабрe, наш основной проект (видео мессенджер) использовал WTL для Windows и GTKmm для Linux. Поддержки мака не было. Огромной неприятностью было тащить два идентичных клиента, которые, по идее, должны делать все строго одно и тоже. Разумеется, это никогда не получалось. От мысли что надо бы сделать ещё один нативный клиент для мака начинался нервный тик...

На резонный вопрос - почему сразу делалось не на Qt могу лишь ответить, что это связано с, так скажем, гурманскими предпочтениями и, отчасти, с любовью к монолитным exe. Да и не требовалось на старте ничего кроме винды.

В течение шести лет жизни с двумя кодовыми базами одного и того же, неспешно подбирались легковесные UI библиотеки написанные хотя бы в стиле C++11.

Надо сказать, что мы активно используем boost и всей душой, как можем, его любим...

В 2021 году видимо Гугл работал плохо или звёзды так сошлись, но не нашлось ничего стоящего. Все что попадалось - основанные на рендеринге html проекты и обертка над wxWidgets. Сейчас то мы знаем про lvgl, да... А вообще, тысячи их.

wxWidgets не плох, но хотелось своего рисования, без окошек под кнопки, поля ввода и списки, boost/bsd подобной лицензией, максимально лаконичной, и в идеале работающей от Windows XP / CentOS 6 на стандартном GDI / X11 до Vulkan на современных машинах.

В итоге, все же было принято волевое решение сделать минимальный UI фреймворк для этого проекта, и сразу выпускать его в Open Source под лицензией boost. 

Задачи для UI фреймворка

- Работать на Windows (Как минимум 7, но работает и на XP)

- Работать на Linux (Начиная от условной Ubuntu 16 / CentOS6)

- Работать на macOS

- Открывать окна и отображать на них контролы. 

- Предоставлять общий интерфейс к подсистеме рисования, который скрывает платформенно-зависимые методы. Это позволят написать контрол один раз, на любой платформе, и он будет выглядеть и вести себя одинаково на всех.

- Предоставлять общий интерфейс к событиям. Любой контрол или пользователь может подписаться на любую группу сообщений, в том числе пользовательскую, с возможностью асинхронной отправки/получения сообщений.

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

- Иметь возможность менять цветовую гамму / стиль / пиктограммы / изображения всех контролов / окон из одного места. Хранить все визуальные настройки приложения в json, в том числе иметь возможность хранить в нем же изображения.

- Предоставлять систему текстовых констант для заголовков и надписей в зависимости от выбранного языка.

- Иметь возможность откреплять / прикреплять окна друг от друга.

- Предоставлять реализацию основных UI контролов и иметь понятную и доступную возможность добавления новых контролов сторонними разработчиками для своих приложений.

- Иметь удобный интерфейс для работы с конфигами приложений. Поддерживается реестр Windows и ini файлы. Естественно, с возможностью изменения.

Общая схема фреймворка

Все базируется на двух сущностях - Window и Control. Окно может содержать контролы, также само окно является контролом. 

Control - это любой визуальный элемент для взаимодействия с пользователем - кнопка, поле ввода, список, меню и т.д. Control знает, как обрабатывать события, поступающие от Window, хранит свои состояния и рисует себя на графическом контексте, который предоставляется содержащим его окном.

Window - принимает системные события и обеспечивает их рассылку подписчикам. Так же окно дает команду на перерисовку своих контролов и предоставляет им свой graphic. Кроме этого, окно управляет фокусом ввода, может сделать модальность и отправить подписанному пользователю или в систему событие.

Graphic - предоставляет интерфейс к системным методам рисования. В настоящий момент, реализовано рисование на Windows GDI/GDI+ и Linux xcb/cairo. Разумеется, нет никаких препятствий реализовать рисование на vulcan/bare metal/etc.

В библиотеке также есть вспомогательные средства для работы - структуры common (содержит такие основные типы, как rect, color, font), event (события мыши, клавиатуры, внутренние и системные события), graphic (для физической отрисовки на системном графическом контексте) theme (система констант для удобной поддержки визуальных тем) , locale (подсистема для удобного хранения текстового контента), config (для удобной, единообразной работы с настройками приложения)

Некоторые основополагающие принципы

В общих чертах процесс работы приложения выглядит следующим образом:

Окно принимает системные события такие как: необходимость отрисовки, ввод с мыши и клавиатуры, изменения устройств, пользовательские сообщения. Данные сообщения передаются подписчикам событий окна, это, во-первых, содержащиеся на окне контролы, во-вторых, пользовательский код приложения, при необходимости. Для упрощения работы, имеется возможность получать только события, относящиеся к контролу: мышь — в прямоугольнике, занимаемом контролом; клавиатура — если контролу принадлежит фокус ввода.

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

При необходимости отрисовки части окна, производится поиск попадающих в область перерисовки контролов и последовательно, по порядку добавления контролов на окно, вызывается метод draw() каждого контрола. Контролы отвечающие в topmost() true рисуются в последнюю очередь, чтобы оказаться наверху стека контролов.

Чтобы немного прояснить, как построена система, предлагаем рассмотреть интерфейсы окна, контрола и графика

Методы window

i_window.hpp

Создание / уничтожение окна

bool init(const std::string &caption, const rect &position, window_style style, std::function<void(void)> close_callback);
void destroy();

Добавление/удаление контрола

void add_control(std::shared_ptr<i_control> control, const rect &position);
void remove_control(std::shared_ptr<i_control> control);

Перерисовывает часть окна с имеющимися на данном участке контролами. Этот метод вызывается контролом, когда ему нужно себя перерисовать. В ответ окно вызывает draw() контрола с подготовленным graphic (контекстом рисования).

void redraw(const rect &position, bool clear = false);

Методы подписки на события, которые получает окно. События бывают от системы, внутренние или от приложения.

std::string subscribe(std::function<void(const event&)> receive_callback, event_type event_types, std::shared_ptr<i_control> control = nullptr);
void unsubscribe(const std::string &subscriber_id);

При подписке указываются желаемые к получению типы событий и подписчику возвращается токен, который можно передать для принудительной отписки через unsubscribe().

Послать сообщение через системный шедулер сообщений (Win32 / X11)

void emit_event(int32_t x, int32_t y);

Ссылка на структуру, содержащую платформо зависимые сущности. Например дескриптор окна HWND в Windows или xcb_connection / Display в Linux.

system_context &context();

Методы control

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

i_control.hpp

Метод где контрол должен нарисовать себя. Вызывается только окном, когда необходима перерисовка контрола. Если контролу необходимо перерисовать себя, он должен вызвать redraw() своего родительского окна.

void draw(graphic &gr, const rect &paint_rect);

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

void set_position(const rect &position, bool redraw = true);

Пользовательский метод, возвращает положение контрола относительно окна

rect position() const;

Метод, вызываемый родительским окном при вызове add_control(), позволяет контролу получить указатель на свое родительское окно.

void set_parent(std::shared_ptr<window> window_);

Возвращает указатель на родительское окно

std::weak_ptr<window> parent() const;

Метод, вызываемый родительским окном при вызове remove_control() очищает указатель на родительское окно контрола

void clear_parent();

Сообщает родительскому окну, нужно ли рисовать контрол поверх всех остальных контролов

bool topmost() const;

Изменяет визуальную тему контрола. Если параметр равен nullptr то используется тема приложения по умолчанию.

void update_theme(std::shared_ptr<i_theme> theme_ = nullptr);

Методы управления видимостью

void show();
void hide();
bool showed() const;

Методы управления “включенностью”

virtual void enable();
void disable();
bool enabled() const;

Методы для выстраивания отношений контрола с клавиатурным фокусом ввода

bool focused() const; /// Returns true if the control is focused
bool focusing() const; /// Returns true if the control receives focus

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

error get_error() const;

Методы graphic

Каждое окно имеет свой график для рисования своих контролов. Но никто не мешает создать свой дополнительный график внутри контрола или из приложения. Для отрисовки контрола, окно предоставляет ссылку на свой график через вызов метода draw() контрола.

graphic.hpp
graphic(system_context &context);

Методы инициализации/деинициализации

void init(const rect &max_size, color background_color);
void release();

Устанавливает цвет фона, этим цветом будет залит холст при вызове clear()

void set_background_color(color background_color);
void clear(const rect &position);

Сброс (отрисовка) области на системный графический контекст

void flush(const rect &updated_size);

Нарисовать точку, линию

void draw_pixel(const rect &position, color color_);
void draw_line(const rect &position, color color_, uint32_t width = 1);

Измерить размер текста с выбранным шрифтом

rect measure_text(const std::string &text, const font &font_);

Написать текст выбранным шрифтом

void draw_text(const rect &position, const std::string &text, color color_, const font &font_);

Нарисовать простой прямоугольник

void draw_rect(const rect &position, color fill_color);

Нарисовать прямоугольник со скругленными краями

void draw_rect(const rect &position, color border_color, color fill_color, uint32_t border_width, uint32_t round);

Нарисовать буфер RGB32

void draw_buffer(const rect &position, uint8_t *buffer, size_t buffer_size);

Нарисовать содержимое другого графика

void draw_graphic(const rect &position, graphic &graphic_, int32_t left_shift, int32_t right_shift);

Доступ к системному DC

#ifdef _WIN32
HDC drawable();
#elif linux
xcb_drawable_t drawable();
#endif

Список методов рисования может и будет расширяться по мере необходимости. 

Главный цикл приложения

В случае работы на Windows запускается стандартный бесконечный цикл:

MSG msg;
while (GetMessage(&msg, nullptr, 0, 0))
{
  TranslateMessage(&msg);
  DispatchMessage(&msg);
}
return (int) msg.wParam;

Каждое запущенное, не дочернее окно, становится получателем сообщений через имеющейся в нем wnd_proc. Далее, в зависимости от типа события, производится либо перерисовка контролов, работа с положением/размером окна, либо событие посылается подписчикам. Срок жизни первого созданного окна определяет срок жизни приложения.

На линукс картина слегка отличается, но для пользователя и контролов выглядит аналогично. Каждое не дочернее окно, запускает отдельный тред для ожидания событий в xcb_wait_for_event() и по мере их поступления рассылает их подписчикам.

bool window::init(...)
{
  ...
  thread = std::thread(std::bind(&window::process_events, this));
}

void window::process_events()
{
    xcb_generic_event_t *e = nullptr;
    while (runned && (e = xcb_wait_for_event(context_.connection)))
    {
        switch (e->response_type & ~0x80)
        {
            case XCB_EXPOSE:

            ...

Весь этот код скрыт в window и framework. framework - это новый компонент появившийся благодаря критике первоначального варианта с торчащим за ifdef'ами платформо зависимым кодом в main.
framework имеет всего 3 главные функции init(), run() и stop(). init() нужно вызвать в первой строке main(), run() после window->init(...), а stop() когда нужно завершить процесс (например пользователь нажал "крестик").

int main(..)
{
  wui::framework::init();

  MainFrame mainFrame;
  mainFrame.Run();

  wui::framework::run();

  return 0;
}

Здесь wui::framework::end(); вызывается в коллбеке закрытия главного окна:

void MainFrame::Run()
{
  window->init(wui::locale("main_frame", "caption"), { -1, -1, width, height },
                wui::window_style::frame, [this]() { 
                  wui::framework::stop(); 
                });
}

Транзиентность

Приложения не мыслимы без модальных диалогов. Для их реализации окно имеет метод:

void set_transient_for(std::shared_ptr<window> window_, bool docked = true);

Этим методом, родительскому окну сообщается, что другое окно нужно сделать модальным относительно него. Флаг docked указывает, что модальное окно должно отображаться в базовом окне, без создания физического системного окна. Если модальное окно больше родительского, этот флаг игнорируется и создается новое системное окно.

Строго говоря, модальности в привычном смысле WinAPI в библиотеке нет. Т. е. вызов init() транзиентного окна не блокирует вызывающий код, но это обходится продолжением логики в коллбэке close_callback передаваемом в init().

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

Ресурсы

Для удобного и единообразного отображения множества контролов и надписей приложения, удобства работы не программистов, например дизайнеров, переводчиков реализованы подсистемы theme и locale.

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

У пользовательского кода для формирования надписей есть доступ к текущей локали приложения. Это позволяет собрать все текстовые ресурсы в одном месте и также, в одном месте, менять их для всего приложения.

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

Технически подсистемы реализованы схоже, рассмотрим на примере theme
Тема представляет из себя json содержащий значения параметров для контролов, например для окна и надписи и изображений.

Тема dark
{    
  "controls": [    
    {      
      "type": "window",      
      "background": "#131519",      
      "border": "#404040",      
      "border_width": 1,      
      "text": "#f5f5f0",      
      "active_button": "#3b3d41",      
      "caption_font": {        
        "name": "Segoe UI",        
        "size": 18,        
        "decorations": "normal"      
      }    
    },    
    {      
      "type": "text",      
      "color": "#f5f5f0",      
      "font": {        
        "name": "Segoe UI",        
        "size": 18      
      }    
    },    
    {      
      "type": "image",      
      "resource": "IMAGES_DARK",      
      "path": "~/.hello_wui/res/images/dark"    
    },    
    . . .
  }

Тема light
{
  "controls": [
    {
      "type": "window",
      "background": "#fffffe",
      "border": "#9a9a9a",
      "border_width": 1,
      "text": "#191914",
      "caption_font": {
        "name": "Segoe UI",
        "size": 18
      }
    },
    {
      "type": "text",
      "color": "#191914",
      "font": {
        "name": "Segoe UI",
        "size": 18
      }
    },
    {
      "type": "image",
      "resource": "IMAGES_LIGHT",
      "path": "~/.hello_wui/res/images/light"
    }
    ...
}

Данный подход предоставляет приложению и контролам прозрачный,  централизованный механизм управления отображением. При необходимости создать кастомный контрол (например красную кнопку) можно просто добавить в json новый раздел:

{
  "type": "red_button",
  "calm": "#c61818",
  "active": "#e31010",
  "border": "#c90000",
  "border_width": 1,
  "focused_border": "#dcd2dc",
  "text": "#f0f1f1",
  "disabled": "#a5a5a0",
  "round": 0,
  "focusing": 1,
  "font": {
     "name": "Segoe UI",
      "size": 18
   }
}

А при создании контрола указать имя контрола: “red_button”, например:

cancelButton(new wui::button(wui::locale("button", "cancel"), this { window->destroy(); }, "red_button"))

Для работы с пиктограммами и подобными изображениями используется контрол image. Он также использует theme для получения идентификатора win32 ресурса или пути к файлу изображения. Это позволяет создать изображение

logoImage(new wui::image(IMG_LOGO)) 

где:

#ifdef _WIN3
#define IMG_LOGO 4010
#else
static constexpr const char* IMG_LOGO = "logo.png";
#endif

Логотип будет загружен в соответствии с заданной темой.

Вопросы многопоточности

WUI не использует ни одного мьютекса. Коллбэки контролов и системные события приходят только из одного потока на Windows (proc_wnd) или из потока окна ожидающего xcb_wait_for_event().

Рекомендуется все манипуляции с UI производить либо в коллбеках / полученных системных событиях, либо в одном специальном UI треде приложения.

Если же планируется window.add_control() / window.remove_control() из разных тредов, то необходимо осуществить защиту на уровне кода приложения.

Unicode

Используется только UTF-8 передаваемый в обычных std::string / char *

Для взаимодействия с WinAPI которой нужен utf16 в wchar, используется boost::nowide::widen() / boost::nowide::narrow(). boost::nowide не имеет зависимостей от boost и поставляется вместе с WUI в thirdparty. Таким образом, если в вашем проекте нет boost вам не придется включать его в зависимости для WUI. 

Приложение также должно использовать boost::nowide для работы WUI совместно с WinAPI.

Подробнее о том, почему wchar не нужен, написано здесь: https://utf8everywhere.org/

На Linux boost::nowide не требуется, и зависимость от него исключается.

Обработка ошибок

WUI не использует исключения. Методы, которые могут завершиться ошибкой возвращают bool. Для получения подробностей о возникшей проблеме используется метод get_error() возвращающий структуру

struct error
{    
    error_type type;    
    std::string component, message;    
    bool is_ok() const;
};

Ошибки, возможно возникшие в конструкторе объекта, нужно проверять так:

newObject(new wui::image(IMG_LOGO))...

if (!newObject->get_error().is_ok()) { log(“error”, newObject->get_error().str()); }

Hello world app

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

Данное приложение находится в examples/hello_world и включает полное наличие необходимых ресурсных файлов. На Windows приложение собирается в монолитный exe, на Linux/Mac хранит ресурсы в папке ”res/” рядом с исполняемым файлом. Для реальных приложений лучше указать пути “~/.app_name/res” или, если приложение ставится из под root, что-то вроде “/opt/app_name/res” .

Показано использование theme, locale и config, в приложении, имеющем две цветовые схемы (темная и светлая), два языка и хранящем свою конфигурацию в реестре на Windows и в ini файле на Linux.

main.cpp

#ifdef _WIN32
int APIENTRY wWinMain(_In_ HINSTANCE,
    _In_opt_ HINSTANCE,
    _In_ LPWSTR    lpCmdLine,
    _In_ int       nCmdShow)
#elif __linux__
int main(int argc, char *argv[])
#endif
{
    wui::framework::init();

    auto ok = wui::config::create_config("hello_world.ini", "Software\\wui\\hello_world");
    if (!ok)
    {
        std::cerr << wui::config::get_error().str() << std::endl;
        return -1;
    }

    wui::error err;

    wui::set_app_locales({
        { wui::locale_type::eng, "English", "res/en_locale.json", TXT_LOCALE_EN },
        { wui::locale_type::rus, "Русский", "res/ru_locale.json", TXT_LOCALE_RU },
    });

    auto current_locale = static_cast<wui::locale_type>(wui::config::get_int("User", "Locale", 
        static_cast<int32_t>(wui::get_default_system_locale())));

    wui::set_current_app_locale(current_locale);

    wui::set_locale_from_type(current_locale, err);
    if (!err.is_ok())
    {
        std::cerr << err.str() << std::endl;
        return -1;
    }

    wui::set_app_themes({
        { "dark",  "res/dark.json",  TXT_DARK_THEME },
        { "light", "res/light.json", TXT_LIGHT_THEME }
    });

    auto current_theme = wui::config::get_string("User", "Theme", "dark");
    wui::set_default_theme("dark");
    wui::set_current_app_theme(current_theme);

    wui::set_default_theme_from_name(current_theme, err);
    if (!err.is_ok())
    {
        std::cerr << err.str() << std::endl;
        return -1;
    }

    MainFrame mainFrame;
    mainFrame.Run();

    wui::framework::run();

    return 0;
}

Демонстрационное приложение показывает логотип, выводит надпись и предоставляет поле ввода. При нажатии на кнопку, происходит вывод окна сообщения и приложение закрывается. Также показано отслеживание закрытия окна пользователем с выводом сообщения подтверждения.

На следующем скриншоте, тема изменена на светлую, язык на английский и нажата кнопка “Приятно познакомиться”

Код главного окна
//////// Header

class MainFrame
{
public:
    MainFrame();

    void Run();

private:
    static const int32_t WND_WIDTH = 400, WND_HEIGHT = 400;

    std::shared_ptr<wui::window> window;

    std::shared_ptr<wui::image> logoImage;
    std::shared_ptr<wui::text> whatsYourNameText;
    std::shared_ptr<wui::input> userNameInput;
    std::shared_ptr<wui::button> okButton;
    std::shared_ptr<wui::message> messageBox;

    void ReceiveEvents(const wui::event &ev);

    void UpdateControlsPosition();
};

//////// Impl

MainFrame::MainFrame()
    : window(new wui::window()),
    
    logoImage(new wui::image(IMG_LOGO)),
    whatsYourNameText(new wui::text(wui::locale("main_frame", "whats_your_name_text"), wui::text_alignment::center, "h1_text")),
    userNameInput(new wui::input(wui::config::get_string("User", "Name", ""))),
    okButton(new wui::button(wui::locale("main_frame", "ok_button"), [this](){
        wui::config::set_string("User", "Name", userNameInput->text());
        messageBox->show(wui::locale("main_frame", "hello_text") + userNameInput->text(),
        wui::locale("main_frame", "ok_message_caption"), wui::message_icon::information, wui::message_button::ok, [this](wui::message_result) {
            runned = false; window->destroy(); }); })),
    messageBox(new wui::message(window)),

    runned(false)
{
    window->subscribe(std::bind(&MainFrame::ReceiveEvents,
        this,
        std::placeholders::_1),
        static_cast<wui::event_type>(static_cast<int32_t>(wui::event_type::internal) |
            static_cast<int32_t>(wui::event_type::system) |
            static_cast<int32_t>(wui::event_type::keyboard)));

    window->add_control(logoImage,         { 0 });
    window->add_control(whatsYourNameText, { 0 });
    window->add_control(userNameInput,     { 0 });
    window->add_control(okButton,          { 0 });

    window->set_default_push_control(okButton);

    window->set_min_size(WND_WIDTH - 1, WND_HEIGHT - 1);
}

void MainFrame::Run()
{
    if (runned)
    {
        return;
    }
    runned = true;

    UpdateControlsPosition();

    window->set_control_callback([&](wui::window_control control, std::string &tooltip_text, bool &continue_) {
        switch (control)
        {
            case wui::window_control::theme:
            {
                wui::error err;

                auto nextTheme = wui::get_next_app_theme();
                wui::set_default_theme_from_name(nextTheme, err);
                if (!err.is_ok())
                {
                    std::cerr << err.str() << std::endl;
                    return;
                }

                wui::config::set_string("User", "Theme", nextTheme);

                window->update_theme();
            }
            break;
			case wui::window_control::lang:
			{
                wui::error err;

                auto nextLocale = wui::get_next_app_locale();
                wui::set_locale_from_type(nextLocale, err);
                if (!err.is_ok())
                {
                    std::cerr << err.str() << std::endl;
                    return;
                }

                wui::config::set_int("User", "Locale", static_cast<int32_t>(nextLocale));

				tooltip_text = wui::locale("window", "switch_lang");

				window->set_caption(wui::locale("main_frame", "caption"));
				whatsYourNameText->set_text(wui::locale("main_frame", "whats_your_name_text"));
				okButton->set_caption(wui::locale("main_frame", "ok_button"));
			}
			break;
            case wui::window_control::close:
                if (runned)
                {
                    continue_ = false;
                    messageBox->show(wui::locale("main_frame", "confirm_close_text"),
                        wui::locale("main_frame", "cross_message_caption"), wui::message_icon::information, wui::message_button::yes_no,
                        [this, &continue_](wui::message_result r) {
							if (r == wui::message_result::yes)
							{
                              wui::framework::stop();
							}
                        });
                }
            break;
        }
    });

    auto width = wui::config::get_int("MainFrame", "Width", WND_WIDTH);
    auto height = wui::config::get_int("MainFrame", "Height", WND_HEIGHT);

    window->init(wui::locale("main_frame", "caption"), { -1, -1, width, height },
        static_cast<wui::window_style>(static_cast<uint32_t>(wui::window_style::frame) |
        static_cast<uint32_t>(wui::window_style::switch_theme_button) |
		static_cast<uint32_t>(wui::window_style::switch_lang_button) |
        static_cast<uint32_t>(wui::window_style::border_all)), [this]() {
          wui::framework::stop();
        });
}

void MainFrame::ReceiveEvents(const wui::event &ev)
{
    if (ev.type == wui::event_type::internal)
    {
        switch (ev.internal_event_.type)
        {
            case wui::internal_event_type::window_created:
        
            break;
            case wui::internal_event_type::size_changed:        
                if (window->state() == wui::window_state::normal &&
                    ev.internal_event_.x > 0 && ev.internal_event_.y > 0)
                {
                    wui::config::set_int("MainFrame", "Width", ev.internal_event_.x);
                    wui::config::set_int("MainFrame", "Height", ev.internal_event_.y);
                }
                UpdateControlsPosition();
            break;
            case wui::internal_event_type::window_expanded:
            case wui::internal_event_type::window_normalized:
                UpdateControlsPosition();
            break;
            case wui::internal_event_type::window_minimized:
            break;
        }
    }
}

void MainFrame::UpdateControlsPosition()
{
    const auto width = window->position().width(), height = window->position().height();

    const int32_t top = 40, element_height = 40, space = 30;

    wui::rect pos = { space, top, width - space, top + element_height };
    whatsYourNameText->set_position(pos);
    wui::line_up_top_bottom(pos, element_height, space);
    userNameInput->set_position(pos);
    wui::line_up_top_bottom(pos, element_height * 2, space);
    
    int32_t center = width / 2;

    pos.left = center - element_height, pos.right = center + element_height;

    logoImage->set_position(pos);

    okButton->set_position({center - 90,
        height - element_height - space,
        center + 90,
        height - space
    });
}

Окно и контролы создаются в конструкторе MainFrame. Там же осуществляется подписка приложения на события и добавляются на окно контролы. Коллбеки контролов для краткости отрабатываются при помощи лямбд. 

Метод Run() запускает окно и содержит лямбду, обрабатывающую коллбеки от контролов окна (кнопки смена языка и темы).
ReceiveEvents() получает события от окна и используется для реагирования на ресайз окна вызывая UpdateControlsPosition(). который и пересчитывает новые координаты контролов.

Контролы

На момент написания статьи реализовано 14 контролов в составе WUI и несколько специфичных в составе нашего приложения. Список имеющихся контролов:

button

Кнопка может быть следующих видов:
        text
    image
    image_right_text
    image_bottom_text
    switcher
    radio
    anchor
    sheet

image

image нужен для единообразного отображения пиктограмм с учетом визуальной темы. Например button использует image для рисования пиктограмм на себе. image рисует себя из ресурса, соответствующего визуальной теме.

Пример использования image:

Создаем в конструкторе содержащего image класса

logoImage(new wui::image(IMG_LOGO))...

IMG_LOGO определен в resourse.h приложения следующим образом:

#ifdef _WIN32
#define IMG_LOGO				  109
#else
static constexpr const char* IMG_LOGO = "logo.png";
#endif

Таким образом, изображение будет взято из ресурса exe на Windows или из файла на других системах.
Магия смены изображения при смене темы реализована следующим образом. image имеет в theme свои настройки:

light.json:
    {
      "type": "image",
      "resource": "IMAGES_LIGHT",
      "path": "res/images/light"
    }
dark.json:
    {
      "type": "image",
      "resource": "IMAGES_DARK",
      "path": "res/images/dark"
    }

Путь к файлу ресурса составляется из пути указанном в theme и имени файла в image что приводит к автоматической замене всех изображений приложения при смене темы.
На Windows стоит упомянуть как организован rc файл приложения.

IMG_LOGO IMAGES_DARK   "res\images\dark\logo.png"
IMG_LOGO IMAGES_LIGHT  "res\images\light\logo.png"

Таким образом, замена группы IMAGES_DARK / IMAGES_LIGHT вызывает аналогичный эффект как с файлами, без необходимости менять ID ресурса.

input

Данный контрол реализовывает стандартное поле ввода. Так как реализация своя, в данный момент нет Undo / Redo, но в перспективе там должен появиться спелл чекинг, подсказки, валидация.

list

Вертикальный список item’ов со скроллингом. Отрисовка элементов производится пользовательским кодом через callback. Имеется возможность создавать item’ы с разной высотой. С его помощью можно сделать чат, таблицу к БД, в принципе любой список.

Пример реализации чата
Пример реализации чата
Список контактов
Список контактов

menu

Меню ленточные, без боковых ответвлений. Вложения раскрываются вниз, удлиняя меню.

Меню задается в декларативном стиле, вектором:

menu->set_items({
            { 0, wui::menu_item_state::separator, "Bla bla bla", "", menuImage1, {}, [](int32_t i) {} },
            { 1, wui::menu_item_state::normal, "Expand me 1", "", nullptr, {
                    { 11, wui::menu_item_state::normal, "Expanded 1.1", "", nullptr, {}, [](int32_t i) {} },
                    { 12, wui::menu_item_state::normal, "Expanded 1.2", "", nullptr, {
                            { 121, wui::menu_item_state::normal, "Expanded 1.1.1", "", nullptr, {}, [](int32_t i) {} },
                            { 122, wui::menu_item_state::normal, "Expanded 1.1.2", "Shift+Del", menuImage2, {}, [](int32_t i) {} },
                            { 123, wui::menu_item_state::separator, "Expanded 1.1.3", "", nullptr, {}, [](int32_t i) {} },
                        }, [](int32_t i) {} },
                    { 13, wui::menu_item_state::normal, "Expanded 1.3", "", nullptr, {}, [](int32_t i) {} },
                }, [](int32_t i) {} },
            { 2, wui::menu_item_state::separator, "Expand me 2", "Ctrl+Z", nullptr, {
                    { 21, wui::menu_item_state::normal, "Expanded 2.1", "", nullptr, {}, [](int32_t i) {} },
                    { 22, wui::menu_item_state::normal, "Expanded 2.2", "", nullptr, {}, [](int32_t i) {} },
                    { 23, wui::menu_item_state::separator, "Expanded 2.3", "", nullptr, {}, [](int32_t i) {} },
                }, [](int32_t i) {} },
            { 3, wui::menu_item_state::normal, "Exit", "Alt+F4", nullptr, {}, [&window](int32_t i) { window->destroy(); } }
        });

Внутри, меню использует list

message

Имеет стандартные наборы кнопок и пиктограмм представленных в перечислениях
message_icon, message_button и message_result. При использовании есть одна особенность, которая по началу покажется непривычной, а именно, вызов message::show() не блокирует вызывающий поток. Поэтому, получение ID нажатой кнопки производится в коллбеке. Пример:

messageBox->show(“message”, "header", wui::message_icon::information,                                  wui::message_button::yes_no, [this](wui::message_result result) {
   if (result == wui::message_result::yes)
   {
       /// Продолжаем здесь
   }
});

panel

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

select

Выпадающий список, он же combo box. Не имеет редактора, т е работает только для выбора из имеющегося. Реализован также на list.

slider

Он же “регулятор громкости”. Как и progress может быть горизонтальным и вертикальным.

Здесь text, slider и button

splitter

Используется для ресайза внутренних окон.

text

Текстовая строка. Позволяет задать выравнивание текста и ставит многоточие если текст не влезает в отведенную область.

tooltip

Всплывающая подсказка.

trayicon

Позволяет управлять иконкой в трее и информировать пользователя плашками.

Зависимости

WUI использует три библиотеки в thirdparty. Это: boost::nowide, nlohman::json и utf8 от Nemanja Trifunovic. Последние две, header only и хлопот не вызывают. boost::widen поставляется в виде “вырезки” из boost, имеются сборки на vs 2017 и 2019 версия boost: 1.82. Если в вашем проекте уже используется boost (тем более другой версии), лучше указать для wui путь к вашему boost.

Внешние зависимости отсутствуют на Windows. На Linux, в данный момент, для работы требуется xcb и cairo.

Вместо завершения

Библиотека в составе нашего приложения прошла опытную эксплуатацию на нескольких крупных предприятиях промышленного и медицинского характера. Использовались различные версии Windows от XP до 11 и Linux от CentOS 6 до Ubuntu 22.

По надежности и производительности система показывает весьма достойные результаты (нет падений, артефактов, утечек памяти), что подтвердило допустимость принятых концепций.

Основные направления развития проекта - это конечно, поддержка macOS и добавление новых контролов. Например нужен календарь, многострочный текстовый редактор, грид для базы данных, чарты и прочее. Нужен графический редактор для создания хотя бы диалогов. Также очень хочется заменить X11 на  Wayland и сделать графику на Vulkan.

Будем благодарны за конструктивную критику, помощь в развитии проекта идеями, кодом, распространением.

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

Github: https://github.com/ud84
Project site: https://libwui.org/

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 28: ↑26 and ↓2+30
Comments55

Articles