Update readme, add potentiometer controls

This commit is contained in:
2026-03-03 16:12:36 -05:00
parent 9c81f21be7
commit da6e868b0f
10 changed files with 751 additions and 5 deletions

View File

@@ -7,6 +7,8 @@
#include "ui/ui_widgets.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
UI_WidgetState g_wstate = {};
@@ -20,6 +22,16 @@ static S32 g_icon_pool_count = 0;
static CustomGradientData g_grad_pool[UI_MAX_GRADIENTS_PER_FRAME];
static S32 g_grad_pool_count = 0;
// Rotated icon per-frame pool (for knobs)
#define UI_MAX_ROTATED_ICONS_PER_FRAME 16
static CustomRotatedIconData g_rotated_icon_pool[UI_MAX_ROTATED_ICONS_PER_FRAME];
static S32 g_rotated_icon_pool_count = 0;
// Static buffer pool for knob value text
#define UI_MAX_KNOB_TEXT_BUFS 8
static char g_knob_text_bufs[UI_MAX_KNOB_TEXT_BUFS][32];
static S32 g_knob_text_buf_count = 0;
static CustomGradientData *alloc_gradient(Clay_Color top, Clay_Color bottom) {
if (g_grad_pool_count >= UI_MAX_GRADIENTS_PER_FRAME) return nullptr;
CustomGradientData *g = &g_grad_pool[g_grad_pool_count++];
@@ -32,6 +44,9 @@ static CustomGradientData *alloc_gradient(Clay_Color top, Clay_Color bottom) {
// Per-frame shadow layer ID counter (each shadow uses N unique IDs)
static S32 g_shadow_id_counter = 0;
// Frame counter for double-click detection
static S32 g_frame_number = 0;
// Emit a smooth multi-layer drop shadow as floating rects.
// bb: bounding box of the element to shadow (previous frame)
// ox/oy: directional offset (light direction)
@@ -113,7 +128,15 @@ void ui_widgets_begin_frame(PlatformInput input) {
g_wstate.mouse_clicked = (input.mouse_down && !input.was_mouse_down);
g_icon_pool_count = 0;
g_grad_pool_count = 0;
g_rotated_icon_pool_count = 0;
g_knob_text_buf_count = 0;
g_shadow_id_counter = 0;
g_frame_number++;
// Release knob drag if mouse is up
if (!input.mouse_down && g_wstate.knob_drag.dragging_id != 0) {
g_wstate.knob_drag.dragging_id = 0;
}
g_wstate.cursor_blink += 1.0f / 60.0f;
g_wstate.text_input_count = 0;
g_wstate.tab_pressed = 0;
@@ -1356,3 +1379,494 @@ S32 ui_tab_bar(const char *id, const char **labels, S32 count, S32 *selected) {
}
return *selected;
}
////////////////////////////////
// Knob / Potentiometer
B32 ui_knob(const char *id, const char *label, F32 *value, F32 max_val, B32 is_signed, F32 default_val, B32 editable) {
ensure_widget_text_configs();
F32 knob_size = WIDGET_KNOB_SIZE;
B32 changed = 0;
// 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;
// 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;
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
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;
angle_rad = (-135.0f + normalized * 270.0f) * deg_to_rad;
}
// Handle text edit keyboard input before layout
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;
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;
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;
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++;
}
// Layout: vertical column (knob → value text → label)
CLAY(WID(id),
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.childGap = WIDGET_KNOB_LABEL_GAP,
.childAlignment = { .x = CLAY_ALIGN_X_CENTER },
.layoutDirection = CLAY_TOP_TO_BOTTOM,
}
) {
// Knob visual: circular background with rotated icon inside
Clay_ElementId knob_eid = CLAY_IDI("KnobBg", (int)knob_hash);
B32 hovered = Clay_PointerOver(knob_eid);
CLAY(knob_eid,
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(knob_size), .height = CLAY_SIZING_FIXED(knob_size) },
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER },
},
.backgroundColor = g_theme.bg_dark,
.cornerRadius = CLAY_CORNER_RADIUS(knob_size / 2.0f)
) {
// 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++];
rdata->type = CUSTOM_RENDER_ROTATED_ICON;
rdata->icon_id = (S32)UI_ICON_KNOB;
rdata->color = g_theme.accent;
rdata->angle_rad = angle_rad;
F32 icon_size = knob_size * 0.75f;
CLAY(CLAY_IDI("KnobIcon", ri_idx),
.layout = {
.sizing = { .width = CLAY_SIZING_FIXED(icon_size), .height = CLAY_SIZING_FIXED(icon_size) },
},
.custom = { .customData = rdata }
) {}
}
}
// Click on knob: double-click resets to default, 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;
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;
}
}
// 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);
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);
// 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,
.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);
}
}
// 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;
}
}
}
// Label
CLAY_TEXT(clay_str(label), &g_widget_text_config_dim);
}
return changed;
}