// 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 #include #include #include 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; }