Skip to content

SDL2 UI Patterns for Embedded Displays

Time: 45 min | Prerequisites: SDL2 Rotating Cube, Touch Paint

This tutorial covers common UI patterns for building interactive applications on embedded displays using SDL2 — without a widget toolkit. You'll learn to build modal overlays, touch buttons, draggable sliders, and hit-test logic using techniques from the Audio Visualizer Full demo.


1. When SDL2 vs Qt

SDL2 Qt Quick/QML
Best for Real-time rendering, games, visualizers, single-screen apps Multi-page apps, forms, complex layouts
UI approach Manual: you draw every pixel Declarative: you describe the layout
Touch SDL_FINGERDOWN/MOTION/UP with normalized 0.0–1.0 coords Built-in gesture recognizers, Flickable, SwipeView
Text Bitmap font or SDL2_ttf Full font rendering, rich text
Binary size ~200 KB ~20 MB (Qt6 + QML)
Startup < 50 ms ~500 ms

Rule of thumb: if your app has more than 2 screens of UI with text input, use Qt. If it's a real-time display with a few buttons, SDL2 is simpler and faster.


2. Bitmap Font Rendering

Without SDL2_ttf, embed a small font as a lookup table. A 5x7 pixel font covers ASCII and renders at any integer scale:

#define FONT_W 5
#define FONT_H 7

/* Each row is 5 bits wide, MSB = leftmost pixel */
static const uint8_t font5x7[128][FONT_H] = {
    ['A'] = {0x0E,0x11,0x11,0x1F,0x11,0x11,0x11},
    ['0'] = {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E},
    /* ... fill in needed characters ... */
};

static void draw_text(SDL_Renderer *r, const char *str,
                      int x0, int y0, int scale)
{
    int cx = x0;
    for (const char *p = str; *p; p++) {
        int ch = (unsigned char)*p;
        if (ch >= 128) ch = ' ';
        for (int row = 0; row < FONT_H; row++) {
            uint8_t bits = font5x7[ch][row];
            for (int col = 0; col < FONT_W; col++) {
                if (bits & (0x10 >> col)) {
                    SDL_Rect px = { cx + col * scale,
                                    y0 + row * scale,
                                    scale, scale };
                    SDL_RenderFillRect(r, &px);
                }
            }
        }
        cx += (FONT_W + 1) * scale;
    }
}
Why Not SDL2_ttf?

SDL2_ttf adds a FreeType dependency (~1 MB), requires .ttf font files, and renders to surface textures. For a handful of labels at fixed sizes, a bitmap font is smaller, faster, and has zero external dependencies. Use SDL2_ttf when you need variable font sizes, Unicode, or anti-aliased text.

Usage: Set the draw color before calling:

SDL_SetRenderDrawColor(ren, 200, 200, 220, 255);
draw_text(ren, "Hello", 10, 10, 2);  /* 2x scale = 10x14 px */


3. Touch Buttons

3.1 Button Data Structure

Define buttons with label, state, color, and normalized touch bounds:

#define BTN_COUNT 4
#define BTN_H     36

typedef struct {
    const char *label;
    int active;           /* toggle state */
    Uint8 r, g, b;        /* accent color */
    float x0, x1;         /* normalized X bounds (0.0 – 1.0) */
} touch_btn_t;

static touch_btn_t buttons[BTN_COUNT] = {
    { "REC",    0, 255, 60, 60,   0, 0 },
    { "FILTER", 0, 60, 200, 255,  0, 0 },
    { "GATE",   1, 60, 255, 120,  0, 0 },
    { "EQ",     0, 255, 200, 50,  0, 0 },
};

3.2 Layout Calculation

Compute button positions once at startup (adapts to screen width):

static void layout_buttons(int win_w, int win_h)
{
    int margin = 10, spacing = 6;
    int total_w = win_w - 2 * margin;
    int btn_w = (total_w - (BTN_COUNT - 1) * spacing) / BTN_COUNT;

    for (int i = 0; i < BTN_COUNT; i++) {
        int bx = margin + i * (btn_w + spacing);
        buttons[i].x0 = (float)bx / win_w;
        buttons[i].x1 = (float)(bx + btn_w) / win_w;
    }
}

3.3 Drawing

Active buttons get a filled background; inactive ones get an outline:

