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 @@
+
+
+
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);