This article covers Building a GUI Library from Scratch. The Complete Guide to creating A UI Library Using Only C and SDL2
"Every GUI toolkit in existence is just rectangles, text, and callbacks all the way down."
We are going to build a complete, production-quality GUI library in C using SDL2 as the only dependency. By the end of this guide you will have:
The result will be named cg (C GUI). The full source is structured to be readable and hackable β not a black box.
There are two dominant paradigms for GUI architecture:
Immediate Mode (IMGUI): Every frame, you call functions that both declare the widget AND handle its logic. There is no persistent widget state; the application re-specifies the entire UI every frame.
// Immediate mode example (Dear ImGui style)
void app_frame(void) {
if (igButton("Click me", (ImVec2){100, 30})) {
handle_click();
}
igSliderFloat("Value", &my_value, 0.0f, 1.0f, "%.2f", 0);
}
Retained Mode: You build a tree of widget objects once (or when the UI structure changes). The tree persists between frames; the library tracks state, layout, and rendering. Qt, GTK, Win32, and every major production toolkit use retained mode.
// Retained mode (our approach)
CgWidget* btn = cg_button_new("Click me");
cg_widget_on_click(btn, on_click_handler, NULL);
cg_panel_add(panel, btn);
// The button now lives in the tree until explicitly destroyed
We choose retained mode because:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Code β
β (creates widgets, sets callbacks, modifies properties) β
βββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββ
β cg β our library β
β β
β Widget Tree Layout Engine Event System β
β ββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β Window β β Measure Pass β β Hit Testing β β
β β β Panel β β Arrange Pass β β Bubbling β β
β β β β Btnβ β Clip Rects β β Capture β β
β β β β Lblβ ββββββββββββββββ ββββββββββββββββ β
β β β Input β β
β ββββββββββββ Theme System Animation β
β ββββββββββββββββ ββββββββββββββββ β
β β Colour Tokensβ β Tween Engine β β
β β Style Inheritβ β Easing Fns β β
β ββββββββββββββββ ββββββββββββββββ β
βββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββ
β Rendering Abstraction (cg_draw.c) β
β rect / rounded_rect / border / text / image / clip β
βββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββ
β SDL2 + SDL_ttf β
β SDL_RenderFillRect / SDL_RenderDrawLine β
β TTF_RenderText_Blended / SDL_RenderCopy β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
cg/
βββ include/
β βββ cg.h β Public API (single header)
βββ src/
β βββ cg_core.c β Widget tree, lifecycle
β βββ cg_layout.c β Box model + flexbox layout
β βββ cg_draw.c β Rendering primitives
β βββ cg_event.c β Event system
β βββ cg_focus.c β Focus + keyboard nav
β βββ cg_theme.c β Theming
β βββ cg_anim.c β Animation/tweening
β βββ cg_font.c β Font management + text layout
β βββ widgets/
β β βββ cg_label.c
β β βββ cg_button.c
β β βββ cg_input.c
β β βββ cg_checkbox.c
β β βββ cg_slider.c
β β βββ cg_scroll.c
β β βββ cg_panel.c
β β βββ cg_image.c
β β βββ cg_combo.c
β βββ cg_internal.h β Internal types (not exported)
βββ demo/
β βββ main.c
βββ CMakeLists.txt
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(cg VERSION 0.1 LANGUAGES C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# Find SDL2 and SDL_ttf
find_package(SDL2 REQUIRED)
find_package(SDL2_ttf REQUIRED)
find_package(SDL2_image REQUIRED)
# Library sources
file(GLOB_RECURSE CG_SOURCES "src/*.c" "src/widgets/*.c")
add_library(cg STATIC ${CG_SOURCES})
target_include_directories(cg
PUBLIC include/
PRIVATE src/)
target_link_libraries(cg
PUBLIC SDL2::SDL2
PUBLIC SDL2_ttf::SDL2_ttf
PUBLIC SDL2_image::SDL2_image)
# Demo executable
add_executable(cg_demo demo/main.c)
target_link_libraries(cg_demo PRIVATE cg)
# Compiler warnings
target_compile_options(cg PRIVATE
-Wall -Wextra -Wpedantic
{{CONTENT}}lt;{{CONTENT}}lt;CONFIG:Debug>:-g -O0 -fsanitize=address,undefined>
{{CONTENT}}lt;{{CONTENT}}lt;CONFIG:Release>:-O2>)
Install dependencies:
# Ubuntu/Debian
sudo apt install libsdl2-dev libsdl2-ttf-dev libsdl2-image-dev
# macOS
brew install sdl2 sdl2_ttf sdl2_image
# Arch Linux
sudo pacman -S sdl2 sdl2_ttf sdl2_image
Before building the library, we need a solid understanding of the SDL2 primitives we will be using.
SDL2 uses a coordinate system where (0, 0) is the top-left corner, X increases rightward, and Y increases downward. Rectangles are specified by {x, y, w, h}.
#include <SDL2/SDL.h>
int main(void) {
SDL_Init(SDL_INIT_VIDEO);
SDL_Window* window = SDL_CreateWindow(
"CG Demo",
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
800, 600,
SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI
);
SDL_Renderer* renderer = SDL_CreateRenderer(
window, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC
);
// HiDPI: the logical size may differ from the pixel size
int window_w, window_h; // Logical (CSS) pixels
int drawable_w, drawable_h; // Physical pixels
SDL_GetWindowSize(window, &window_w, &window_h);
SDL_GL_GetDrawableSize(window, &drawable_w, &drawable_h);
float dpi_scale = (float)drawable_w / (float)window_w;
// Set logical size so coordinates are in CSS pixels
SDL_RenderSetLogicalSize(renderer, window_w, window_h);
bool running = true;
while (running) {
SDL_Event e;
while (SDL_PollEvent(&e)) {
if (e.type == SDL_QUIT) running = false;
}
// Clear with background colour
SDL_SetRenderDrawColor(renderer, 30, 30, 30, 255);
SDL_RenderClear(renderer);
// Draw a red rectangle
SDL_SetRenderDrawColor(renderer, 220, 50, 50, 255);
SDL_Rect rect = {100, 100, 200, 80};
SDL_RenderFillRect(renderer, &rect);
SDL_RenderPresent(renderer);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
SDL2 delivers events through a queue. The most important event types:
SDL_Event e;
while (SDL_PollEvent(&e)) {
switch (e.type) {
// Window
case SDL_QUIT: break;
case SDL_WINDOWEVENT:
if (e.window.event == SDL_WINDOWEVENT_RESIZED) {
int w = e.window.data1, h = e.window.data2;
}
break;
// Mouse
case SDL_MOUSEMOTION:
// e.motion.x, e.motion.y β position
// e.motion.xrel, e.motion.yrel β delta since last event
break;
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP:
// e.button.button β SDL_BUTTON_LEFT/MIDDLE/RIGHT
// e.button.x, e.button.y
// e.button.clicks β 1 or 2 (double-click)
break;
case SDL_MOUSEWHEEL:
// e.wheel.x, e.wheel.y (positive = scroll up/right)
// e.wheel.direction β SDL_MOUSEWHEEL_NORMAL or FLIPPED
break;
// Keyboard
case SDL_KEYDOWN:
case SDL_KEYUP:
// e.key.keysym.sym β SDL_Keycode (SDLK_a, SDLK_RETURN, ...)
// e.key.keysym.scancode β physical key (SDL_SCANCODE_*)
// e.key.keysym.mod β modifiers (KMOD_CTRL, KMOD_SHIFT, ...)
// e.key.repeat β non-zero if key-repeat event
break;
// Text input (handles IME, Unicode, key combos correctly)
case SDL_TEXTINPUT:
// e.text.text β UTF-8 string (up to 32 bytes)
break;
case SDL_TEXTEDITING:
// e.edit.text, e.edit.start, e.edit.length β IME composition
break;
}
}
SDL2 supports clip rectangles. Any drawing outside the clip rect is silently discarded. We use this for scrollable containers and widget clipping:
SDL_Rect clip = {50, 50, 200, 100};
SDL_RenderSetClipRect(renderer, &clip);
// Anything drawn here is clipped to the 200x100 region
SDL_RenderFillRect(renderer, &some_big_rect);
// Clear clip (back to full screen)
SDL_RenderSetClipRect(renderer, NULL);
Important: SDL2's clip rect is a single stack level β there is no push/pop. Our library must implement clip stack management manually (we will do this in Chapter 3).
SDL2 supports alpha blending. Set blend mode on the renderer for semi-transparent drawing:
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 128); // 50% black
SDL_RenderFillRect(renderer, &shadow_rect);
For textures (images, text surfaces), set blend mode on the texture:
SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
SDL_SetTextureAlphaMod(texture, 200); // 0β255
SDL_RenderCopy(renderer, texture, NULL, &dst_rect);
All rendered elements β text, images β live in SDL_Texture objects. Textures are GPU-resident and must be explicitly destroyed. Our library will implement a texture cache to avoid re-rendering the same text repeatedly.
// Create a texture from a surface (e.g., rendered text)
SDL_Surface* surface = TTF_RenderUTF8_Blended(font, text, color);
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
SDL_FreeSurface(surface); // Surface can be freed once texture is created
// Query texture dimensions
int w, h;
SDL_QueryTexture(texture, NULL, NULL, &w, &h);
// Render texture
SDL_Rect dst = {x, y, w, h};
SDL_RenderCopy(renderer, texture, NULL, &dst);
// Cleanup
SDL_DestroyTexture(texture);
We wrap SDL2's rendering API in a thin layer that adds: rounded rectangles, clip stack management, a draw call batch, and convenience functions used throughout the library.
// src/cg_internal.h (excerpt)
#pragma once
#include <SDL2/SDL.h>
#include <SDL2/SDL_ttf.h>
#include "cg.h"
#define CG_CLIP_STACK_MAX 32
#define CG_MAX_FONTS 64
typedef struct CgCtx {
SDL_Window* window;
SDL_Renderer* renderer;
int window_w, window_h;
float dpi_scale;
// Clip stack
SDL_Rect clip_stack[CG_CLIP_STACK_MAX];
int clip_depth;
// Theme
CgTheme* theme;
// Font registry
TTF_Font* fonts[CG_MAX_FONTS];
char* font_paths[CG_MAX_FONTS];
int font_sizes[CG_MAX_FONTS];
int font_count;
// Widget tree root
struct CgWidget* root;
// Focus
struct CgWidget* focused;
// Mouse state (persists between events)
int mouse_x, mouse_y;
bool mouse_buttons[5];
struct CgWidget* hovered;
struct CgWidget* mouse_captured; // Widget that captured the mouse
// Animation list
struct CgAnim* anim_head;
// Frame timing
Uint32 last_tick;
float delta_ms;
} CgCtx;
extern CgCtx* cg; // Global context (one per application)
// include/cg.h (excerpt)
#pragma once
#include <SDL2/SDL.h>
#include <stdbool.h>
#include <stdint.h>
// Colours stored as RGBA bytes
typedef struct { uint8_t r, g, b, a; } CgColor;
#define CG_RGBA(r,g,b,a) ((CgColor){r, g, b, a})
#define CG_RGB(r,g,b) ((CgColor){r, g, b, 255})
#define CG_TRANSPARENT ((CgColor){0,0,0,0})
// Convert to SDL_Color
static inline SDL_Color cg_to_sdl_color(CgColor c) {
return (SDL_Color){c.r, c.g, c.b, c.a};
}
// Interpolate two colours (t in 0..1)
static inline CgColor cg_color_lerp(CgColor a, CgColor b, float t) {
return (CgColor){
(uint8_t)(a.r + (b.r - a.r) * t),
(uint8_t)(a.g + (b.g - a.g) * t),
(uint8_t)(a.b + (b.b - a.b) * t),
(uint8_t)(a.a + (b.a - a.a) * t),
};
}
// Darken/lighten
static inline CgColor cg_color_darken(CgColor c, float amount) {
return (CgColor){
(uint8_t)(c.r * (1.0f - amount)),
(uint8_t)(c.g * (1.0f - amount)),
(uint8_t)(c.b * (1.0f - amount)),
c.a
};
}
// src/cg_draw.c
#include "cg_internal.h"
#include <string.h>
// Push a clip rect β intersects with the current clip
void cg_push_clip(SDL_Rect rect) {
SDL_Rect current;
SDL_RenderGetClipRect(cg->renderer, ¤t);
SDL_Rect intersection;
if (cg->clip_depth > 0) {
// Intersect with parent clip
int x1 = SDL_max(current.x, rect.x);
int y1 = SDL_max(current.y, rect.y);
int x2 = SDL_min(current.x + current.w, rect.x + rect.w);
int y2 = SDL_min(current.y + current.h, rect.y + rect.h);
intersection = (SDL_Rect){x1, y1, SDL_max(0, x2-x1), SDL_max(0, y2-y1)};
} else {
intersection = rect;
}
if (cg->clip_depth < CG_CLIP_STACK_MAX) {
cg->clip_stack[cg->clip_depth++] = intersection;
SDL_RenderSetClipRect(cg->renderer, &intersection);
}
}
void cg_pop_clip(void) {
if (cg->clip_depth > 0) cg->clip_depth--;
if (cg->clip_depth > 0) {
SDL_RenderSetClipRect(cg->renderer, &cg->clip_stack[cg->clip_depth - 1]);
} else {
SDL_RenderSetClipRect(cg->renderer, NULL);
}
}
// Set draw colour from CgColor
static void set_color(CgColor c) {
SDL_SetRenderDrawBlendMode(cg->renderer,
c.a < 255 ? SDL_BLENDMODE_BLEND : SDL_BLENDMODE_NONE);
SDL_SetRenderDrawColor(cg->renderer, c.r, c.g, c.b, c.a);
}
// Filled rectangle
void cg_draw_rect(SDL_Rect r, CgColor color) {
if (color.a == 0) return;
set_color(color);
SDL_RenderFillRect(cg->renderer, &r);
}
// Rectangle outline (1px border)
void cg_draw_rect_outline(SDL_Rect r, int thickness, CgColor color) {
if (color.a == 0 || thickness <= 0) return;
set_color(color);
for (int i = 0; i < thickness; i++) {
SDL_Rect border = {r.x+i, r.y+i, r.w-2*i, r.h-2*i};
SDL_RenderDrawRect(cg->renderer, &border);
}
}
// Horizontal / vertical lines
void cg_draw_hline(int x, int y, int w, CgColor color) {
if (color.a == 0) return;
set_color(color);
SDL_RenderDrawLine(cg->renderer, x, y, x + w - 1, y);
}
void cg_draw_vline(int x, int y, int h, CgColor color) {
if (color.a == 0) return;
set_color(color);
SDL_RenderDrawLine(cg->renderer, x, y, x, y + h - 1);
}
SDL2 has no native rounded rectangle. We implement one using quarter-circle rasterisation:
// Draw a filled circle quadrant at (cx, cy)
static void fill_circle_quadrant(int cx, int cy, int r,
int qx, int qy) { // qx,qy in {-1,+1}
int x = 0, y = r, d = 3 - 2 * r;
while (y >= x) {
// Fill horizontal spans for both octants
SDL_Rect row1 = {cx + qx * x, cy + qy * y, 1, 1}; // simplified
(void)row1;
// ... (see full Bresenham implementation below)
x++;
if (d < 0) d += 4 * x + 6;
else { d += 4 * (x - y) + 10; y--; }
}
}
// Full rounded rect implementation using SDL_RenderFillRects batch
void cg_draw_rounded_rect(SDL_Rect r, int radius, CgColor color) {
if (color.a == 0) return;
if (radius <= 0) { cg_draw_rect(r, color); return; }
// Clamp radius to half the smallest dimension
int max_r = (SDL_min(r.w, r.h)) / 2;
if (radius > max_r) radius = max_r;
set_color(color);
// Strategy: fill three overlapping rectangles (cross shape) +
// four filled circle quadrants in the corners.
// Central column
SDL_Rect col = {r.x, r.y + radius, r.w, r.h - 2 * radius};
SDL_RenderFillRect(cg->renderer, &col);
// Top and bottom bars (excluding corners)
SDL_Rect top_bar = {r.x + radius, r.y, r.w - 2 * radius, radius};
SDL_Rect bot_bar = {r.x + radius, r.y + r.h - radius,
r.w - 2 * radius, radius};
SDL_RenderFillRect(cg->renderer, &top_bar);
SDL_RenderFillRect(cg->renderer, &bot_bar);
// Corner circles using Midpoint Circle Algorithm
// We fill horizontal scanlines for each quarter circle
int corners[4][2] = {
{r.x + radius, r.y + radius}, // top-left
{r.x + r.w - radius - 1, r.y + radius}, // top-right
{r.x + radius, r.y + r.h - radius - 1},// bot-left
{r.x + r.w - radius - 1, r.y + r.h - radius - 1} // bot-right
};
int x = 0, y = radius;
int d = 1 - radius;
while (x <= y) {
// Top-left corner
SDL_Rect s;
s = (SDL_Rect){corners[0][0] - y, corners[0][1] - x, y, 1};
SDL_RenderFillRect(cg->renderer, &s);
s = (SDL_Rect){corners[0][0] - x, corners[0][1] - y, x, 1};
SDL_RenderFillRect(cg->renderer, &s);
// Top-right corner
s = (SDL_Rect){corners[1][0] + 1, corners[1][1] - x, y, 1};
SDL_RenderFillRect(cg->renderer, &s);
s = (SDL_Rect){corners[1][0] + 1, corners[1][1] - y, x, 1};
SDL_RenderFillRect(cg->renderer, &s);
// Bot-left corner
s = (SDL_Rect){corners[2][0] - y, corners[2][1] + 1, y, 1};
SDL_RenderFillRect(cg->renderer, &s);
s = (SDL_Rect){corners[2][0] - x, corners[2][1] + 1, x, 1};
SDL_RenderFillRect(cg->renderer, &s);
// Bot-right corner
s = (SDL_Rect){corners[3][0] + 1, corners[3][1] + 1, y, 1};
SDL_RenderFillRect(cg->renderer, &s);
s = (SDL_Rect){corners[3][0] + 1, corners[3][1] + 1, x, 1};
SDL_RenderFillRect(cg->renderer, &s);
x++;
if (d < 0) d += 2 * x + 1;
else { d += 2 * (x - y) + 1; y--; }
}
}
// Rounded rect outline only
void cg_draw_rounded_rect_outline(SDL_Rect r, int radius,
int thickness, CgColor color) {
if (color.a == 0) return;
for (int i = 0; i < thickness; i++) {
SDL_Rect inner = {r.x+i, r.y+i, r.w-2*i, r.h-2*i};
// Draw each side as lines, corners as arcs
cg_draw_hline(inner.x + radius, inner.y, inner.w - 2*radius, color);
cg_draw_hline(inner.x + radius, inner.y + inner.h - 1,
inner.w - 2*radius, color);
cg_draw_vline(inner.x, inner.y + radius, inner.h - 2*radius, color);
cg_draw_vline(inner.x + inner.w - 1, inner.y + radius,
inner.h - 2*radius, color);
// Arc corners omitted for brevity β see full source
}
}
A real drop shadow requires blurring, which SDL2 doesn't natively support. We simulate it with layered semi-transparent rectangles:
void cg_draw_shadow(SDL_Rect r, int radius, int blur_radius, CgColor color) {
// Stack semi-transparent offset rectangles for a soft shadow
int layers = blur_radius;
for (int i = layers; i >= 0; i--) {
float alpha_t = 1.0f - (float)i / (float)(layers + 1);
float alpha = (float)color.a * alpha_t * alpha_t * 0.4f;
CgColor layer_color = {color.r, color.g, color.b, (uint8_t)alpha};
SDL_Rect shadow_r = {
r.x - i, r.y - i + layers / 2,
r.w + 2 * i, r.h + 2 * i
};
cg_draw_rounded_rect(shadow_r, radius + i, layer_color);
}
}
Every UI element in our library β a button, label, container, or window β is a CgWidget. Widgets form a tree. The library traverses this tree for layout, painting, and event dispatch.
// include/cg.h
// Forward declarations
typedef struct CgWidget CgWidget;
typedef struct CgStyle CgStyle;
typedef struct CgEvent CgEvent;
// Widget type identifier
typedef uint32_t CgWidgetType;
#define CG_TYPE_BASE 0
#define CG_TYPE_LABEL 1
#define CG_TYPE_BUTTON 2
#define CG_TYPE_INPUT 3
#define CG_TYPE_CHECKBOX 4
#define CG_TYPE_RADIO 5
#define CG_TYPE_SLIDER 6
#define CG_TYPE_PANEL 7
#define CG_TYPE_SCROLL 8
#define CG_TYPE_IMAGE 9
#define CG_TYPE_COMBO 10
#define CG_TYPE_LISTBOX 11
#define CG_TYPE_SEPARATOR 12
// Virtual function table for widget polymorphism
typedef struct CgWidgetVTable {
void (*measure) (CgWidget*, int avail_w, int avail_h);
void (*arrange) (CgWidget*, SDL_Rect bounds);
void (*paint) (CgWidget*);
bool (*on_event) (CgWidget*, CgEvent*);
void (*destroy) (CgWidget*);
} CgWidgetVTable;
// Widget state flags (bit field)
typedef uint32_t CgWidgetFlags;
#define CG_FLAG_VISIBLE (1u << 0) // Widget is drawn
#define CG_FLAG_ENABLED (1u << 1) // Widget accepts input
#define CG_FLAG_HOVERED (1u << 2) // Mouse is over widget
#define CG_FLAG_PRESSED (1u << 3) // Primary button held
#define CG_FLAG_FOCUSED (1u << 4) // Has keyboard focus
#define CG_FLAG_DIRTY (1u << 5) // Needs re-layout
#define CG_FLAG_CLIP (1u << 6) // Clip children to bounds
#define CG_FLAG_FOCUSABLE (1u << 7) // Can receive keyboard focus
#define CG_FLAG_SELECTED (1u << 8) // For checkboxes, listbox items
#define CG_FLAG_EXPAND_W (1u << 9) // Expand to fill available width
#define CG_FLAG_EXPAND_H (1u << 10) // Expand to fill available height
// Edge insets (padding, margin)
typedef struct { int top, right, bottom, left; } CgInsets;
#define CG_INSETS(t,r,b,l) ((CgInsets){t,r,b,l})
#define CG_INSETS_ALL(v) ((CgInsets){v,v,v,v})
#define CG_INSETS_NONE ((CgInsets){0,0,0,0})
// 2D size
typedef struct { int w, h; } CgSize;
#define CG_SIZE(w,h) ((CgSize){w,h})
#define CG_SIZE_ZERO ((CgSize){0,0})
#define CG_SIZE_WRAP (-1) // Wrap to content
#define CG_SIZE_FILL (-2) // Fill available space
// Event handler callback
typedef bool (*CgEventHandler)(CgWidget* sender, CgEvent* event, void* userdata);
// ββ The Widget βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
struct CgWidget {
// Identity
CgWidgetType type;
const CgWidgetVTable* vtable;
uint32_t id; // Unique numeric ID
char* name; // Optional debug name
// Tree
CgWidget* parent;
CgWidget* first_child;
CgWidget* next_sibling;
CgWidget* prev_sibling;
int child_count;
// Layout inputs (set by application)
int desired_w; // CG_SIZE_WRAP / FILL / explicit pixels
int desired_h;
CgInsets margin;
CgInsets padding;
int min_w, max_w;
int min_h, max_h;
// Layout outputs (computed by layout engine)
SDL_Rect bounds; // Final position+size in parent coords
SDL_Rect bounds_abs; // Final position+size in window coords
CgSize content_size; // Intrinsic content dimensions
// State
CgWidgetFlags flags;
// Style
CgStyle* style; // NULL = inherit from theme
// Event handlers (linked list of handlers per event type)
struct CgHandlerNode* handlers;
// Tooltip
char* tooltip;
// Tab order
int tab_index; // -1 = not in tab order
// Animation targets (linked list)
struct CgAnim* anims;
};
// src/cg_core.c
#include "cg_internal.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
static uint32_t next_widget_id = 1;
// Allocate a new widget of a given size (widgets are always heap-allocated)
CgWidget* cg_widget_alloc(size_t size, CgWidgetType type,
const CgWidgetVTable* vtable) {
CgWidget* w = calloc(1, size);
if (!w) {
fprintf(stderr, "[cg] Out of memory allocating widget\n");
abort();
}
w->type = type;
w->vtable = vtable;
w->id = next_widget_id++;
w->flags = CG_FLAG_VISIBLE | CG_FLAG_ENABLED;
w->desired_w = CG_SIZE_WRAP;
w->desired_h = CG_SIZE_WRAP;
w->max_w = 32767;
w->max_h = 32767;
return w;
}
// Add child to parent (appended to end)
void cg_widget_add_child(CgWidget* parent, CgWidget* child) {
if (child->parent) cg_widget_remove(child);
child->parent = parent;
child->next_sibling = NULL;
if (!parent->first_child) {
parent->first_child = child;
child->prev_sibling = NULL;
} else {
// Walk to last child
CgWidget* last = parent->first_child;
while (last->next_sibling) last = last->next_sibling;
last->next_sibling = child;
child->prev_sibling = last;
}
parent->child_count++;
cg_widget_mark_dirty(parent);
}
// Remove a widget from its parent
void cg_widget_remove(CgWidget* w) {
if (!w->parent) return;
CgWidget* parent = w->parent;
if (w->prev_sibling) w->prev_sibling->next_sibling = w->next_sibling;
else parent->first_child = w->next_sibling;
if (w->next_sibling) w->next_sibling->prev_sibling = w->prev_sibling;
w->parent = NULL;
w->prev_sibling = NULL;
w->next_sibling = NULL;
parent->child_count--;
cg_widget_mark_dirty(parent);
}
// Recursively destroy widget tree
void cg_widget_destroy(CgWidget* w) {
if (!w) return;
// Remove from focus / hover / capture
if (cg->focused == w) cg->focused = NULL;
if (cg->hovered == w) cg->hovered = NULL;
if (cg->mouse_captured == w) cg->mouse_captured = NULL;
// Destroy children first
CgWidget* child = w->first_child;
while (child) {
CgWidget* next = child->next_sibling;
child->parent = NULL; // Prevent remove() from touching our list
cg_widget_destroy(child);
child = next;
}
// Call type-specific cleanup
if (w->vtable && w->vtable->destroy)
w->vtable->destroy(w);
// Free generic resources
free(w->name);
free(w->tooltip);
// handlers are freed by cg_event_clear_handlers
cg_event_clear_handlers(w);
free(w);
}
// Mark dirty (needs re-layout)
void cg_widget_mark_dirty(CgWidget* w) {
while (w) {
w->flags |= CG_FLAG_DIRTY;
w = w->parent;
}
}
// Show / hide
void cg_widget_set_visible(CgWidget* w, bool visible) {
if (visible) w->flags |= CG_FLAG_VISIBLE;
else w->flags &= ~CG_FLAG_VISIBLE;
cg_widget_mark_dirty(w->parent ? w->parent : w);
}
void cg_widget_set_enabled(CgWidget* w, bool enabled) {
if (enabled) w->flags |= CG_FLAG_ENABLED;
else w->flags &= ~CG_FLAG_ENABLED;
}
We need to iterate over widgets frequently (layout, painting, hit testing). A depth-first iterator:
// Iterate depth-first over the subtree rooted at 'root'
// Returns widgets in paint order (parents before children)
typedef struct {
CgWidget* stack[256];
int top;
} CgIter;
void cg_iter_init(CgIter* it, CgWidget* root) {
it->top = 0;
if (root) it->stack[it->top++] = root;
}
CgWidget* cg_iter_next(CgIter* it) {
if (it->top == 0) return NULL;
CgWidget* w = it->stack[--it->top];
// Push children in reverse order (so first child is processed first)
CgWidget* child = w->first_child;
// Count children, then push in reverse
int count = 0;
CgWidget* c = child;
while (c) { count++; c = c->next_sibling; }
// Push in reverse (last first, so first child is top of stack)
CgWidget* children[256];
c = child;
for (int i = 0; i < count && i < 256; i++) {
children[i] = c;
c = c->next_sibling;
}
for (int i = count - 1; i >= 0; i--) {
if (it->top < 256)
it->stack[it->top++] = children[i];
}
return w;
}
The layout engine computes where each widget sits on screen. We implement a simplified CSS box model with flexbox-inspired row/column layout for containers.
Each widget has:
margin: Space outside the border (between siblings)
border: Visual border (drawn, not laid out separately)
padding: Space between border and content
content: The widget's actual content area
ββββββββββββββββββββ bounds ββββββββββββββββββββββββββββββββ β MARGIN β β βββββββββββββββββ border ββββββββββββββββββββββββββββββ β β β PADDING β β β β βββββββββββββ content ββββββββββββββββββββββββββ β β β β β β β β β β ββββββββββββββββββββββββββββββββββββββββββββββββ β β β β β β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ-β
Layout happens in two passes:
Measure Pass: Bottom-up. Each widget computes its intrinsic content_size given available space. Leaves measure themselves based on content (text width, image dimensions). Containers measure their children first, then compute their own size.
Arrange Pass: Top-down. The root is given its final bounds (the window size). Each container arranges its children within its content area, setting each child's bounds and bounds_abs.
// src/cg_layout.c
#include "cg_internal.h"
// ββ Content area from bounds ββββββββββββββββββββββββββββββββββββββββββββββ
SDL_Rect cg_content_rect(CgWidget* w) {
SDL_Rect r = w->bounds_abs;
r.x += w->padding.left;
r.y += w->padding.top;
r.w -= w->padding.left + w->padding.right;
r.h -= w->padding.top + w->padding.bottom;
if (r.w < 0) r.w = 0;
if (r.h < 0) r.h = 0;
return r;
}
// ββ Measure pass βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_layout_measure(CgWidget* w, int avail_w, int avail_h) {
if (!(w->flags & CG_FLAG_VISIBLE)) {
w->content_size = CG_SIZE_ZERO;
return;
}
// Subtract our own padding from available space for children
int inner_w = avail_w - w->padding.left - w->padding.right;
int inner_h = avail_h - w->padding.top - w->padding.bottom;
if (inner_w < 0) inner_w = 0;
if (inner_h < 0) inner_h = 0;
// Delegate to type-specific measure
if (w->vtable && w->vtable->measure)
w->vtable->measure(w, inner_w, inner_h);
// Apply desired_w / desired_h overrides
if (w->desired_w > 0) w->content_size.w = w->desired_w
- w->padding.left
- w->padding.right;
if (w->desired_h > 0) w->content_size.h = w->desired_h
- w->padding.top
- w->padding.bottom;
// Clamp to min/max
CgSize cs = w->content_size;
int total_w = cs.w + w->padding.left + w->padding.right;
int total_h = cs.h + w->padding.top + w->padding.bottom;
total_w = SDL_clamp(total_w, w->min_w, w->max_w);
total_h = SDL_clamp(total_h, w->min_h, w->max_h);
w->content_size.w = total_w - w->padding.left - w->padding.right;
w->content_size.h = total_h - w->padding.top - w->padding.bottom;
}
// ββ Arrange pass βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_layout_arrange(CgWidget* w, SDL_Rect bounds_abs) {
w->bounds_abs = bounds_abs;
w->bounds = bounds_abs; // For root; children get relative coords
if (!(w->flags & CG_FLAG_VISIBLE)) return;
if (w->vtable && w->vtable->arrange)
w->vtable->arrange(w, bounds_abs);
}
// ββ Top-level layout ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_layout_root(CgWidget* root) {
int w = cg->window_w, h = cg->window_h;
cg_layout_measure(root, w, h);
SDL_Rect full = {0, 0, w, h};
cg_layout_arrange(root, full);
root->flags &= ~CG_FLAG_DIRTY;
}
Our containers support row and column layout with alignment and wrapping:
// include/cg.h
typedef enum {
CG_DIRECTION_ROW, // Children laid out left-to-right
CG_DIRECTION_COLUMN, // Children laid out top-to-bottom
} CgDirection;
typedef enum {
CG_ALIGN_START, // Pack to start (left/top)
CG_ALIGN_CENTER, // Centre in cross axis
CG_ALIGN_END, // Pack to end (right/bottom)
CG_ALIGN_STRETCH, // Stretch to fill cross axis
} CgAlign;
typedef enum {
CG_JUSTIFY_START,
CG_JUSTIFY_CENTER,
CG_JUSTIFY_END,
CG_JUSTIFY_SPACE_BETWEEN,
CG_JUSTIFY_SPACE_AROUND,
CG_JUSTIFY_SPACE_EVENLY,
} CgJustify;
// Extended widget structure for panels
typedef struct CgPanel {
CgWidget base;
CgDirection direction;
CgAlign align_items; // Cross axis
CgJustify justify; // Main axis
int gap; // Gap between children (pixels)
bool wrap; // Wrap to next line when full
CgColor bg_color;
int border_radius;
CgColor border_color;
int border_width;
bool has_shadow;
} CgPanel;
// Panel measure: sum children along main axis
static void panel_measure(CgWidget* w, int avail_w, int avail_h) {
CgPanel* p = (CgPanel*)w;
bool is_row = (p->direction == CG_DIRECTION_ROW);
int main_total = 0; // Total size along main axis
int cross_max = 0; // Max size along cross axis
int gap_total = 0;
int visible_count = 0;
CgWidget* child = w->first_child;
while (child) {
if (!(child->flags & CG_FLAG_VISIBLE)) {
child = child->next_sibling;
continue;
}
// Available space for this child
int child_avail_w = is_row ? avail_w : avail_w;
int child_avail_h = is_row ? avail_h : avail_h;
cg_layout_measure(child, child_avail_w, child_avail_h);
int child_outer_w = child->content_size.w
+ child->padding.left + child->padding.right
+ child->margin.left + child->margin.right;
int child_outer_h = child->content_size.h
+ child->padding.top + child->padding.bottom
+ child->margin.top + child->margin.bottom;
if (is_row) {
main_total += child_outer_w;
if (child_outer_h > cross_max) cross_max = child_outer_h;
} else {
main_total += child_outer_h;
if (child_outer_w > cross_max) cross_max = child_outer_w;
}
visible_count++;
child = child->next_sibling;
}
if (visible_count > 1) gap_total = p->gap * (visible_count - 1);
if (is_row) {
w->content_size.w = main_total + gap_total;
w->content_size.h = cross_max;
} else {
w->content_size.w = cross_max;
w->content_size.h = main_total + gap_total;
}
}
// Panel arrange: position children
static void panel_arrange(CgWidget* w, SDL_Rect abs) {
CgPanel* p = (CgPanel*)w;
bool is_row = (p->direction == CG_DIRECTION_ROW);
SDL_Rect content = cg_content_rect(w);
// Count visible children + expandable children
int visible_count = 0;
int expand_count = 0;
int fixed_main = 0;
CgWidget* child = w->first_child;
while (child) {
if (!(child->flags & CG_FLAG_VISIBLE)) {
child = child->next_sibling;
continue;
}
visible_count++;
bool expands = is_row ? (child->flags & CG_FLAG_EXPAND_W)
: (child->flags & CG_FLAG_EXPAND_H);
if (expands) {
expand_count++;
} else {
int outer = is_row
? child->content_size.w + child->padding.left + child->padding.right
+ child->margin.left + child->margin.right
: child->content_size.h + child->padding.top + child->padding.bottom
+ child->margin.top + child->margin.bottom;
fixed_main += outer;
}
child = child->next_sibling;
}
int gap_total = (visible_count > 1) ? p->gap * (visible_count - 1) : 0;
int avail_main = (is_row ? content.w : content.h) - gap_total;
int expand_each = 0;
if (expand_count > 0)
expand_each = SDL_max(0, (avail_main - fixed_main) / expand_count);
// Determine start position based on justify
int main_used = fixed_main + gap_total
+ expand_each * expand_count;
int main_avail = is_row ? content.w : content.h;
int main_free = SDL_max(0, main_avail - main_used);
int start_pos, between_gap;
switch (p->justify) {
case CG_JUSTIFY_CENTER:
start_pos = main_free / 2;
between_gap = 0;
break;
case CG_JUSTIFY_END:
start_pos = main_free;
between_gap = 0;
break;
case CG_JUSTIFY_SPACE_BETWEEN:
start_pos = 0;
between_gap = (visible_count > 1) ? main_free / (visible_count - 1) : 0;
break;
case CG_JUSTIFY_SPACE_AROUND:
between_gap = (visible_count > 0) ? main_free / visible_count : 0;
start_pos = between_gap / 2;
break;
case CG_JUSTIFY_SPACE_EVENLY:
between_gap = (visible_count > 0) ? main_free / (visible_count + 1) : 0;
start_pos = between_gap;
break;
default: // START
start_pos = 0;
between_gap = 0;
break;
}
// Position each child
int cursor = (is_row ? content.x : content.y) + start_pos;
child = w->first_child;
while (child) {
if (!(child->flags & CG_FLAG_VISIBLE)) {
child = child->next_sibling;
continue;
}
bool expands = is_row ? (child->flags & CG_FLAG_EXPAND_W)
: (child->flags & CG_FLAG_EXPAND_H);
int outer_main = is_row
? child->content_size.w + child->padding.left + child->padding.right
+ child->margin.left + child->margin.right
: child->content_size.h + child->padding.top + child->padding.bottom
+ child->margin.top + child->margin.bottom;
if (expands) outer_main = expand_each
+ (is_row ? child->padding.left + child->padding.right
+ child->margin.left + child->margin.right
: child->padding.top + child->padding.bottom
+ child->margin.top + child->margin.bottom);
int cross_avail = is_row ? content.h : content.w;
int outer_cross = is_row
? child->content_size.h + child->padding.top + child->padding.bottom
+ child->margin.top + child->margin.bottom
: child->content_size.w + child->padding.left + child->padding.right
+ child->margin.left + child->margin.right;
// Cross axis alignment
int cross_pos;
bool stretch = (p->align_items == CG_ALIGN_STRETCH);
switch (p->align_items) {
case CG_ALIGN_CENTER:
cross_pos = (is_row ? content.y : content.x)
+ (cross_avail - outer_cross) / 2;
break;
case CG_ALIGN_END:
cross_pos = (is_row ? content.y : content.x)
+ cross_avail - outer_cross;
break;
default:
cross_pos = is_row ? content.y : content.x;
break;
}
SDL_Rect child_bounds_abs;
if (is_row) {
child_bounds_abs.x = cursor + child->margin.left;
child_bounds_abs.y = cross_pos + child->margin.top;
child_bounds_abs.w = outer_main - child->margin.left - child->margin.right;
child_bounds_abs.h = stretch ? cross_avail - child->margin.top
- child->margin.bottom
: outer_cross - child->margin.top
- child->margin.bottom;
} else {
child_bounds_abs.x = cross_pos + child->margin.left;
child_bounds_abs.y = cursor + child->margin.top;
child_bounds_abs.w = stretch ? cross_avail - child->margin.left
- child->margin.right
: outer_cross - child->margin.left
- child->margin.right;
child_bounds_abs.h = outer_main - child->margin.top - child->margin.bottom;
}
// Re-measure child with its final dimensions if stretching
if (stretch) {
if (is_row) child->content_size.h = child_bounds_abs.h
- child->padding.top
- child->padding.bottom;
else child->content_size.w = child_bounds_abs.w
- child->padding.left
- child->padding.right;
}
cg_layout_arrange(child, child_bounds_abs);
cursor += outer_main + p->gap + between_gap;
child = child->next_sibling;
}
}
The event system routes SDL2 input events to the correct widgets. It implements:
// include/cg.h
typedef enum {
// Mouse
CG_EVENT_MOUSE_MOVE,
CG_EVENT_MOUSE_DOWN,
CG_EVENT_MOUSE_UP,
CG_EVENT_MOUSE_CLICK,
CG_EVENT_MOUSE_DOUBLE_CLICK,
CG_EVENT_MOUSE_ENTER, // Mouse entered widget bounds
CG_EVENT_MOUSE_LEAVE, // Mouse left widget bounds
CG_EVENT_MOUSE_WHEEL,
// Keyboard
CG_EVENT_KEY_DOWN,
CG_EVENT_KEY_UP,
CG_EVENT_TEXT_INPUT,
// Focus
CG_EVENT_FOCUS_IN,
CG_EVENT_FOCUS_OUT,
// Widget-specific (semantic events)
CG_EVENT_CHANGE, // Value changed (slider, input, checkbox)
CG_EVENT_SUBMIT, // Enter pressed in text input
CG_EVENT_SELECT, // Item selected in list/combo
// Lifecycle
CG_EVENT_RESIZE,
} CgEventType;
typedef struct CgEvent {
CgEventType type;
CgWidget* target; // Widget that originally received the event
CgWidget* current; // Widget currently handling the event (bubbling)
bool bubbles; // Does this event bubble to parent?
bool cancelled; // Stop bubbling (set by handler)
union {
struct {
int x, y; // Window-space position
int rel_x, rel_y; // Relative to current widget
int button; // SDL_BUTTON_LEFT etc.
int clicks; // 1 or 2
float wheel_x, wheel_y;
} mouse;
struct {
SDL_Keycode sym;
SDL_Scancode scancode;
uint16_t mod;
bool repeat;
} key;
struct {
char text[32]; // UTF-8
} text;
struct {
float value; // For CG_EVENT_CHANGE on numeric widgets
char* str_value; // For CG_EVENT_CHANGE on text widgets
int index; // For CG_EVENT_SELECT
} change;
};
void* userdata; // Application-supplied context
} CgEvent;
// src/cg_event.c
// Find the deepest visible, enabled widget at (x, y)
CgWidget* cg_hit_test(CgWidget* root, int x, int y) {
if (!root || !(root->flags & CG_FLAG_VISIBLE)) return NULL;
// Check if point is within this widget's bounds
SDL_Rect r = root->bounds_abs;
if (x < r.x || x >= r.x + r.w || y < r.y || y >= r.y + r.h)
return NULL;
// Check children in reverse order (last child = topmost visually)
// Collect children first (can't iterate backwards with single-linked list easily)
CgWidget* children[256];
int count = 0;
CgWidget* child = root->first_child;
while (child && count < 256) {
children[count++] = child;
child = child->next_sibling;
}
// Test from last (topmost) to first
for (int i = count - 1; i >= 0; i--) {
CgWidget* hit = cg_hit_test(children[i], x, y);
if (hit) return hit;
}
// The root itself was hit (but no child was)
return root;
}
// Handler node (linked list per widget)
typedef struct CgHandlerNode {
CgEventType type;
CgEventHandler fn;
void* userdata;
struct CgHandlerNode* next;
} CgHandlerNode;
// Register a handler
void cg_widget_on(CgWidget* w, CgEventType type,
CgEventHandler fn, void* userdata) {
CgHandlerNode* node = calloc(1, sizeof(CgHandlerNode));
node->type = type;
node->fn = fn;
node->userdata = userdata;
node->next = (CgHandlerNode*)w->handlers;
w->handlers = (struct CgHandlerNode*)node;
}
// Convenience macros
#define cg_on_click(w, fn, ud) cg_widget_on(w, CG_EVENT_MOUSE_CLICK, fn, ud)
#define cg_on_change(w, fn, ud) cg_widget_on(w, CG_EVENT_CHANGE, fn, ud)
#define cg_on_key(w, fn, ud) cg_widget_on(w, CG_EVENT_KEY_DOWN, fn, ud)
// Dispatch event to a widget (runs all matching handlers)
static bool dispatch_to_widget(CgWidget* w, CgEvent* ev) {
ev->current = w;
CgHandlerNode* node = (CgHandlerNode*)w->handlers;
while (node) {
if (node->type == ev->type) {
if (node->fn(w, ev, node->userdata)) return true;
}
node = node->next;
}
return false;
}
// Fire an event, bubbling from target up to root
bool cg_fire_event(CgWidget* target, CgEvent* ev) {
ev->target = target;
ev->bubbles = true; // Default: events bubble
// Dispatch to target first
CgWidget* w = target;
while (w) {
if (dispatch_to_widget(w, ev)) return true;
if (ev->cancelled) break;
if (!ev->bubbles) break;
w = w->parent;
}
return false;
}
// src/cg_event.c
void cg_process_sdl_event(SDL_Event* sdl_ev) {
CgEvent ev = {0};
switch (sdl_ev->type) {
// ββ Mouse motion ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case SDL_MOUSEMOTION: {
int mx = sdl_ev->motion.x;
int my = sdl_ev->motion.y;
cg->mouse_x = mx;
cg->mouse_y = my;
// If a widget has captured the mouse, send all events there
CgWidget* target = cg->mouse_captured
? cg->mouse_captured
: cg_hit_test(cg->root, mx, my);
// Handle ENTER / LEAVE transitions
if (target != cg->hovered) {
if (cg->hovered) {
cg->hovered->flags &= ~CG_FLAG_HOVERED;
CgEvent leave = {.type = CG_EVENT_MOUSE_LEAVE};
cg_fire_event(cg->hovered, &leave);
}
cg->hovered = target;
if (target) {
target->flags |= CG_FLAG_HOVERED;
CgEvent enter = {.type = CG_EVENT_MOUSE_ENTER};
cg_fire_event(target, &enter);
}
}
if (target) {
ev.type = CG_EVENT_MOUSE_MOVE;
ev.mouse.x = mx;
ev.mouse.y = my;
ev.mouse.rel_x = mx - target->bounds_abs.x;
ev.mouse.rel_y = my - target->bounds_abs.y;
cg_fire_event(target, &ev);
}
break;
}
// ββ Mouse button ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP: {
int mx = sdl_ev->button.x;
int my = sdl_ev->button.y;
bool is_down = (sdl_ev->type == SDL_MOUSEBUTTONDOWN);
int button = sdl_ev->button.button;
CgWidget* target = cg->mouse_captured
? cg->mouse_captured
: cg_hit_test(cg->root, mx, my);
if (is_down) {
// Focus on click
if (target && (target->flags & CG_FLAG_FOCUSABLE)) {
cg_set_focus(target);
}
// Capture mouse on button 1 press
if (button == SDL_BUTTON_LEFT) {
cg->mouse_captured = target;
if (target) target->flags |= CG_FLAG_PRESSED;
}
} else {
if (button == SDL_BUTTON_LEFT) {
if (cg->mouse_captured)
cg->mouse_captured->flags &= ~CG_FLAG_PRESSED;
cg->mouse_captured = NULL;
}
}
if (target) {
ev.type = is_down ? CG_EVENT_MOUSE_DOWN : CG_EVENT_MOUSE_UP;
ev.mouse.x = mx;
ev.mouse.y = my;
ev.mouse.button = button;
ev.mouse.clicks = sdl_ev->button.clicks;
cg_fire_event(target, &ev);
// Synthesize click on button-up if still over target
if (!is_down && (target->flags & CG_FLAG_ENABLED)) {
bool still_over = (cg->hovered == target);
if (still_over) {
ev.type = (sdl_ev->button.clicks == 2)
? CG_EVENT_MOUSE_DOUBLE_CLICK
: CG_EVENT_MOUSE_CLICK;
cg_fire_event(target, &ev);
}
}
}
break;
}
// ββ Mouse wheel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case SDL_MOUSEWHEEL: {
CgWidget* target = cg->hovered;
if (target) {
float dx = (float)sdl_ev->wheel.x;
float dy = (float)sdl_ev->wheel.y;
if (sdl_ev->wheel.direction == SDL_MOUSEWHEEL_FLIPPED) {
dx = -dx; dy = -dy;
}
ev.type = CG_EVENT_MOUSE_WHEEL;
ev.mouse.wheel_x = dx;
ev.mouse.wheel_y = dy;
cg_fire_event(target, &ev);
}
break;
}
// ββ Keyboard ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case SDL_KEYDOWN:
case SDL_KEYUP: {
CgWidget* target = cg->focused ? cg->focused : cg->root;
ev.type = (sdl_ev->type == SDL_KEYDOWN)
? CG_EVENT_KEY_DOWN : CG_EVENT_KEY_UP;
ev.key.sym = sdl_ev->key.keysym.sym;
ev.key.scancode = sdl_ev->key.keysym.scancode;
ev.key.mod = sdl_ev->key.keysym.mod;
ev.key.repeat = sdl_ev->key.repeat != 0;
if (!cg_fire_event(target, &ev)) {
// Global key bindings
if (sdl_ev->type == SDL_KEYDOWN) {
if (sdl_ev->key.keysym.sym == SDLK_TAB) {
bool shift = (sdl_ev->key.keysym.mod & KMOD_SHIFT) != 0;
cg_focus_next(shift);
}
}
}
break;
}
// ββ Text input ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case SDL_TEXTINPUT: {
CgWidget* target = cg->focused;
if (target) {
ev.type = CG_EVENT_TEXT_INPUT;
strncpy(ev.text.text, sdl_ev->text.text, 31);
cg_fire_event(target, &ev);
}
break;
}
// ββ Window resize βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case SDL_WINDOWEVENT:
if (sdl_ev->window.event == SDL_WINDOWEVENT_RESIZED ||
sdl_ev->window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
SDL_GetWindowSize(cg->window, &cg->window_w, &cg->window_h);
SDL_RenderSetLogicalSize(cg->renderer, cg->window_w, cg->window_h);
cg_widget_mark_dirty(cg->root);
}
break;
}
}
A GUI library that can't be navigated with the keyboard is inaccessible. Focus management is more complex than it first appears.
CG_FLAG_FOCUSABLE can receive focusTab advances to the next focusable widget (by tab index or tree order)Shift+Tab goes backwardsEscape usually releases focus// src/cg_focus.c
// Collect all focusable widgets in tab order
static void collect_focusable(CgWidget* w, CgWidget** out, int* count, int max) {
if (!w || !(w->flags & CG_FLAG_VISIBLE) || !(*count < max)) return;
if (w->flags & CG_FLAG_FOCUSABLE) {
out[(*count)++] = w;
}
CgWidget* child = w->first_child;
while (child) {
collect_focusable(child, out, count, max);
child = child->next_sibling;
}
}
static int compare_tab_index(const void* a, const void* b) {
const CgWidget* wa = *(const CgWidget**)a;
const CgWidget* wb = *(const CgWidget**)b;
if (wa->tab_index != wb->tab_index) return wa->tab_index - wb->tab_index;
return (int)(wa->id - wb->id); // Secondary: creation order
}
void cg_focus_next(bool reverse) {
CgWidget* focusable[512];
int count = 0;
collect_focusable(cg->root, focusable, &count, 512);
if (count == 0) return;
// Sort by tab_index
qsort(focusable, count, sizeof(CgWidget*), compare_tab_index);
// Find current focused position
int current_idx = -1;
for (int i = 0; i < count; i++) {
if (focusable[i] == cg->focused) { current_idx = i; break; }
}
int next_idx;
if (reverse) {
next_idx = (current_idx <= 0) ? count - 1 : current_idx - 1;
} else {
next_idx = (current_idx < 0 || current_idx >= count - 1)
? 0 : current_idx + 1;
}
cg_set_focus(focusable[next_idx]);
}
void cg_set_focus(CgWidget* w) {
if (!w || !(w->flags & CG_FLAG_FOCUSABLE)) return;
if (cg->focused == w) return;
// Blur old widget
if (cg->focused) {
cg->focused->flags &= ~CG_FLAG_FOCUSED;
CgEvent blur = {.type = CG_EVENT_FOCUS_OUT};
cg_fire_event(cg->focused, &blur);
// Stop text input if old widget used it
SDL_StopTextInput();
}
cg->focused = w;
w->flags |= CG_FLAG_FOCUSED;
CgEvent focus = {.type = CG_EVENT_FOCUS_IN};
cg_fire_event(w, &focus);
}
void cg_clear_focus(void) {
if (cg->focused) {
cg->focused->flags &= ~CG_FLAG_FOCUSED;
CgEvent blur = {.type = CG_EVENT_FOCUS_OUT};
cg_fire_event(cg->focused, &blur);
SDL_StopTextInput();
cg->focused = NULL;
}
}
Text rendering is the hardest part of any GUI library. We need to handle: font loading, size variants, Unicode, line wrapping, alignment, and caret positioning.
// src/cg_font.c
#include "cg_internal.h"
#include <SDL2/SDL_ttf.h>
typedef struct {
char* path;
int size;
int style; // TTF_STYLE_*
TTF_Font* font;
uint32_t hash;
} FontEntry;
#define FONT_CACHE_MAX 64
static FontEntry font_cache[FONT_CACHE_MAX];
static int font_cache_count = 0;
void cg_fonts_init(void) {
if (TTF_Init() == -1) {
fprintf(stderr, "[cg] TTF_Init failed: %s\n", TTF_GetError());
abort();
}
TTF_ByteSwappedUNICODE(SDL_FALSE);
}
// Get or load a font
TTF_Font* cg_font_get(const char* path, int size, int style) {
// Hash for lookup
uint32_t h = 5381;
for (const char* p = path; *p; p++) h = h * 33 ^ (uint8_t)*p;
h = h * 33 ^ (uint32_t)size;
h = h * 33 ^ (uint32_t)style;
// Search cache
for (int i = 0; i < font_cache_count; i++) {
if (font_cache[i].hash == h &&
font_cache[i].size == size &&
font_cache[i].style == style &&
strcmp(font_cache[i].path, path) == 0) {
return font_cache[i].font;
}
}
// Load
if (font_cache_count >= FONT_CACHE_MAX) {
fprintf(stderr, "[cg] Font cache full!\n");
return NULL;
}
TTF_Font* font = TTF_OpenFont(path, size);
if (!font) {
fprintf(stderr, "[cg] Failed to load font '%s': %s\n", path, TTF_GetError());
return NULL;
}
TTF_SetFontStyle(font, style);
TTF_SetFontHinting(font, TTF_HINTING_LIGHT);
FontEntry* entry = &font_cache[font_cache_count++];
entry->path = strdup(path);
entry->size = size;
entry->style = style;
entry->font = font;
entry->hash = h;
return font;
}
void cg_fonts_shutdown(void) {
for (int i = 0; i < font_cache_count; i++) {
TTF_CloseFont(font_cache[i].font);
free(font_cache[i].path);
}
font_cache_count = 0;
TTF_Quit();
}
// Measure the pixel dimensions of a UTF-8 string
CgSize cg_text_measure(TTF_Font* font, const char* text) {
if (!font || !text || !*text) return CG_SIZE_ZERO;
int w, h;
TTF_SizeUTF8(font, text, &w, &h);
return (CgSize){w, h};
}
// Measure line height for a font
int cg_font_line_height(TTF_Font* font) {
return TTF_FontLineSkip(font);
}
int cg_font_ascent(TTF_Font* font) { return TTF_FontAscent(font); }
int cg_font_descent(TTF_Font* font) { return TTF_FontDescent(font); }
Re-rendering text every frame is very slow. We cache rendered text as textures, keyed by (text, font, color):
typedef struct TextCacheEntry {
uint32_t hash;
char* text;
TTF_Font* font;
CgColor color;
SDL_Texture* texture;
int w, h;
uint32_t last_frame; // For LRU eviction
struct TextCacheEntry* next; // Hash-table chaining
} TextCacheEntry;
#define TEXT_CACHE_BUCKETS 256
#define TEXT_CACHE_MAX_ENTRIES 512
static TextCacheEntry* text_cache_buckets[TEXT_CACHE_BUCKETS];
static int text_cache_count;
static uint32_t current_frame;
static uint32_t text_hash(const char* text, TTF_Font* font, CgColor c) {
uint32_t h = 5381;
for (const char* p = text; *p; p++) h = h * 33 ^ (uint8_t)*p;
h = h * 33 ^ (uint32_t)(uintptr_t)font;
h = h * 33 ^ c.r ^ (c.g << 8) ^ (c.b << 16) ^ (c.a << 24);
return h;
}
SDL_Texture* cg_text_texture(TTF_Font* font, const char* text, CgColor color,
int* out_w, int* out_h) {
if (!text || !*text) { *out_w = 0; *out_h = 0; return NULL; }
uint32_t hash = text_hash(text, font, color);
uint32_t bucket = hash % TEXT_CACHE_BUCKETS;
// Lookup
TextCacheEntry* e = text_cache_buckets[bucket];
while (e) {
if (e->hash == hash && e->font == font &&
e->color.r == color.r && e->color.g == color.g &&
e->color.b == color.b && e->color.a == color.a &&
strcmp(e->text, text) == 0) {
e->last_frame = current_frame;
*out_w = e->w; *out_h = e->h;
return e->texture;
}
e = e->next;
}
// Render new texture
SDL_Color sdl_color = cg_to_sdl_color(color);
SDL_Surface* surf = TTF_RenderUTF8_Blended(font, text, sdl_color);
if (!surf) { *out_w = 0; *out_h = 0; return NULL; }
SDL_Texture* tex = SDL_CreateTextureFromSurface(cg->renderer, surf);
SDL_FreeSurface(surf);
if (!tex) { *out_w = 0; *out_h = 0; return NULL; }
// Insert into cache (with eviction if full)
if (text_cache_count >= TEXT_CACHE_MAX_ENTRIES) {
cg_text_cache_evict_lru();
}
TextCacheEntry* entry = calloc(1, sizeof(TextCacheEntry));
entry->hash = hash;
entry->text = strdup(text);
entry->font = font;
entry->color = color;
entry->texture = tex;
SDL_QueryTexture(tex, NULL, NULL, &entry->w, &entry->h);
entry->last_frame = current_frame;
entry->next = text_cache_buckets[bucket];
text_cache_buckets[bucket] = entry;
text_cache_count++;
*out_w = entry->w; *out_h = entry->h;
return tex;
}
typedef struct {
char** lines; // Array of heap-allocated line strings
int* widths; // Width in pixels of each line
int count; // Number of lines
int total_h; // Total height
int max_w; // Width of widest line
} CgTextLayout;
// Wrap text to fit within max_width pixels
CgTextLayout cg_text_wrap(TTF_Font* font, const char* text, int max_width) {
CgTextLayout layout = {0};
if (!text || !*text) return layout;
// Split into words first
char* copy = strdup(text);
char* lines_buf[512];
int widths[512];
int line_count = 0;
char* line_start = copy;
char* p = copy;
int line_w = 0;
int space_w;
TTF_SizeUTF8(font, " ", &space_w, NULL);
while (line_count < 511) {
// Find end of word
char* word_start = p;
while (*p && *p != ' ' && *p != '\n') p++;
char saved = *p;
*p = '\0';
int word_w;
TTF_SizeUTF8(font, word_start, &word_w, NULL);
*p = saved;
bool is_newline = (saved == '\n');
bool fits = (line_w == 0) ? true
: (line_w + space_w + word_w <= max_width);
if (!fits || is_newline) {
// Emit current line
int len = (int)(word_start - line_start);
if (len > 0 && line_start[len-1] == ' ') len--; // Trim trailing space
char* line = malloc(len + 1);
memcpy(line, line_start, len);
line[len] = '\0';
lines_buf[line_count] = line;
widths[line_count] = line_w;
if (line_w > layout.max_w) layout.max_w = line_w;
line_count++;
line_start = word_start;
line_w = word_w;
} else {
line_w = (line_w == 0) ? word_w : line_w + space_w + word_w;
}
if (!saved) break;
if (is_newline) line_start = ++p;
else p++; // Skip space
}
// Emit last line
if (*line_start) {
int len = (int)(p - line_start);
char* line = malloc(len + 1);
memcpy(line, line_start, len);
line[len] = '\0';
TTF_SizeUTF8(font, line, &line_w, NULL);
lines_buf[line_count] = line;
widths[line_count] = line_w;
if (line_w > layout.max_w) layout.max_w = line_w;
line_count++;
}
free(copy);
layout.count = line_count;
layout.lines = malloc(sizeof(char*) * line_count);
layout.widths = malloc(sizeof(int) * line_count);
memcpy(layout.lines, lines_buf, sizeof(char*) * line_count);
memcpy(layout.widths, widths, sizeof(int) * line_count);
layout.total_h = line_count * TTF_FontLineSkip(font);
return layout;
}
void cg_text_layout_free(CgTextLayout* layout) {
for (int i = 0; i < layout->count; i++) free(layout->lines[i]);
free(layout->lines);
free(layout->widths);
memset(layout, 0, sizeof(*layout));
}
// Draw a wrapped text layout
void cg_draw_text_layout(CgTextLayout* layout, TTF_Font* font,
CgColor color, SDL_Rect bounds,
int halign, int valign) {
// halign: -1=left, 0=center, 1=right
// valign: -1=top, 0=center, 1=bottom
int total_h = layout->total_h;
int lh = TTF_FontLineSkip(font);
int start_y;
if (valign == 0) start_y = bounds.y + (bounds.h - total_h) / 2;
else if (valign == 1) start_y = bounds.y + bounds.h - total_h;
else start_y = bounds.y;
for (int i = 0; i < layout->count; i++) {
if (!layout->lines[i] || !*layout->lines[i]) {
start_y += lh;
continue;
}
int tw, th;
SDL_Texture* tex = cg_text_texture(font, layout->lines[i], color, &tw, &th);
if (!tex) { start_y += lh; continue; }
int tx;
if (halign == 0) tx = bounds.x + (bounds.w - tw) / 2;
else if (halign == 1) tx = bounds.x + bounds.w - tw;
else tx = bounds.x;
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
SDL_SetTextureColorMod(tex, 255, 255, 255);
SDL_SetTextureAlphaMod(tex, color.a);
SDL_Rect dst = {tx, start_y, tw, th};
SDL_RenderCopy(cg->renderer, tex, NULL, &dst);
start_y += lh;
}
}
With the foundation in place, we can build widgets. Let's start with the two most fundamental: Label and Button.
// src/widgets/cg_label.c
#include "cg_internal.h"
typedef struct CgLabel {
CgWidget base;
char* text;
TTF_Font* font;
CgColor color;
int halign; // -1=left, 0=center, 1=right
int valign; // -1=top, 0=center, 1=bottom
bool wrap; // Word-wrap text
CgTextLayout layout; // Computed layout (when wrap=true)
} CgLabel;
static void label_measure(CgWidget* w, int avail_w, int avail_h) {
CgLabel* l = (CgLabel*)w;
(void)avail_h;
if (!l->text || !*l->text || !l->font) {
w->content_size = CG_SIZE_ZERO;
return;
}
if (l->wrap && avail_w > 0) {
cg_text_layout_free(&l->layout);
l->layout = cg_text_wrap(l->font, l->text, avail_w);
w->content_size.w = l->layout.max_w;
w->content_size.h = l->layout.total_h;
} else {
CgSize s = cg_text_measure(l->font, l->text);
w->content_size = s;
}
}
static void label_paint(CgWidget* w) {
CgLabel* l = (CgLabel*)w;
if (!l->text || !*l->text || !l->font) return;
SDL_Rect content = cg_content_rect(w);
if (content.w <= 0 || content.h <= 0) return;
cg_push_clip(content);
if (l->wrap && l->layout.count > 0) {
cg_draw_text_layout(&l->layout, l->font, l->color, content,
l->halign, l->valign);
} else {
int tw, th;
SDL_Texture* tex = cg_text_texture(l->font, l->text, l->color, &tw, &th);
if (tex) {
int tx, ty;
if (l->halign == 0) tx = content.x + (content.w - tw) / 2;
else if (l->halign == 1) tx = content.x + content.w - tw;
else tx = content.x;
if (l->valign == 0) ty = content.y + (content.h - th) / 2;
else if (l->valign == 1) ty = content.y + content.h - th;
else ty = content.y;
SDL_Rect dst = {tx, ty, tw, th};
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
SDL_RenderCopy(cg->renderer, tex, NULL, &dst);
}
}
cg_pop_clip();
}
static void label_destroy(CgWidget* w) {
CgLabel* l = (CgLabel*)w;
free(l->text);
cg_text_layout_free(&l->layout);
}
static const CgWidgetVTable label_vtable = {
.measure = label_measure,
.arrange = NULL, // No children, no arrange needed
.paint = label_paint,
.destroy = label_destroy,
};
// Public API
CgWidget* cg_label_new(const char* text) {
CgLabel* l = (CgLabel*)cg_widget_alloc(sizeof(CgLabel), CG_TYPE_LABEL,
&label_vtable);
l->text = strdup(text ? text : "");
l->font = cg_theme_default_font();
l->color = cg->theme->colors.text_primary;
l->halign = -1; // Left by default
l->valign = 0; // Centre vertical by default
l->wrap = false;
return (CgWidget*)l;
}
void cg_label_set_text(CgWidget* w, const char* text) {
CgLabel* l = (CgLabel*)w;
free(l->text);
l->text = strdup(text ? text : "");
cg_text_layout_free(&l->layout);
cg_widget_mark_dirty(w);
}
void cg_label_set_font(CgWidget* w, TTF_Font* font) {
((CgLabel*)w)->font = font;
cg_widget_mark_dirty(w);
}
void cg_label_set_color(CgWidget* w, CgColor color) {
((CgLabel*)w)->color = color;
}
void cg_label_set_align(CgWidget* w, int halign, int valign) {
CgLabel* l = (CgLabel*)w;
l->halign = halign;
l->valign = valign;
}
void cg_label_set_wrap(CgWidget* w, bool wrap) {
((CgLabel*)w)->wrap = wrap;
cg_widget_mark_dirty(w);
}
// src/widgets/cg_button.c
#include "cg_internal.h"
typedef enum {
CG_BTN_STYLE_FILLED,
CG_BTN_STYLE_OUTLINED,
CG_BTN_STYLE_TEXT,
CG_BTN_STYLE_ICON,
} CgButtonStyle;
typedef struct CgButton {
CgWidget base;
char* label;
SDL_Texture* icon; // Optional icon texture
int icon_w, icon_h;
CgButtonStyle btn_style;
TTF_Font* font;
// Colors for each state (set from theme on creation)
CgColor bg_normal, bg_hover, bg_pressed, bg_disabled;
CgColor fg_normal, fg_hover, fg_pressed, fg_disabled;
CgColor border_normal, border_hover;
int border_radius;
int border_width;
// Animation state (for hover/press transitions)
float anim_t; // 0.0 = normal, 1.0 = hovered/pressed
CgColor bg_current;
CgColor fg_current;
} CgButton;
static void button_measure(CgWidget* w, int avail_w, int avail_h) {
CgButton* b = (CgButton*)w;
(void)avail_w; (void)avail_h;
int tw = 0, th = 0;
if (b->label && *b->label && b->font) {
CgSize ts = cg_text_measure(b->font, b->label);
tw = ts.w; th = ts.h;
}
int iw = 0;
if (b->icon) {
iw = b->icon_w + (tw > 0 ? 6 : 0); // 6px gap between icon and text
}
int min_h = SDL_max(th, b->icon_h);
w->content_size.w = SDL_max(tw + iw, 32);
w->content_size.h = SDL_max(min_h, 16);
}
static void button_paint(CgWidget* w) {
CgButton* b = (CgButton*)w;
SDL_Rect r = w->bounds_abs;
bool hovered = (w->flags & CG_FLAG_HOVERED) != 0;
bool pressed = (w->flags & CG_FLAG_PRESSED) != 0;
bool disabled = !(w->flags & CG_FLAG_ENABLED);
bool focused = (w->flags & CG_FLAG_FOCUSED) != 0;
// Interpolate colours based on state
CgColor bg, fg, border;
if (disabled) {
bg = b->bg_disabled;
fg = b->fg_disabled;
border = b->border_normal;
} else if (pressed) {
bg = b->bg_pressed;
fg = b->fg_pressed;
border = b->border_hover;
} else if (hovered) {
bg = cg_color_lerp(b->bg_normal, b->bg_hover, b->anim_t);
fg = cg_color_lerp(b->fg_normal, b->fg_hover, b->anim_t);
border = b->border_hover;
} else {
bg = cg_color_lerp(b->bg_hover, b->bg_normal, 1.0f - b->anim_t);
fg = b->fg_normal;
border = b->border_normal;
}
// Draw background
switch (b->btn_style) {
case CG_BTN_STYLE_FILLED:
cg_draw_rounded_rect(r, b->border_radius, bg);
break;
case CG_BTN_STYLE_OUTLINED:
// Transparent fill with border
if (hovered || pressed) {
CgColor fill = {bg.r, bg.g, bg.b, 30};
cg_draw_rounded_rect(r, b->border_radius, fill);
}
cg_draw_rounded_rect_outline(r, b->border_radius,
b->border_width, border);
break;
case CG_BTN_STYLE_TEXT:
if (hovered || pressed) {
CgColor fill = {bg.r, bg.g, bg.b, 20};
cg_draw_rounded_rect(r, b->border_radius, fill);
}
break;
default:
break;
}
// Focus ring
if (focused) {
SDL_Rect ring = {r.x-2, r.y-2, r.w+4, r.h+4};
CgColor focus_color = cg->theme->colors.accent;
focus_color.a = 200;
cg_draw_rounded_rect_outline(ring, b->border_radius + 2, 2, focus_color);
}
// Content area
SDL_Rect content = cg_content_rect(w);
cg_push_clip(r);
// Icon (if any)
int text_x = content.x;
if (b->icon) {
int iy = content.y + (content.h - b->icon_h) / 2;
SDL_Rect icon_dst = {content.x, iy, b->icon_w, b->icon_h};
SDL_SetTextureColorMod(b->icon, fg.r, fg.g, fg.b);
SDL_SetTextureAlphaMod(b->icon, fg.a);
SDL_RenderCopy(cg->renderer, b->icon, NULL, &icon_dst);
text_x += b->icon_w + 6;
}
// Label
if (b->label && *b->label && b->font) {
int tw, th;
SDL_Texture* tex = cg_text_texture(b->font, b->label, fg, &tw, &th);
if (tex) {
SDL_Rect text_bounds = {text_x, content.y,
content.w - (text_x - content.x), content.h};
int tx = text_bounds.x + (text_bounds.w - tw) / 2;
int ty = text_bounds.y + (text_bounds.h - th) / 2;
SDL_Rect dst = {tx, ty, tw, th};
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
SDL_RenderCopy(cg->renderer, tex, NULL, &dst);
}
}
cg_pop_clip();
}
static bool button_on_event(CgWidget* w, CgEvent* ev) {
CgButton* b = (CgButton*)w;
(void)b;
// Animate hover state
if (ev->type == CG_EVENT_MOUSE_ENTER) {
cg_tween_float(w, &b->anim_t, 1.0f, 120, CG_EASE_OUT_CUBIC);
return false; // Continue bubbling
}
if (ev->type == CG_EVENT_MOUSE_LEAVE) {
cg_tween_float(w, &b->anim_t, 0.0f, 120, CG_EASE_OUT_CUBIC);
return false;
}
// Space/Enter to activate button from keyboard
if (ev->type == CG_EVENT_KEY_DOWN) {
if (ev->key.sym == SDLK_SPACE || ev->key.sym == SDLK_RETURN) {
if (w->flags & CG_FLAG_ENABLED) {
CgEvent click = {.type = CG_EVENT_MOUSE_CLICK};
return cg_fire_event(w, &click);
}
}
}
return false;
}
static void button_destroy(CgWidget* w) {
CgButton* b = (CgButton*)w;
free(b->label);
}
static const CgWidgetVTable button_vtable = {
.measure = button_measure,
.arrange = NULL,
.paint = button_paint,
.on_event = button_on_event,
.destroy = button_destroy,
};
CgWidget* cg_button_new(const char* label) {
CgButton* b = (CgButton*)cg_widget_alloc(sizeof(CgButton), CG_TYPE_BUTTON,
&button_vtable);
b->label = strdup(label ? label : "");
b->font = cg_theme_default_font();
b->btn_style = CG_BTN_STYLE_FILLED;
b->border_radius = cg->theme->metrics.button_radius;
b->border_width = 1;
// Colors from theme
CgTheme* t = cg->theme;
b->bg_normal = t->colors.button_bg;
b->bg_hover = t->colors.button_bg_hover;
b->bg_pressed = t->colors.button_bg_pressed;
b->bg_disabled = t->colors.surface_disabled;
b->fg_normal = t->colors.button_fg;
b->fg_hover = t->colors.button_fg;
b->fg_pressed = t->colors.button_fg;
b->fg_disabled = t->colors.text_disabled;
b->border_normal = t->colors.button_bg;
b->border_hover = t->colors.accent;
b->base.flags |= CG_FLAG_FOCUSABLE;
b->base.padding = CG_INSETS(8, 16, 8, 16);
b->base.tab_index = 0;
return (CgWidget*)b;
}
void cg_button_set_style(CgWidget* w, CgButtonStyle style) {
CgButton* b = (CgButton*)w;
b->btn_style = style;
// Adjust colors for outlined / text styles
if (style == CG_BTN_STYLE_OUTLINED || style == CG_BTN_STYLE_TEXT) {
b->fg_normal = cg->theme->colors.accent;
b->fg_hover = cg->theme->colors.accent;
b->fg_pressed = cg_color_darken(cg->theme->colors.accent, 0.2f);
b->border_normal = cg->theme->colors.accent;
}
}
The text input widget is one of the most complex to implement correctly. It must handle cursor positioning, selection, copy/paste, IME, and placeholder text.
// src/widgets/cg_input.c
#define INPUT_BUF_CAP 4096
typedef struct CgInput {
CgWidget base;
// Text content
char buf[INPUT_BUF_CAP]; // UTF-8 text buffer
int buf_len; // Length in bytes (not chars)
int cursor; // Cursor position in bytes
int sel_start; // Selection start (-1 = no selection)
int sel_end; // Selection end
// Display
char* placeholder;
TTF_Font* font;
CgColor text_color;
CgColor placeholder_color;
CgColor bg_color;
CgColor border_color;
CgColor border_focus_color;
CgColor selection_color;
int border_radius;
int border_width;
// Scroll offset (for long text that doesn't fit)
int scroll_x;
// Caret blink
Uint32 caret_timer;
bool caret_visible;
// Password mode
bool password;
// Max length in characters
int max_chars;
// Editing composition (IME)
char composition[64];
int comp_cursor;
} CgInput;
Because UTF-8 is a variable-width encoding, we must step by code points, not bytes:
// Return byte length of the UTF-8 character at ptr
static int utf8_char_len(const char* p) {
unsigned char c = (unsigned char)*p;
if (c < 0x80) return 1;
else if (c < 0xE0) return 2;
else if (c < 0xF0) return 3;
else return 4;
}
// Move byte offset forward by one character
static int utf8_next(const char* buf, int pos, int len) {
if (pos >= len) return pos;
return pos + utf8_char_len(buf + pos);
}
// Move byte offset backward by one character
static int utf8_prev(const char* buf, int pos) {
if (pos <= 0) return 0;
pos--;
while (pos > 0 && (((unsigned char)buf[pos]) & 0xC0) == 0x80) pos--;
return pos;
}
// Count Unicode characters in buf[0..byte_pos)
static int utf8_count_chars(const char* buf, int byte_pos) {
int count = 0;
for (int i = 0; i < byte_pos; ) {
i += utf8_char_len(buf + i);
count++;
}
return count;
}
// Convert pixel X position to byte offset in text
static int input_pixel_to_cursor(CgInput* inp, int px) {
if (!inp->buf[0]) return 0;
TTF_Font* font = inp->font;
// Walk character by character, find where px falls
char tmp[INPUT_BUF_CAP];
int best_pos = 0;
int best_diff = INT_MAX;
for (int i = 0; i <= inp->buf_len; ) {
memcpy(tmp, inp->buf, i);
tmp[i] = '\0';
int tw;
TTF_SizeUTF8(font, tmp, &tw, NULL);
int diff = abs(tw - px);
if (diff < best_diff) { best_diff = diff; best_pos = i; }
if (i == inp->buf_len) break;
i = utf8_next(inp->buf, i, inp->buf_len);
}
return best_pos;
}
// Insert UTF-8 text at cursor position
static void input_insert(CgInput* inp, const char* text) {
int text_len = (int)strlen(text);
if (text_len == 0) return;
// Delete selection first
if (inp->sel_start >= 0) {
int s = SDL_min(inp->sel_start, inp->sel_end);
int e = SDL_max(inp->sel_start, inp->sel_end);
memmove(inp->buf + s, inp->buf + e, inp->buf_len - e + 1);
inp->buf_len -= (e - s);
inp->cursor = s;
inp->sel_start = -1;
}
// Check max_chars
if (inp->max_chars > 0) {
int chars = utf8_count_chars(inp->buf, inp->buf_len);
int new_chars = utf8_count_chars(text, text_len);
if (chars + new_chars > inp->max_chars) return;
}
// Check buffer capacity
if (inp->buf_len + text_len >= INPUT_BUF_CAP - 1) return;
// Shift existing text to make room
memmove(inp->buf + inp->cursor + text_len,
inp->buf + inp->cursor,
inp->buf_len - inp->cursor + 1);
memcpy(inp->buf + inp->cursor, text, text_len);
inp->buf_len += text_len;
inp->cursor += text_len;
}
// Delete character at/before cursor (forward=true: delete key, false: backspace)
static void input_delete(CgInput* inp, bool forward) {
if (inp->sel_start >= 0) {
// Delete selection
int s = SDL_min(inp->sel_start, inp->sel_end);
int e = SDL_max(inp->sel_start, inp->sel_end);
memmove(inp->buf + s, inp->buf + e, inp->buf_len - e + 1);
inp->buf_len -= (e - s);
inp->cursor = s;
inp->sel_start = -1;
return;
}
if (forward) {
if (inp->cursor >= inp->buf_len) return;
int end = utf8_next(inp->buf, inp->cursor, inp->buf_len);
memmove(inp->buf + inp->cursor, inp->buf + end,
inp->buf_len - end + 1);
inp->buf_len -= (end - inp->cursor);
} else {
if (inp->cursor == 0) return;
int start = utf8_prev(inp->buf, inp->cursor);
memmove(inp->buf + start, inp->buf + inp->cursor,
inp->buf_len - inp->cursor + 1);
inp->buf_len -= (inp->cursor - start);
inp->cursor = start;
}
}
static void input_paint(CgWidget* w) {
CgInput* inp = (CgInput*)w;
SDL_Rect r = w->bounds_abs;
bool focused = (w->flags & CG_FLAG_FOCUSED) != 0;
// Background
cg_draw_rounded_rect(r, inp->border_radius, inp->bg_color);
// Border (thicker / different color when focused)
CgColor border = focused ? inp->border_focus_color : inp->border_color;
int bw = focused ? inp->border_width + 1 : inp->border_width;
cg_draw_rounded_rect_outline(r, inp->border_radius, bw, border);
SDL_Rect content = cg_content_rect(w);
cg_push_clip(r);
bool has_text = (inp->buf_len > 0);
const char* display_text = inp->buf;
// Show placeholder if empty and not focused
if (!has_text && !focused && inp->placeholder) {
int tw, th;
SDL_Texture* tex = cg_text_texture(inp->font, inp->placeholder,
inp->placeholder_color, &tw, &th);
if (tex) {
int ty = content.y + (content.h - th) / 2;
SDL_Rect dst = {content.x - inp->scroll_x, ty, tw, th};
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
SDL_RenderCopy(cg->renderer, tex, NULL, &dst);
}
cg_pop_clip();
return;
}
// Compute cursor pixel position
char before_cursor[INPUT_BUF_CAP];
memcpy(before_cursor, inp->buf, inp->cursor);
before_cursor[inp->cursor] = '\0';
int cursor_px = 0;
if (inp->cursor > 0) TTF_SizeUTF8(inp->font, before_cursor, &cursor_px, NULL);
// Auto-scroll to keep cursor visible
int visible_w = content.w;
if (cursor_px - inp->scroll_x > visible_w - 4)
inp->scroll_x = cursor_px - visible_w + 4;
if (cursor_px - inp->scroll_x < 0)
inp->scroll_x = SDL_max(0, cursor_px - 4);
// Draw selection background
if (focused && inp->sel_start >= 0 && inp->sel_start != inp->sel_end) {
int s = SDL_min(inp->sel_start, inp->sel_end);
int e = SDL_max(inp->sel_start, inp->sel_end);
char s_str[INPUT_BUF_CAP]; memcpy(s_str, inp->buf, s); s_str[s] = '\0';
char e_str[INPUT_BUF_CAP]; memcpy(e_str, inp->buf, e); e_str[e] = '\0';
int s_px = 0, e_px = 0;
if (s > 0) TTF_SizeUTF8(inp->font, s_str, &s_px, NULL);
if (e > 0) TTF_SizeUTF8(inp->font, e_str, &e_px, NULL);
SDL_Rect sel_rect = {
content.x + s_px - inp->scroll_x,
content.y,
e_px - s_px,
content.h
};
cg_draw_rect(sel_rect, inp->selection_color);
}
// Draw text
if (has_text) {
int tw, th;
SDL_Texture* tex = cg_text_texture(inp->font, display_text,
inp->text_color, &tw, &th);
if (tex) {
int ty = content.y + (content.h - th) / 2;
SDL_Rect dst = {content.x - inp->scroll_x, ty, tw, th};
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
SDL_RenderCopy(cg->renderer, tex, NULL, &dst);
}
}
// Draw caret
if (focused && inp->caret_visible) {
int font_h = TTF_FontHeight(inp->font);
int caret_x = content.x + cursor_px - inp->scroll_x;
int caret_y = content.y + (content.h - font_h) / 2;
CgColor caret_color = cg->theme->colors.accent;
cg_draw_vline(caret_x, caret_y, font_h, caret_color);
}
cg_pop_clip();
}
static bool input_on_event(CgWidget* w, CgEvent* ev) {
CgInput* inp = (CgInput*)w;
switch (ev->type) {
case CG_EVENT_FOCUS_IN:
SDL_StartTextInput();
inp->caret_visible = true;
inp->caret_timer = SDL_GetTicks();
return false;
case CG_EVENT_FOCUS_OUT:
SDL_StopTextInput();
inp->sel_start = -1;
return false;
case CG_EVENT_TEXT_INPUT:
if (w->flags & CG_FLAG_ENABLED) {
input_insert(inp, ev->text.text);
cg_fire_change_event(w, inp->buf);
}
return true;
case CG_EVENT_MOUSE_DOWN:
if (ev->mouse.button == SDL_BUTTON_LEFT) {
SDL_Rect content = cg_content_rect(w);
int rel_x = ev->mouse.x - content.x + inp->scroll_x;
inp->cursor = input_pixel_to_cursor(inp, rel_x);
inp->sel_start = inp->cursor;
inp->sel_end = inp->cursor;
}
return true;
case CG_EVENT_MOUSE_MOVE:
if (cg->mouse_buttons[SDL_BUTTON_LEFT - 1] && inp->sel_start >= 0) {
SDL_Rect content = cg_content_rect(w);
int rel_x = ev->mouse.x - content.x + inp->scroll_x;
inp->cursor = input_pixel_to_cursor(inp, rel_x);
inp->sel_end = inp->cursor;
}
return true;
case CG_EVENT_KEY_DOWN: {
SDL_Keycode k = ev->key.sym;
uint16_t mod = ev->key.mod;
bool ctrl = (mod & KMOD_CTRL) != 0;
bool shift = (mod & KMOD_SHIFT) != 0;
switch (k) {
case SDLK_LEFT:
if (ctrl) {
// Jump word left
while (inp->cursor > 0 && inp->buf[inp->cursor-1] == ' ')
inp->cursor = utf8_prev(inp->buf, inp->cursor);
while (inp->cursor > 0 && inp->buf[inp->cursor-1] != ' ')
inp->cursor = utf8_prev(inp->buf, inp->cursor);
} else {
inp->cursor = utf8_prev(inp->buf, inp->cursor);
}
if (!shift) inp->sel_start = -1;
else if (inp->sel_start < 0) { inp->sel_start = inp->cursor + 1; }
inp->sel_end = inp->cursor;
break;
case SDLK_RIGHT:
if (ctrl) {
while (inp->cursor < inp->buf_len && inp->buf[inp->cursor] == ' ')
inp->cursor = utf8_next(inp->buf, inp->cursor, inp->buf_len);
while (inp->cursor < inp->buf_len && inp->buf[inp->cursor] != ' ')
inp->cursor = utf8_next(inp->buf, inp->cursor, inp->buf_len);
} else {
inp->cursor = utf8_next(inp->buf, inp->cursor, inp->buf_len);
}
if (!shift) inp->sel_start = -1;
else if (inp->sel_start < 0) { inp->sel_start = inp->cursor - 1; }
inp->sel_end = inp->cursor;
break;
case SDLK_HOME:
if (shift && inp->sel_start < 0) inp->sel_start = inp->cursor;
inp->cursor = 0;
if (!shift) inp->sel_start = -1;
inp->sel_end = inp->cursor;
break;
case SDLK_END:
if (shift && inp->sel_start < 0) inp->sel_start = inp->cursor;
inp->cursor = inp->buf_len;
if (!shift) inp->sel_start = -1;
inp->sel_end = inp->cursor;
break;
case SDLK_BACKSPACE:
if (w->flags & CG_FLAG_ENABLED) {
input_delete(inp, false);
cg_fire_change_event(w, inp->buf);
}
break;
case SDLK_DELETE:
if (w->flags & CG_FLAG_ENABLED) {
input_delete(inp, true);
cg_fire_change_event(w, inp->buf);
}
break;
case SDLK_RETURN:
case SDLK_KP_ENTER: {
CgEvent submit = {.type = CG_EVENT_SUBMIT};
cg_fire_event(w, &submit);
break;
}
case SDLK_a:
if (ctrl) { // Select all
inp->sel_start = 0;
inp->cursor = inp->buf_len;
inp->sel_end = inp->cursor;
}
break;
case SDLK_c:
case SDLK_x:
if (ctrl && inp->sel_start >= 0 && inp->sel_start != inp->sel_end) {
int s = SDL_min(inp->sel_start, inp->sel_end);
int e = SDL_max(inp->sel_start, inp->sel_end);
char clip[INPUT_BUF_CAP];
memcpy(clip, inp->buf + s, e - s);
clip[e - s] = '\0';
SDL_SetClipboardText(clip);
if (k == SDLK_x && (w->flags & CG_FLAG_ENABLED)) {
input_delete(inp, false);
cg_fire_change_event(w, inp->buf);
}
}
break;
case SDLK_v:
if (ctrl && (w->flags & CG_FLAG_ENABLED)) {
char* clip = SDL_GetClipboardText();
if (clip && *clip) {
input_insert(inp, clip);
cg_fire_change_event(w, inp->buf);
}
SDL_free(clip);
}
break;
}
return true;
}
default:
break;
}
return false;
}
// Update caret blink in the main loop
void cg_input_tick(CgInput* inp) {
Uint32 now = SDL_GetTicks();
if (now - inp->caret_timer > 530) { // 530ms blink period
inp->caret_visible = !inp->caret_visible;
inp->caret_timer = now;
}
}
Panels are the backbone of layout. We expose convenient constructors for the most common cases.
// include/cg.h β Public panel API
// Create a panel with specified layout direction
CgWidget* cg_panel_new(CgDirection direction);
// Convenience constructors
static inline CgWidget* cg_row(void) {
return cg_panel_new(CG_DIRECTION_ROW);
}
static inline CgWidget* cg_col(void) {
return cg_panel_new(CG_DIRECTION_COLUMN);
}
// Add a child widget
void cg_panel_add(CgWidget* panel, CgWidget* child);
// Add a spacer (flexible space filler)
CgWidget* cg_spacer_new(void);
// Set layout properties
void cg_panel_set_gap(CgWidget* panel, int gap);
void cg_panel_set_justify(CgWidget* panel, CgJustify justify);
void cg_panel_set_align(CgWidget* panel, CgAlign align);
void cg_panel_set_padding(CgWidget* panel, CgInsets padding);
void cg_panel_set_bg(CgWidget* panel, CgColor color);
void cg_panel_set_border(CgWidget* panel, int width, int radius, CgColor color);
A spacer is an invisible widget with CG_FLAG_EXPAND_W or CG_FLAG_EXPAND_H set, consuming all remaining space along the main axis:
CgWidget* cg_spacer_new(void) {
CgWidget* s = cg_widget_alloc(sizeof(CgWidget), CG_TYPE_BASE, NULL);
s->flags |= CG_FLAG_EXPAND_W | CG_FLAG_EXPAND_H;
s->desired_w = CG_SIZE_FILL;
s->desired_h = CG_SIZE_FILL;
return s;
}
// A typical toolbar layout:
// [Menu] [Save] [Open] <spacer> [Search input] [Settings]
CgWidget* toolbar = cg_row();
cg_panel_set_padding(toolbar, CG_INSETS_ALL(8));
cg_panel_set_gap(toolbar, 4);
cg_panel_set_bg(toolbar, cg->theme->colors.surface);
cg_panel_add(toolbar, cg_button_new("File"));
cg_panel_add(toolbar, cg_button_new("Edit"));
cg_panel_add(toolbar, cg_button_new("View"));
cg_panel_add(toolbar, cg_spacer_new()); // Flexible space
CgWidget* search = cg_input_new();
cg_input_set_placeholder(search, "Search...");
search->desired_w = 200;
cg_panel_add(toolbar, search);
cg_panel_add(toolbar, cg_button_new("β Settings"));
A scroll view clips its content to a viewport and allows panning via scrollbar or mouse wheel.
typedef struct CgScroll {
CgWidget base;
CgWidget* content; // The single child being scrolled
int scroll_x;
int scroll_y;
int content_w; // Measured content size
int content_h;
bool hbar_visible;
bool vbar_visible;
bool hbar_always; // Force horizontal scrollbar
bool vbar_always; // Force vertical scrollbar
// Scrollbar dragging state
bool dragging_vbar;
bool dragging_hbar;
int drag_start_scroll;
int drag_start_mouse;
CgColor bar_color;
CgColor bar_track_color;
int bar_size; // Scrollbar thickness in pixels
int bar_radius;
} CgScroll;
#define SCROLLBAR_SIZE 8 // Thin scrollbar
#define SCROLLBAR_MIN_THUMB 20
static void scroll_measure(CgWidget* w, int avail_w, int avail_h) {
CgScroll* s = (CgScroll*)w;
if (!s->content) {
w->content_size = CG_SIZE_ZERO;
return;
}
// Measure content unconstrained (so it can be as big as needed)
cg_layout_measure(s->content, 65535, 65535);
s->content_w = s->content->content_size.w
+ s->content->padding.left + s->content->padding.right;
s->content_h = s->content->content_size.h
+ s->content->padding.top + s->content->padding.bottom;
// The scroll view itself fills available space
w->content_size.w = avail_w;
w->content_size.h = avail_h;
// Determine scrollbar visibility
s->vbar_visible = s->vbar_always || (s->content_h > avail_h);
s->hbar_visible = s->hbar_always || (s->content_w > avail_w);
}
static void scroll_arrange(CgWidget* w, SDL_Rect abs) {
CgScroll* s = (CgScroll*)w;
if (!s->content) return;
// Content is arranged at its natural size, offset by scroll_x/y
int viewport_w = abs.w - (s->vbar_visible ? s->bar_size : 0);
int viewport_h = abs.h - (s->hbar_visible ? s->bar_size : 0);
// Clamp scroll values
int max_scroll_x = SDL_max(0, s->content_w - viewport_w);
int max_scroll_y = SDL_max(0, s->content_h - viewport_h);
s->scroll_x = SDL_clamp(s->scroll_x, 0, max_scroll_x);
s->scroll_y = SDL_clamp(s->scroll_y, 0, max_scroll_y);
SDL_Rect content_abs = {
abs.x - s->scroll_x,
abs.y - s->scroll_y,
SDL_max(s->content_w, viewport_w),
SDL_max(s->content_h, viewport_h)
};
cg_layout_arrange(s->content, content_abs);
}
static void scroll_paint(CgWidget* w) {
CgScroll* s = (CgScroll*)w;
SDL_Rect r = w->bounds_abs;
// Push clip to viewport (exclude scrollbar area)
int vp_w = r.w - (s->vbar_visible ? s->bar_size : 0);
int vp_h = r.h - (s->hbar_visible ? s->bar_size : 0);
SDL_Rect viewport = {r.x, r.y, vp_w, vp_h};
cg_push_clip(viewport);
// Paint the content (it's already positioned with offset)
if (s->content && (s->content->flags & CG_FLAG_VISIBLE)) {
if (s->content->vtable && s->content->vtable->paint)
s->content->vtable->paint(s->content);
}
cg_pop_clip();
// Draw vertical scrollbar
if (s->vbar_visible) {
SDL_Rect track = {r.x + vp_w, r.y, s->bar_size, vp_h};
cg_draw_rect(track, s->bar_track_color);
// Thumb size proportional to viewport vs content
float ratio = (float)vp_h / (float)s->content_h;
int thumb_h = SDL_max(SCROLLBAR_MIN_THUMB, (int)(vp_h * ratio));
int thumb_y = (int)((float)s->scroll_y / s->content_h * vp_h);
thumb_y = SDL_clamp(thumb_y, 0, vp_h - thumb_h);
SDL_Rect thumb = {
r.x + vp_w + 1,
r.y + thumb_y,
s->bar_size - 2,
thumb_h
};
cg_draw_rounded_rect(thumb, s->bar_size / 2, s->bar_color);
}
// Draw horizontal scrollbar
if (s->hbar_visible) {
SDL_Rect track = {r.x, r.y + vp_h, vp_w, s->bar_size};
cg_draw_rect(track, s->bar_track_color);
float ratio = (float)vp_w / (float)s->content_w;
int thumb_w = SDL_max(SCROLLBAR_MIN_THUMB, (int)(vp_w * ratio));
int thumb_x = (int)((float)s->scroll_x / s->content_w * vp_w);
thumb_x = SDL_clamp(thumb_x, 0, vp_w - thumb_w);
SDL_Rect thumb = {
r.x + thumb_x,
r.y + vp_h + 1,
thumb_w,
s->bar_size - 2
};
cg_draw_rounded_rect(thumb, s->bar_size / 2, s->bar_color);
}
}
static bool scroll_on_event(CgWidget* w, CgEvent* ev) {
CgScroll* s = (CgScroll*)w;
SDL_Rect r = w->bounds_abs;
if (ev->type == CG_EVENT_MOUSE_WHEEL) {
// Scroll vertically (hold Shift for horizontal)
SDL_Keymod mod = SDL_GetModState();
if (mod & KMOD_SHIFT) {
s->scroll_x -= (int)(ev->mouse.wheel_y * 40);
} else {
s->scroll_y -= (int)(ev->mouse.wheel_y * 40);
}
cg_widget_mark_dirty(w);
return true;
}
// Scrollbar drag handling
if (ev->type == CG_EVENT_MOUSE_DOWN && ev->mouse.button == SDL_BUTTON_LEFT) {
int vp_w = r.w - (s->vbar_visible ? s->bar_size : 0);
int vp_h = r.h - (s->hbar_visible ? s->bar_size : 0);
// Hit test vertical scrollbar
if (s->vbar_visible) {
SDL_Rect vbar = {r.x + vp_w, r.y, s->bar_size, vp_h};
if (ev->mouse.x >= vbar.x && ev->mouse.x < vbar.x + vbar.w &&
ev->mouse.y >= vbar.y && ev->mouse.y < vbar.y + vbar.h) {
s->dragging_vbar = true;
s->drag_start_scroll = s->scroll_y;
s->drag_start_mouse = ev->mouse.y;
cg->mouse_captured = w;
return true;
}
}
// Hit test horizontal scrollbar
if (s->hbar_visible) {
SDL_Rect hbar = {r.x, r.y + vp_h, vp_w, s->bar_size};
if (ev->mouse.x >= hbar.x && ev->mouse.x < hbar.x + hbar.w &&
ev->mouse.y >= hbar.y && ev->mouse.y < hbar.y + hbar.h) {
s->dragging_hbar = true;
s->drag_start_scroll = s->scroll_x;
s->drag_start_mouse = ev->mouse.x;
cg->mouse_captured = w;
return true;
}
}
}
if (ev->type == CG_EVENT_MOUSE_MOVE) {
if (s->dragging_vbar) {
int vp_h = r.h - (s->hbar_visible ? s->bar_size : 0);
int delta = ev->mouse.y - s->drag_start_mouse;
float ratio = (float)s->content_h / (float)vp_h;
s->scroll_y = s->drag_start_scroll + (int)(delta * ratio);
cg_widget_mark_dirty(w);
return true;
}
if (s->dragging_hbar) {
int vp_w = r.w - (s->vbar_visible ? s->bar_size : 0);
int delta = ev->mouse.x - s->drag_start_mouse;
float ratio = (float)s->content_w / (float)vp_w;
s->scroll_x = s->drag_start_scroll + (int)(delta * ratio);
cg_widget_mark_dirty(w);
return true;
}
}
if (ev->type == CG_EVENT_MOUSE_UP) {
s->dragging_vbar = false;
s->dragging_hbar = false;
if (cg->mouse_captured == w) cg->mouse_captured = NULL;
}
return false;
}
A good theming system lets applications switch the visual appearance of the entire UI with a single call.
// include/cg.h
typedef struct CgColorTokens {
// Backgrounds
CgColor bg_app; // Application background
CgColor bg_surface; // Card / panel surface
CgColor bg_surface_raised; // Elevated surface (dropdown, dialog)
CgColor bg_overlay; // Modal overlay (semi-transparent)
// Text
CgColor text_primary;
CgColor text_secondary;
CgColor text_disabled;
CgColor text_on_accent; // Text on accent-color backgrounds
// Accent / interactive
CgColor accent;
CgColor accent_hover;
CgColor accent_pressed;
CgColor accent_disabled;
// Surface-level interactive elements
CgColor button_bg;
CgColor button_bg_hover;
CgColor button_bg_pressed;
CgColor button_fg;
// Input fields
CgColor input_bg;
CgColor input_border;
CgColor input_border_focus;
CgColor input_selection;
CgColor input_placeholder;
// Borders / dividers
CgColor border;
CgColor divider;
// Scrollbars
CgColor scrollbar;
CgColor scrollbar_track;
// Status
CgColor success;
CgColor warning;
CgColor error;
CgColor info;
// Surface disabled
CgColor surface_disabled;
} CgColorTokens;
typedef struct CgMetrics {
int button_radius; // Border radius for buttons
int input_radius; // Border radius for inputs
int card_radius; // Border radius for cards/panels
int tooltip_radius;
int dialog_radius;
int border_width;
int focus_ring_width;
int spacing_xs; // Extra-small spacing (4px)
int spacing_sm; // Small spacing (8px)
int spacing_md; // Medium spacing (16px)
int spacing_lg; // Large spacing (24px)
int spacing_xl; // Extra-large spacing (32px)
int font_size_sm;
int font_size_md;
int font_size_lg;
int font_size_xl;
} CgMetrics;
typedef struct CgTheme {
const char* name;
CgColorTokens colors;
CgMetrics metrics;
char font_path[256];
TTF_Font* font_sm;
TTF_Font* font_md;
TTF_Font* font_lg;
TTF_Font* font_xl;
TTF_Font* font_icon;
} CgTheme;
// src/cg_theme.c
static CgTheme theme_dark = {
.name = "Dark",
.colors = {
.bg_app = CG_RGB( 18, 18, 18),
.bg_surface = CG_RGB( 30, 30, 30),
.bg_surface_raised = CG_RGB( 40, 40, 40),
.bg_overlay = CG_RGBA(0, 0, 0, 160),
.text_primary = CG_RGB(236, 236, 236),
.text_secondary = CG_RGB(170, 170, 170),
.text_disabled = CG_RGB(100, 100, 100),
.text_on_accent = CG_RGB(255, 255, 255),
.accent = CG_RGB( 99, 149, 255), // Blue
.accent_hover = CG_RGB(120, 166, 255),
.accent_pressed = CG_RGB( 70, 120, 220),
.accent_disabled = CG_RGB( 60, 80, 130),
.button_bg = CG_RGB( 99, 149, 255),
.button_bg_hover = CG_RGB(120, 166, 255),
.button_bg_pressed = CG_RGB( 70, 120, 220),
.button_fg = CG_RGB(255, 255, 255),
.input_bg = CG_RGB( 40, 40, 40),
.input_border = CG_RGB( 80, 80, 80),
.input_border_focus= CG_RGB( 99, 149, 255),
.input_selection = CG_RGBA(99, 149, 255, 80),
.input_placeholder = CG_RGB(100, 100, 100),
.border = CG_RGB( 60, 60, 60),
.divider = CG_RGB( 50, 50, 50),
.scrollbar = CG_RGB( 80, 80, 80),
.scrollbar_track = CG_RGB( 30, 30, 30),
.success = CG_RGB( 72, 199, 142),
.warning = CG_RGB(255, 182, 72),
.error = CG_RGB(255, 85, 85),
.info = CG_RGB( 99, 149, 255),
.surface_disabled = CG_RGB( 50, 50, 50),
},
.metrics = {
.button_radius = 6,
.input_radius = 6,
.card_radius = 10,
.tooltip_radius = 4,
.dialog_radius = 12,
.border_width = 1,
.focus_ring_width= 2,
.spacing_xs = 4,
.spacing_sm = 8,
.spacing_md = 16,
.spacing_lg = 24,
.spacing_xl = 32,
.font_size_sm = 12,
.font_size_md = 14,
.font_size_lg = 18,
.font_size_xl = 24,
},
};
static CgTheme theme_light = {
.name = "Light",
.colors = {
.bg_app = CG_RGB(245, 245, 247),
.bg_surface = CG_RGB(255, 255, 255),
.bg_surface_raised = CG_RGB(255, 255, 255),
.bg_overlay = CG_RGBA(0, 0, 0, 80),
.text_primary = CG_RGB( 20, 20, 20),
.text_secondary = CG_RGB( 90, 90, 90),
.text_disabled = CG_RGB(160, 160, 160),
.text_on_accent = CG_RGB(255, 255, 255),
.accent = CG_RGB( 37, 99, 235),
.accent_hover = CG_RGB( 59,118, 255),
.accent_pressed = CG_RGB( 20, 70, 180),
.button_bg = CG_RGB( 37, 99, 235),
.button_bg_hover = CG_RGB( 59, 118, 255),
.button_bg_pressed = CG_RGB( 20, 70, 180),
.button_fg = CG_RGB(255, 255, 255),
.input_bg = CG_RGB(255, 255, 255),
.input_border = CG_RGB(200, 200, 210),
.input_border_focus= CG_RGB( 37, 99, 235),
.input_selection = CG_RGBA(37, 99, 235, 60),
.input_placeholder = CG_RGB(160, 160, 170),
.border = CG_RGB(220, 220, 225),
.divider = CG_RGB(230, 230, 235),
.scrollbar = CG_RGB(180, 180, 190),
.scrollbar_track = CG_RGB(245, 245, 247),
.success = CG_RGB( 22, 163, 74),
.warning = CG_RGB(202, 138, 4),
.error = CG_RGB(220, 38, 38),
.info = CG_RGB( 37, 99, 235),
.surface_disabled = CG_RGB(235, 235, 240),
},
.metrics = { // Same metrics as dark
.button_radius = 6, .input_radius = 6, .card_radius = 10,
.border_width = 1, .focus_ring_width = 2,
.spacing_xs = 4, .spacing_sm = 8, .spacing_md = 16,
.spacing_lg = 24, .spacing_xl = 32,
.font_size_sm = 12, .font_size_md = 14,
.font_size_lg = 18, .font_size_xl = 24,
},
};
void cg_theme_set(CgTheme* theme) {
cg->theme = theme;
// Load fonts with theme's font sizes
const char* font_path = theme->font_path[0]
? theme->font_path
: "fonts/Inter-Regular.ttf";
theme->font_sm = cg_font_get(font_path, theme->metrics.font_size_sm, 0);
theme->font_md = cg_font_get(font_path, theme->metrics.font_size_md, 0);
theme->font_lg = cg_font_get(font_path, theme->metrics.font_size_lg, 0);
theme->font_xl = cg_font_get(font_path, theme->metrics.font_size_xl, 0);
}
TTF_Font* cg_theme_default_font(void) {
return cg->theme ? cg->theme->font_md : NULL;
}
void cg_use_dark_theme(void) { cg_theme_set(&theme_dark); }
void cg_use_light_theme(void) { cg_theme_set(&theme_light); }
Smooth transitions make a UI feel polished. Our animation engine supports tweening any float property over time with easing functions.
// include/cg.h
typedef enum {
CG_EASE_LINEAR,
CG_EASE_IN_QUAD,
CG_EASE_OUT_QUAD,
CG_EASE_IN_OUT_QUAD,
CG_EASE_IN_CUBIC,
CG_EASE_OUT_CUBIC,
CG_EASE_IN_OUT_CUBIC,
CG_EASE_OUT_BOUNCE,
CG_EASE_OUT_ELASTIC,
CG_EASE_OUT_BACK,
} CgEasing;
// src/cg_anim.c
#include <math.h>
static float apply_easing(float t, CgEasing easing) {
switch (easing) {
case CG_EASE_LINEAR: return t;
case CG_EASE_IN_QUAD: return t * t;
case CG_EASE_OUT_QUAD: return t * (2.0f - t);
case CG_EASE_IN_OUT_QUAD:
return t < 0.5f ? 2.0f * t * t : -1.0f + (4.0f - 2.0f * t) * t;
case CG_EASE_IN_CUBIC: return t * t * t;
case CG_EASE_OUT_CUBIC: {
float p = t - 1.0f;
return p * p * p + 1.0f;
}
case CG_EASE_IN_OUT_CUBIC:
return t < 0.5f ? 4.0f * t * t * t
: (t - 1.0f) * (2.0f * t - 2.0f) * (2.0f * t - 2.0f) + 1.0f;
case CG_EASE_OUT_BOUNCE: {
const float n1 = 7.5625f, d1 = 2.75f;
if (t < 1.0f / d1) return n1 * t * t;
if (t < 2.0f / d1) { t -= 1.5f / d1; return n1 * t * t + 0.75f; }
if (t < 2.5f / d1) { t -= 2.25f / d1; return n1 * t * t + 0.9375f; }
t -= 2.625f / d1; return n1 * t * t + 0.984375f;
}
case CG_EASE_OUT_ELASTIC: {
if (t == 0.0f || t == 1.0f) return t;
float c4 = (2.0f * (float)M_PI) / 3.0f;
return powf(2.0f, -10.0f * t) * sinf((t * 10.0f - 0.75f) * c4) + 1.0f;
}
case CG_EASE_OUT_BACK: {
const float c1 = 1.70158f, c3 = c1 + 1.0f;
float p = t - 1.0f;
return 1.0f + c3 * p * p * p + c1 * p * p;
}
default: return t;
}
}
typedef struct CgAnim {
float* target; // Pointer to the float being animated
float from;
float to;
float elapsed_ms;
float duration_ms;
CgEasing easing;
bool done;
CgWidget* owner;
void (*on_complete)(CgWidget*, void*);
void* on_complete_ud;
struct CgAnim* next;
} CgAnim;
// Start a float tween
CgAnim* cg_tween_float(CgWidget* owner, float* target, float to,
float duration_ms, CgEasing easing) {
// Cancel any existing tween targeting the same float
CgAnim** p = (CgAnim**)&owner->anims;
while (*p) {
if ((*p)->target == target) {
CgAnim* old = *p;
*p = old->next;
free(old);
} else {
p = &(*p)->next;
}
}
CgAnim* anim = calloc(1, sizeof(CgAnim));
anim->target = target;
anim->from = *target;
anim->to = to;
anim->elapsed_ms = 0.0f;
anim->duration_ms = SDL_max(duration_ms, 1.0f);
anim->easing = easing;
anim->owner = owner;
// Prepend to owner's animation list
anim->next = (CgAnim*)owner->anims;
owner->anims = (struct CgAnim*)anim;
// Also register in global list for update
anim->next = cg->anim_head;
cg->anim_head = anim;
return anim;
}
// Update all animations (called each frame)
void cg_anims_tick(float delta_ms) {
CgAnim** pp = &cg->anim_head;
while (*pp) {
CgAnim* a = *pp;
a->elapsed_ms += delta_ms;
float t = SDL_min(1.0f, a->elapsed_ms / a->duration_ms);
float et = apply_easing(t, a->easing);
*a->target = a->from + (a->to - a->from) * et;
if (t >= 1.0f) {
*a->target = a->to;
if (a->on_complete) a->on_complete(a->owner, a->on_complete_ud);
*pp = a->next;
free(a);
} else {
pp = &a->next;
}
}
}
typedef struct CgCheckbox {
CgWidget base;
char* label;
bool checked;
TTF_Font* font;
float check_anim; // 0.0 = unchecked, 1.0 = checked (animated)
} CgCheckbox;
static void checkbox_paint(CgWidget* w) {
CgCheckbox* cb = (CgCheckbox*)w;
SDL_Rect r = w->bounds_abs;
bool focused = (w->flags & CG_FLAG_FOCUSED) != 0;
bool hovered = (w->flags & CG_FLAG_HOVERED) != 0;
bool disabled = !(w->flags & CG_FLAG_ENABLED);
int box_size = cg_font_line_height(cb->font);
SDL_Rect box = {r.x, r.y + (r.h - box_size) / 2, box_size, box_size};
// Box background
CgColor box_bg;
if (cb->checked) {
box_bg = disabled ? cg->theme->colors.accent_disabled
: hovered ? cg->theme->colors.accent_hover
: cg->theme->colors.accent;
} else {
box_bg = disabled ? cg->theme->colors.surface_disabled
: cg->theme->colors.input_bg;
}
cg_draw_rounded_rect(box, 4, box_bg);
cg_draw_rounded_rect_outline(box, 4, 1, cg->theme->colors.input_border);
// Check mark (drawn as two lines forming a β)
if (cb->check_anim > 0.0f) {
// Interpolate check mark drawing based on anim
CgColor check_color = cg->theme->colors.text_on_accent;
check_color.a = (uint8_t)(255 * cb->check_anim);
// Line 1: short stroke of the checkmark
SDL_SetRenderDrawColor(cg->renderer, check_color.r, check_color.g,
check_color.b, check_color.a);
SDL_SetRenderDrawBlendMode(cg->renderer, SDL_BLENDMODE_BLEND);
int x1 = box.x + box.w * 2/8;
int y1 = box.y + box.h * 5/8;
int xm = box.x + box.w * 4/8;
int ym = box.y + box.h * 7/8;
int x2 = box.x + box.w * 7/8;
int y2 = box.y + box.h * 2/8;
// Draw with 2px thickness
for (int t = -1; t <= 1; t++) {
SDL_RenderDrawLine(cg->renderer, x1+t, y1, xm+t, ym);
SDL_RenderDrawLine(cg->renderer, xm+t, ym, x2+t, y2);
}
}
// Focus ring
if (focused) {
SDL_Rect ring = {box.x-2, box.y-2, box.w+4, box.h+4};
CgColor fc = cg->theme->colors.accent; fc.a = 180;
cg_draw_rounded_rect_outline(ring, 6, 2, fc);
}
// Label
if (cb->label && *cb->label) {
CgColor lc = disabled ? cg->theme->colors.text_disabled
: cg->theme->colors.text_primary;
int tw, th;
SDL_Texture* tex = cg_text_texture(cb->font, cb->label, lc, &tw, &th);
if (tex) {
int ty = r.y + (r.h - th) / 2;
SDL_Rect dst = {box.x + box.w + 8, ty, tw, th};
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
SDL_RenderCopy(cg->renderer, tex, NULL, &dst);
}
}
}
static bool checkbox_on_event(CgWidget* w, CgEvent* ev) {
CgCheckbox* cb = (CgCheckbox*)w;
if ((ev->type == CG_EVENT_MOUSE_CLICK && ev->mouse.button == SDL_BUTTON_LEFT) ||
(ev->type == CG_EVENT_KEY_DOWN &&
(ev->key.sym == SDLK_SPACE || ev->key.sym == SDLK_RETURN))) {
if (!(w->flags & CG_FLAG_ENABLED)) return true;
cb->checked = !cb->checked;
// Animate check mark
cg_tween_float(w, &cb->check_anim,
cb->checked ? 1.0f : 0.0f,
150, CG_EASE_OUT_CUBIC);
CgEvent change = {.type = CG_EVENT_CHANGE};
change.change.value = cb->checked ? 1.0f : 0.0f;
cg_fire_event(w, &change);
return true;
}
return false;
}
typedef struct CgSlider {
CgWidget base;
float value; // Current value in [min, max]
float min, max;
float step; // 0 = continuous
bool vertical;
TTF_Font* font; // For value label
bool show_value;
// Visuals
CgColor track_color;
CgColor fill_color;
CgColor thumb_color;
CgColor thumb_hover_color;
int track_height;
int thumb_size;
float thumb_anim; // Hover animation
// Dragging
bool dragging;
int drag_start_x;
float drag_start_val;
} CgSlider;
static void slider_paint(CgWidget* w) {
CgSlider* s = (CgSlider*)w;
SDL_Rect r = w->bounds_abs;
bool hovered = (w->flags & CG_FLAG_HOVERED) != 0;
bool focused = (w->flags & CG_FLAG_FOCUSED) != 0;
bool disabled = !(w->flags & CG_FLAG_ENABLED);
// Track
int track_y = r.y + (r.h - s->track_height) / 2;
SDL_Rect track = {r.x + s->thumb_size/2, track_y,
r.w - s->thumb_size, s->track_height};
cg_draw_rounded_rect(track, s->track_height/2, s->track_color);
// Fill (0 to thumb)
float norm = (s->value - s->min) / (s->max - s->min);
norm = SDL_clamp(norm, 0.0f, 1.0f);
int fill_w = (int)(norm * track.w);
SDL_Rect fill = {track.x, track.y, fill_w, track.h};
CgColor fill_c = disabled ? cg->theme->colors.accent_disabled : s->fill_color;
cg_draw_rounded_rect(fill, s->track_height/2, fill_c);
// Thumb
int thumb_x = track.x + fill_w - s->thumb_size/2;
int thumb_y = r.y + (r.h - s->thumb_size) / 2;
SDL_Rect thumb = {thumb_x, thumb_y, s->thumb_size, s->thumb_size};
CgColor thumb_c = disabled ? cg->theme->colors.surface_disabled
: cg_color_lerp(s->thumb_color, s->thumb_hover_color,
s->thumb_anim);
// Thumb shadow
cg_draw_shadow(thumb, s->thumb_size/2, 3, CG_RGBA(0,0,0,60));
cg_draw_rounded_rect(thumb, s->thumb_size/2, thumb_c);
// Focus ring
if (focused) {
SDL_Rect ring = {thumb.x-2, thumb.y-2, thumb.w+4, thumb.h+4};
CgColor fc = cg->theme->colors.accent; fc.a = 150;
cg_draw_rounded_rect_outline(ring, s->thumb_size/2 + 2, 2, fc);
}
// Value label
if (s->show_value && s->font) {
char val_str[32];
if (s->step >= 1.0f) snprintf(val_str, sizeof(val_str), "%.0f", s->value);
else snprintf(val_str, sizeof(val_str), "%.2f", s->value);
int tw, th;
CgColor tc = disabled ? cg->theme->colors.text_disabled
: cg->theme->colors.text_secondary;
SDL_Texture* tex = cg_text_texture(s->font, val_str, tc, &tw, &th);
if (tex) {
int tx = thumb_x + (s->thumb_size - tw) / 2;
int ty = thumb_y - th - 6;
SDL_Rect dst = {tx, ty, tw, th};
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
SDL_RenderCopy(cg->renderer, tex, NULL, &dst);
}
}
}
static bool slider_on_event(CgWidget* w, CgEvent* ev) {
CgSlider* s = (CgSlider*)w;
SDL_Rect r = w->bounds_abs;
if (ev->type == CG_EVENT_MOUSE_ENTER) {
cg_tween_float(w, &s->thumb_anim, 1.0f, 100, CG_EASE_OUT_QUAD);
return false;
}
if (ev->type == CG_EVENT_MOUSE_LEAVE && !s->dragging) {
cg_tween_float(w, &s->thumb_anim, 0.0f, 100, CG_EASE_OUT_QUAD);
return false;
}
if (ev->type == CG_EVENT_MOUSE_DOWN && ev->mouse.button == SDL_BUTTON_LEFT) {
if (!(w->flags & CG_FLAG_ENABLED)) return true;
s->dragging = true;
s->drag_start_x = ev->mouse.x;
s->drag_start_val = s->value;
cg->mouse_captured = w;
// Jump to click position
int track_x = r.x + s->thumb_size/2;
int track_w = r.w - s->thumb_size;
float norm = (float)(ev->mouse.x - track_x) / track_w;
s->value = s->min + norm * (s->max - s->min);
s->value = SDL_clamp(s->value, s->min, s->max);
if (s->step > 0.0f) s->value = roundf(s->value / s->step) * s->step;
CgEvent change = {.type = CG_EVENT_CHANGE, .change.value = s->value};
cg_fire_event(w, &change);
return true;
}
if (ev->type == CG_EVENT_MOUSE_MOVE && s->dragging) {
int track_w = r.w - s->thumb_size;
int delta = ev->mouse.x - s->drag_start_x;
float delta_val = (float)delta / track_w * (s->max - s->min);
s->value = s->drag_start_val + delta_val;
s->value = SDL_clamp(s->value, s->min, s->max);
if (s->step > 0.0f) s->value = roundf(s->value / s->step) * s->step;
CgEvent change = {.type = CG_EVENT_CHANGE, .change.value = s->value};
cg_fire_event(w, &change);
return true;
}
if (ev->type == CG_EVENT_MOUSE_UP) {
s->dragging = false;
if (cg->mouse_captured == w) cg->mouse_captured = NULL;
if (!(w->flags & CG_FLAG_HOVERED))
cg_tween_float(w, &s->thumb_anim, 0.0f, 100, CG_EASE_OUT_QUAD);
return true;
}
if (ev->type == CG_EVENT_KEY_DOWN) {
float delta = s->step > 0 ? s->step : (s->max - s->min) * 0.05f;
if (ev->key.sym == SDLK_LEFT || ev->key.sym == SDLK_DOWN)
s->value -= delta;
else if (ev->key.sym == SDLK_RIGHT || ev->key.sym == SDLK_UP)
s->value += delta;
else
return false;
s->value = SDL_clamp(s->value, s->min, s->max);
CgEvent change = {.type = CG_EVENT_CHANGE, .change.value = s->value};
cg_fire_event(w, &change);
return true;
}
return false;
}
We will build cgPad: a minimal text editor / settings application that demonstrates every major feature of the library.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β [cgPad] [Dark] [Light] [Γ] β β Titlebar
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β [New] [Open] [Save] <spacer> [Font: 14 βΌ] β β Toolbar
ββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ€
β Properties β β
β β Main text editing area β
β Font Size: β (scrollable) β
β [====β’====] β β
β β β
β Word Wrap: β β
β [β] β β
β β β
β Theme: β β
β (β’) Dark β β
β ( ) Light β β
β β β
β Opacity: β β
β [====β’====] β β
ββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββ€
β Line: 1, Col: 1 β UTF-8 β CRLF β Ready β β Statusbar
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// demo/main.c β cgPad demo application
#include "cg.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// ββ Application State ββββββββββββββββββββββββββββββββββββββββββββββββββββ
typedef struct AppState {
// Widgets we need to reference later
CgWidget* title_label;
CgWidget* editor; // Main text input
CgWidget* font_slider;
CgWidget* wrap_check;
CgWidget* status_label;
CgWidget* dark_radio;
CgWidget* light_radio;
CgWidget* opacity_slider;
// State
bool dark_theme;
int font_size;
bool word_wrap;
float opacity;
char status_buf[128];
// Window (for opacity)
SDL_Window* window;
} AppState;
static AppState app = {
.dark_theme = true,
.font_size = 14,
.word_wrap = true,
.opacity = 1.0f,
};
// ββ Callbacks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
static bool on_font_size_change(CgWidget* w, CgEvent* ev, void* ud) {
(void)w; (void)ud;
app.font_size = (int)ev->change.value;
char buf[64];
snprintf(buf, sizeof(buf), "Font: %d px", app.font_size);
cg_label_set_text(app.title_label, buf);
return true;
}
static bool on_wrap_change(CgWidget* w, CgEvent* ev, void* ud) {
(void)w; (void)ud;
app.word_wrap = ev->change.value > 0.5f;
cg_label_set_wrap(app.editor, app.word_wrap);
return true;
}
static bool on_dark_click(CgWidget* w, CgEvent* ev, void* ud) {
(void)w; (void)ev; (void)ud;
app.dark_theme = true;
cg_use_dark_theme();
return true;
}
static bool on_light_click(CgWidget* w, CgEvent* ev, void* ud) {
(void)w; (void)ev; (void)ud;
app.dark_theme = false;
cg_use_light_theme();
return true;
}
static bool on_opacity_change(CgWidget* w, CgEvent* ev, void* ud) {
(void)w; (void)ud;
app.opacity = ev->change.value;
SDL_SetWindowOpacity(app.window, app.opacity);
return true;
}
static bool on_new_click(CgWidget* w, CgEvent* ev, void* ud) {
(void)w; (void)ev; (void)ud;
cg_input_set_text(app.editor, "");
snprintf(app.status_buf, sizeof(app.status_buf), "New file");
cg_label_set_text(app.status_label, app.status_buf);
return true;
}
static bool on_editor_change(CgWidget* w, CgEvent* ev, void* ud) {
(void)w; (void)ud;
int len = ev->change.str_value ? (int)strlen(ev->change.str_value) : 0;
snprintf(app.status_buf, sizeof(app.status_buf), "%d characters", len);
cg_label_set_text(app.status_label, app.status_buf);
return false;
}
// ββ Build UI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
static CgWidget* build_titlebar(void) {
CgWidget* bar = cg_row();
cg_panel_set_bg(bar, cg->theme->colors.bg_surface);
cg_panel_set_padding(bar, CG_INSETS(0, 12, 0, 12));
cg_panel_set_gap(bar, 8);
bar->desired_h = 42;
bar->flags |= CG_FLAG_EXPAND_W;
app.title_label = cg_label_new("cgPad β Untitled");
cg_label_set_font(app.title_label,
cg_font_get("fonts/Inter-SemiBold.ttf", 14, 0));
cg_panel_add(bar, app.title_label);
cg_panel_add(bar, cg_spacer_new());
// Theme toggles
CgWidget* dark_btn = cg_button_new("β Dark");
cg_button_set_style(dark_btn, CG_BTN_STYLE_TEXT);
cg_on_click(dark_btn, on_dark_click, NULL);
cg_panel_add(bar, dark_btn);
CgWidget* light_btn = cg_button_new("β Light");
cg_button_set_style(light_btn, CG_BTN_STYLE_TEXT);
cg_on_click(light_btn, on_light_click, NULL);
cg_panel_add(bar, light_btn);
return bar;
}
static CgWidget* build_toolbar(void) {
CgWidget* bar = cg_row();
cg_panel_set_bg(bar, cg->theme->colors.bg_surface);
cg_panel_set_padding(bar, CG_INSETS(6, 8, 6, 8));
cg_panel_set_gap(bar, 4);
bar->desired_h = 46;
bar->flags |= CG_FLAG_EXPAND_W;
// Add a separator helper
CgWidget* sep(void) {
CgWidget* s = cg_widget_alloc(sizeof(CgWidget), CG_TYPE_SEPARATOR, NULL);
s->desired_w = 1;
s->flags |= CG_FLAG_EXPAND_H;
s->margin = CG_INSETS(4, 4, 4, 4);
return s;
}
CgWidget* new_btn = cg_button_new("β New");
CgWidget* open_btn = cg_button_new("π Open");
CgWidget* save_btn = cg_button_new("πΎ Save");
cg_button_set_style(new_btn, CG_BTN_STYLE_OUTLINED);
cg_button_set_style(open_btn, CG_BTN_STYLE_TEXT);
cg_button_set_style(save_btn, CG_BTN_STYLE_TEXT);
cg_on_click(new_btn, on_new_click, NULL);
cg_panel_add(bar, new_btn);
cg_panel_add(bar, open_btn);
cg_panel_add(bar, save_btn);
cg_panel_add(bar, cg_spacer_new());
CgWidget* font_label = cg_label_new("Font size:");
cg_panel_add(bar, font_label);
CgWidget* fslider = cg_slider_new(8, 48, app.font_size);
fslider->desired_w = 120;
cg_slider_set_step(fslider, 1.0f);
cg_slider_set_show_value(fslider, true);
cg_on_change(fslider, on_font_size_change, NULL);
app.font_slider = fslider;
cg_panel_add(bar, fslider);
return bar;
}
static CgWidget* build_sidebar(void) {
CgWidget* side = cg_col();
cg_panel_set_bg(side, cg->theme->colors.bg_surface);
cg_panel_set_padding(side, CG_INSETS_ALL(16));
cg_panel_set_gap(side, 16);
side->desired_w = 180;
side->flags |= CG_FLAG_EXPAND_H;
// Section: Text settings
CgWidget* sec1 = cg_label_new("TEXT");
cg_label_set_color(sec1, cg->theme->colors.text_secondary);
cg_label_set_font(sec1, cg_font_get("fonts/Inter-SemiBold.ttf", 11, 0));
cg_panel_add(side, sec1);
CgWidget* wrap = cg_checkbox_new("Word wrap", app.word_wrap);
cg_on_change(wrap, on_wrap_change, NULL);
app.wrap_check = wrap;
cg_panel_add(side, wrap);
// Divider
CgWidget* div = cg_widget_alloc(sizeof(CgWidget), CG_TYPE_SEPARATOR, NULL);
div->desired_h = 1;
div->flags |= CG_FLAG_EXPAND_W;
cg_panel_add(side, div);
// Section: Theme
CgWidget* sec2 = cg_label_new("THEME");
cg_label_set_color(sec2, cg->theme->colors.text_secondary);
cg_label_set_font(sec2, cg_font_get("fonts/Inter-SemiBold.ttf", 11, 0));
cg_panel_add(side, sec2);
CgWidget* dark_r = cg_radio_new("Dark", "theme", true);
CgWidget* light_r = cg_radio_new("Light", "theme", false);
cg_on_click(dark_r, on_dark_click, NULL);
cg_on_click(light_r, on_light_click, NULL);
app.dark_radio = dark_r;
app.light_radio = light_r;
cg_panel_add(side, dark_r);
cg_panel_add(side, light_r);
// Divider
CgWidget* div2 = cg_widget_alloc(sizeof(CgWidget), CG_TYPE_SEPARATOR, NULL);
div2->desired_h = 1;
div2->flags |= CG_FLAG_EXPAND_W;
cg_panel_add(side, div2);
// Section: Window
CgWidget* sec3 = cg_label_new("WINDOW");
cg_label_set_color(sec3, cg->theme->colors.text_secondary);
cg_label_set_font(sec3, cg_font_get("fonts/Inter-SemiBold.ttf", 11, 0));
cg_panel_add(side, sec3);
CgWidget* op_label = cg_label_new("Opacity");
cg_panel_add(side, op_label);
CgWidget* op_slider = cg_slider_new(0.1f, 1.0f, 1.0f);
op_slider->flags |= CG_FLAG_EXPAND_W;
cg_on_change(op_slider, on_opacity_change, NULL);
app.opacity_slider = op_slider;
cg_panel_add(side, op_slider);
cg_panel_add(side, cg_spacer_new());
// Version info at bottom
CgWidget* ver = cg_label_new("cgPad v0.1\nBuilt with cg + SDL2");
cg_label_set_color(ver, cg->theme->colors.text_secondary);
cg_label_set_wrap(ver, true);
cg_panel_add(side, ver);
return side;
}
static CgWidget* build_editor_area(void) {
// Scrollable text input area
CgWidget* scroll = cg_scroll_new();
scroll->flags |= CG_FLAG_EXPAND_W | CG_FLAG_EXPAND_H;
CgWidget* editor = cg_input_new();
editor->flags |= CG_FLAG_EXPAND_W | CG_FLAG_EXPAND_H;
cg_input_set_multiline(editor, true);
cg_input_set_placeholder(editor, "Start typing...\n\n"
"This editor supports:\n"
" β’ Cursor navigation (arrow keys, Home, End)\n"
" β’ Selection (Shift+arrows)\n"
" β’ Copy/Paste (Ctrl+C, Ctrl+V)\n"
" β’ Undo/Redo (Ctrl+Z, Ctrl+Y)\n");
cg_on_change(editor, on_editor_change, NULL);
app.editor = editor;
cg_scroll_set_content(scroll, editor);
return scroll;
}
static CgWidget* build_statusbar(void) {
CgWidget* bar = cg_row();
cg_panel_set_bg(bar, cg->theme->colors.bg_surface);
cg_panel_set_padding(bar, CG_INSETS(4, 12, 4, 12));
cg_panel_set_gap(bar, 12);
bar->desired_h = 26;
bar->flags |= CG_FLAG_EXPAND_W;
app.status_label = cg_label_new("Ready");
cg_label_set_color(app.status_label, cg->theme->colors.text_secondary);
cg_label_set_font(app.status_label, cg_font_get("fonts/Inter-Regular.ttf", 11, 0));
cg_panel_add(bar, app.status_label);
cg_panel_add(bar, cg_spacer_new());
CgWidget* enc = cg_label_new("UTF-8 Β· LF");
cg_label_set_color(enc, cg->theme->colors.text_secondary);
cg_label_set_font(enc, cg_font_get("fonts/Inter-Regular.ttf", 11, 0));
cg_panel_add(bar, enc);
return bar;
}
static CgWidget* build_ui(void) {
// Root: full-window column
CgWidget* root = cg_col();
cg_panel_set_bg(root, cg->theme->colors.bg_app);
root->flags |= CG_FLAG_EXPAND_W | CG_FLAG_EXPAND_H;
cg_panel_add(root, build_titlebar());
// Add horizontal divider
CgWidget* hdiv = cg_widget_alloc(sizeof(CgWidget), CG_TYPE_SEPARATOR, NULL);
hdiv->desired_h = 1;
hdiv->flags |= CG_FLAG_EXPAND_W;
cg_panel_add(root, hdiv);
cg_panel_add(root, build_toolbar());
CgWidget* hdiv2 = cg_widget_alloc(sizeof(CgWidget), CG_TYPE_SEPARATOR, NULL);
hdiv2->desired_h = 1;
hdiv2->flags |= CG_FLAG_EXPAND_W;
cg_panel_add(root, hdiv2);
// Main area: sidebar + editor
CgWidget* main_area = cg_row();
main_area->flags |= CG_FLAG_EXPAND_W | CG_FLAG_EXPAND_H;
cg_panel_add(main_area, build_sidebar());
// Vertical divider
CgWidget* vdiv = cg_widget_alloc(sizeof(CgWidget), CG_TYPE_SEPARATOR, NULL);
vdiv->desired_w = 1;
vdiv->flags |= CG_FLAG_EXPAND_H;
cg_panel_add(main_area, vdiv);
cg_panel_add(main_area, build_editor_area());
cg_panel_add(root, main_area);
CgWidget* hdiv3 = cg_widget_alloc(sizeof(CgWidget), CG_TYPE_SEPARATOR, NULL);
hdiv3->desired_h = 1;
hdiv3->flags |= CG_FLAG_EXPAND_W;
cg_panel_add(root, hdiv3);
cg_panel_add(root, build_statusbar());
return root;
}
// ββ Main Loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
int main(int argc, char* argv[]) {
(void)argc; (void)argv;
// Initialise cg
cg_init(800, 600, "cgPad", SDL_WINDOW_RESIZABLE);
cg_use_dark_theme();
app.window = cg->window;
// Build UI
cg->root = build_ui();
cg_set_focus(app.editor);
// Main loop
bool running = true;
while (running) {
Uint32 frame_start = SDL_GetTicks();
// Events
SDL_Event sdl_ev;
while (SDL_PollEvent(&sdl_ev)) {
if (sdl_ev.type == SDL_QUIT) { running = false; break; }
cg_process_sdl_event(&sdl_ev);
}
// Update animations
cg_anims_tick(cg->delta_ms);
// Layout (only if dirty)
if (cg->root->flags & CG_FLAG_DIRTY)
cg_layout_root(cg->root);
// Paint
SDL_SetRenderDrawColor(cg->renderer, 0, 0, 0, 255);
SDL_RenderClear(cg->renderer);
cg_paint_widget(cg->root);
SDL_RenderPresent(cg->renderer);
// Timing
Uint32 frame_ms = SDL_GetTicks() - frame_start;
if (frame_ms < 16) SDL_Delay(16 - frame_ms); // Cap at ~60 FPS
cg->delta_ms = (float)(SDL_GetTicks() - frame_start);
}
// Cleanup
cg_widget_destroy(cg->root);
cg_shutdown();
return 0;
}
cg_init and cg_shutdown Functions// src/cg_core.c
CgCtx* cg = NULL;
void cg_init(int w, int h, const char* title, Uint32 window_flags) {
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1");
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER);
cg = calloc(1, sizeof(CgCtx));
cg->window = SDL_CreateWindow(title,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
w, h,
window_flags | SDL_WINDOW_ALLOW_HIGHDPI);
cg->renderer = SDL_CreateRenderer(cg->window, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
SDL_GetWindowSize(cg->window, &cg->window_w, &cg->window_h);
SDL_RenderSetLogicalSize(cg->renderer, cg->window_w, cg->window_h);
int dw, dh;
SDL_GL_GetDrawableSize(cg->window, &dw, &dh);
cg->dpi_scale = (float)dw / cg->window_w;
cg_fonts_init();
cg->last_tick = SDL_GetTicks();
}
void cg_shutdown(void) {
cg_fonts_shutdown();
SDL_DestroyRenderer(cg->renderer);
SDL_DestroyWindow(cg->window);
SDL_Quit();
free(cg);
cg = NULL;
}
void cg_paint_widget(CgWidget* w) {
if (!w || !(w->flags & CG_FLAG_VISIBLE)) return;
// Push clip if requested
bool clipped = (w->flags & CG_FLAG_CLIP) != 0;
if (clipped) cg_push_clip(w->bounds_abs);
// Draw this widget
if (w->vtable && w->vtable->paint)
w->vtable->paint(w);
// Recurse to children (depth-first, preserves paint order)
CgWidget* child = w->first_child;
while (child) {
cg_paint_widget(child);
child = child->next_sibling;
}
if (clipped) cg_pop_clip();
}
// ββ Lifecycle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_init(int w, int h, const char* title, Uint32 flags);
void cg_shutdown(void);
void cg_use_dark_theme(void);
void cg_use_light_theme(void);
void cg_theme_set(CgTheme* theme);
// ββ Widget tree ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_widget_add_child(CgWidget* parent, CgWidget* child);
void cg_widget_remove(CgWidget* w);
void cg_widget_destroy(CgWidget* w);
void cg_widget_mark_dirty(CgWidget* w);
void cg_widget_set_visible(CgWidget* w, bool visible);
void cg_widget_set_enabled(CgWidget* w, bool enabled);
void cg_widget_on(CgWidget* w, CgEventType type,
CgEventHandler fn, void* ud);
// ββ Layout βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_layout_root(CgWidget* root);
SDL_Rect cg_content_rect(CgWidget* w);
// ββ Events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_process_sdl_event(SDL_Event* ev);
bool cg_fire_event(CgWidget* target, CgEvent* ev);
// ββ Focus ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_set_focus(CgWidget* w);
void cg_clear_focus(void);
void cg_focus_next(bool reverse);
// ββ Painting βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_paint_widget(CgWidget* root);
void cg_push_clip(SDL_Rect rect);
void cg_pop_clip(void);
void cg_draw_rect(SDL_Rect r, CgColor c);
void cg_draw_rect_outline(SDL_Rect r, int thickness, CgColor c);
void cg_draw_rounded_rect(SDL_Rect r, int radius, CgColor c);
void cg_draw_rounded_rect_outline(SDL_Rect r, int rad, int bw, CgColor c);
void cg_draw_hline(int x, int y, int w, CgColor c);
void cg_draw_vline(int x, int y, int h, CgColor c);
void cg_draw_shadow(SDL_Rect r, int radius, int blur, CgColor c);
// ββ Fonts / Text βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void cg_fonts_init(void);
void cg_fonts_shutdown(void);
TTF_Font* cg_font_get(const char* path, int size, int style);
CgSize cg_text_measure(TTF_Font* font, const char* text);
int cg_font_line_height(TTF_Font* font);
CgTextLayout cg_text_wrap(TTF_Font* font, const char* text, int max_w);
void cg_text_layout_free(CgTextLayout* layout);
SDL_Texture* cg_text_texture(TTF_Font* font, const char* text, CgColor c,
int* out_w, int* out_h);
// ββ Animation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
CgAnim* cg_tween_float(CgWidget* owner, float* target, float to,
float duration_ms, CgEasing easing);
void cg_anims_tick(float delta_ms);
// ββ Widgets ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
CgWidget* cg_panel_new(CgDirection direction);
CgWidget* cg_row(void);
CgWidget* cg_col(void);
void cg_panel_add(CgWidget* panel, CgWidget* child);
void cg_panel_set_gap(CgWidget* panel, int gap);
void cg_panel_set_justify(CgWidget* panel, CgJustify justify);
void cg_panel_set_align(CgWidget* panel, CgAlign align);
void cg_panel_set_bg(CgWidget* panel, CgColor color);
void cg_panel_set_border(CgWidget* panel, int w, int r, CgColor c);
CgWidget* cg_spacer_new(void);
CgWidget* cg_label_new(const char* text);
void cg_label_set_text(CgWidget* w, const char* text);
void cg_label_set_font(CgWidget* w, TTF_Font* font);
void cg_label_set_color(CgWidget* w, CgColor color);
void cg_label_set_align(CgWidget* w, int halign, int valign);
void cg_label_set_wrap(CgWidget* w, bool wrap);
CgWidget* cg_button_new(const char* label);
void cg_button_set_style(CgWidget* w, CgButtonStyle style);
void cg_button_set_label(CgWidget* w, const char* label);
CgWidget* cg_input_new(void);
void cg_input_set_placeholder(CgWidget* w, const char* text);
void cg_input_set_text(CgWidget* w, const char* text);
const char* cg_input_get_text(CgWidget* w);
void cg_input_set_multiline(CgWidget* w, bool multiline);
void cg_input_set_password(CgWidget* w, bool password);
void cg_input_set_max_chars(CgWidget* w, int max);
CgWidget* cg_checkbox_new(const char* label, bool checked);
bool cg_checkbox_get_checked(CgWidget* w);
void cg_checkbox_set_checked(CgWidget* w, bool checked);
CgWidget* cg_radio_new(const char* label, const char* group, bool selected);
CgWidget* cg_slider_new(float min, float max, float value);
float cg_slider_get_value(CgWidget* w);
void cg_slider_set_value(CgWidget* w, float value);
void cg_slider_set_step(CgWidget* w, float step);
void cg_slider_set_show_value(CgWidget* w, bool show);
CgWidget* cg_scroll_new(void);
void cg_scroll_set_content(CgWidget* scroll, CgWidget* content);
void cg_scroll_set_scroll(CgWidget* scroll, int x, int y);
CgWidget* cg_image_new(const char* path);
CgWidget* cg_image_from_texture(SDL_Texture* tex, int w, int h);
// WRONG: desired_w in content pixels but expecting full-widget pixels
CgWidget* btn = cg_button_new("Click");
btn->desired_w = 100; // This is fine β explicit pixel width for the widget
// CORRECT: Use FILL to make widget take remaining space
CgWidget* input = cg_input_new();
input->flags |= CG_FLAG_EXPAND_W; // Flex-grow: take all remaining width
input->desired_w = CG_SIZE_FILL; // Equivalent hint
// WRONG: Destroying and rebuilding the tree every frame
void render_frame(void) {
cg_widget_destroy(cg->root); // Kills all widgets β slow!
cg->root = build_ui();
}
// CORRECT: Update widget properties in place
void update_label(const char* new_text) {
cg_label_set_text(my_label, new_text); // Just dirty the label, fast
}
// WRONG: handler references stack memory
void some_function(void) {
int counter = 0;
cg_on_click(btn, handler, &counter); // &counter is gone after return!
}
// CORRECT: use heap-allocated userdata
typedef struct { int counter; } HandlerData;
void setup(void) {
HandlerData* d = malloc(sizeof(HandlerData));
d->counter = 0;
cg_on_click(btn, handler, d);
// d will be freed when widget is destroyed via destroy callback
}
// Every push_clip MUST have a matching pop_clip
static void custom_paint(CgWidget* w) {
cg_push_clip(w->bounds_abs);
// ... draw ...
if (something_went_wrong) {
cg_pop_clip(); // Must pop before returning!
return;
}
// ... more drawing ...
cg_pop_clip();
}
// 1. Define your widget struct (must start with CgWidget base)
typedef struct MyWidget {
CgWidget base; // MUST be first
// ... your state ...
float my_value;
char* my_text;
} MyWidget;
// 2. Implement the virtual functions you need
static void my_measure(CgWidget* w, int aw, int ah) {
// Compute content_size
w->content_size = (CgSize){100, 40};
}
static void my_paint(CgWidget* w) {
MyWidget* m = (MyWidget*)w;
cg_draw_rounded_rect(w->bounds_abs, 8, CG_RGB(200, 100, 50));
// ...
}
static bool my_on_event(CgWidget* w, CgEvent* ev) {
MyWidget* m = (MyWidget*)w;
if (ev->type == CG_EVENT_MOUSE_CLICK) {
// handle click
return true; // Consumed
}
return false; // Propagate
}
static void my_destroy(CgWidget* w) {
MyWidget* m = (MyWidget*)w;
free(m->my_text);
}
// 3. Create the vtable
#define CG_TYPE_MY_WIDGET 100 // Use values above 20 for custom widgets
static const CgWidgetVTable my_widget_vtable = {
.measure = my_measure,
.arrange = NULL, // No children, skip arrange
.paint = my_paint,
.on_event = my_on_event,
.destroy = my_destroy,
};
// 4. Constructor
CgWidget* my_widget_new(float value, const char* text) {
MyWidget* m = (MyWidget*)cg_widget_alloc(sizeof(MyWidget),
CG_TYPE_MY_WIDGET, &my_widget_vtable);
m->my_value = value;
m->my_text = strdup(text);
m->base.flags |= CG_FLAG_FOCUSABLE;
m->base.padding = CG_INSETS_ALL(8);
return (CgWidget*)m;
}
This guide covered the complete implementation of a GUI library from scratch: SDL2 foundations, rendering primitives with rounded corners and alpha, a retained-mode widget tree, a flexbox-inspired layout engine, a bubbling event system with hit testing, focus management, font rendering with SDL_ttf including word wrap and caret, all core widgets (label, button, text input, checkbox, slider, scrollview, panel), a theming system with dark and light modes, a tween animation engine, and a complete demo application. Every concept is grounded in working C code that can be compiled and run today.