static void draw_buttons(SDL_Renderer *r, int win_w, int win_h)
{
    int margin = 10, spacing = 6;
    int total_w = win_w - 2 * margin;
    int btn_w = (total_w - (BTN_COUNT - 1) * spacing) / BTN_COUNT;
    int by = win_h - BTN_H - 4;

    for (int i = 0; i < BTN_COUNT; i++) {
        int bx = margin + i * (btn_w + spacing);
        SDL_Rect rect = { bx, by, btn_w, BTN_H };

        if (buttons[i].active) {
            SDL_SetRenderDrawColor(r, buttons[i].r, buttons[i].g,
                                   buttons[i].b, 200);
            SDL_RenderFillRect(r, &rect);
            SDL_SetRenderDrawColor(r, 255, 255, 255, 255);
        } else {
            SDL_SetRenderDrawColor(r, 40, 40, 45, 255);
            SDL_RenderFillRect(r, &rect);
            SDL_SetRenderDrawColor(r, buttons[i].r, buttons[i].g,
                                   buttons[i].b, 255);
            SDL_RenderDrawRect(r, &rect);
        }

        /* Center the label */
        int tw = strlen(buttons[i].label) * (FONT_W + 1) * 2;
        draw_text(r, buttons[i].label,
                  bx + (btn_w - tw) / 2,
                  by + (BTN_H - FONT_H * 2) / 2, 2);
    }
}

3.4 Hit Testing

Convert normalized touch coordinates to button index:

static int hit_test_button(float fx, float fy, int win_h)
{
    float btn_top = (float)(win_h - BTN_H - 4) / win_h;
    if (fy < btn_top) return -1;

    for (int i = 0; i < BTN_COUNT; i++) {
        if (fx >= buttons[i].x0 && fx <= buttons[i].x1)
            return i;
    }
    return -1;
}

3.5 Tap vs Swipe Disambiguation

On touch displays, you need to distinguish taps (button presses) from swipes (gestures). The trick: track the finger start position and only fire a button on FINGERUP if the finger didn't travel far:

float touch_start_y = 0;
int swipe_active = 0;

/* In event loop: */
if (ev.type == SDL_FINGERDOWN) {
    touch_start_y = ev.tfinger.y;
    if (ev.tfinger.y > 0.85f)
        swipe_active = 1;   /* potential swipe-up exit */
}

if (ev.type == SDL_FINGERMOTION && swipe_active && ev.tfinger.y < 0.5f)
    running = 0;  /* swipe-up = exit */

if (ev.type == SDL_FINGERUP) {
    float dy = fabsf(ev.tfinger.y - touch_start_y);
    if (dy < 0.05f) {
        /* Small movement = tap */
        int btn = hit_test_button(ev.tfinger.x, ev.tfinger.y, WIN_H);
        if (btn >= 0)
            buttons[btn].active = !buttons[btn].active;
    }
    swipe_active = 0;
}
Warning

Common mistake: handling buttons on FINGERDOWN. This makes swipe gestures impossible because every swipe starting over a button triggers it. Always fire actions on FINGERUP with distance check.


4. Modal Overlays

A modal overlay draws on top of the main content with a semi-transparent background. The pattern:

  1. Draw the main scene (spectrum, waveform, etc.)
  2. Draw buttons (always visible)
  3. If overlay is active, draw it last so it covers the main scene

4.1 Semi-Transparent Background

static void draw_overlay(SDL_Renderer *r, int win_w, int win_h)
{
    int margin = 30;
    int ox = margin, oy = margin;
    int ow = win_w - 2 * margin;
    int oh = win_h - 2 * margin;

    /* Dark semi-transparent background */
    SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
    SDL_SetRenderDrawColor(r, 10, 10, 15, 220);
    SDL_Rect bg = { ox, oy, ow, oh };
    SDL_RenderFillRect(r, &bg);

    /* Border */
    SDL_SetRenderDrawColor(r, 80, 80, 100, 255);
    SDL_RenderDrawRect(r, &bg);
    SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);

    /* Title */
    SDL_SetRenderDrawColor(r, 200, 200, 220, 255);
    draw_text(r, "SETTINGS", ox + 10, oy + 8, 2);

    /* ... draw overlay content ... */
}
Tip

Always restore SDL_BLENDMODE_NONE after drawing translucent elements. Leaving blend mode on causes all subsequent drawing to be alpha-composited, which is slower and produces unexpected results.

4.2 Rendering Order

/* Main render loop */
SDL_RenderClear(ren);
draw_waveform(ren, ...);
draw_spectrum(ren, ...);
draw_buttons(ren, WIN_W, WIN_H);

/* Overlays drawn LAST — on top of everything */
if (overlay_active)
    draw_overlay(ren, WIN_W, WIN_H);

SDL_RenderPresent(ren);

4.3 Input Routing

When an overlay is active, touch events should go to the overlay, not the main content:

