Getting Started

Lightweight X11 GUI library for C++20 with software rendering, dirty-rectangle partial updates, TTF fonts (FreeType), two built-in themes (WIN11 / TERMINAL) and smooth animations.

Features

  • Partial redraw (dirty rectangles) — only changed regions repaint
  • Double buffering — no flicker
  • TTF fonts via FreeType with cached 1-bit masks (fast text)
  • Two themes: WIN11 and TERMINAL, fully customizable
  • Animations: hover, press, push-down, check, dropdown
  • EventBus + per-widget callbacks
  • Debug overlay, dirty-rect visualization, click markers (F1–F6)

Build

find_package(X11 REQUIRED)
find_package(Freetype REQUIRED)

add_executable(host
    main.cpp
    libs/gui/core/event_bus.cpp
    libs/gui/core/raw_picture.cpp
    libs/gui/core/font_manager.cpp
    libs/gui/core/theme.cpp
    libs/gui/widgets/widget.cpp
    libs/gui/widgets/draw_basic.cpp
    libs/gui/widgets/draw_toggles.cpp
    libs/gui/widgets/draw_lists.cpp
    libs/gui/window.cpp
    libs/gui/demo.cpp
)
target_include_directories(host PRIVATE libs/gui ${X11_INCLUDE_DIR})
target_link_libraries(host PRIVATE ${X11_LIBRARIES} Freetype::Freetype)

Quick start

#include "gui.h"
int main() {
    gui::set_theme(gui::ThemeStyle::WIN11);
    gui::window win("My App");
    win.set_font("SGr-Iosevka-Regular.ttc");
    win.set_default_icon();

    win.configur_page("main", [](gui::page& p) {
        gui::WidgetData b;
        b.id = "btn"; b.text = "Hello";
        b.x = 100; b.y = 100; b.width = 300; b.height = 70;
        p.Button(b);
    }, true);

    gui::EventBus::get().on("button.click", [](const gui::EventData& e) {
        std::cout << "clicked: " << e.source_id << "\n";
    });

    while (win.running()) win.update();
}
Put SGr-Iosevka-Regular.ttc (or any TTF/TTC) next to the binary. If not found, a fallback X11 core font is used.

Window & Pages

gui::window

MethodDescription
window(title)Create X11 window (1600×900), backbuffer, load font
update()Process events + render one frame (call in a loop)
running()false after WM_DELETE (close button)
set_font(path)Load TTF/TTC font
set_font_size(px)Global font size (default 32)
set_icon(path) / set_icon(pic) / set_default_icon()Window icon (_NET_WM_ICON)
set_max_fps(n)Frame cap (default 240)
on_keystd::function<void(KeySym)> — custom hotkeys (F6+)

Pages & tabs

// tabs (left column)
gui::WidgetData tab; tab.id = "main"; tab.text = "main";
tab.on_window_click = [&win](gui::window& w){ w.openPAGE("main"); };
win.addTAB_BUTTON(tab);

// pages
win.addPAGE("main");
win.configur_page("main", [](gui::page& p){ /* fill widgets */ }, true);
win.openPAGE("main");
Only the active page is ticked and rendered — inactive pages cost zero CPU.

Finding widgets

gui::Widget* w = win.find("btn");   // search all pages
if (w) { w->setValue(0.7f); win.mark_dirty(100,100,300,70); }

Widgets

WidgetData fields

FieldDefaultMeaning
id, text""identifier / label
x,y,width,height0,0,200,60geometry (2× scale)
bg_color, fg_color, frame_color00 = take from theme, else override
font_size00 = theme size, else per-widget
primaryfalseaccent-colored button
checkedfalsecheckbox / radio state
value, target_value0slider / progress (0..1)
items[]listbox / dropdown entries
picturenullRawPicture for sprite / raw widgets
on_change / on_page_click / on_window_clicknullcallbacks

Factory methods (on page or any container)

p.Button(d);  p.Label(d);  p.Input(d);  p.Checkbox(d);  p.Radiobutton(d);
p.Slider(d);  p.Progressbar(d);  p.Listbox(d);  p.Dropdown(d);
p.TreeView(d);  p.TreeNode(d);  p.ScrollView(d);
p.RawPictureWidget(d);  p.SpriteButton(d);

Examples

// accent button
gui::WidgetData b; b.id="go"; b.text="Run"; b.primary=true;
b.x=100; b.y=100; b.width=300; b.height=70;
p.Button(b);

// input field (click to focus, bottom line + blinking caret)
gui::WidgetData i; i.id="name"; i.x=100; i.y=200; i.width=500; i.height=68;
p.Input(i);

