Nuklear — идеальный GUI для микро-проектов?
Nuklear — это библиотека для создания immediate mode пользовательских интерфейсов. Библиотека не имеет никаких зависимостей (только C89! только хардкор!), но и не умеет создавать окна операционной системы или выполнять реальный рендеринг. Nuklear — встраиваемая библиотека, которая предоставляет удобные интерфейсы для отрисовки средствами реализованного приложения. Есть примеры на WinAPI, X11, SDL, Allegro, GLFW, OpenGL, DirectX. Родителем концепции была библиотека ImGUI.
Чем прекрасна именно Nuklear? Она имеет небольшой размер (порядка 15 тысяч строк кода), полностью содержится в одном заголовочном файле, создавалась с упором на портативность и простоту использования. Лицензия Public Domain.
Постановка задачи
У меня часто возникают задачи, для реализации которых приходится писать мелкие утилитки в несколько сотен строк кода. Обычно в результате получается консольное приложение, которое кроме меня никто толком использовать не может. Возможно, простой GUI сможет сделать эти утилиты более удобными?
Итак, требования к результату:
- Малый размер, до сотен килобайт.
- Кроссплатформенность, для начала хотя бы Windows и Linux.
- Отсутствие зависимости от внешних библиотек в Windows, всё должно быть в одном EXE-файле.
- Приличный/красивый внешний вид.
- Поддержка картинок в форматах JPG и PNG.
- Простота разработки, возможность разработки в Windows и Linux.
Справится ли Nuklear?
Для примера рассмотрим создание утилиты dxBin2h (GitHub) — она считывает файл побайтово и записывает в виде Си-массива. Кроме основного функционала программа имеет всякие «плюшки», типа удаления ненужных символов и т.п. Обычно ради стороннего функционала и создаются свои маленькие утилиты. Например, dxBin2h создавалась для Winter Novel, для предварительной обработки ASCII-файлов.
Простота разработки, кроссплатформенность
Уж с чем, а с простотой разработки проблем быть не должно. Ведь с прицелом на неё библиотека и создавалась, так? Прямо в Readme на GitHub есть пример. Абсолютно понятные и лаконичные 20 строк кода дают красивый и чёткий результат.
/* init gui state */
struct nk_context ctx;
nk_init_fixed(&ctx, calloc(1, MAX_MEMORY), MAX_MEMORY, &font);
enum {EASY, HARD};
int op = EASY;
float value = 0.6f;
int i = 20;
if (nk_begin(&ctx, "Show", nk_rect(50, 50, 220, 220),
NK_WINDOW_BORDER|NK_WINDOW_MOVABLE|NK_WINDOW_CLOSABLE)) {
/* fixed widget pixel width */
nk_layout_row_static(&ctx, 30, 80, 1);
if (nk_button_label(&ctx, "button")) {
/* event handling */
}
/* fixed widget window ratio width */
nk_layout_row_dynamic(&ctx, 30, 2);
if (nk_option_label(&ctx, "easy", op == EASY)) op = EASY;
if (nk_option_label(&ctx, "hard", op == HARD)) op = HARD;
/* custom widget pixel width */
nk_layout_row_begin(&ctx, NK_STATIC, 30, 2);
{
nk_layout_row_push(&ctx, 50);
nk_label(&ctx, "Volume:", NK_TEXT_LEFT);
nk_layout_row_push(&ctx, 110);
nk_slider_float(&ctx, 0, &value, 1.0f, 0.1f);
}
nk_layout_row_end(&ctx);
}
nk_end(&ctx);
Но не всё так просто. Часть, отвечающая непосредственно за просчёт GUI действительно проста. Только должен быть ещё и рендер. Идём в папку demo, выбираем понравившийся. И видим уже далеко не 20 строк. Мало того, хотя примеры и рисуют на экране примерно одинаковый результат, но код значительно отличается именно из-за рендера.
WinAPI:
static LRESULT CALLBACK
WindowProc(HWND wnd, UINT msg, WPARAM wparam, LPARAM lparam)
{
switch (msg) {
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
if (nk_gdip_handle_event(wnd, msg, wparam, lparam))
return 0;
return DefWindowProcW(wnd, msg, wparam, lparam);
}
int main(void)
{
GdipFont* font;
struct nk_context *ctx;
WNDCLASSW wc;
RECT rect = { 0, 0, WINDOW_WIDTH, WINDOW_HEIGHT };
DWORD style = WS_OVERLAPPEDWINDOW;
DWORD exstyle = WS_EX_APPWINDOW;
HWND wnd;
int running = 1;
int needs_refresh = 1;
/* Win32 */
memset(&wc, 0, sizeof(wc));
wc.lpfnWndProc = WindowProc;
wc.hInstance = GetModuleHandleW(0);
wc.hIcon = LoadIcon(NULL, IDI_APPLICATION);
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.lpszClassName = L"NuklearWindowClass";
RegisterClassW(&wc);
AdjustWindowRectEx(&rect, style, FALSE, exstyle);
wnd = CreateWindowExW(exstyle, wc.lpszClassName, L"Nuklear Demo",
style | WS_VISIBLE, CW_USEDEFAULT, CW_USEDEFAULT,
rect.right - rect.left, rect.bottom - rect.top,
NULL, NULL, wc.hInstance, NULL);
SDL:
int
main(int argc, char* argv[])
{
/* Platform */
SDL_Window *win;
SDL_GLContext glContext;
struct nk_color background;
int win_width, win_height;
int running = 1;
/* GUI */
struct nk_context *ctx;
/* SDL setup */
SDL_SetHint(SDL_HINT_VIDEO_HIGHDPI_DISABLED, "0");
SDL_Init(SDL_INIT_VIDEO);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
win = SDL_CreateWindow("Demo",
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
WINDOW_WIDTH, WINDOW_HEIGHT, SDL_WINDOW_OPENGL|SDL_WINDOW_SHOWN|SDL_WINDOW_ALLOW_HIGHDPI);
glContext = SDL_GL_CreateContext(win);
SDL_GetWindowSize(win, &win_width, &win_height);
Отсутствие зависимостей в Windows
Ну хорошо, берём рендером SDL2 с OpenGL и получаем результирующее приложение под Windows, Linux, Mac OS X, Android, iOS и ещё кучу чего! Всё супер, только вот в стандартной поставке Windows библиотеки SDL нет. Значит, придётся тащить с собой. А это нарушает первое требование (малый размер), т.к. сама SDL весит порядка мегабайта.
Зато в списке примеров виднеется GDI+, которая есть в Windows начиная с XP. GDI+ умеет ttf-шрифты, картинки PNG и JPG, и всё это возможно загружать прямо из памяти. Пускай в итоге будет 2 возможных рендера: GDI+ для Windows и SDL для всех остальных случаев. Можно вынести часть кода, зависящую от рендера, в отдельный Си-файл (nuklear_cross.c). Тогда основной код не будет перегружен, и можно будет сфокусироваться именно на интерфейсе, что значительно упрощает разработку. Дополнительным плюсом получаем ускорение компиляции — весь Nuklear будет компилироваться в отдельный объектный файл, который будет редко изменяться.
Windows, отрисовка через GDI+, шрифт Arial 12pt:
Linux, отрисовка через SDL2 и OpenGL, шрифт по умолчанию:
Приложение выглядит совсем по-разному! И первое, что бросается в глаза — шрифт.
Шрифт
Чтобы приложение выглядело одинаково во всех операционных системах нужно использовать один и тот же шрифт. Можно было бы взять какой-нибудь системный шрифт, который гарантированно есть везде. Но такого шрифта нет. Поэтому шрифт придётся включать в своё приложение. ttf-шрифты обычно весят сотни килобайт, но из них хорошо создаются подмножества с необходимыми символами. Например, с помощью веб-сервиса FontSquirrel. DejaVu Serif ужался до 40kb, хотя и содержит в себе кириллицу, польский и ещё кучу языков.
Всё было бы отлично, но GDI+ драйвер для Nuklear не умел загружать шрифт из памяти, только из файла. Пришлось исправлять… Кстати, шрифт можно включить в своё приложение с помощью той же dxBin2h.
Windows, DejaVu Serif:
Linux, DejaVu Serif:
Уже намного лучше. Но мне не нравится внешний вид чекбоксов. И хотелось бы увидеть картинки.
Картинки: PNG, JPG
И SDL2 и GDI+ умеют загружать картинки. Но для SDL при загрузке JPG и PNG появляется дополнительная зависимость — SDL_image. Избавиться от неё довольно просто: используем stb_image.h, если проект собирается с SDL.
С GDI+ тоже не всё было хорошо. А именно, GDI+ драйвер для Nuklear не умел отрисовывать изображения средствами GDI+. Пришлось вникать в работу с изображениями и реализовывать самому (Pull Request). Теперь всё исправлено и код в официальном репозитории.
struct nk_image dxNkLoadImageFromMem(const void* buf, int bufSize){
int x,y,n;
GLuint tex;
unsigned char *data = stbi_load_from_memory(buf, bufSize, &x, &y, &n, 0);
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR_MIPMAP_NEAREST);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, x, y, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
return nk_image_id((int)tex);
}
Внешний вид приложения
Чтобы изменить вид чекбоксов в Nuklear есть механизм выставления стилей. Здесь включенный и выключенный чекбокс являются отдельными PNG-картинками. В этом же коде же выставляется красная тема из примеров Nuklear (файл style.c):
nk_image checked = dxNkLoadImageFromMem( (void*)checked_image, sizeof(checked_image) );
nk_image unchecked = dxNkLoadImageFromMem( (void*)unchecked_image, sizeof(unchecked_image) );
set_style(ctx, THEME_RED);
{struct nk_style_toggle *toggle;
toggle = &ctx->style.checkbox;
toggle->border = -2; /* cursor must overlap original image */
toggle->normal = nk_style_item_image(unchecked);
toggle->hover = nk_style_item_image(unchecked);
toggle->active = nk_style_item_image(unchecked);
toggle->cursor_normal = nk_style_item_image(checked);
toggle->cursor_hover = nk_style_item_image(checked);
}
Приложение в Windows выглядит так:
В Linux:
Что в итоге?
- Windows EXE после компиляции 200kb, после ужатия UPX 90kb. В Linux из-за использования stb_image размер приложения в среднем на 100kb больше.
- Проверена работа в Windows и Linux.
- Шрифт и картинки хранятся как массивы в памяти приложения. Зависимостей не от WinAPI в Windows нет.
- Движок изменения стиля приложения работает.
- PNG и JPG загружаются средствами GDI+ и stb_image.
- Весь «грязный» платформенно-зависимый код вынесен в отдельный файл. Разработчик фокусируется именно на создании приложения.
Известные проблемы
- Различное сглаживание шрифтов в разных операционных системах
- Разный размер чекбоксов
- Разная поддержка изображений (при использовании stb_image нужно избегать проблемных изображений)
- Не полная поддержка юникода при урезанном шрифте
- Нет примера на технологиях Mac OS X
Как пользоваться наработками
- Склонировать репозиторий https://github.com/DeXP/dxBin2h
- Скопировать оттуда папку «GUI» в свой проект
- Подключить «GUI/nuklear_cross.h», использовать функции оттуда
- При необходимости обновления файлов Nuklear скопировать их из официального репозитория поверх текущих.
Заключение
Приложение выглядит немного по-разному в разных операционных системах. Однако отличия незначительны, полученный результат меня удовлетворил. Nuklear не входит в категорию «я уверен, что будет работать везде и без тестирования». Зато входит в категорию «если что будет нужно — легко допишу».
Полезные ссылки
- Nuklear на GitHub
- Код разработанной утилиты dxBin2h
- Список моих правок для корректной работы драйвера GDI+
- Тема на форуме GameDev, где анонсируется релиз Nuklear
- Обсуждение на тему полезности immediate mode GUI
- Пример создания приложений на ImGUI