if (ev.type == SDL_FINGERDOWN) {
    if (overlay_active) {
        /* Handle overlay interaction */
        int slider = overlay_hit_test(ev.tfinger.x, ev.tfinger.y);
        if (slider >= 0) dragging = slider;
    } else {
        /* Normal button handling */
    }
}

5. Draggable Sliders

Sliders are vertical (or horizontal) bars that the user drags to set a value. The interaction model:

  1. FINGERDOWN — hit-test to see if a slider was touched, start dragging
  2. FINGERMOTION — update the value based on finger position
  3. FINGERUP — stop dragging

5.1 Drawing a Vertical Slider

void draw_slider(SDL_Renderer *r, int cx, int top, int bot,
                 float value, /* -1.0 to +1.0 */
                 Uint8 cr, Uint8 cg, Uint8 cb)
{
    int h = bot - top;
    int mid = top + h / 2;
    int bw = 20;  /* bar width */

    /* Track line */
    SDL_SetRenderDrawColor(r, 50, 50, 60, 255);
    SDL_RenderDrawLine(r, cx, top, cx, bot);

    /* Filled bar from center to value */
    int val_y = mid - (int)(value * h / 2);
    int bar_top = val_y < mid ? val_y : mid;
    int bar_bot = val_y < mid ? mid : val_y;

    SDL_SetRenderDrawColor(r, cr, cg, cb, 180);
    SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
    SDL_Rect bar = { cx - bw/2, bar_top, bw, bar_bot - bar_top };
    SDL_RenderFillRect(r, &bar);
    SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);

    /* Thumb (drag handle) */
    SDL_SetRenderDrawColor(r, 255, 255, 255, 255);
    SDL_Rect thumb = { cx - bw/2, val_y - 3, bw, 6 };
    SDL_RenderFillRect(r, &thumb);
}

5.2 Drag Handling

static int dragging = -1;  /* slider index, -1 = none */

/* FINGERDOWN: start drag */
if (ev.type == SDL_FINGERDOWN) {
    dragging = slider_hit_test(ev.tfinger.x, ev.tfinger.y);
}

/* FINGERMOTION: update value */
if (ev.type == SDL_FINGERMOTION && dragging >= 0) {
    int py = (int)(ev.tfinger.y * WIN_H);
    float norm = (float)(slider_mid - py) / (slider_height / 2.0f);
    if (norm > 1) norm = 1;
    if (norm < -1) norm = -1;
    sliders[dragging].value = norm;
}

/* FINGERUP: stop drag */
if (ev.type == SDL_FINGERUP) {
    dragging = -1;
}

6. Putting It Together: EQ Overlay Example

The Audio Visualizer Full demo combines all these patterns into a graphic equalizer overlay:

┌──────────────────────────────────────────────────┐
│  EQUALIZER                         tap EQ to close│
│                                                   │
│   60  150  400  1K  2.5K  6K  12K  16K    DELAY  │
│   ██                              ██              │
│   ██       ██                     ██       ██     │
│  ─██───────██──────────────────── ██ ──────██─ 0dB│
│            ██  ██  ██   ██                 ██     │
│                ██  ██   ██                        │
│  +3   0   -2  -6  -4   -3   0   +6      200ms    │
│                                                   │
│  EQ ON  Delay ON  Playback OFF  Latency 45ms      │
└──────────────────────────────────────────────────┘

Each band is a draggable slider controlling a biquad peaking filter. The spectrum view updates in real time so students can see the EQ effect. See audio_viz_full.c for the complete implementation.


7. Performance Tips

Technique Impact When to use
Avoid SDL_RenderDrawPoint in loops 10–50x faster Use SDL_RenderDrawLine or SDL_RenderFillRect instead
Batch draw calls by color ~2x faster Group all draws of the same color before calling SDL_SetRenderDrawColor
Use textures for static content ~5x faster Pre-render complex backgrounds to a texture, SDL_RenderCopy each frame
Minimize blend mode switches ~1.5x faster Draw all opaque content first, then all translucent content
PRESENTVSYNC flag Consistent Always use — prevents tearing and limits CPU to display refresh rate

Challenges

Tip

Challenge 1: Toggle Buttons with Visual Feedback Add a brief "press" animation: when a button is tapped, flash it brighter for 2 frames before settling to the toggled state. Track a flash_timer per button.

Tip

Challenge 2: Horizontal Slider Modify the vertical slider code to create a horizontal volume slider. The value should map to the gain variable.

Tip

Challenge 3: Multi-Page Overlay Create an overlay with two pages (tab-style) — one for EQ and one for effects. Draw two tab buttons at the top of the overlay that switch which content is shown.


See also: Audio Visualizer Challenges | Signal Processing Reference | Qt Quick for Embedded