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:
WIN11andTERMINAL, 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
| Method | Description |
|---|---|
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_key | std::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
| Field | Default | Meaning |
|---|---|---|
id, text | "" | identifier / label |
x,y,width,height | 0,0,200,60 | geometry (2× scale) |
bg_color, fg_color, frame_color | 0 | 0 = take from theme, else override |
font_size | 0 | 0 = theme size, else per-widget |
primary | false | accent-colored button |
checked | false | checkbox / radio state |
value, target_value | 0 | slider / progress (0..1) |
items | [] | listbox / dropdown entries |
picture | null | RawPicture for sprite / raw widgets |
on_change / on_page_click / on_window_click | null | callbacks |
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
widget.h: add enum valueWidgetType::MY_WIDGETwidget.h: add factory declWidget& MyWidget(WidgetData);widget.cpp: implement factory (a.type = ...; return add_widget(...))- draw: add a case in
Widget::drawdispatch → yourwdraw::my(...) - 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
| Event | Data |
|---|---|
button.click | source_id, text |
checkbox.change | source_id, flag |
radio.change | source_id, flag=true |
slider.change | source_id, value 0..1 |
list.select | source_id, text, index |
dropdown.select | source_id, text, index |
tree.toggle | source_id, flag=expanded |
input.submit | source_id, text (Enter) |
page.open | source_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().
| Field | Drives | Where applied |
|---|---|---|
hover_a | hover highlight | draw_basic / toggles color mix |
press_a | press darkening | draw_basic color mix |
push_a | push-down offset (6px) | Widget::draw: ay += ease(push_a)*6 |
check_a | checkmark / radio dot reveal | draw_toggles |
anim | dropdown open/close | draw_lists popup height |
cursor_timer / cursor_visible | input 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
| Key | Action |
|---|---|
| F1 | Debug overlay (FPS, widgets, partial updates, mouse, event log) |
| F2 | Dirty-rect visualization (XOR flash of repainted regions) |
| F3 | Click markers (red cross at click point) |
| F4 | Widget bounds (magenta outlines) |
| F5 | Console event logging |
| F6 | Toggle 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
- Dirty rectangles — only changed regions redraw;
Partial updatescounter shows it - Only active page is ticked/rendered
- Text cache — strings rendered once to 1-bit masks, drawn with one
XFillRectangle(no XGetImage) - Overlay throttle — debug panel refreshes at 10 Hz
- Idle sleep —
usleep(10ms)when nothing is dirty → ~0% CPU at rest - Frame cap —
set_max_fps(), default 240
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)