// slider drives progress
gui::WidgetData s; s.id="vol"; s.x=100; s.y=300; s.width=600; s.height=48; s.value=0.4f;
p.Slider(s);
gui::WidgetData pr; pr.id="prog"; pr.x=100; pr.y=380; pr.width=600; pr.height=44;
p.Progressbar(pr);

Nested containers

auto& sv = p.ScrollView(d);          // returns Widget&
sv.Button(inner);                    // children live inside
auto& tv = p.TreeView(d);
auto& n1 = tv.TreeNode(n);           // node
n1.Label(child);                     // leaf inside node

Themes & Customization

Switch built-in themes

gui::set_theme(gui::ThemeStyle::WIN11);     // rounded, blue accent
gui::set_theme(gui::ThemeStyle::TERMINAL);  // square, green accent
// hotkey F6 toggles at runtime (see demo)

Theme struct (all tunable)

struct Theme {
    ThemeStyle style;
    int radius, radius_sm, font_size;      // 12/8/32 (win11), 0/0/32 (term)
    uint32_t window_bg, panel_bg, text, text_dim;
    uint32_t btn, btn_h, btn_p, border, border_h;   // button + hover + press
    uint32_t accent, accent_h, accent_p, on_accent;
    uint32_t input_bg, focus;
    uint32_t track, prog_bg;
    uint32_t list_bg, list_h, list_sel, list_sel_text;
    uint32_t tab_bg, tab_h, tab_active;
};
const Theme& theme();   // current

Per-widget override (beats theme)

gui::WidgetData d;
d.bg_color    = 0x332211;   // non-zero overrides theme
d.fg_color    = 0xFFCC00;
d.frame_color = 0x00FF88;
d.font_size   = 40;         // per-widget size
Rule: 0 = use theme. Any other value = your override. This is the C(override, theme) helper in widget.h.

Where theme values live

Edit libs/gui/core/theme.cpp — the two static structs t_win11 / t_term. Field order matches the struct above (e.g. the pair input_bg, focus is the 14th/15th value).

Custom Widgets

Three levels of extension, from easy to full.

1. Restyle an existing widget (no code change)

gui::WidgetData d;
d.type = gui::WidgetType::BUTTON;   // or set via factory
d.bg_color = 0x402020; d.frame_color = 0xFF4444; d.font_size = 28;
p.Button(d);

2. Compose with RawPicture (sprite button)

auto pic = std::make_shared<gui::RawPicture>(300, 100, 32);
pic->clear(0x202020);
pic->fillRoundRect(0,0,300,100,16,0x0078D4);
pic->drawText(win.display(), 20, 30, "Custom", 0xFFFFFF, 1);
gui::WidgetData d; d.picture = pic; d.width=300; d.height=100;
p.SpriteButton(d);   // still clickable, emits button.click

3. Add a brand-new widget type

  1. widget.h: add enum value WidgetType::MY_WIDGET
  2. widget.h: add factory decl Widget& MyWidget(WidgetData);
  3. widget.cpp: implement factory (a.type = ...; return add_widget(...))
  4. draw: add a case in Widget::draw dispatch → your wdraw::my(...)
  5. input: add a case in Widget::handle_press (return 1 consumed / 2 capture)
// widget.cpp factory
Widget& combat::MyWidget(WidgetData a){ a.type=WidgetType::MY_WIDGET; return add_widget(std::move(a)); }

// draw dispatch (Widget::draw)
case WidgetType::MY_WIDGET: wdraw::my(d, win, gc, *this, ax, ay); break;

// your renderer (any draw_*.cpp)
void wdraw::my(Display* d, Drawable win, GC gc, const Widget& w, int ax, int ay) {
    const auto& t = theme();
    XSetForeground(d, gc, t.accent);
    x_fill_round(d, win, gc, ax, ay, w.data.width, w.data.height, t.radius);
    gui_text(d, win, gc, ax+12, gui_base_c(ay, w.data.height, t.font_size),
             w.data.text, t.on_accent, t.font_size);
}
Declare your wdraw::my in widget.h and, if it needs protected widgets, add it as friend of combat (like basic/toggles/lists).

Events

EventBus

int tok = gui::EventBus::get().on("button.click", [](const gui::EventData& e){ ... });
gui::EventBus::get().off(tok);
gui::EventBus::get().emit("my.event", {"id","text",0.5f,1,true});

Built-in events

EventData
button.clicksource_id, text
checkbox.changesource_id, flag
radio.changesource_id, flag=true
slider.changesource_id, value 0..1
list.selectsource_id, text, index
dropdown.selectsource_id, text, index
tree.togglesource_id, flag=expanded
input.submitsource_id, text (Enter)
page.opensource_id = page name

