fix ui widgets

This commit is contained in:
2026-02-25 16:40:14 -05:00
parent 12dae774e4
commit 68235b57ce
6 changed files with 759 additions and 27 deletions

View File

@@ -1,2 +1,483 @@
// ui_widgets.cpp - Removed: Clay handles all layout and widgets directly.
// This file is kept empty for the unity build include order.
// 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>
UI_WidgetState g_wstate = {};
void ui_widgets_init() {
g_wstate = {};
}
void ui_widgets_begin_frame(PlatformInputEvents input, B32 mouse_down, B32 was_mouse_down) {
g_wstate.input = input;
g_wstate.was_mouse_down = g_wstate.mouse_down;
g_wstate.mouse_down = mouse_down;
g_wstate.mouse_clicked = (mouse_down && !g_wstate.was_mouse_down);
g_wstate.cursor_blink += 1.0f / 60.0f;
ui_text_input_reset_display_bufs();
}
////////////////////////////////
// Helpers
static Clay_TextElementConfig g_widget_text_config;
static Clay_TextElementConfig g_widget_text_config_dim;
static bool g_widget_text_configs_init = false;
static void ensure_widget_text_configs() {
if (g_widget_text_configs_init) return;
g_widget_text_configs_init = true;
g_widget_text_config = {};
g_widget_text_config.textColor = g_theme.text;
g_widget_text_config.fontSize = 15;
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 = 15;
g_widget_text_config_dim.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)
////////////////////////////////
// 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, 2, 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(eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(30) },
.padding = { 12, 12, 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = hovered ? g_theme.accent_hover : g_theme.accent,
.cornerRadius = CLAY_CORNER_RADIUS(3)
) {
CLAY_TEXT(clay_str(text), &g_widget_text_config);
}
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(28) },
.childGap = 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(18), .height = CLAY_SIZING_FIXED(18) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = box_bg,
.cornerRadius = CLAY_CORNER_RADIUS(3),
.border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } }
) {
if (*value) {
CLAY_TEXT(CLAY_STRING("x"), &g_widget_text_config);
}
}
// 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 = 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(26) },
.childGap = 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(16), .height = CLAY_SIZING_FIXED(16) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = dot_bg,
.cornerRadius = CLAY_CORNER_RADIUS(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(8), .height = CLAY_SIZING_FIXED(8) },
},
.backgroundColor = g_theme.text,
.cornerRadius = CLAY_CORNER_RADIUS(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 so Clay string pointers
// remain valid until the end of the frame. We support up to
// MAX_TEXT_INPUTS simultaneous text inputs per frame.
#define MAX_TEXT_INPUTS 8
static char g_text_display_bufs[MAX_TEXT_INPUTS][512];
static S32 g_text_display_buf_idx = 0;
void ui_text_input_reset_display_bufs() {
g_text_display_buf_idx = 0;
}
B32 ui_text_input(const char *id, char *buf, S32 buf_size) {
ensure_widget_text_configs();
B32 text_changed = 0;
// Grab a unique display buffer for this text input
S32 my_buf_idx = g_text_display_buf_idx++;
if (my_buf_idx >= MAX_TEXT_INPUTS) my_buf_idx = MAX_TEXT_INPUTS - 1;
char *display_buf = g_text_display_bufs[my_buf_idx];
Clay_ElementId eid = WID(id);
B32 hovered = Clay_PointerOver(eid);
B32 is_focused = (g_wstate.focused_id == eid.id);
// 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.cursor_blink = 0;
is_focused = 1;
}
} 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);
// Clamp cursor
if (g_wstate.cursor_pos > len) g_wstate.cursor_pos = len;
if (g_wstate.cursor_pos < 0) g_wstate.cursor_pos = 0;
// Key events first (backspace generates both WM_KEYDOWN and WM_CHAR;
// we handle backspace/delete/arrows via keys, printable chars via chars)
for (S32 k = 0; k < g_wstate.input.key_count; k++) {
uint8_t key = g_wstate.input.keys[k];
switch (key) {
case PKEY_BACKSPACE:
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;
}
break;
case PKEY_DELETE:
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;
}
break;
case PKEY_LEFT:
if (g_wstate.cursor_pos > 0) g_wstate.cursor_pos--;
g_wstate.cursor_blink = 0;
break;
case PKEY_RIGHT:
if (g_wstate.cursor_pos < len) g_wstate.cursor_pos++;
g_wstate.cursor_blink = 0;
break;
case PKEY_HOME:
g_wstate.cursor_pos = 0;
g_wstate.cursor_blink = 0;
break;
case PKEY_END:
g_wstate.cursor_pos = 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 && 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;
}
}
}
}
// Build the display string.
// The cursor is shown as a '|' character when focused. To avoid layout
// flicker from the cursor blinking (which changes text width), we always
// include the cursor character when focused and simply don't blink it.
S32 len = (S32)strlen(buf);
if (is_focused) {
S32 cp = g_wstate.cursor_pos;
if (cp > len) cp = len;
S32 total = len + 1; // +1 for cursor char
if (total > 510) total = 510;
S32 before = cp < total ? cp : total;
memcpy(display_buf, buf, before);
display_buf[before] = '|';
S32 after = total - before - 1;
if (after > 0) memcpy(display_buf + before + 1, buf + cp, after);
display_buf[total] = '\0';
} else {
S32 copy_len = len < 511 ? len : 511;
memcpy(display_buf, buf, copy_len);
display_buf[copy_len] = '\0';
}
const char *show_text = display_buf;
Clay_TextElementConfig *text_cfg = &g_widget_text_config;
if (len == 0 && !is_focused) {
show_text = "...";
text_cfg = &g_widget_text_config_dim;
}
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;
CLAY(eid,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(30) },
.padding = { 8, 8, 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = bg,
.cornerRadius = CLAY_CORNER_RADIUS(3),
.border = { .color = border_color, .width = { 1, 1, 1, 1 } }
) {
CLAY_TEXT(clay_str(show_text), text_cfg);
}
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
const char *current_label = (*selected >= 0 && *selected < count) ? options[*selected] : "Select...";
Clay_Color bg = is_open ? g_theme.bg_dark : g_theme.bg_medium;
if (header_hovered && !is_open) bg = g_theme.bg_lighter;
CLAY(eid,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(30) },
.padding = { 8, 8, 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
.layoutDirection = CLAY_LEFT_TO_RIGHT,
},
.backgroundColor = bg,
.cornerRadius = CLAY_CORNER_RADIUS(3),
.border = { .color = is_open ? g_theme.accent : g_theme.border, .width = { 1, 1, 1, 1 } }
) {
CLAY(WIDI(id, 500),
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
}
) {
CLAY_TEXT(clay_str(current_label), &g_widget_text_config);
}
CLAY(WIDI(id, 501),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(20), .height = CLAY_SIZING_FIT() },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
}
) {
CLAY_TEXT(CLAY_STRING("v"), &g_widget_text_config_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
if (is_open) {
Clay_ElementId list_id = WIDI(id, 502);
CLAY(list_id,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
},
.backgroundColor = g_theme.bg_dark,
.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(item_id,
.layout = {
.sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(28) },
.padding = { 8, 8, 0, 0 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = item_bg
) {
CLAY_TEXT(clay_str(options[i]), &g_widget_text_config);
}
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;
}