Files
autosample/src/ui/ui_widgets.cpp

2402 lines
93 KiB
C++

// ui_widgets.cpp - Immediate-mode widget implementations on top of Clay.
//
// IMPORTANT: Clay_Hovered() only works inside CLAY() macro declaration args
// (where the element is the "open" element on the stack). For hover checks
// AFTER a CLAY() block, use Clay_PointerOver(elementId).
#include "ui/ui_widgets.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
UI_WidgetState g_wstate = {};
// Icon per-frame pool (forward declaration for begin_frame)
#define UI_MAX_ICONS_PER_FRAME 32
static CustomIconData g_icon_pool[UI_MAX_ICONS_PER_FRAME];
static S32 g_icon_pool_count = 0;
// Gradient per-frame pool (for button/title bar/dropdown/input gradients)
#define UI_MAX_GRADIENTS_PER_FRAME 64
static CustomGradientData g_grad_pool[UI_MAX_GRADIENTS_PER_FRAME];
static S32 g_grad_pool_count = 0;
// Rotated icon per-frame pool (for knobs)
#define UI_MAX_ROTATED_ICONS_PER_FRAME 16
static CustomRotatedIconData g_rotated_icon_pool[UI_MAX_ROTATED_ICONS_PER_FRAME];
static S32 g_rotated_icon_pool_count = 0;
// Static buffer pool for knob value text
#define UI_MAX_KNOB_TEXT_BUFS 16
static char g_knob_text_bufs[UI_MAX_KNOB_TEXT_BUFS][32];
static S32 g_knob_text_buf_count = 0;
static CustomGradientData *alloc_gradient(Clay_Color top, Clay_Color bottom) {
if (g_grad_pool_count >= UI_MAX_GRADIENTS_PER_FRAME) return nullptr;
CustomGradientData *g = &g_grad_pool[g_grad_pool_count++];
g->type = CUSTOM_RENDER_VGRADIENT;
g->top_color = top;
g->bottom_color = bottom;
return g;
}
// Per-frame shadow layer ID counter (each shadow uses N unique IDs)
static S32 g_shadow_id_counter = 0;
// Frame counter for double-click detection
static S32 g_frame_number = 0;
// Emit a smooth multi-layer drop shadow as floating rects.
// bb: bounding box of the element to shadow (previous frame)
// ox/oy: directional offset (light direction)
// radius: max blur spread
// peak_alpha: total alpha at center when all layers stack
// z: zIndex for shadow layers
// attach_to_root: if true, use absolute positioning; if false, attach to parent_id
// parent_id: only used when attach_to_root=false
// parent_attach: attachment point on parent (e.g. LEFT_BOTTOM for dropdowns)
#define SHADOW_LAYERS 7
static void emit_shadow(Clay_BoundingBox bb, F32 ox, F32 oy, F32 radius,
float peak_alpha, int16_t z,
B32 attach_to_root, uint32_t parent_id,
Clay_FloatingAttachPointType parent_attach) {
if (bb.width <= 0) return;
float per_layer = peak_alpha / (float)SHADOW_LAYERS;
// Draw outermost first (largest, lowest alpha contribution), innermost last
for (S32 i = SHADOW_LAYERS - 1; i >= 0; i--) {
float t = (float)(i + 1) / (float)SHADOW_LAYERS; // 1/N .. 1.0
F32 expand = radius * t;
S32 sid = g_shadow_id_counter++;
Clay_ElementId shadow_eid = CLAY_IDI("Shadow", sid);
if (attach_to_root) {
CLAY(shadow_eid,
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(bb.width + expand*2),
.height = CLAY_SIZING_FIXED(bb.height + expand*2),
},
},
.backgroundColor = {0, 0, 0, per_layer},
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS + expand),
.floating = {
.offset = { bb.x - expand + ox, bb.y - expand + oy },
.zIndex = z,
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.attachTo = CLAY_ATTACH_TO_ROOT,
}
) {}
} else {
CLAY(shadow_eid,
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(bb.width + expand*2),
.height = CLAY_SIZING_FIXED(bb.height + expand*2),
},
},
.backgroundColor = {0, 0, 0, per_layer},
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS + expand),
.floating = {
.offset = { -expand + ox, -expand + oy },
.parentId = parent_id,
.zIndex = z,
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = parent_attach,
},
.attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID,
}
) {}
}
}
}
void ui_widgets_init() {
g_wstate = {};
}
void ui_widgets_begin_frame(PlatformInput input) {
g_wstate.input = input;
g_wstate.mouse_clicked = (input.mouse_down && !input.was_mouse_down);
g_icon_pool_count = 0;
g_grad_pool_count = 0;
g_rotated_icon_pool_count = 0;
g_knob_text_buf_count = 0;
g_shadow_id_counter = 0;
g_frame_number++;
// Release knob drag if mouse is up
if (!input.mouse_down && g_wstate.knob_drag.dragging_id != 0) {
g_wstate.knob_drag.dragging_id = 0;
}
g_wstate.cursor_blink += 1.0f / 60.0f;
g_wstate.text_input_count = 0;
g_wstate.tab_pressed = 0;
// Detect Tab key press this frame
for (S32 k = 0; k < input.key_count; k++) {
if (input.keys[k] == PKEY_TAB) {
g_wstate.tab_pressed = 1;
break;
}
}
// Drag cleanup: if mouse was released between frames, end any active drag
if (g_wstate.drag.dragging_id != 0 && !input.mouse_down) {
g_wstate.drag.dragging_id = 0;
}
ui_text_input_reset_display_bufs();
}
////////////////////////////////
// Helpers
static Clay_TextElementConfig g_widget_text_config;
static Clay_TextElementConfig g_widget_text_config_dim;
static Clay_TextElementConfig g_widget_text_config_sel;
static Clay_TextElementConfig g_widget_text_config_btn;
static Clay_TextElementConfig g_widget_text_config_tab;
static F32 g_widget_text_configs_scale = 0;
void ui_widgets_theme_changed() {
g_widget_text_configs_scale = 0;
}
static void ensure_widget_text_configs() {
if (g_widget_text_configs_scale == g_ui_scale) return;
g_widget_text_configs_scale = g_ui_scale;
g_widget_text_config = {};
g_widget_text_config.textColor = g_theme.text;
g_widget_text_config.fontSize = FONT_SIZE_NORMAL;
g_widget_text_config.wrapMode = CLAY_TEXT_WRAP_NONE;
g_widget_text_config_dim = {};
g_widget_text_config_dim.textColor = g_theme.text_dim;
g_widget_text_config_dim.fontSize = FONT_SIZE_NORMAL;
g_widget_text_config_dim.wrapMode = CLAY_TEXT_WRAP_NONE;
// Selected text: white on accent background (background set on parent element)
g_widget_text_config_sel = {};
g_widget_text_config_sel.textColor = Clay_Color{255, 255, 255, 255};
g_widget_text_config_sel.fontSize = FONT_SIZE_NORMAL;
g_widget_text_config_sel.wrapMode = CLAY_TEXT_WRAP_NONE;
// Button text (always readable on accent background)
g_widget_text_config_btn = {};
g_widget_text_config_btn.textColor = g_theme.button_text;
g_widget_text_config_btn.fontSize = FONT_SIZE_NORMAL;
g_widget_text_config_btn.wrapMode = CLAY_TEXT_WRAP_NONE;
// Tab text (always light — readable on colored tab gradient)
g_widget_text_config_tab = {};
g_widget_text_config_tab.textColor = g_theme.tab_text;
g_widget_text_config_tab.fontSize = FONT_SIZE_NORMAL;
g_widget_text_config_tab.wrapMode = CLAY_TEXT_WRAP_NONE;
}
static Clay_String clay_str(const char *s) {
Clay_String r = {};
r.isStaticallyAllocated = false;
r.length = (S32)strlen(s);
r.chars = s;
return r;
}
// Build Clay_ElementId from runtime string (CLAY_ID requires string literals)
#define WID(s) CLAY_SID(clay_str(s))
#define WIDI(s, i) CLAY_SIDI(clay_str(s), i)
////////////////////////////////
// Icon
void ui_icon(UI_IconID icon, F32 size, Clay_Color color) {
if (g_icon_pool_count >= UI_MAX_ICONS_PER_FRAME) return;
S32 idx = g_icon_pool_count;
CustomIconData *data = &g_icon_pool[g_icon_pool_count++];
data->type = CUSTOM_RENDER_ICON;
data->icon_id = (S32)icon;
data->color = color;
CLAY(CLAY_IDI("UIIcon", idx),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(size), .height = CLAY_SIZING_FIXED(size) },
},
.custom = { .customData = data }
) {}
}
////////////////////////////////
// Label
void ui_label(const char *id, const char *text) {
ensure_widget_text_configs();
CLAY(WID(id),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
.padding = { 0, 0, uip(2), uip(2) },
}
) {
CLAY_TEXT(clay_str(text), &g_widget_text_config);
}
}
////////////////////////////////
// Button
B32 ui_button(const char *id, const char *text) {
ensure_widget_text_configs();
Clay_ElementId eid = WID(id);
B32 hovered = Clay_PointerOver(eid);
Clay_Color base = hovered ? g_theme.accent_hover : g_theme.accent;
Clay_Color top = {(float)Min((int)base.r+12,255), (float)Min((int)base.g+12,255), (float)Min((int)base.b+12,255), base.a};
Clay_Color bot = {(float)Max((int)base.r-15,0), (float)Max((int)base.g-15,0), (float)Max((int)base.b-15,0), base.a};
CustomGradientData *grad = alloc_gradient(top, bot);
CLAY(eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(WIDGET_BUTTON_HEIGHT) },
.padding = { uip(12), uip(12), 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = base,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS),
.custom = { .customData = grad },
) {
CLAY_TEXT(clay_str(text), &g_widget_text_config_btn);
}
return (hovered && g_wstate.mouse_clicked) ? 1 : 0;
}
////////////////////////////////
// Checkbox
B32 ui_checkbox(const char *id, const char *label, B32 *value) {
ensure_widget_text_configs();
B32 changed = 0;
Clay_ElementId eid = WID(id);
B32 hovered = Clay_PointerOver(eid);
CLAY(eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(WIDGET_CHECKBOX_HEIGHT) },
.childGap = uip(8),
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
}
) {
// Box
Clay_Color box_bg = *value ? g_theme.accent : g_theme.bg_dark;
if (hovered) {
box_bg = *value ? g_theme.accent_hover : g_theme.bg_lighter;
}
CLAY(WIDI(id, 1),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(WIDGET_CHECKBOX_SIZE), .height = CLAY_SIZING_FIXED(WIDGET_CHECKBOX_SIZE) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = box_bg,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS),
.border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } }
) {
if (*value) {
ui_icon(UI_ICON_CHECK, WIDGET_CHECKBOX_SIZE * 0.75f, g_theme.button_text);
}
}
// Label
CLAY_TEXT(clay_str(label), &g_widget_text_config);
}
if (hovered && g_wstate.mouse_clicked) {
*value = !(*value);
changed = 1;
}
return changed;
}
////////////////////////////////
// Radio group
B32 ui_radio_group(const char *id, const char **options, S32 count, S32 *selected) {
ensure_widget_text_configs();
B32 changed = 0;
CLAY(WID(id),
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.childGap = uip(4),
.layoutDirection = CLAY_TOP_TO_BOTTOM,
}
) {
for (S32 i = 0; i < count; i++) {
B32 is_selected = (*selected == i);
Clay_ElementId row_id = WIDI(id, i + 100);
B32 row_hovered = Clay_PointerOver(row_id);
CLAY(row_id,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(uis(26)) },
.childGap = uip(8),
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
}
) {
// Radio circle
Clay_Color dot_bg = is_selected ? g_theme.accent : g_theme.bg_dark;
if (row_hovered) {
dot_bg = is_selected ? g_theme.accent_hover : g_theme.bg_lighter;
}
CLAY(WIDI(id, i + 200),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(WIDGET_RADIO_OUTER), .height = CLAY_SIZING_FIXED(WIDGET_RADIO_OUTER) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = dot_bg,
.cornerRadius = CLAY_CORNER_RADIUS(uis(8)),
.border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } }
) {
if (is_selected) {
CLAY(WIDI(id, i + 300),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(WIDGET_RADIO_INNER), .height = CLAY_SIZING_FIXED(WIDGET_RADIO_INNER) },
},
.backgroundColor = g_theme.button_text,
.cornerRadius = CLAY_CORNER_RADIUS(uis(4))
) {}
}
}
// Label
CLAY_TEXT(clay_str(options[i]), &g_widget_text_config);
}
if (row_hovered && g_wstate.mouse_clicked && !is_selected) {
*selected = i;
changed = 1;
}
}
}
return changed;
}
////////////////////////////////
// Text input
//
// Each text input gets its own display buffer(s) so Clay string pointers
// remain valid until the end of the frame. We support up to
// MAX_TEXT_INPUTS simultaneous text inputs per frame.
// Each text input may need up to 3 display buffers (before/sel/after).
#define MAX_TEXT_INPUTS 8
#define DISPLAY_BUF_SIZE 512
// 3 buffers per input: [0]=before selection, [1]=selected text, [2]=after selection
static char g_text_display_bufs[MAX_TEXT_INPUTS * 3][DISPLAY_BUF_SIZE];
static S32 g_text_display_buf_idx = 0;
void ui_text_input_reset_display_bufs() {
g_text_display_buf_idx = 0;
}
// Helper: get selection range in normalized order (lo <= hi)
static void text_input_get_sel(S32 *lo, S32 *hi) {
S32 a = g_wstate.sel_start;
S32 b = g_wstate.sel_end;
if (a <= b) { *lo = a; *hi = b; }
else { *lo = b; *hi = a; }
}
// Helper: true if there's an active selection
static B32 text_input_has_sel() {
return (g_wstate.sel_start != g_wstate.sel_end);
}
// Helper: delete selected range from buf, update cursor, clear selection
// Returns new string length.
static S32 text_input_delete_sel(char *buf, S32 len) {
S32 lo, hi;
text_input_get_sel(&lo, &hi);
if (lo == hi) return len;
memmove(&buf[lo], &buf[hi], len - hi + 1);
g_wstate.cursor_pos = lo;
g_wstate.sel_start = lo;
g_wstate.sel_end = lo;
return len - (hi - lo);
}
// Helper: copy selected text to clipboard
static void text_input_copy_sel(const char *buf, S32 len) {
S32 lo, hi;
text_input_get_sel(&lo, &hi);
if (lo == hi || lo >= len) return;
if (hi > len) hi = len;
// Use a temp buffer to null-terminate the selection
char tmp[DISPLAY_BUF_SIZE];
S32 sel_len = hi - lo;
if (sel_len > (S32)sizeof(tmp) - 1) sel_len = (S32)sizeof(tmp) - 1;
memcpy(tmp, &buf[lo], sel_len);
tmp[sel_len] = '\0';
platform_clipboard_set(tmp);
}
B32 ui_text_input(const char *id, char *buf, S32 buf_size) {
ensure_widget_text_configs();
B32 text_changed = 0;
// Register this text input for Tab cycling
Clay_ElementId eid = WID(id);
if (g_wstate.text_input_count < UI_WIDGET_MAX_TEXT_INPUTS) {
g_wstate.text_input_ids[g_wstate.text_input_count++] = eid.id;
}
// Grab unique display buffers for this text input (up to 3)
S32 my_buf_base = g_text_display_buf_idx;
g_text_display_buf_idx += 3;
if (my_buf_base + 2 >= MAX_TEXT_INPUTS * 3) my_buf_base = 0; // safety
char *dbuf_before = g_text_display_bufs[my_buf_base + 0];
char *dbuf_sel = g_text_display_bufs[my_buf_base + 1];
char *dbuf_after = g_text_display_bufs[my_buf_base + 2];
B32 hovered = Clay_PointerOver(eid);
B32 is_focused = (g_wstate.focused_id == eid.id);
// Tab cycling: if Tab was pressed and we're focused, move to next input.
// We handle this BEFORE processing other input so the Tab key doesn't
// get processed as a regular key. The actual focus change is deferred
// to after all text inputs have registered (see end of function).
B32 wants_tab = 0;
if (is_focused && g_wstate.tab_pressed) {
wants_tab = 1;
}
// Click to focus / unfocus
if (g_wstate.mouse_clicked) {
if (hovered) {
if (!is_focused) {
g_wstate.focused_id = eid.id;
g_wstate.cursor_pos = (S32)strlen(buf);
g_wstate.sel_start = g_wstate.cursor_pos;
g_wstate.sel_end = g_wstate.cursor_pos;
g_wstate.cursor_blink = 0;
is_focused = 1;
} else {
// Already focused, clicking clears selection
g_wstate.sel_start = g_wstate.cursor_pos;
g_wstate.sel_end = g_wstate.cursor_pos;
}
} else if (is_focused) {
g_wstate.focused_id = 0;
is_focused = 0;
}
}
// Process keyboard input if focused
if (is_focused) {
S32 len = (S32)strlen(buf);
B32 ctrl = g_wstate.input.ctrl_held;
// Clamp cursor and selection
if (g_wstate.cursor_pos > len) g_wstate.cursor_pos = len;
if (g_wstate.cursor_pos < 0) g_wstate.cursor_pos = 0;
if (g_wstate.sel_start > len) g_wstate.sel_start = len;
if (g_wstate.sel_end > len) g_wstate.sel_end = len;
// Key events
for (S32 k = 0; k < g_wstate.input.key_count; k++) {
uint8_t key = g_wstate.input.keys[k];
// Skip Tab — handled via tab cycling
if (key == PKEY_TAB) continue;
// Ctrl shortcuts
if (ctrl) {
if (key == PKEY_A) {
// Select all
g_wstate.sel_start = 0;
g_wstate.sel_end = len;
g_wstate.cursor_pos = len;
continue;
}
if (key == PKEY_C) {
// Copy
text_input_copy_sel(buf, len);
continue;
}
if (key == PKEY_X) {
// Cut
if (text_input_has_sel()) {
text_input_copy_sel(buf, len);
len = text_input_delete_sel(buf, len);
text_changed = 1;
}
continue;
}
if (key == PKEY_V) {
// Paste
const char *clip = platform_clipboard_get();
if (clip) {
// Delete selection first if any
if (text_input_has_sel()) {
len = text_input_delete_sel(buf, len);
}
S32 clip_len = (S32)strlen(clip);
// Filter to single line (stop at newline)
for (S32 i = 0; i < clip_len; i++) {
if (clip[i] == '\n' || clip[i] == '\r') { clip_len = i; break; }
}
S32 space = buf_size - 1 - len;
if (clip_len > space) clip_len = space;
if (clip_len > 0) {
memmove(&buf[g_wstate.cursor_pos + clip_len], &buf[g_wstate.cursor_pos], len - g_wstate.cursor_pos + 1);
memcpy(&buf[g_wstate.cursor_pos], clip, clip_len);
g_wstate.cursor_pos += clip_len;
len += clip_len;
text_changed = 1;
}
g_wstate.sel_start = g_wstate.cursor_pos;
g_wstate.sel_end = g_wstate.cursor_pos;
}
continue;
}
// Other Ctrl combos: ignore
continue;
}
switch (key) {
case PKEY_BACKSPACE:
if (text_input_has_sel()) {
len = text_input_delete_sel(buf, len);
text_changed = 1;
} else if (g_wstate.cursor_pos > 0) {
memmove(&buf[g_wstate.cursor_pos - 1], &buf[g_wstate.cursor_pos], len - g_wstate.cursor_pos + 1);
g_wstate.cursor_pos--;
len--;
text_changed = 1;
}
g_wstate.sel_start = g_wstate.cursor_pos;
g_wstate.sel_end = g_wstate.cursor_pos;
break;
case PKEY_DELETE:
if (text_input_has_sel()) {
len = text_input_delete_sel(buf, len);
text_changed = 1;
} else if (g_wstate.cursor_pos < len) {
memmove(&buf[g_wstate.cursor_pos], &buf[g_wstate.cursor_pos + 1], len - g_wstate.cursor_pos);
len--;
text_changed = 1;
}
g_wstate.sel_start = g_wstate.cursor_pos;
g_wstate.sel_end = g_wstate.cursor_pos;
break;
case PKEY_LEFT:
if (text_input_has_sel()) {
S32 lo, hi; text_input_get_sel(&lo, &hi);
g_wstate.cursor_pos = lo;
} else if (g_wstate.cursor_pos > 0) {
g_wstate.cursor_pos--;
}
g_wstate.sel_start = g_wstate.cursor_pos;
g_wstate.sel_end = g_wstate.cursor_pos;
g_wstate.cursor_blink = 0;
break;
case PKEY_RIGHT:
if (text_input_has_sel()) {
S32 lo, hi; text_input_get_sel(&lo, &hi);
g_wstate.cursor_pos = hi;
} else if (g_wstate.cursor_pos < len) {
g_wstate.cursor_pos++;
}
g_wstate.sel_start = g_wstate.cursor_pos;
g_wstate.sel_end = g_wstate.cursor_pos;
g_wstate.cursor_blink = 0;
break;
case PKEY_HOME:
g_wstate.cursor_pos = 0;
g_wstate.sel_start = 0;
g_wstate.sel_end = 0;
g_wstate.cursor_blink = 0;
break;
case PKEY_END:
g_wstate.cursor_pos = len;
g_wstate.sel_start = len;
g_wstate.sel_end = len;
g_wstate.cursor_blink = 0;
break;
case PKEY_RETURN:
case PKEY_ESCAPE:
g_wstate.focused_id = 0;
is_focused = 0;
break;
}
}
// Character input (printable only, skip control chars)
if (is_focused) {
for (S32 c = 0; c < g_wstate.input.char_count; c++) {
uint16_t ch = g_wstate.input.chars[c];
if (ch >= 32 && ch < 127) {
// Delete selection first if any
if (text_input_has_sel()) {
len = text_input_delete_sel(buf, len);
}
if (len < buf_size - 1) {
memmove(&buf[g_wstate.cursor_pos + 1], &buf[g_wstate.cursor_pos], len - g_wstate.cursor_pos + 1);
buf[g_wstate.cursor_pos] = (char)ch;
g_wstate.cursor_pos++;
len++;
text_changed = 1;
g_wstate.cursor_blink = 0;
}
g_wstate.sel_start = g_wstate.cursor_pos;
g_wstate.sel_end = g_wstate.cursor_pos;
}
}
}
}
// Tab cycling: find next text input after this one
if (wants_tab && is_focused) {
// We need to find our index. Since text inputs register in order,
// our ID was just added above. Find it and advance to next.
S32 my_idx = -1;
for (S32 i = 0; i < g_wstate.text_input_count; i++) {
if (g_wstate.text_input_ids[i] == eid.id) { my_idx = i; break; }
}
if (my_idx >= 0) {
// Focus next (wrapping). But we might not have all inputs registered
// yet this frame. Store the "next index" request; actual focus change
// happens via a simple approach: just focus the next ID if known,
// otherwise wrap to first.
S32 next_idx = my_idx + 1;
// We can't know total count yet (more inputs may register after us).
// Instead, unfocus ourselves and set a "pending tab focus" that will
// be resolved. Simpler approach: Tab from last input wraps to first,
// Tab from others goes to next. Since we know the IDs registered so
// far, and inputs are registered in declaration order:
// If there IS a next already-registered input, go to it.
// Otherwise, wrap to index 0.
if (next_idx < g_wstate.text_input_count) {
g_wstate.focused_id = g_wstate.text_input_ids[next_idx];
} else {
// Wrap to first
g_wstate.focused_id = g_wstate.text_input_ids[0];
}
g_wstate.cursor_pos = 0;
g_wstate.sel_start = 0;
g_wstate.sel_end = 0;
is_focused = 0; // no longer focused on THIS input
}
}
// Build display
S32 len = (S32)strlen(buf);
S32 sel_lo = 0, sel_hi = 0;
B32 has_sel = 0;
if (is_focused) {
text_input_get_sel(&sel_lo, &sel_hi);
if (sel_lo < 0) sel_lo = 0;
if (sel_hi > len) sel_hi = len;
has_sel = (sel_lo != sel_hi);
}
Clay_Color bg = is_focused ? g_theme.bg_dark : g_theme.bg_medium;
Clay_Color border_color = is_focused ? g_theme.accent : g_theme.border;
// Inset effect: darker top, lighter bottom (recessed look)
Clay_Color inset_top = {(float)Max((int)bg.r-8,0), (float)Max((int)bg.g-8,0), (float)Max((int)bg.b-8,0), bg.a};
Clay_Color inset_bot = {(float)Min((int)bg.r+3,255), (float)Min((int)bg.g+3,255), (float)Min((int)bg.b+3,255), bg.a};
CustomGradientData *inset_grad = alloc_gradient(inset_top, inset_bot);
CLAY(eid,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(WIDGET_INPUT_HEIGHT) },
.padding = { uip(8), uip(8), 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
},
.backgroundColor = bg,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS),
.custom = { .customData = inset_grad },
.border = { .color = border_color, .width = { 1, 1, 1, 1 } },
) {
if (len == 0 && !is_focused) {
// Placeholder
CLAY_TEXT(CLAY_STRING("..."), &g_widget_text_config_dim);
} else if (is_focused && has_sel) {
// Three segments: before | selected | after
// Before selection
if (sel_lo > 0) {
S32 n = sel_lo < (S32)DISPLAY_BUF_SIZE - 1 ? sel_lo : (S32)DISPLAY_BUF_SIZE - 1;
memcpy(dbuf_before, buf, n);
dbuf_before[n] = '\0';
CLAY_TEXT(clay_str(dbuf_before), &g_widget_text_config);
}
// Selected text with highlight background
{
S32 slen = sel_hi - sel_lo;
if (slen > (S32)DISPLAY_BUF_SIZE - 1) slen = (S32)DISPLAY_BUF_SIZE - 1;
memcpy(dbuf_sel, &buf[sel_lo], slen);
dbuf_sel[slen] = '\0';
CLAY(WIDI(id, 900),
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
},
.backgroundColor = g_theme.accent
) {
CLAY_TEXT(clay_str(dbuf_sel), &g_widget_text_config_sel);
}
}
// After selection
if (sel_hi < len) {
S32 n = len - sel_hi;
if (n > (S32)DISPLAY_BUF_SIZE - 1) n = (S32)DISPLAY_BUF_SIZE - 1;
memcpy(dbuf_after, &buf[sel_hi], n);
dbuf_after[n] = '\0';
CLAY_TEXT(clay_str(dbuf_after), &g_widget_text_config);
}
} else {
// No selection: show text with cursor '|'
if (is_focused) {
S32 cp = g_wstate.cursor_pos;
if (cp > len) cp = len;
S32 total = len + 1; // +1 for cursor char
if (total > (S32)DISPLAY_BUF_SIZE - 1) total = (S32)DISPLAY_BUF_SIZE - 1;
S32 before = cp < total ? cp : total;
memcpy(dbuf_before, buf, before);
dbuf_before[before] = '|';
S32 after = total - before - 1;
if (after > 0) memcpy(dbuf_before + before + 1, buf + cp, after);
dbuf_before[total] = '\0';
CLAY_TEXT(clay_str(dbuf_before), &g_widget_text_config);
} else {
S32 n = len < (S32)DISPLAY_BUF_SIZE - 1 ? len : (S32)DISPLAY_BUF_SIZE - 1;
memcpy(dbuf_before, buf, n);
dbuf_before[n] = '\0';
CLAY_TEXT(clay_str(dbuf_before), &g_widget_text_config);
}
}
}
return text_changed;
}
////////////////////////////////
// Dropdown
B32 ui_dropdown(const char *id, const char **options, S32 count, S32 *selected) {
ensure_widget_text_configs();
B32 changed = 0;
Clay_ElementId eid = WID(id);
B32 header_hovered = Clay_PointerOver(eid);
B32 is_open = (g_wstate.open_dropdown_id == eid.id);
// Display current selection — truncate with "..." if it overflows the header
const char *current_label = (*selected >= 0 && *selected < count) ? options[*selected] : "Select...";
static char dd_trunc_buf[256];
const char *display_label = current_label;
Clay_ElementId text_eid = WIDI(id, 500);
float avail_w = Clay_GetElementData(text_eid).boundingBox.width;
if (avail_w > 0) {
S32 label_len = (S32)strlen(current_label);
Vec2F32 text_size = ui_measure_text(current_label, label_len, FONT_SIZE_NORMAL);
if (text_size.x > avail_w) {
Vec2F32 dots = ui_measure_text("...", 3, FONT_SIZE_NORMAL);
float target_w = avail_w - dots.x;
S32 lo = 0, hi = label_len;
while (lo < hi) {
S32 mid = (lo + hi + 1) / 2;
Vec2F32 seg = ui_measure_text(current_label, mid, FONT_SIZE_NORMAL);
if (seg.x <= target_w) lo = mid; else hi = mid - 1;
}
if (lo + 3 < (S32)sizeof(dd_trunc_buf)) {
memcpy(dd_trunc_buf, current_label, lo);
dd_trunc_buf[lo] = '.'; dd_trunc_buf[lo+1] = '.'; dd_trunc_buf[lo+2] = '.';
dd_trunc_buf[lo+3] = '\0';
display_label = dd_trunc_buf;
}
}
}
Clay_Color bg = is_open ? g_theme.bg_dark : g_theme.bg_medium;
if (header_hovered && !is_open) bg = g_theme.bg_lighter;
Clay_Color dd_top = {(float)Min((int)bg.r+6,255), (float)Min((int)bg.g+6,255), (float)Min((int)bg.b+6,255), bg.a};
CustomGradientData *dd_grad = alloc_gradient(dd_top, bg);
CLAY(eid,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(WIDGET_DROPDOWN_HEIGHT) },
.padding = { uip(8), uip(8), 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
},
.backgroundColor = bg,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS),
.custom = { .customData = dd_grad },
.border = { .color = is_open ? g_theme.accent : g_theme.border, .width = { 1, 1, 1, 1 } },
) {
CLAY(text_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
}
) {
CLAY_TEXT(clay_str(display_label), &g_widget_text_config);
}
CLAY(WIDI(id, 501),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(uis(20)), .height = CLAY_SIZING_FIXED(uis(20)) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
}
) {
ui_icon(UI_ICON_CHEVRON_DOWN, uis(12), g_theme.text_dim);
}
}
// Toggle open on click of header
if (header_hovered && g_wstate.mouse_clicked) {
if (is_open) {
g_wstate.open_dropdown_id = 0;
is_open = 0;
} else {
g_wstate.open_dropdown_id = eid.id;
is_open = 1;
}
}
// Draw dropdown list if open (floating so it escapes modal/container clipping)
if (is_open) {
Clay_ElementId list_id = WIDI(id, 502);
float header_width = Clay_GetElementData(eid).boundingBox.width;
// Dropdown list shadow
{
Clay_BoundingBox dd_bb = Clay_GetElementData(list_id).boundingBox;
emit_shadow(dd_bb, uis(2), uis(2), uis(5),
g_theme.shadow.a, 1999,
0, eid.id, CLAY_ATTACH_POINT_LEFT_BOTTOM);
}
CLAY(list_id,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(.min = header_width), .height = CLAY_SIZING_FIT() },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
},
.backgroundColor = g_theme.bg_dark,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS),
.floating = {
.parentId = eid.id,
.zIndex = 2000,
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_BOTTOM,
},
.attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID,
},
.border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } },
) {
for (S32 i = 0; i < count; i++) {
B32 is_item_selected = (*selected == i);
Clay_ElementId item_id = WIDI(id, i + 600);
B32 item_hovered = Clay_PointerOver(item_id);
Clay_Color item_bg = is_item_selected ? g_theme.accent : g_theme.bg_dark;
if (item_hovered) item_bg = g_theme.bg_lighter;
Clay_TextElementConfig *item_text = (is_item_selected && !item_hovered)
? &g_widget_text_config_btn : &g_widget_text_config;
Clay_CornerRadius item_radius = {};
if (i == 0) { item_radius.topLeft = CORNER_RADIUS; item_radius.topRight = CORNER_RADIUS; }
if (i == count - 1) { item_radius.bottomLeft = CORNER_RADIUS; item_radius.bottomRight = CORNER_RADIUS; }
CLAY(item_id,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(WIDGET_DROPDOWN_ITEM_H) },
.padding = { uip(8), uip(8), 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = item_bg,
.cornerRadius = item_radius
) {
CLAY_TEXT(clay_str(options[i]), item_text);
}
if (item_hovered && g_wstate.mouse_clicked) {
*selected = i;
changed = 1;
g_wstate.open_dropdown_id = 0;
}
}
}
// Close if clicked outside both header and list
if (g_wstate.mouse_clicked && !header_hovered && !Clay_PointerOver(list_id)) {
// Check if any item was clicked (already handled above)
B32 clicked_item = 0;
for (S32 i = 0; i < count; i++) {
if (Clay_PointerOver(WIDI(id, i + 600))) { clicked_item = 1; break; }
}
if (!clicked_item) {
g_wstate.open_dropdown_id = 0;
}
}
}
return changed;
}
////////////////////////////////
// Modal dialog
B32 ui_modal_is_active() {
return g_wstate.modal.active;
}
S32 ui_modal(const char *id, const char *title, const char *message,
const char **buttons, S32 button_count) {
ensure_widget_text_configs();
Clay_ElementId eid = WID(id);
// First call activates the modal
if (!g_wstate.modal.active) {
g_wstate.modal.active = 1;
g_wstate.modal.id = eid.id;
g_wstate.modal.result = -1;
}
// If a different modal is active, ignore this one
if (g_wstate.modal.id != eid.id) return -1;
S32 result = -1;
// Check Escape key to dismiss
for (S32 k = 0; k < g_wstate.input.key_count; k++) {
if (g_wstate.input.keys[k] == PKEY_ESCAPE) {
result = -2;
break;
}
}
// Full-screen overlay (dims background, captures all pointer events)
CLAY(WIDI(id, 2000),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_GROW() },
},
.backgroundColor = Clay_Color{0, 0, 0, 120},
.floating = {
.zIndex = 1000,
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_CAPTURE,
.attachTo = CLAY_ATTACH_TO_ROOT,
}
) {}
// Modal drop shadow
{
Clay_ElementId modal_box_id = WIDI(id, 2001);
Clay_BoundingBox modal_bb = Clay_GetElementData(modal_box_id).boundingBox;
emit_shadow(modal_bb, uis(3), uis(4), uis(10),
g_theme.shadow.a, 1000,
1, 0, CLAY_ATTACH_POINT_LEFT_TOP);
}
// Dialog box (centered)
CLAY(WIDI(id, 2001),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(MODAL_WIDTH), .height = CLAY_SIZING_FIT() },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
},
.backgroundColor = g_theme.bg_medium,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS),
.floating = {
.zIndex = 1001,
.attachPoints = {
.element = CLAY_ATTACH_POINT_CENTER_CENTER,
.parent = CLAY_ATTACH_POINT_CENTER_CENTER,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_CAPTURE,
.attachTo = CLAY_ATTACH_TO_ROOT,
},
.border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } }
) {
// Title bar (gradient: lighter top)
{
Clay_Color mtb = g_theme.title_bar;
Clay_Color mtb_top = {(float)Min((int)mtb.r+12,255), (float)Min((int)mtb.g+12,255), (float)Min((int)mtb.b+12,255), mtb.a};
CustomGradientData *mtb_grad = alloc_gradient(mtb_top, mtb);
CLAY(WIDI(id, 2002),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(WINDOW_TITLE_HEIGHT) },
.padding = { uip(12), uip(12), 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = g_theme.title_bar,
.cornerRadius = { CORNER_RADIUS, CORNER_RADIUS, 0, 0 },
.custom = { .customData = mtb_grad },
.border = { .color = g_theme.border, .width = { .bottom = 1 } },
) {
CLAY_TEXT(clay_str(title), &g_widget_text_config);
}
}
// Message body
static Clay_TextElementConfig msg_text_config;
msg_text_config = g_widget_text_config;
msg_text_config.wrapMode = CLAY_TEXT_WRAP_WORDS;
CLAY(WIDI(id, 2003),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
.padding = { uip(16), uip(16), uip(16), uip(16) },
}
) {
CLAY_TEXT(clay_str(message), &msg_text_config);
}
// Button row (right-aligned)
CLAY(WIDI(id, 2004),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
.padding = { uip(16), uip(16), uip(8), uip(16) },
.childGap = uip(8),
.childAlignment = { .x = CLAY_ALIGN_X_RIGHT, .y = CLAY_ALIGN_Y_CENTER },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
}
) {
for (S32 i = 0; i < button_count; i++) {
Clay_ElementId btn_id = WIDI(id, 2100 + i);
B32 btn_hovered = Clay_PointerOver(btn_id);
Clay_Color mbtn_base = btn_hovered ? g_theme.accent_hover : g_theme.accent;
Clay_Color mbtn_top = {(float)Min((int)mbtn_base.r+12,255), (float)Min((int)mbtn_base.g+12,255), (float)Min((int)mbtn_base.b+12,255), mbtn_base.a};
Clay_Color mbtn_bot = {(float)Max((int)mbtn_base.r-15,0), (float)Max((int)mbtn_base.g-15,0), (float)Max((int)mbtn_base.b-15,0), mbtn_base.a};
CustomGradientData *mbtn_grad = alloc_gradient(mbtn_top, mbtn_bot);
CLAY(btn_id,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(WIDGET_BUTTON_HEIGHT) },
.padding = { uip(16), uip(16), 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = mbtn_base,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS),
.custom = { .customData = mbtn_grad },
) {
CLAY_TEXT(clay_str(buttons[i]), &g_widget_text_config_btn);
}
if (btn_hovered && g_wstate.mouse_clicked) {
result = i;
}
}
}
}
// Deactivate modal if a result was produced
if (result != -1) {
g_wstate.modal.active = 0;
g_wstate.modal.id = 0;
g_wstate.modal.result = result;
}
return result;
}
////////////////////////////////
// Draggable window
static UI_WindowSlot *find_or_create_window_slot(uint32_t id, Vec2F32 initial_pos, Vec2F32 initial_size) {
// Look for existing slot
for (S32 i = 0; i < g_wstate.window_count; i++) {
if (g_wstate.windows[i].id == id) {
return &g_wstate.windows[i];
}
}
// Create new slot
if (g_wstate.window_count >= UI_WIDGET_MAX_WINDOWS) return nullptr;
UI_WindowSlot *slot = &g_wstate.windows[g_wstate.window_count++];
slot->id = id;
slot->position = initial_pos;
slot->size = initial_size;
slot->open = 1;
slot->z_order = g_wstate.next_z++;
return slot;
}
static void bring_window_to_front(UI_WindowSlot *slot) {
// Renormalize if approaching modal z-range
if (g_wstate.next_z > 800) {
int16_t sorted[UI_WIDGET_MAX_WINDOWS];
S32 count = g_wstate.window_count;
for (S32 i = 0; i < count; i++) sorted[i] = g_wstate.windows[i].z_order;
// Bubble sort (tiny array)
for (S32 i = 0; i < count - 1; i++) {
for (S32 j = i + 1; j < count; j++) {
if (sorted[j] < sorted[i]) {
int16_t tmp = sorted[i]; sorted[i] = sorted[j]; sorted[j] = tmp;
}
}
}
for (S32 i = 0; i < count; i++) {
for (S32 j = 0; j < count; j++) {
if (g_wstate.windows[j].z_order == sorted[i]) {
g_wstate.windows[j].z_order = (int16_t)i;
}
}
}
g_wstate.next_z = (int16_t)count;
}
slot->z_order = g_wstate.next_z++;
}
B32 ui_window(const char *id, const char *title, B32 *open,
Vec2F32 initial_pos, Vec2F32 initial_size,
UI_WindowContentFn content_fn, void *user_data) {
ensure_widget_text_configs();
if (!*open) return 0;
Clay_ElementId eid = WID(id);
UI_WindowSlot *slot = find_or_create_window_slot(eid.id, initial_pos, initial_size);
if (!slot) return 0;
// Drag handling
Clay_ElementId title_bar_id = WIDI(id, 3000);
B32 title_hovered = Clay_PointerOver(title_bar_id);
// Start drag on title bar click
if (title_hovered && g_wstate.mouse_clicked && g_wstate.drag.dragging_id == 0) {
g_wstate.drag.dragging_id = eid.id;
g_wstate.drag.drag_anchor = g_wstate.input.mouse_pos;
g_wstate.drag.pos_anchor = slot->position;
bring_window_to_front(slot);
}
// Continue drag
if (g_wstate.drag.dragging_id == eid.id && g_wstate.input.mouse_down) {
Vec2F32 delta;
delta.x = g_wstate.input.mouse_pos.x - g_wstate.drag.drag_anchor.x;
delta.y = g_wstate.input.mouse_pos.y - g_wstate.drag.drag_anchor.y;
slot->position.x = g_wstate.drag.pos_anchor.x + delta.x;
slot->position.y = g_wstate.drag.pos_anchor.y + delta.y;
}
// End drag on release
if (g_wstate.drag.dragging_id == eid.id && !g_wstate.input.mouse_down) {
g_wstate.drag.dragging_id = 0;
}
// Click anywhere on window body brings to front
B32 body_hovered = Clay_PointerOver(eid);
if (body_hovered && g_wstate.mouse_clicked && g_wstate.drag.dragging_id == 0) {
bring_window_to_front(slot);
}
// Close button
Clay_ElementId close_id = WIDI(id, 3001);
B32 close_hovered = Clay_PointerOver(close_id);
// Drop shadow
{
Clay_BoundingBox win_bb = Clay_GetElementData(eid).boundingBox;
// Use absolute position since window uses offset-based floating
Clay_BoundingBox shadow_bb = { slot->position.x, slot->position.y, win_bb.width, win_bb.height };
emit_shadow(shadow_bb, uis(3), uis(3), uis(8),
g_theme.shadow.a, (int16_t)(100 + slot->z_order - 1),
1, 0, CLAY_ATTACH_POINT_LEFT_TOP);
}
// Window floating element
CLAY(eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(slot->size.x * g_ui_scale), .height = CLAY_SIZING_FIT() },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
},
.backgroundColor = g_theme.bg_medium,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS),
.floating = {
.offset = { slot->position.x, slot->position.y },
.zIndex = (int16_t)(100 + slot->z_order),
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_CAPTURE,
.attachTo = CLAY_ATTACH_TO_ROOT,
},
.border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } }
) {
// Title bar (gradient: lighter top)
Clay_Color tb = g_theme.title_bar;
Clay_Color tb_top = {(float)Min((int)tb.r+12,255), (float)Min((int)tb.g+12,255), (float)Min((int)tb.b+12,255), tb.a};
CustomGradientData *tb_grad = alloc_gradient(tb_top, tb);
CLAY(title_bar_id,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(WINDOW_TITLE_HEIGHT) },
.padding = { uip(10), uip(10), 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
},
.backgroundColor = g_theme.title_bar,
.cornerRadius = { CORNER_RADIUS, CORNER_RADIUS, 0, 0 },
.custom = { .customData = tb_grad },
.border = { .color = g_theme.border, .width = { .bottom = 1 } },
) {
// Title text (grows to push close button right)
CLAY(WIDI(id, 3002),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
}
) {
CLAY_TEXT(clay_str(title), &g_widget_text_config);
}
// Close button
Clay_Color close_bg = g_theme_id == 1
? (close_hovered ? Clay_Color{220, 50, 50, 255} : Clay_Color{200, 70, 70, 255})
: (close_hovered ? Clay_Color{200, 60, 60, 255} : Clay_Color{120, 50, 50, 255});
CLAY(close_id,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(uis(20)), .height = CLAY_SIZING_FIXED(uis(20)) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = close_bg,
.cornerRadius = CLAY_CORNER_RADIUS(CORNER_RADIUS)
) {
ui_icon(UI_ICON_CLOSE, uis(12), g_theme.button_text);
}
}
// Content area
CLAY(WIDI(id, 3003),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
.padding = { uip(12), uip(12), uip(10), uip(10) },
.childGap = uip(8),
.layoutDirection = CLAY_TOP_TO_BOTTOM,
}
) {
if (content_fn) {
content_fn(user_data);
}
}
}
// Handle close button click
if (close_hovered && g_wstate.mouse_clicked) {
*open = 0;
return 0;
}
return 1;
}
////////////////////////////////
// Tab bar
static CustomGradientData g_tab_gradient;
S32 ui_tab_bar(const char *id, const char **labels, S32 count, S32 *selected) {
g_tab_gradient.type = CUSTOM_RENDER_VGRADIENT;
g_tab_gradient.top_color = TAB_ACTIVE_TOP;
g_tab_gradient.bottom_color = TAB_ACTIVE_BOTTOM;
Clay_String id_str = clay_str(id);
Clay_ElementId row_eid = Clay__HashString(id_str, 0);
CLAY(row_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
.padding = { 0, 0, uip(4), 0 },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
},
.backgroundColor = g_theme.bg_medium,
.border = { .color = g_theme.border, .width = { .bottom = 1 } },
) {
for (S32 i = 0; i < count; i++) {
Clay_ElementId tab_eid = Clay__HashStringWithOffset(id_str, (uint32_t)i, 0);
B32 is_active = (i == *selected);
B32 hovered = Clay_PointerOver(tab_eid);
Clay_String lbl_str = clay_str(labels[i]);
if (is_active) {
CLAY(tab_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(TAB_HEIGHT) },
.padding = { TAB_PADDING_H, TAB_PADDING_H, 0, uip(6) },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.cornerRadius = { .topLeft = TAB_CORNER_RADIUS, .topRight = TAB_CORNER_RADIUS, .bottomLeft = 0, .bottomRight = 0 },
.custom = { .customData = &g_tab_gradient },
) {
CLAY_TEXT(lbl_str, &g_widget_text_config_tab);
}
} else {
Clay_Color bg = hovered ? (Clay_Color)TAB_INACTIVE_HOVER : (Clay_Color)TAB_INACTIVE_BG;
CLAY(tab_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(TAB_HEIGHT) },
.padding = { TAB_PADDING_H, TAB_PADDING_H, 0, uip(6) },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = bg,
.cornerRadius = { .topLeft = TAB_CORNER_RADIUS, .topRight = TAB_CORNER_RADIUS, .bottomLeft = 0, .bottomRight = 0 },
) {
CLAY_TEXT(lbl_str, &g_widget_text_config);
}
}
if (hovered && g_wstate.mouse_clicked) {
*selected = i;
}
}
}
return *selected;
}
////////////////////////////////
// Shared value-edit helpers (used by knob, sliders, fader)
// Process keyboard input for value text editing.
// Returns 0 = still editing, 1 = committed, 2 = cancelled.
static S32 value_edit_process_keys(F32 *value, F32 max_val, B32 is_signed, B32 *changed) {
char *ebuf = g_wstate.knob_edit_buf;
S32 elen = (S32)strlen(ebuf);
S32 *ecur = &g_wstate.knob_edit_cursor;
S32 *esel0 = &g_wstate.knob_edit_sel_start;
S32 *esel1 = &g_wstate.knob_edit_sel_end;
B32 commit = 0;
B32 cancel = 0;
B32 ctrl = g_wstate.input.ctrl_held;
if (*ecur > elen) *ecur = elen;
if (*esel0 > elen) *esel0 = elen;
if (*esel1 > elen) *esel1 = elen;
#define KE_HAS_SEL() (*esel0 != *esel1)
#define KE_SEL_LO() (*esel0 < *esel1 ? *esel0 : *esel1)
#define KE_SEL_HI() (*esel0 < *esel1 ? *esel1 : *esel0)
#define KE_CLEAR_SEL() do { *esel0 = *ecur; *esel1 = *ecur; } while(0)
auto ke_delete_sel = [&]() -> S32 {
S32 lo = KE_SEL_LO(), hi = KE_SEL_HI();
if (lo == hi) return elen;
memmove(&ebuf[lo], &ebuf[hi], elen - hi + 1);
*ecur = lo;
KE_CLEAR_SEL();
return elen - (hi - lo);
};
for (S32 k = 0; k < g_wstate.input.key_count; k++) {
uint8_t key = g_wstate.input.keys[k];
if (ctrl) {
if (key == PKEY_A) {
*esel0 = 0; *esel1 = elen; *ecur = elen; continue;
}
if (key == PKEY_C) {
if (KE_HAS_SEL()) {
S32 lo = KE_SEL_LO(), hi = KE_SEL_HI();
char tmp[32]; S32 n = hi - lo; if (n > 31) n = 31;
memcpy(tmp, &ebuf[lo], n); tmp[n] = '\0';
platform_clipboard_set(tmp);
}
continue;
}
if (key == PKEY_X) {
if (KE_HAS_SEL()) {
S32 lo = KE_SEL_LO(), hi = KE_SEL_HI();
char tmp[32]; S32 n = hi - lo; if (n > 31) n = 31;
memcpy(tmp, &ebuf[lo], n); tmp[n] = '\0';
platform_clipboard_set(tmp);
elen = ke_delete_sel();
}
continue;
}
if (key == PKEY_V) {
const char *clip = platform_clipboard_get();
if (clip) {
if (KE_HAS_SEL()) elen = ke_delete_sel();
S32 clip_len = (S32)strlen(clip);
char filtered[32]; S32 flen = 0;
for (S32 i = 0; i < clip_len && flen < 30; i++) {
char c = clip[i];
if ((c >= '0' && c <= '9') || c == '.' || c == '-')
filtered[flen++] = c;
}
S32 space = 30 - elen;
if (flen > space) flen = space;
if (flen > 0) {
memmove(&ebuf[*ecur + flen], &ebuf[*ecur], elen - *ecur + 1);
memcpy(&ebuf[*ecur], filtered, flen);
*ecur += flen; elen += flen;
}
KE_CLEAR_SEL();
}
continue;
}
continue;
}
if (key == PKEY_RETURN) { commit = 1; }
else if (key == PKEY_ESCAPE) { cancel = 1; }
else if (key == PKEY_BACKSPACE) {
if (KE_HAS_SEL()) { elen = ke_delete_sel(); }
else if (*ecur > 0) {
memmove(&ebuf[*ecur - 1], &ebuf[*ecur], elen - *ecur + 1);
(*ecur)--; elen--;
}
KE_CLEAR_SEL();
} else if (key == PKEY_DELETE) {
if (KE_HAS_SEL()) { elen = ke_delete_sel(); }
else if (*ecur < elen) {
memmove(&ebuf[*ecur], &ebuf[*ecur + 1], elen - *ecur);
elen--;
}
KE_CLEAR_SEL();
} else if (key == PKEY_LEFT) {
if (KE_HAS_SEL()) { *ecur = KE_SEL_LO(); }
else if (*ecur > 0) { (*ecur)--; }
KE_CLEAR_SEL();
} else if (key == PKEY_RIGHT) {
if (KE_HAS_SEL()) { *ecur = KE_SEL_HI(); }
else if (*ecur < elen) { (*ecur)++; }
KE_CLEAR_SEL();
}
}
if (!commit && !cancel) {
for (S32 c = 0; c < g_wstate.input.char_count; c++) {
uint16_t ch = g_wstate.input.chars[c];
B32 valid = (ch >= '0' && ch <= '9') || ch == '.' || ch == '-';
if (valid) {
if (KE_HAS_SEL()) elen = ke_delete_sel();
if (elen < 30) {
memmove(&ebuf[*ecur + 1], &ebuf[*ecur], elen - *ecur + 1);
ebuf[*ecur] = (char)ch; (*ecur)++; elen++;
}
KE_CLEAR_SEL();
}
}
}
#undef KE_HAS_SEL
#undef KE_SEL_LO
#undef KE_SEL_HI
#undef KE_CLEAR_SEL
if (commit) {
char *end = nullptr;
F32 parsed = strtof(ebuf, &end);
if (end != ebuf) {
F32 lo = is_signed ? -max_val : 0.0f;
if (parsed < lo) parsed = lo;
if (parsed > max_val) parsed = max_val;
if (parsed != *value) { *value = parsed; *changed = 1; }
}
g_wstate.knob_edit_id = 0;
return 1;
} else if (cancel) {
g_wstate.knob_edit_id = 0;
return 2;
}
return 0;
}
// Render the text edit box with selection/cursor display.
static void value_edit_render(uint32_t hash, F32 width) {
Clay_ElementId edit_eid = CLAY_IDI("ValEdit", (int)hash);
char *ebuf = g_wstate.knob_edit_buf;
S32 elen = (S32)strlen(ebuf);
S32 ecur = g_wstate.knob_edit_cursor;
S32 esel0 = g_wstate.knob_edit_sel_start;
S32 esel1 = g_wstate.knob_edit_sel_end;
if (ecur > elen) ecur = elen;
if (esel0 > elen) esel0 = elen;
if (esel1 > elen) esel1 = elen;
S32 sel_lo = esel0 < esel1 ? esel0 : esel1;
S32 sel_hi = esel0 < esel1 ? esel1 : esel0;
B32 has_sel = (sel_lo != sel_hi);
static char ke_dbuf_before[32];
static char ke_dbuf_sel[32];
static char ke_dbuf_after[32];
static Clay_TextElementConfig edit_cfg = {};
edit_cfg.textColor = g_theme.text;
edit_cfg.fontSize = FONT_SIZE_SMALL;
edit_cfg.wrapMode = CLAY_TEXT_WRAP_NONE;
static Clay_TextElementConfig edit_sel_cfg = {};
edit_sel_cfg.textColor = Clay_Color{255, 255, 255, 255};
edit_sel_cfg.fontSize = FONT_SIZE_SMALL;
edit_sel_cfg.wrapMode = CLAY_TEXT_WRAP_NONE;
CLAY(edit_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(width), .height = CLAY_SIZING_FIT() },
.padding = { uip(2), uip(2), uip(1), uip(1) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
},
.backgroundColor = g_theme.bg_dark,
.cornerRadius = CLAY_CORNER_RADIUS(uis(2)),
.border = { .color = g_theme.accent, .width = { 1, 1, 1, 1 } }
) {
if (has_sel) {
if (sel_lo > 0) {
S32 n = sel_lo;
memcpy(ke_dbuf_before, ebuf, n); ke_dbuf_before[n] = '\0';
Clay_String s_before = { .length = n, .chars = ke_dbuf_before };
CLAY_TEXT(s_before, &edit_cfg);
}
{
S32 n = sel_hi - sel_lo;
memcpy(ke_dbuf_sel, &ebuf[sel_lo], n); ke_dbuf_sel[n] = '\0';
Clay_String s_sel = { .length = n, .chars = ke_dbuf_sel };
CLAY(CLAY_IDI("ValEditSel", (int)hash),
.layout = { .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() } },
.backgroundColor = g_theme.accent
) {
CLAY_TEXT(s_sel, &edit_sel_cfg);
}
}
if (sel_hi < elen) {
S32 n = elen - sel_hi;
memcpy(ke_dbuf_after, &ebuf[sel_hi], n); ke_dbuf_after[n] = '\0';
Clay_String s_after = { .length = n, .chars = ke_dbuf_after };
CLAY_TEXT(s_after, &edit_cfg);
}
} else {
static char edit_display[64];
S32 di = 0;
for (S32 i = 0; i < elen + 1 && di < 62; i++) {
if (i == ecur) edit_display[di++] = '|';
if (i < elen) edit_display[di++] = ebuf[i];
}
edit_display[di] = '\0';
Clay_String s_cursor = { .length = di, .chars = edit_display };
CLAY_TEXT(s_cursor, &edit_cfg);
}
}
}
// Get the Clay element ID of the current edit box (for click-away detection).
static Clay_ElementId value_edit_eid(uint32_t hash) {
return CLAY_IDI("ValEdit", (int)hash);
}
// Handle click-outside to commit the edit.
static void value_edit_click_away(Clay_ElementId eid, F32 *value, F32 max_val, B32 is_signed, B32 *changed) {
if (g_wstate.mouse_clicked && !Clay_PointerOver(eid)) {
char *end = nullptr;
F32 parsed = strtof(g_wstate.knob_edit_buf, &end);
if (end != g_wstate.knob_edit_buf) {
F32 lo = is_signed ? -max_val : 0.0f;
if (parsed < lo) parsed = lo;
if (parsed > max_val) parsed = max_val;
if (parsed != *value) { *value = parsed; *changed = 1; }
}
g_wstate.knob_edit_id = 0;
}
}
// Enter text edit mode for a widget.
static void value_edit_enter(uint32_t hash, F32 value, B32 is_signed) {
g_wstate.knob_edit_id = hash;
g_wstate.focused_id = 0;
if (is_signed) {
snprintf(g_wstate.knob_edit_buf, sizeof(g_wstate.knob_edit_buf), "%+.1f", value);
} else {
snprintf(g_wstate.knob_edit_buf, sizeof(g_wstate.knob_edit_buf), "%.1f", value);
}
S32 slen = (S32)strlen(g_wstate.knob_edit_buf);
g_wstate.knob_edit_cursor = slen;
g_wstate.knob_edit_sel_start = 0;
g_wstate.knob_edit_sel_end = slen;
}
// Normalize a value to [0,1] range.
static F32 value_normalize(F32 value, F32 max_val, B32 is_signed) {
F32 n;
if (is_signed) { n = (value + max_val) / (2.0f * max_val); }
else { n = value / max_val; }
if (n < 0.0f) n = 0.0f;
if (n > 1.0f) n = 1.0f;
return n;
}
// Format a value into a text buf from the pool. Returns null if pool exhausted.
static char *value_format_text(F32 value, B32 is_signed, S32 *out_len) {
if (g_knob_text_buf_count >= UI_MAX_KNOB_TEXT_BUFS) return nullptr;
char *buf = g_knob_text_bufs[g_knob_text_buf_count++];
if (is_signed) { *out_len = snprintf(buf, 32, "%+.1f", value); }
else { *out_len = snprintf(buf, 32, "%.1f", value); }
return buf;
}
////////////////////////////////
// Knob / Potentiometer
B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_signed, F32 default_val, B32 editable) {
ensure_widget_text_configs();
F32 knob_size = WIDGET_KNOB_SIZE;
B32 changed = 0;
F32 normalized = value_normalize(*value, max_val, is_signed);
// Angle: 270-degree sweep, -135 to +135 degrees
F32 deg_to_rad = 3.14159265f / 180.0f;
F32 angle_rad = (-135.0f + normalized * 270.0f) * deg_to_rad;
uint32_t knob_hash = Clay__HashString(clay_str(id), 0).id;
B32 is_editing = (editable && g_wstate.knob_edit_id == knob_hash);
UI_KnobDragState *kd = &g_wstate.knob_drag;
// Drag interaction (vertical mouse delta)
if (!is_editing && kd->dragging_id == knob_hash && g_wstate.input.mouse_down) {
B32 shift_now = g_wstate.input.shift_held;
if (shift_now != kd->was_shift) {
kd->drag_start_y = g_wstate.input.mouse_pos.y;
kd->value_at_start = *value;
kd->was_shift = shift_now;
}
F32 dy = kd->drag_start_y - g_wstate.input.mouse_pos.y;
F32 sensitivity = 200.0f * g_ui_scale;
if (shift_now) sensitivity *= 5.0f;
F32 range = is_signed ? (2.0f * max_val) : max_val;
F32 new_val = kd->value_at_start + (dy / sensitivity) * range;
F32 lo = is_signed ? -max_val : 0.0f;
if (new_val < lo) new_val = lo;
if (new_val > max_val) new_val = max_val;
if (new_val != *value) { *value = new_val; changed = 1; }
normalized = value_normalize(*value, max_val, is_signed);
angle_rad = (-135.0f + normalized * 270.0f) * deg_to_rad;
}
// Text edit keyboard input
if (is_editing) {
S32 result = value_edit_process_keys(value, max_val, is_signed, &changed);
if (result) {
is_editing = 0;
normalized = value_normalize(*value, max_val, is_signed);
angle_rad = (-135.0f + normalized * 270.0f) * deg_to_rad;
}
}
// Format value text
S32 val_len = 0;
char *val_text = is_editing ? nullptr : value_format_text(*value, is_signed, &val_len);
// Layout: vertical column (knob → value text → label)
CLAY(WID(id),
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.childGap = WIDGET_KNOB_LABEL_GAP,
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
}
) {
Clay_ElementId knob_eid = CLAY_IDI("KnobBg", (int)knob_hash);
B32 hovered = Clay_PointerOver(knob_eid);
CLAY(knob_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(knob_size), .height = CLAY_SIZING_FIXED(knob_size) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = g_theme.bg_dark,
.cornerRadius = CLAY_CORNER_RADIUS(knob_size / 2.0f)
) {
if (g_rotated_icon_pool_count < UI_MAX_ROTATED_ICONS_PER_FRAME) {
S32 ri_idx = g_rotated_icon_pool_count;
CustomRotatedIconData *rdata = &g_rotated_icon_pool[g_rotated_icon_pool_count++];
rdata->type = CUSTOM_RENDER_ROTATED_ICON;
rdata->icon_id = (S32)UI_ICON_KNOB;
rdata->color = g_theme.accent;
rdata->angle_rad = angle_rad;
F32 icon_size = knob_size * 0.75f;
CLAY(CLAY_IDI("KnobIcon", ri_idx),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(icon_size), .height = CLAY_SIZING_FIXED(icon_size) },
},
.custom = { .customData = rdata }
) {}
}
}
// Click: double-click resets, single click starts drag
if (!is_editing && hovered && g_wstate.mouse_clicked && kd->dragging_id == 0) {
B32 is_double_click = (kd->last_click_id == knob_hash &&
(g_frame_number - kd->last_click_frame) < 20);
kd->last_click_id = knob_hash;
kd->last_click_frame = g_frame_number;
if (is_double_click) {
if (*value != default_val) { *value = default_val; changed = 1; }
normalized = value_normalize(*value, max_val, is_signed);
angle_rad = (-135.0f + normalized * 270.0f) * deg_to_rad;
kd->last_click_id = 0;
} else {
kd->dragging_id = knob_hash;
kd->drag_start_y = g_wstate.input.mouse_pos.y;
kd->value_at_start = *value;
}
}
// Value text / text edit area
if (is_editing) {
value_edit_render(knob_hash, knob_size);
value_edit_click_away(value_edit_eid(knob_hash), value, max_val, is_signed, &changed);
} else if (val_text && val_len > 0) {
Clay_ElementId val_eid = CLAY_IDI("KnobVal", (int)knob_hash);
B32 val_hovered = Clay_PointerOver(val_eid);
Clay_String val_str = { .isStaticallyAllocated = false, .length = val_len, .chars = val_text };
static Clay_TextElementConfig knob_val_cfg = {};
knob_val_cfg.textColor = g_theme.text;
knob_val_cfg.fontSize = FONT_SIZE_SMALL;
knob_val_cfg.wrapMode = CLAY_TEXT_WRAP_NONE;
CLAY(val_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(knob_size), .height = CLAY_SIZING_FIT() },
.padding = { uip(2), uip(2), uip(1), uip(1) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
}
) {
CLAY_TEXT(val_str, &knob_val_cfg);
}
if (editable && val_hovered && g_wstate.mouse_clicked) {
value_edit_enter(knob_hash, *value, is_signed);
}
}
// Label
CLAY_TEXT(clay_str(label), &g_widget_text_config_dim);
}
return changed;
}
////////////////////////////////
// Horizontal Slider
B32 ui_slider_h(const char *id, const char *label, F32 *value, F32 max_val, B32 is_signed, F32 default_val, B32 editable) {
ensure_widget_text_configs();
B32 changed = 0;
F32 normalized = value_normalize(*value, max_val, is_signed);
uint32_t hash = Clay__HashString(clay_str(id), 0).id;
B32 is_editing = (editable && g_wstate.knob_edit_id == hash);
UI_KnobDragState *kd = &g_wstate.knob_drag;
F32 track_w = WIDGET_SLIDER_H_WIDTH;
F32 track_h = WIDGET_SLIDER_H_TRACK_H;
F32 thumb_w = WIDGET_SLIDER_H_THUMB_W;
F32 thumb_h = WIDGET_SLIDER_H_THUMB_H;
// Drag interaction (horizontal mouse delta)
if (!is_editing && kd->dragging_id == hash && g_wstate.input.mouse_down) {
B32 shift_now = g_wstate.input.shift_held;
if (shift_now != kd->was_shift) {
kd->drag_start_x = g_wstate.input.mouse_pos.x;
kd->value_at_start = *value;
kd->was_shift = shift_now;
}
F32 dx = g_wstate.input.mouse_pos.x - kd->drag_start_x; // right = positive
F32 sensitivity = 200.0f * g_ui_scale;
if (shift_now) sensitivity *= 5.0f;
F32 range = is_signed ? (2.0f * max_val) : max_val;
F32 new_val = kd->value_at_start + (dx / sensitivity) * range;
F32 lo = is_signed ? -max_val : 0.0f;
if (new_val < lo) new_val = lo;
if (new_val > max_val) new_val = max_val;
if (new_val != *value) { *value = new_val; changed = 1; }
normalized = value_normalize(*value, max_val, is_signed);
}
// Text edit keyboard input
if (is_editing) {
S32 result = value_edit_process_keys(value, max_val, is_signed, &changed);
if (result) {
is_editing = 0;
normalized = value_normalize(*value, max_val, is_signed);
}
}
// Format value text
S32 val_len = 0;
char *val_text = is_editing ? nullptr : value_format_text(*value, is_signed, &val_len);
// Dimmed accent for fill bar
Clay_Color fill_color = g_theme.accent;
fill_color.a = 160;
// Layout: vertical column (label → hit area with track → value/edit)
CLAY(WID(id),
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.childGap = WIDGET_KNOB_LABEL_GAP,
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
}
) {
// Label
CLAY_TEXT(clay_str(label), &g_widget_text_config_dim);
// Hit area (transparent, sized to encompass thumb travel)
Clay_ElementId hit_eid = CLAY_IDI("SlHHit", (int)hash);
B32 hovered = Clay_PointerOver(hit_eid);
CLAY(hit_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(track_w), .height = CLAY_SIZING_FIXED(thumb_h) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
}
) {
// Visible track (centered inside hit area)
CLAY(CLAY_IDI("SlHTrack", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(track_w), .height = CLAY_SIZING_FIXED(track_h) },
},
.backgroundColor = g_theme.bg_dark,
.cornerRadius = CLAY_CORNER_RADIUS(track_h / 2.0f)
) {
// Fill bar
F32 fill_w = normalized * track_w;
if (fill_w < 1.0f) fill_w = 1.0f;
CLAY(CLAY_IDI("SlHFill", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(fill_w), .height = CLAY_SIZING_GROW() },
},
.backgroundColor = fill_color,
.cornerRadius = CLAY_CORNER_RADIUS(track_h / 2.0f)
) {}
}
// Floating icon thumb (attached to hit area)
if (g_icon_pool_count < UI_MAX_ICONS_PER_FRAME) {
CustomIconData *idata = &g_icon_pool[g_icon_pool_count++];
idata->type = CUSTOM_RENDER_ICON;
idata->icon_id = (S32)UI_ICON_SLIDER_THUMB;
idata->color = g_theme.accent;
CLAY(CLAY_IDI("SlHThumb", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(thumb_w), .height = CLAY_SIZING_FIXED(thumb_h) },
},
.floating = {
.offset = {
.x = normalized * (track_w - thumb_w),
.y = 0,
},
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_PASSTHROUGH,
.attachTo = CLAY_ATTACH_TO_PARENT,
.clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
},
.custom = { .customData = idata },
) {}
}
}
// Click: double-click resets, single click starts drag
if (!is_editing && hovered && g_wstate.mouse_clicked && kd->dragging_id == 0) {
B32 is_double_click = (kd->last_click_id == hash &&
(g_frame_number - kd->last_click_frame) < 20);
kd->last_click_id = hash;
kd->last_click_frame = g_frame_number;
if (is_double_click) {
if (*value != default_val) { *value = default_val; changed = 1; }
normalized = value_normalize(*value, max_val, is_signed);
kd->last_click_id = 0;
} else {
kd->dragging_id = hash;
kd->drag_start_x = g_wstate.input.mouse_pos.x;
kd->value_at_start = *value;
}
}
// Value text / text edit area
if (is_editing) {
value_edit_render(hash, track_w);
value_edit_click_away(value_edit_eid(hash), value, max_val, is_signed, &changed);
} else if (val_text && val_len > 0) {
Clay_ElementId val_eid = CLAY_IDI("SlHVal", (int)hash);
B32 val_hovered = Clay_PointerOver(val_eid);
Clay_String val_str = { .isStaticallyAllocated = false, .length = val_len, .chars = val_text };
static Clay_TextElementConfig sl_val_cfg = {};
sl_val_cfg.textColor = g_theme.text;
sl_val_cfg.fontSize = FONT_SIZE_SMALL;
sl_val_cfg.wrapMode = CLAY_TEXT_WRAP_NONE;
CLAY(val_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(track_w), .height = CLAY_SIZING_FIT() },
.padding = { uip(2), uip(2), uip(1), uip(1) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
}
) {
CLAY_TEXT(val_str, &sl_val_cfg);
}
if (editable && val_hovered && g_wstate.mouse_clicked) {
value_edit_enter(hash, *value, is_signed);
}
}
}
return changed;
}
////////////////////////////////
// Vertical Slider
B32 ui_slider_v(const char *id, const char *label, F32 *value, F32 max_val, B32 is_signed, F32 default_val, B32 editable) {
ensure_widget_text_configs();
B32 changed = 0;
F32 normalized = value_normalize(*value, max_val, is_signed);
uint32_t hash = Clay__HashString(clay_str(id), 0).id;
B32 is_editing = (editable && g_wstate.knob_edit_id == hash);
UI_KnobDragState *kd = &g_wstate.knob_drag;
F32 track_w = WIDGET_SLIDER_V_TRACK_W;
F32 track_h = WIDGET_SLIDER_V_HEIGHT;
F32 thumb_w = WIDGET_SLIDER_V_THUMB_W;
F32 thumb_h = WIDGET_SLIDER_V_THUMB_H;
// Drag interaction (vertical mouse delta, up = increase)
if (!is_editing && kd->dragging_id == hash && g_wstate.input.mouse_down) {
B32 shift_now = g_wstate.input.shift_held;
if (shift_now != kd->was_shift) {
kd->drag_start_y = g_wstate.input.mouse_pos.y;
kd->value_at_start = *value;
kd->was_shift = shift_now;
}
F32 dy = kd->drag_start_y - g_wstate.input.mouse_pos.y; // up = positive
F32 sensitivity = 200.0f * g_ui_scale;
if (shift_now) sensitivity *= 5.0f;
F32 range = is_signed ? (2.0f * max_val) : max_val;
F32 new_val = kd->value_at_start + (dy / sensitivity) * range;
F32 lo = is_signed ? -max_val : 0.0f;
if (new_val < lo) new_val = lo;
if (new_val > max_val) new_val = max_val;
if (new_val != *value) { *value = new_val; changed = 1; }
normalized = value_normalize(*value, max_val, is_signed);
}
// Text edit keyboard input
if (is_editing) {
S32 result = value_edit_process_keys(value, max_val, is_signed, &changed);
if (result) {
is_editing = 0;
normalized = value_normalize(*value, max_val, is_signed);
}
}
// Format value text
S32 val_len = 0;
char *val_text = is_editing ? nullptr : value_format_text(*value, is_signed, &val_len);
// Dimmed accent for fill bar
Clay_Color fill_color = g_theme.accent;
fill_color.a = 160;
// Layout: vertical column (label → hit area with track → value/edit)
CLAY(WID(id),
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.childGap = WIDGET_KNOB_LABEL_GAP,
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
}
) {
// Label
CLAY_TEXT(clay_str(label), &g_widget_text_config_dim);
// Hit area (transparent, sized to encompass thumb travel)
Clay_ElementId hit_eid = CLAY_IDI("SlVHit", (int)hash);
B32 hovered = Clay_PointerOver(hit_eid);
CLAY(hit_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(thumb_w), .height = CLAY_SIZING_FIXED(track_h) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
}
) {
// Visible track (centered inside hit area)
CLAY(CLAY_IDI("SlVTrack", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(track_w), .height = CLAY_SIZING_FIXED(track_h) },
.childAlignment = { .y = CLAY_ALIGN_Y_BOTTOM },
},
.backgroundColor = g_theme.bg_dark,
.cornerRadius = CLAY_CORNER_RADIUS(track_w / 2.0f)
) {
// Fill bar (from bottom)
F32 fill_h = normalized * track_h;
if (fill_h < 1.0f) fill_h = 1.0f;
CLAY(CLAY_IDI("SlVFill", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(fill_h) },
},
.backgroundColor = fill_color,
.cornerRadius = CLAY_CORNER_RADIUS(track_w / 2.0f)
) {}
}
// Floating icon thumb (attached to hit area)
if (g_icon_pool_count < UI_MAX_ICONS_PER_FRAME) {
CustomIconData *idata = &g_icon_pool[g_icon_pool_count++];
idata->type = CUSTOM_RENDER_ICON;
idata->icon_id = (S32)UI_ICON_SLIDER_THUMB;
idata->color = g_theme.accent;
CLAY(CLAY_IDI("SlVThumb", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(thumb_w), .height = CLAY_SIZING_FIXED(thumb_h) },
},
.floating = {
.offset = {
.x = 0,
.y = (1.0f - normalized) * (track_h - thumb_h),
},
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_PASSTHROUGH,
.attachTo = CLAY_ATTACH_TO_PARENT,
.clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
},
.custom = { .customData = idata },
) {}
}
}
// Click: double-click resets, single click starts drag
if (!is_editing && hovered && g_wstate.mouse_clicked && kd->dragging_id == 0) {
B32 is_double_click = (kd->last_click_id == hash &&
(g_frame_number - kd->last_click_frame) < 20);
kd->last_click_id = hash;
kd->last_click_frame = g_frame_number;
if (is_double_click) {
if (*value != default_val) { *value = default_val; changed = 1; }
normalized = value_normalize(*value, max_val, is_signed);
kd->last_click_id = 0;
} else {
kd->dragging_id = hash;
kd->drag_start_y = g_wstate.input.mouse_pos.y;
kd->value_at_start = *value;
}
}
// Value text / text edit area
if (is_editing) {
value_edit_render(hash, thumb_w * 2.0f);
value_edit_click_away(value_edit_eid(hash), value, max_val, is_signed, &changed);
} else if (val_text && val_len > 0) {
Clay_ElementId val_eid = CLAY_IDI("SlVVal", (int)hash);
B32 val_hovered = Clay_PointerOver(val_eid);
Clay_String val_str = { .isStaticallyAllocated = false, .length = val_len, .chars = val_text };
static Clay_TextElementConfig sl_val_cfg = {};
sl_val_cfg.textColor = g_theme.text;
sl_val_cfg.fontSize = FONT_SIZE_SMALL;
sl_val_cfg.wrapMode = CLAY_TEXT_WRAP_NONE;
CLAY(val_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.padding = { uip(2), uip(2), uip(1), uip(1) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
}
) {
CLAY_TEXT(val_str, &sl_val_cfg);
}
if (editable && val_hovered && g_wstate.mouse_clicked) {
value_edit_enter(hash, *value, is_signed);
}
}
}
return changed;
}
////////////////////////////////
// Fader (DAW-style vertical slider with fader cap icon)
B32 ui_fader(const char *id, const char *label, F32 *value, F32 max_val, B32 is_signed, F32 default_val, B32 editable) {
ensure_widget_text_configs();
B32 changed = 0;
F32 normalized = value_normalize(*value, max_val, is_signed);
uint32_t hash = Clay__HashString(clay_str(id), 0).id;
B32 is_editing = (editable && g_wstate.knob_edit_id == hash);
UI_KnobDragState *kd = &g_wstate.knob_drag;
F32 track_w = WIDGET_FADER_TRACK_W;
F32 track_h = WIDGET_FADER_HEIGHT;
F32 cap_w = WIDGET_FADER_CAP_W;
F32 cap_h = WIDGET_FADER_CAP_H;
// Drag interaction (vertical mouse delta, up = increase)
if (!is_editing && kd->dragging_id == hash && g_wstate.input.mouse_down) {
B32 shift_now = g_wstate.input.shift_held;
if (shift_now != kd->was_shift) {
kd->drag_start_y = g_wstate.input.mouse_pos.y;
kd->value_at_start = *value;
kd->was_shift = shift_now;
}
F32 dy = kd->drag_start_y - g_wstate.input.mouse_pos.y;
F32 sensitivity = 200.0f * g_ui_scale;
if (shift_now) sensitivity *= 5.0f;
F32 range = is_signed ? (2.0f * max_val) : max_val;
F32 new_val = kd->value_at_start + (dy / sensitivity) * range;
F32 lo = is_signed ? -max_val : 0.0f;
if (new_val < lo) new_val = lo;
if (new_val > max_val) new_val = max_val;
if (new_val != *value) { *value = new_val; changed = 1; }
normalized = value_normalize(*value, max_val, is_signed);
}
// Text edit keyboard input
if (is_editing) {
S32 result = value_edit_process_keys(value, max_val, is_signed, &changed);
if (result) {
is_editing = 0;
normalized = value_normalize(*value, max_val, is_signed);
}
}
// Format value text
S32 val_len = 0;
char *val_text = is_editing ? nullptr : value_format_text(*value, is_signed, &val_len);
// Tick mark dimensions
F32 tick_major_w = WIDGET_FADER_TICK_MAJOR_W;
F32 tick_minor_w = WIDGET_FADER_TICK_MINOR_W;
F32 tick_h = WIDGET_FADER_TICK_H;
S32 num_ticks = 10;
F32 track_left = (cap_w - track_w) / 2.0f;
// Layout: vertical column (label → hit area with track → value/edit)
CLAY(WID(id),
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.childGap = WIDGET_KNOB_LABEL_GAP,
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
}
) {
// Label
CLAY_TEXT(clay_str(label), &g_widget_text_config_dim);
// Hit area (transparent, sized to encompass fader cap travel)
Clay_ElementId hit_eid = CLAY_IDI("FdrHit", (int)hash);
B32 hovered = Clay_PointerOver(hit_eid);
CLAY(hit_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(cap_w), .height = CLAY_SIZING_FIXED(track_h) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
}
) {
// Visible track (centered inside hit area, empty)
CLAY(CLAY_IDI("FdrTrack", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(track_w), .height = CLAY_SIZING_FIXED(track_h) },
},
.backgroundColor = g_theme.bg_dark,
.cornerRadius = CLAY_CORNER_RADIUS(track_w / 2.0f)
) {}
// Tick marks on both sides of the track
for (S32 i = 0; i <= num_ticks; i++) {
F32 norm = (F32)i / (F32)num_ticks;
F32 tick_y = (1.0f - norm) * (track_h - tick_h);
F32 tw = (i % 5 == 0) ? tick_major_w : tick_minor_w;
// Left tick
CLAY(CLAY_IDI("FdrTkL", (int)(hash * 100 + i)),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(tw), .height = CLAY_SIZING_FIXED(tick_h) },
},
.backgroundColor = g_theme.text_dim,
.floating = {
.offset = {
.x = track_left - tw,
.y = tick_y,
},
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_PASSTHROUGH,
.attachTo = CLAY_ATTACH_TO_PARENT,
.clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
},
) {}
// Right tick
CLAY(CLAY_IDI("FdrTkR", (int)(hash * 100 + i)),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(tw), .height = CLAY_SIZING_FIXED(tick_h) },
},
.backgroundColor = g_theme.text_dim,
.floating = {
.offset = {
.x = track_left + track_w,
.y = tick_y,
},
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_PASSTHROUGH,
.attachTo = CLAY_ATTACH_TO_PARENT,
.clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
},
) {}
}
// Floating fader cap (RGBA icon from asset SVG)
if (g_icon_pool_count < UI_MAX_ICONS_PER_FRAME) {
CustomIconData *idata = &g_icon_pool[g_icon_pool_count++];
idata->type = CUSTOM_RENDER_ICON;
idata->icon_id = (S32)UI_ICON_FADER;
idata->color = Clay_Color{255, 255, 255, 255};
F32 cap_y = (1.0f - normalized) * (track_h - cap_h);
CLAY(CLAY_IDI("FdrCap", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(cap_w), .height = CLAY_SIZING_FIXED(cap_h) },
},
.floating = {
.offset = { .x = 0, .y = cap_y },
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_PASSTHROUGH,
.attachTo = CLAY_ATTACH_TO_PARENT,
.clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
},
.custom = { .customData = idata },
) {}
// Color tint overlay on top of fader cap
Clay_Color tint = g_theme.accent;
tint.a = 80;
F32 cap_corner = cap_w * (3.0f / 62.2f);
CLAY(CLAY_IDI("FdrTint", (int)hash),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(cap_w), .height = CLAY_SIZING_FIXED(cap_h) },
},
.backgroundColor = tint,
.cornerRadius = CLAY_CORNER_RADIUS(cap_corner),
.floating = {
.offset = { .x = 0, .y = cap_y },
.attachPoints = {
.element = CLAY_ATTACH_POINT_LEFT_TOP,
.parent = CLAY_ATTACH_POINT_LEFT_TOP,
},
.pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_PASSTHROUGH,
.attachTo = CLAY_ATTACH_TO_PARENT,
.clipTo = CLAY_CLIP_TO_ATTACHED_PARENT,
},
) {}
}
}
// Click: double-click resets, single click starts drag
if (!is_editing && hovered && g_wstate.mouse_clicked && kd->dragging_id == 0) {
B32 is_double_click = (kd->last_click_id == hash &&
(g_frame_number - kd->last_click_frame) < 20);
kd->last_click_id = hash;
kd->last_click_frame = g_frame_number;
if (is_double_click) {
if (*value != default_val) { *value = default_val; changed = 1; }
normalized = value_normalize(*value, max_val, is_signed);
kd->last_click_id = 0;
} else {
kd->dragging_id = hash;
kd->drag_start_y = g_wstate.input.mouse_pos.y;
kd->value_at_start = *value;
}
}
// Value text / text edit area
if (is_editing) {
value_edit_render(hash, cap_w * 2.0f);
value_edit_click_away(value_edit_eid(hash), value, max_val, is_signed, &changed);
} else if (val_text && val_len > 0) {
Clay_ElementId val_eid = CLAY_IDI("FdrVal", (int)hash);
B32 val_hovered = Clay_PointerOver(val_eid);
Clay_String val_str = { .isStaticallyAllocated = false, .length = val_len, .chars = val_text };
static Clay_TextElementConfig fdr_val_cfg = {};
fdr_val_cfg.textColor = g_theme.text;
fdr_val_cfg.fontSize = FONT_SIZE_SMALL;
fdr_val_cfg.wrapMode = CLAY_TEXT_WRAP_NONE;
CLAY(val_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.padding = { uip(2), uip(2), uip(1), uip(1) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
}
) {
CLAY_TEXT(val_str, &fdr_val_cfg);
}
if (editable && val_hovered && g_wstate.mouse_clicked) {
value_edit_enter(hash, *value, is_signed);
}
}
}
return changed;
}