Per-widget callbacks (alternative)

d.on_change      = []{ ... };              // value/state changed
d.on_page_click  = [](gui::page& p){ ... }; // button clicked
d.on_window_click= [](gui::window& w){ ... };

Global logger (debug)

gui::EventBus::get().set_global_logger([](const std::string& e, const gui::EventData& d){
    std::cout << "[LOG] " << e << "\n";
});

Animations

All animation state lives in struct Widget as 0..1 floats, advanced in Widget::tick(dt) and applied in Widget::draw().

FieldDrivesWhere applied
hover_ahover highlightdraw_basic / toggles color mix
press_apress darkeningdraw_basic color mix
push_apush-down offset (6px)Widget::draw: ay += ease(push_a)*6
check_acheckmark / radio dot revealdraw_toggles
animdropdown open/closedraw_lists popup height
cursor_timer / cursor_visibleinput caret blink (0.5s)draw_basic

Tune speed

// Widget::tick — the speed constants
hover_a = approach(hover_a, hovered?1:0, dt, 10);   // higher = snappier
push_a  = approach(push_a,  pressed?1:0, dt, 18);
check_a = approach(check_a, checked?1:0, dt, 10);

Disable push-down (e.g. for slider)

// Widget::draw
int ay = data.y + oy + (data.type == WidgetType::SLIDER ? 0 : (int)(ease(push_a)*6));

Remove color flicker (design rule)

Colors fed into x_fill_round/x_draw_round/XDrawArc must NOT depend on animated hover_a/press_a. Keep animation in safe places: checkmark lines, radio dot, slider thumb position, push-down offset. This is why checkbox/radio use static border colors.

Debug & Performance

Hotkeys

KeyAction
F1Debug overlay (FPS, widgets, partial updates, mouse, event log)
F2Dirty-rect visualization (XOR flash of repainted regions)
F3Click markers (red cross at click point)
F4Widget bounds (magenta outlines)
F5Console event logging
F6Toggle theme WIN11 ↔ TERMINAL (via on_key)

Programmatic access

win.debug().show_debug_overlay = true;
win.debug().show_dirty_rects   = true;
win.debug().max_log_size       = 30;
std::cout << win.debug().fps << "\n";

win.mark_dirty(x,y,w,h);   // invalidate a region
win.mark_all_dirty();      // full repaint

Why it's fast

F2 flashes only visualize user dirty rects (snapshot taken before flash/marker/overlay rects) — no feedback loop, FPS stays high.

API Reference

gui::window

window(title) / ~window()
update()  running()  display()
addTAB_BUTTON(d)  addPAGE(n)  configur_page(n, fn, active)  openPAGE(n)
find(id)  set_focus(w)
set_font(p)  set_font_size(px)  set_icon(p|pic)  set_default_icon()  set_max_fps(n)
debug()  mark_dirty(x,y,w,h)  mark_all_dirty()
std::function<void(KeySym)> on_key;

gui::combat (container base: page, Widget)

Button/Label/Input/Checkbox/Radiobutton/Slider/Progressbar/
Listbox/Dropdown/TreeView/TreeNode/ScrollView/RawPictureWidget/SpriteButton
visit(fn)  find(id)  find_at(x,y)  count_widgets()
tick(dt)  render_all(...)  render_dirty(...)  mark_all_dirty(...)
process_press(...)  process_wheel(...)

gui::Widget

WidgetData data;
float hover_a, press_a, check_a, push_a, anim, phase;
bool hovered, pressed, focused, cursor_visible, open_target;
float cursor_timer;
setValue(v)  getValue()
tick(dt)  draw(...)  draw_popup(...)
handle_press/drag/release/wheel(...)
contains(...)  content_height()  mark_dirty(...)

gui::RawPicture

create(w,h,bits)  clear(c)  setPixel/getPixel/blendPixel
drawLine/drawRect/fillRect/fillRoundRect
drawCircle/fillCircle/fillTriangle/fillPolygon
drawText(dpy,x,y,t,c,scale)  blit(src,dx,dy)
load(path)  // BMP 24/32, PPM P6
present(dpy,win,gc,dx,dy)

gui::FontManager

FontManager::get()
load(path)  available()  set_default_size(px)  default_size()
ascent/descent/line_height/text_width(size)
draw(RawPicture&,...)  draw(Display*,Drawable,GC,...)

gui::EventBus / gui::DirtyRegion / gui::Theme

EventBus::get().on/off/emit/set_global_logger
DirtyRegion: add/mark_all/is_dirty/intersects/get/clear
theme()  set_theme(ThemeStyle)