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

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

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

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

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

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

В итоге, все же было принято волевое решение сделать минимальный 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) = 0;

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

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

void emit_event(int32_t x, int32_t y);

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

system_context &context();

Методы control

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

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

i_control.hpp
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/

Комментарии (39)


  1. reficul0
    18.10.2023 14:22

    Здравствуйте! Спасибо за статью!
    Небольшой совет, для улучшения читаемости код лучше поместить в специальный блок:


    1. udattsk Автор
      18.10.2023 14:22

      Приветствую!

      Да вроде так и делал, все что можно было в блоки код добавил.. но подсветки синтаксиса почему-то нет. Возможно там как-то надо указать ему что это вот c++, а это json


      1. includedlibrary
        18.10.2023 14:22
        +1

        Да, нужно для каждого блока указывать язык, чтобы подсветка работала.


      1. reficul0
        18.10.2023 14:22
        +1

        Да, вам нужно выбрать нужный язык в выпадающем списке:


        1. udattsk Автор
          18.10.2023 14:22
          +2

          Спасибо! Поправил ????


  1. spinmozg
    18.10.2023 14:22
    +1

    При попытке скачать демо-приложение с сайта https://libwui.org/ Windows Defender сказал, что обнаружен вирус


    1. udattsk Автор
      18.10.2023 14:22

      Видимо, это из-за того что оно просто не подписано. Собиралось на чистой виртуалке точно без вирусов.


      1. timoxa_dev
        18.10.2023 14:22

        Я тебе верю ????


        1. udattsk Автор
          18.10.2023 14:22

          Никаких проблем собрать самому ????
          А так это не вирус, просто ругается что скачан подозрительный неподписанный exe. Еще лет 10 назад, все такие были )


  1. JerryI
    18.10.2023 14:22
    +2

    Гигантская работа...
    Хотел спросить, а почему был выбран подход sleep в случае GNU/Linux?

    Там же есть poll, epoll, т.е. можно "нативным" образом "ждать" не жертвуя временем в функции ожидания. Там какой-нибудь контроллер сработал и пнул пулл событий, тогда поток проснулся и обработал их. Под Windows что-то похожее было типа WaitForMultipleObjects


  1. udattsk Автор
    18.10.2023 14:22

    Я понял про какой вы слип. Он используется в цикле не дающем функции main завершиться. Там можно спать хоть по секунде, его задача просто узнать не закрыл ли юзер главное окно и дальше отдыхать...


  1. Karopka
    18.10.2023 14:22

    Удалено.


  1. udattsk Автор
    18.10.2023 14:22
    +2

    Ну вы не учли фатальный недостаток этих систем )

    https://neolurk.org/wiki/Фатальный_недостаток


  1. Tyiler
    18.10.2023 14:22
    +2

    Приветствую.

    Покритикую немного:

    • код примера окна написан плохо: #ifdef _WIN32 int APIENTRY wWinMain и тд пользователю это зачем. Если так придется писать везде, то не представляю как будет выглядеть приложение от 10к строк

    • в репе код тоже страшен

    • нет разделения кода для разных ОС, все склеено как и в примере #ifdef по месту

    • класс window.cpp весь в одном файле и занимает 3к строк кода, все остальные классы так же устроены

    • есть такая вещь как регистр символов, у вас классы, структуры, обычные переменные, статич переменные и тд - все в нижнем регистре. Как вы их отличаете?

    • что еще.. ладно

    В общем, обычная поделка, долго не протянет.

    Если хотите дальше упражняться, то сравните размеры wxwidget и того что у вас есть сейчас, прикиньте сколько еще бежать, может с пользой лучше энергию потратить?

    Совет: возьмите wxwidget, добавьте туда удобную настройку графики (аналогично css на вэбе, или qss в qt), мне вот только ее не хватало, когда работал с wxwidget, остальное там все уже есть.


    1. udattsk Автор
      18.10.2023 14:22

      Вы не зрите в корень... wxWidgets с дизайном из начала 90х, против максимально приятного modern c++ да ещё и без шаблонной жести...

      Насчёт "поделия", то это сделано для коммерческого продукта который решает боевые задачи и зарабатывает деньги компании.

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

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


      1. Tyiler
        18.10.2023 14:22
        +3

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

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


        1. udattsk Автор
          18.10.2023 14:22

          Эхх, были бы эти "личные договоренности" я бы донаты не собирал ))

          Про дизайн из 90х я имел ввиду прежде всего дизайн интерфейсов. А дизайн wxWidgets/MFC/WTL строился вокруг системного API во время ещё до c++98...

          Современный C++ гораздо гибче, приятнее и лаконичнее, так что писать прием сообщений на макросах нет никакой нужды. Так же как и запихивать интерфейсы контролов в прокрустово ложе устаревших API и концепций авторов, которых уже некоторых нет в живых...


    1. eao197
      18.10.2023 14:22
      +1

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

      есть такая вещь как регистр символов, у вас классы, структуры, обычные переменные, статич переменные и тд - все в нижнем регистре. Как вы их отличаете?

      вы зря. В C++ нет де-юре официального code style, каждый проект имеет право использовать то, что нравится больше всего.


      1. udattsk Автор
        18.10.2023 14:22

        99% библиотек, которые мне нравятся, используют snake_case. Это, так же, требования boost code style, которого и придерживаемся.

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


    1. maisvendoo
      18.10.2023 14:22
      +4

      Соглашусь с критикой. Директивы условной компиляции типа #ifdef __WIN32 и им подобные, подразумевают что между ними будет платформозависимый код. Таки и смысл перекладываться подобную условную компиляцию на плечи пользователя библиотеки? Не грамотнее было бы сокрыть платформенно зависимые вещи внутри кода своей библиотеки? О какой такой "кроссплатформенности" при таком подходе можно заявлять?


      1. udattsk Автор
        18.10.2023 14:22
        -1

        Все #ifdef _WIN32 приложения (не библиотеки) имеются только в файле где находится точка входа int main(...)
        Можно сделать два файла а-ля: main_win.cpp, main_lin.cpp и обойтись вообще без #ifdef _WIN32 платой за это, станет дублирование части (довольно хорошей) кода функции main()

        Этот файл с функцией main, вообще такое место, куда обычно никто не лазит, там ничего нового - инициализация конфига, логгера (если есть в приложении), темы и локали.

        Весь пользовательский код, пишется в классах реализации окон, и там никаких #ifdef _WIN32 обычно нет. Ну если вам не нужно, например, работать с аудио / видео, так как системные API очень отличаются. Тут мы уже делаем как раз camera_lin.cpp / camera_win.cpp потому что дублирования кода практически нет.


  1. uvlad7
    18.10.2023 14:22
    +2

    Как на линуксах с пользовательскими настройками? Ну иконки/темы в пролете, я так понимаю, да и ладно, а что насчёт масштабирования на high DPI?


    1. udattsk Автор
      18.10.2023 14:22
      +1

      Смотрите, размер окна и все координаты контролов отображается 1 в 1 на экране, так как задается в пикселях. Т.е. с этим проблем нет, приложение имеет одинаковую геометрию на Win (при масштабировании 100%) и на Lin. Одно но - при использовании одинаковых шрифтов.

      Я на Линукс затащил шрифты с Винды и кайфую )) тот же Телеграм в 100 удобнее читать стало. При использовании например Open Sans вместо Segoe UI, размер 14 Segoe выглядит на Linux + Open Sans на все 18. Это пока решаем поставкой тем для лиункса с уменьшенными размерами шрифтов на 4.

      Самым грамотным решением, я считаю, как сделано в lvgl - тащим шрифты с собой и получаем, хоть на bare metal, пиксель в пискель то что мы хотим.


  1. codecity
    18.10.2023 14:22
    +2

    Весьма круто, надеюсь сделаете на macOS чтобы работало. Делали ли уже публикацию на Reddit? Стоит попробовать собрать донаты на уровне стран первого мира - тут вам максимум копейки накидают - разве что как-то подсесть на гос. заказы.


    1. udattsk Автор
      18.10.2023 14:22
      +1

      Да какие донаты, это как в деда мороза верить )

      Я вообще бы хотел платить за разработку новых / улучшение имеющихся контролов...
      Например проводим конкурс на лучшую реализацию мнострочного редактора, победитель получает деньги, а все сообщество получает его контрол включенным в официальную поставку. Это как-то больше похоже на опен сорц здорового человека, как мне кажется...

      Публикация пока первая, Реддитом займемся ????


  1. alex-open-plc
    18.10.2023 14:22

    Про wx: если вы не любите кошек, то это значит, что вы не умеете их готовить.


    1. udattsk Автор
      18.10.2023 14:22

      Я всех люблю ????
      Посмотрите лучше: https://lvgl.io/
      wx отдыхает, как и wui ????


  1. domix32
    18.10.2023 14:22

    static constexpr const char* IMG_LOGO = "logo.png";

    Для полного счастья надо было ещё unsigned добавить. Вы кажется не очень понимаете, как работают constexpr выражения.

    Планируется ли сделать нормальную архитектуру, чтобы пользователь мог сделать какой-нибудь gui->run() и не обмазываться миллионом платформенных дефайнов?

    Планируется ли перейти на string_view вместо string& ?


    1. udattsk Автор
      18.10.2023 14:22

      Спасибо за замечания по constexpr ????

      По поводу платформенных кишков с ifdef вопрос уже решен - создана новая подсистема framework.

      Теперь минимальный hello_word выглядит как-то так:

      #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();
      
          // Здесь код инициализации конфига, темы и локали
      
          MainFrame mainFrame;
          mainFrame.Run();
      
          wui::framework::run();
      
          return 0;
      }
      
      

      MainFrame.cpp:

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

      Сейчас еще сделаю селектор для конфигов и обновлю статью.

      Вам, и всем кто указал на эту недоработку огромная благодарность ????


      1. udattsk Автор
        18.10.2023 14:22

        Существенно переписал раздел "Главный цикл приложения". Теперь #ifdef'ы не торчат и все красиво. Спасибо еще раз за критику.

        По stringview нужно подумать, скорее всего если c++14 на XP соберется то сделаем.


        1. domix32
          18.10.2023 14:22

          Думал cpp17, но ладно.


        1. crackedmind
          18.10.2023 14:22
          +1

          Последний из тулчейнов 2017 студии с поддержкой xp, вполне себе С++17 умеет


      1. domix32
        18.10.2023 14:22
        +1

        Дык ежели вы все эти HInstance не используете, то простой `int main()` должен завестись без проблем


        1. udattsk Автор
          18.10.2023 14:22

          1>LIBCMTD.lib(exe_winmain.obj) : error LNK2019: unresolved external symbol WinMain referenced in function "int __cdecl invoke_main(void)" (?invoke_main@@YAHXZ)


          К сожалению, на msvc так не работает.


  1. abagnale
    18.10.2023 14:22

    почему сразу делалось не на Qt [...] это связано с [...] любовью к монолитным exe

    Можно собрать статический Qt и линковать с ним, будет тоже монолитно.

    Кстати, интересно, какой у вас получился финальный размер релизного бинарника/exe? Статический Qt (Quick, не Widgets) с лёгкостью бы накинул 20-30 МБ для среднего приложения (одно окно, простые/стандартные графические эффекты, сеть).


    1. udattsk Автор
      18.10.2023 14:22
      +2

      10 Mb x64, 8 Mb win32


  1. satarsa
    18.10.2023 14:22
    +1

    >while (runned
    Кажется вам не только Qt не нравится, но английский язык тоже, нужно переписать таблицы сильных глаголов! :)


    1. osmanpasha
      18.10.2023 14:22

      /pedantic-mode Неправильных глаголов) Сильные - это другое.


      1. satarsa
        18.10.2023 14:22

        Извините, я первым учил немецкий, поэтому диск для меня навсегда ц, а эти глаголы - сильные)