2402 lines
93 KiB
C++
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;
|
|
}
|