From 2927335975e5203dd6a5e1f1cf3cea321fd8e107 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Tue, 3 Mar 2026 19:41:20 -0500 Subject: [PATCH] play around with faders, WIP --- assets/faders.svg | 3490 +++++++++++++++++++++++++++++++++++++++++ src/main.cpp | 36 + src/ui/ui_core.h | 13 + src/ui/ui_icons.cpp | 25 + src/ui/ui_icons.h | 2 + src/ui/ui_widgets.cpp | 1215 +++++++++----- src/ui/ui_widgets.h | 10 + 7 files changed, 4416 insertions(+), 375 deletions(-) create mode 100644 assets/faders.svg diff --git a/assets/faders.svg b/assets/faders.svg new file mode 100644 index 0000000..b05a201 --- /dev/null +++ b/assets/faders.svg @@ -0,0 +1,3490 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 60 + + + 50 + + + 40 + + + 30 + + + 20 + + + 10 + + + 0 + + + 5 + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 60 + + + 50 + + + 40 + + + 30 + + + 20 + + + 10 + + + 0 + + + 5 + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 60 + + + 50 + + + 40 + + + 30 + + + 20 + + + 10 + + + 0 + + + 5 + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + + + + + + + + + diff --git a/src/main.cpp b/src/main.cpp index 0e10a2b..27cb6fe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -110,6 +110,11 @@ struct AppState { F32 demo_knob_unsigned; F32 demo_knob_signed; + // Slider/fader demo state + F32 demo_slider_h; + F32 demo_slider_v; + F32 demo_fader; + // Audio device selection S32 audio_device_sel; // dropdown index: 0 = None, 1+ = device S32 audio_device_prev; // previous selection for change detection @@ -300,6 +305,34 @@ static void build_main_panel(AppState *app) { .backgroundColor = g_theme.border ) {} + // Section: Sliders + ui_label("LblSliders", "Sliders"); + CLAY(CLAY_ID("SliderHRow"), + .layout = { + .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() }, + .childGap = uip(16), + .layoutDirection = CLAY_LEFT_TO_RIGHT, + } + ) { + ui_slider_h("SliderH", "Horizontal", &app->demo_slider_h, 100.0f, 0, 50.0f, 1); + } + CLAY(CLAY_ID("SliderVRow"), + .layout = { + .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() }, + .childGap = uip(24), + .childAlignment = { .y = CLAY_ALIGN_Y_TOP }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + } + ) { + ui_slider_v("SliderV", "Vertical", &app->demo_slider_v, 100.0f, 0, 75.0f, 1); + ui_fader("Fader1", "Fader", &app->demo_fader, 50.0f, 1, 0.0f, 1); + } + + CLAY(CLAY_ID("Sep7"), + .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(1) } }, + .backgroundColor = g_theme.border + ) {} + // Section: Windows & Modals ui_label("LblWindows", "Windows & Modals"); CLAY(CLAY_ID("WindowBtnRow"), @@ -900,6 +933,9 @@ int main(int argc, char **argv) { app.show_midi_devices = 1; app.demo_knob_unsigned = 75.0f; app.demo_knob_signed = 0.0f; + app.demo_slider_h = 50.0f; + app.demo_slider_v = 75.0f; + app.demo_fader = 0.0f; app.demo_dropdown_sel = 1; // default to 48000 Hz app.radius_sel = 1; // default to "Small" (4.0f) snprintf(app.demo_text_a, sizeof(app.demo_text_a), "My Instrument"); diff --git a/src/ui/ui_core.h b/src/ui/ui_core.h index 8af72b7..a742e9a 100644 --- a/src/ui/ui_core.h +++ b/src/ui/ui_core.h @@ -160,6 +160,19 @@ struct CustomRotatedIconData { #define WIDGET_KNOB_SIZE uis(48) #define WIDGET_KNOB_LABEL_GAP uip(4) +#define WIDGET_SLIDER_H_WIDTH uis(160) +#define WIDGET_SLIDER_H_TRACK_H uis(6) +#define WIDGET_SLIDER_H_THUMB_W uis(14) +#define WIDGET_SLIDER_H_THUMB_H uis(20) +#define WIDGET_SLIDER_V_HEIGHT uis(100) +#define WIDGET_SLIDER_V_TRACK_W uis(6) +#define WIDGET_SLIDER_V_THUMB_W uis(20) +#define WIDGET_SLIDER_V_THUMB_H uis(14) +#define WIDGET_FADER_HEIGHT uis(160) +#define WIDGET_FADER_TRACK_W uis(4) +#define WIDGET_FADER_CAP_W uis(38) +#define WIDGET_FADER_CAP_H uis(52) + //////////////////////////////// // Corner radius (from theme) diff --git a/src/ui/ui_icons.cpp b/src/ui/ui_icons.cpp index 0e68648..a1789d1 100644 --- a/src/ui/ui_icons.cpp +++ b/src/ui/ui_icons.cpp @@ -29,6 +29,31 @@ static const char *g_icon_svgs[UI_ICON_COUNT] = { )", + + // UI_ICON_SLIDER_THUMB - layered body with grip ridges + R"( + + + + + + + + )", + + // UI_ICON_FADER - Pro Tools-style fader cap: solid body, bright center indicator, beveled caps + R"( + + + + + + + + + + + )", }; U8 *ui_icons_rasterize_atlas(S32 *out_w, S32 *out_h, S32 icon_size) { diff --git a/src/ui/ui_icons.h b/src/ui/ui_icons.h index 31fa8ff..6a57e3d 100644 --- a/src/ui/ui_icons.h +++ b/src/ui/ui_icons.h @@ -8,6 +8,8 @@ enum UI_IconID { UI_ICON_CHECK, UI_ICON_CHEVRON_DOWN, UI_ICON_KNOB, + UI_ICON_SLIDER_THUMB, + UI_ICON_FADER, UI_ICON_COUNT }; diff --git a/src/ui/ui_widgets.cpp b/src/ui/ui_widgets.cpp index 1ea7940..67aa4b6 100644 --- a/src/ui/ui_widgets.cpp +++ b/src/ui/ui_widgets.cpp @@ -28,7 +28,7 @@ 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 8 +#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; @@ -1380,6 +1380,288 @@ S32 ui_tab_bar(const char *id, const char **labels, S32 count, S32 *selected) { 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 @@ -1389,267 +1671,50 @@ B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_s F32 knob_size = WIDGET_KNOB_SIZE; B32 changed = 0; - // Normalize value to [0,1] - F32 normalized; - if (is_signed) { - normalized = (*value + max_val) / (2.0f * max_val); - } else { - normalized = *value / max_val; - } - if (normalized < 0.0f) normalized = 0.0f; - if (normalized > 1.0f) normalized = 1.0f; + 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; - // Hash the ID for drag state tracking uint32_t knob_hash = Clay__HashString(clay_str(id), 0).id; - - // Text edit state B32 is_editing = (editable && g_wstate.knob_edit_id == knob_hash); - - // Drag interaction (only when not text-editing) 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) { - // Re-anchor when shift state changes so there's no jump 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; } - - // Continue drag: vertical mouse delta mapped to value range - // Hold Shift for fine control (5x slower) - F32 dy = kd->drag_start_y - g_wstate.input.mouse_pos.y; // up = positive - F32 sensitivity = 200.0f * g_ui_scale; // pixels for full range + 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; - - // Clamp 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; - } - - // Recalculate normalized/angle after drag - if (is_signed) { - normalized = (*value + max_val) / (2.0f * max_val); - } else { - normalized = *value / max_val; - } - if (normalized < 0.0f) normalized = 0.0f; - if (normalized > 1.0f) normalized = 1.0f; + 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; } - // Handle text edit keyboard input before layout + // Text edit keyboard input if (is_editing) { - 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; - - // Clamp - if (*ecur > elen) *ecur = elen; - if (*esel0 > elen) *esel0 = elen; - if (*esel1 > elen) *esel1 = elen; - - // Selection helpers (local lambdas via inline) - #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) - - // Delete selection, returns new length - 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); - }; - - // Process key events - 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); - // Filter: only keep numeric chars - 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; // ignore other ctrl combos - } - - 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(); - } - } - - // Process character input (digits, minus, period) - 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) { - // Parse and apply - 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; + S32 result = value_edit_process_keys(value, max_val, is_signed, &changed); + if (result) { is_editing = 0; - - // Recalculate after commit - if (is_signed) { - normalized = (*value + max_val) / (2.0f * max_val); - } else { - normalized = *value / max_val; - } - if (normalized < 0.0f) normalized = 0.0f; - if (normalized > 1.0f) normalized = 1.0f; + normalized = value_normalize(*value, max_val, is_signed); angle_rad = (-135.0f + normalized * 270.0f) * deg_to_rad; - } else if (cancel) { - g_wstate.knob_edit_id = 0; - is_editing = 0; } } - // Format value text (only when not editing) - char *val_text = nullptr; + // Format value text S32 val_len = 0; - if (!is_editing && g_knob_text_buf_count < UI_MAX_KNOB_TEXT_BUFS) { - val_text = g_knob_text_bufs[g_knob_text_buf_count]; - if (is_signed) { - val_len = snprintf(val_text, 32, "%+.1f", *value); - } else { - val_len = snprintf(val_text, 32, "%.1f", *value); - } - g_knob_text_buf_count++; - } + char *val_text = is_editing ? nullptr : value_format_text(*value, is_signed, &val_len); // Layout: vertical column (knob → value text → label) CLAY(WID(id), @@ -1660,7 +1725,6 @@ B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_s .layoutDirection = CLAY_TOP_TO_BOTTOM, } ) { - // Knob visual: circular background with rotated icon inside Clay_ElementId knob_eid = CLAY_IDI("KnobBg", (int)knob_hash); B32 hovered = Clay_PointerOver(knob_eid); @@ -1672,7 +1736,6 @@ B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_s .backgroundColor = g_theme.bg_dark, .cornerRadius = CLAY_CORNER_RADIUS(knob_size / 2.0f) ) { - // Rotated icon at 75% container size 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++]; @@ -1691,33 +1754,19 @@ B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_s } } - // Click on knob: double-click resets to default, single click starts drag + // Click: double-click resets, single click starts drag if (!is_editing && hovered && g_wstate.mouse_clicked && kd->dragging_id == 0) { - // Double-click detection: same knob within 20 frames (~333ms at 60fps) 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) { - // Reset to default value - if (*value != default_val) { - *value = default_val; - changed = 1; - } - // Recalculate after reset - if (is_signed) { - normalized = (*value + max_val) / (2.0f * max_val); - } else { - normalized = *value / max_val; - } - if (normalized < 0.0f) normalized = 0.0f; - if (normalized > 1.0f) normalized = 1.0f; + 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; - // Clear so triple-click doesn't re-trigger kd->last_click_id = 0; } else { - // Start drag kd->dragging_id = knob_hash; kd->drag_start_y = g_wstate.input.mouse_pos.y; kd->value_at_start = *value; @@ -1726,141 +1775,30 @@ B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_s // Value text / text edit area if (is_editing) { - // Show text input for direct value entry - Clay_ElementId edit_eid = CLAY_IDI("KnobEdit", (int)knob_hash); + 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); - 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); + 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; - // Display buffers for before/selected/after segments - static char ke_dbuf_before[32]; - static char ke_dbuf_sel[32]; - static char ke_dbuf_after[32]; - - static Clay_TextElementConfig knob_edit_cfg = {}; - knob_edit_cfg.textColor = g_theme.text; - knob_edit_cfg.fontSize = FONT_SIZE_SMALL; - knob_edit_cfg.wrapMode = CLAY_TEXT_WRAP_NONE; - - static Clay_TextElementConfig knob_edit_sel_cfg = {}; - knob_edit_sel_cfg.textColor = Clay_Color{255, 255, 255, 255}; - knob_edit_sel_cfg.fontSize = FONT_SIZE_SMALL; - knob_edit_sel_cfg.wrapMode = CLAY_TEXT_WRAP_NONE; - - CLAY(edit_eid, + 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 }, - .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) { - // Three segments: before | selected | after - 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, &knob_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("KnobEditSel", (int)knob_hash), - .layout = { .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() } }, - .backgroundColor = g_theme.accent - ) { - CLAY_TEXT(s_sel, &knob_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, &knob_edit_cfg); - } - } else { - // No selection: show text with '|' cursor - static char knob_edit_display[64]; - S32 di = 0; - for (S32 i = 0; i < elen + 1 && di < 62; i++) { - if (i == ecur) knob_edit_display[di++] = '|'; - if (i < elen) knob_edit_display[di++] = ebuf[i]; - } - knob_edit_display[di] = '\0'; - Clay_String s_cursor = { .length = di, .chars = knob_edit_display }; - CLAY_TEXT(s_cursor, &knob_edit_cfg); - } + CLAY_TEXT(val_str, &knob_val_cfg); } - // Click away from edit box → commit - if (g_wstate.mouse_clicked && !Clay_PointerOver(edit_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; - } - } else { - // Static value text display - Clay_ElementId val_eid = CLAY_IDI("KnobVal", (int)knob_hash); - B32 val_hovered = Clay_PointerOver(val_eid); - - if (val_text && val_len > 0) { - 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); - } - - // Click on value text → enter edit mode with select-all - if (editable && val_hovered && g_wstate.mouse_clicked) { - g_wstate.knob_edit_id = knob_hash; - g_wstate.focused_id = 0; // unfocus any text input - // Seed buffer with current value - 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; - } + if (editable && val_hovered && g_wstate.mouse_clicked) { + value_edit_enter(knob_hash, *value, is_signed); } } @@ -1870,3 +1808,530 @@ B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_s 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) }, + }, + .custom = { .customData = idata }, + .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, + } + ) {} + } + } + + // 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) }, + }, + .custom = { .customData = idata }, + .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, + } + ) {} + } + } + + // 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); + + // 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 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) + CLAY(CLAY_IDI("FdrTrack", (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("FdrFill", (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 fader cap (icon, silver colored) + 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{200, 200, 210, 255}; + + CLAY(CLAY_IDI("FdrCap", (int)hash), + .layout = { + .sizing = { .width = CLAY_SIZING_FIXED(cap_w), .height = CLAY_SIZING_FIXED(cap_h) }, + }, + .custom = { .customData = idata }, + .floating = { + .offset = { + .x = 0, + .y = (1.0f - normalized) * (track_h - cap_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, + } + ) {} + } + } + + // 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; +} diff --git a/src/ui/ui_widgets.h b/src/ui/ui_widgets.h index 65ae801..407a36e 100644 --- a/src/ui/ui_widgets.h +++ b/src/ui/ui_widgets.h @@ -34,6 +34,7 @@ struct UI_WindowSlot { struct UI_KnobDragState { uint32_t dragging_id; // Hash of the knob being dragged (0 = none) F32 drag_start_y; // Mouse Y when drag started + F32 drag_start_x; // Mouse X when drag started (for h-slider) F32 value_at_start; // Value when drag started B32 was_shift; // Shift state last frame (to re-anchor on change) uint32_t last_click_id; // Knob hash of last click (for double-click detection) @@ -158,3 +159,12 @@ B32 ui_window(const char *id, const char *title, B32 *open, // Hold Shift while dragging for fine control. // Returns true if value changed this frame. B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_signed, F32 default_val, B32 editable = 0); + +// Horizontal slider. Drag left/right to change value. +B32 ui_slider_h(const char *id, const char *label, F32 *value, F32 max_val, B32 is_signed, F32 default_val, B32 editable = 0); + +// Vertical slider. Drag up/down to change value. +B32 ui_slider_v(const char *id, const char *label, F32 *value, F32 max_val, B32 is_signed, F32 default_val, B32 editable = 0); + +// DAW-style fader (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 = 0);