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

@@ -47,11 +47,29 @@ Abstracts window creation, event polling, menus, clipboard, and native handles b
A thin wrapper around [Clay](https://github.com/nicbarker/clay) (v0.14), a single-header C layout library. Clay uses macros (`CLAY()`, `CLAY_TEXT()`, etc.) for declarative layout with automatic sizing, flex-like child arrangement, and built-in text measurement.
- `ui_core.h` / `ui_core.cpp` — Defines `UI_Context` and `UI_Theme`, handles Clay initialization, lifecycle (`ui_begin_frame` / `ui_end_frame`), text measurement bridge, and error handling. `CLAY_IMPLEMENTATION` is defined here.
- `ui_widgets.h` / `ui_widgets.cpp`Widget abstractions (currently stubs, reserved for future use).
- `ui_core.h` / `ui_core.cpp` — Defines `UI_Context` and `UI_Theme`, handles Clay initialization, lifecycle (`ui_begin_frame` / `ui_end_frame`), text measurement bridge, theme/accent management, and error handling. `CLAY_IMPLEMENTATION` is defined here.
- `ui_widgets.h` / `ui_widgets.cpp`Immediate-mode widget library: buttons, checkboxes, radio groups, text inputs (with selection, clipboard, Tab cycling), dropdowns, tab bars, draggable windows, and modal dialogs.
- `ui_icons.h` / `ui_icons.cpp` — SVG icon atlas rasterized at startup via lunasvg. Icons are rendered as custom Clay elements.
The application layout is built in `main.cpp` using Clay macros directly. Panel builder functions (`build_browser_panel`, `build_main_panel`, etc.) compose the UI each frame.
#### Theme system
The UI supports a full theme system managed through `UI_Theme` (`ui_core.h`) with live switching at runtime:
- **Base themes**: Dark and Light, selectable via `ui_set_theme()`. Each defines a full palette — backgrounds, borders, text, accent colors, title bars, scrollbars, tab colors, and drop shadow opacity.
- **Accent palettes**: 7 color options (Blue, Turquoise, Orange, Purple, Pink, Red, Green) applied via `ui_set_accent()`. Each palette provides coordinated accent, hover, tab gradient, button text, and tab text colors tuned for both dark and light base themes.
- **Corner radius**: Configurable at runtime (None / Small / Medium / Large) via `g_theme.corner_radius`. All widgets read `CORNER_RADIUS` which scales with `uis()`.
- **UI scale**: Cmd+/Cmd- (Ctrl on Windows) zoom from 0.5x to 3.0x. All widget sizes, padding, font sizes, and corner radii scale via `uis()` / `uip()` / `uifs()` helpers.
#### Visual depth
Interactive elements use subtle visual effects for a DAW-style look:
- **Gradients**: Buttons, title bars, dropdown headers, and tab bars use vertical gradients via Clay's `CLAY_RENDER_COMMAND_TYPE_CUSTOM` system and a per-frame `CustomGradientData` pool. Buttons and title bars are lighter on top (raised), text inputs are darker on top (inset/recessed).
- **Drop shadows**: Floating windows, modals, and dropdown lists cast soft multi-layer shadows. 7 concentric rects with linearly decreasing opacity simulate a gaussian blur, offset down-right for directional lighting.
- **Panel highlights**: 1px lighter lines at the top of panel content areas simulate beveled edges.
### Renderer (`src/renderer/`)
Custom SDF-based pipeline for UI rendering. Processes Clay's `Clay_RenderCommandArray` output directly — no intermediate scene graph.
@@ -102,9 +120,10 @@ src/
renderer_dx12.cpp DirectX 12 implementation
renderer_metal.mm Metal implementation
ui/
ui_core.h Clay wrapper types and lifecycle API
ui_core.cpp Clay init, text measurement bridge, theme
ui_widgets.h / .cpp Widget stubs (reserved)
ui_core.h Clay wrapper, UI_Theme, scale helpers, custom render types
ui_core.cpp Clay init, text measurement bridge, theme/accent palettes
ui_widgets.h / .cpp Immediate-mode widgets (button, checkbox, radio, text input, dropdown, tab bar, window, modal)
ui_icons.h / .cpp SVG icon atlas (check, close, chevron) via lunasvg
audio/
audio.h Audio device and playback API
audio_asio.cpp Windows ASIO implementation
@@ -142,3 +161,4 @@ All dependencies are vendored as source. On Windows, nothing to install beyond t
- [nob.h](https://github.com/tsoding/nob.h) — build system
- [Clay](https://github.com/nicbarker/clay) — single-header C layout library (v0.14, with MSVC C++ patches)
- [LunaSVG](https://github.com/nicbarker/lunasvg) — SVG rendering library (MIT, by Samuel Ugochukwu) used to rasterize icon SVGs into an R8 texture atlas at startup. Icons (close, check, chevron, knob) are defined as inline SVG strings in `ui_icons.cpp` and rendered at a fixed pixel size into a packed atlas. The atlas is uploaded to the GPU once and sampled by the renderer for icon and rotated-icon custom elements. Bundled with [PlutoVG](https://github.com/nicbarker/plutovg) as its 2D vector graphics backend.

View File

@@ -106,6 +106,10 @@ struct AppState {
// Corner radius selection
S32 radius_sel;
// Knob demo state
F32 demo_knob_unsigned;
F32 demo_knob_signed;
// Audio device selection
S32 audio_device_sel; // dropdown index: 0 = None, 1+ = device
S32 audio_device_prev; // previous selection for change detection
@@ -278,6 +282,24 @@ static void build_main_panel(AppState *app) {
.backgroundColor = g_theme.border
) {}
// Section: Knobs
ui_label("LblKnobs", "Knobs");
CLAY(CLAY_ID("KnobRow"),
.layout = {
.sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() },
.childGap = uip(16),
.layoutDirection = CLAY_LEFT_TO_RIGHT,
}
) {
ui_knob("KnobVolume", "Volume", &app->demo_knob_unsigned, 100.0f, 0, 75.0f, 1);
ui_knob("KnobPan", "Pan", &app->demo_knob_signed, 50.0f, 1, 0.0f, 1);
}
CLAY(CLAY_ID("Sep6"),
.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"),
@@ -876,6 +898,8 @@ int main(int argc, char **argv) {
app.show_props = 1;
app.show_log = 1;
app.show_midi_devices = 1;
app.demo_knob_unsigned = 75.0f;
app.demo_knob_signed = 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");

View File

@@ -15,6 +15,7 @@ enum {
kVK_RightShift = 0x3C, kVK_RightCommand = 0x36,
kVK_ANSI_Equal = 0x18, kVK_ANSI_Minus = 0x1B,
kVK_ANSI_0 = 0x1D,
kVK_ANSI_KeypadEnter = 0x4C,
};
static uint8_t macos_keycode_to_pkey(uint16_t keycode) {
@@ -24,6 +25,7 @@ static uint8_t macos_keycode_to_pkey(uint16_t keycode) {
case kVK_ANSI_V: return PKEY_V;
case kVK_ANSI_X: return PKEY_X;
case kVK_Return: return PKEY_RETURN;
case kVK_ANSI_KeypadEnter: return PKEY_RETURN;
case kVK_Tab: return PKEY_TAB;
case kVK_Delete: return PKEY_BACKSPACE;
case kVK_ForwardDelete:return PKEY_DELETE;
@@ -399,6 +401,11 @@ PlatformInput platform_get_input(PlatformWindow *window) {
result.mouse_down = window->mouse_down_state;
window->prev_mouse_down = result.mouse_down;
// Poll current modifier state (so shift/ctrl are accurate even without key events)
NSEventModifierFlags mods = [NSEvent modifierFlags];
result.ctrl_held = (mods & NSEventModifierFlagCommand) != 0;
result.shift_held = (mods & NSEventModifierFlagShift) != 0;
// Clear accumulated events for next frame
window->input = {};
return result;

View File

@@ -870,6 +870,61 @@ static void emit_quad(DrawBatch *batch,
batch->index_count += 6;
}
static void emit_quad_rotated(DrawBatch *batch,
float x0, float y0, float x1, float y1,
float u0, float v0, float u1, float v1,
float cr, float cg, float cb, float ca,
float angle_rad)
{
if (batch->vertex_count + 4 > MAX_VERTICES || batch->index_count + 6 > MAX_INDICES)
return;
U32 base = batch->vertex_count;
UIVertex *v = &batch->vertices[base];
float cx = (x0 + x1) * 0.5f;
float cy = (y0 + y1) * 0.5f;
float cosA = cosf(angle_rad);
float sinA = sinf(angle_rad);
float dx0 = x0 - cx, dy0 = y0 - cy;
float dx1 = x1 - cx, dy1 = y1 - cy;
v[0].pos[0] = cx + dx0 * cosA - dy0 * sinA;
v[0].pos[1] = cy + dx0 * sinA + dy0 * cosA;
v[0].uv[0] = u0; v[0].uv[1] = v0;
v[1].pos[0] = cx + dx1 * cosA - dy0 * sinA;
v[1].pos[1] = cy + dx1 * sinA + dy0 * cosA;
v[1].uv[0] = u1; v[1].uv[1] = v0;
v[2].pos[0] = cx + dx1 * cosA - dy1 * sinA;
v[2].pos[1] = cy + dx1 * sinA + dy1 * cosA;
v[2].uv[0] = u1; v[2].uv[1] = v1;
v[3].pos[0] = cx + dx0 * cosA - dy1 * sinA;
v[3].pos[1] = cy + dx0 * sinA + dy1 * cosA;
v[3].uv[0] = u0; v[3].uv[1] = v1;
for (int i = 0; i < 4; i++) {
v[i].col[0] = cr; v[i].col[1] = cg; v[i].col[2] = cb; v[i].col[3] = ca;
v[i].rect_min[0] = 0; v[i].rect_min[1] = 0;
v[i].rect_max[0] = 0; v[i].rect_max[1] = 0;
v[i].corner_radii[0] = 0; v[i].corner_radii[1] = 0;
v[i].corner_radii[2] = 0; v[i].corner_radii[3] = 0;
v[i].border_thickness = 0;
v[i].softness = 0;
v[i].mode = 1.0f;
}
U32 *idx = &batch->indices[batch->index_count];
idx[0] = base; idx[1] = base + 1; idx[2] = base + 2;
idx[3] = base; idx[4] = base + 2; idx[5] = base + 3;
batch->vertex_count += 4;
batch->index_count += 6;
}
static void emit_rect(DrawBatch *batch,
float x0, float y0, float x1, float y1,
float cr, float cg, float cb, float ca,
@@ -1276,6 +1331,18 @@ void renderer_end_frame(Renderer *r, Clay_RenderCommandArray render_commands) {
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 1.0f);
} else if (type == CUSTOM_RENDER_ROTATED_ICON) {
bind_icon();
CustomRotatedIconData *ri = (CustomRotatedIconData *)custom->customData;
Clay_Color c = ri->color;
float cr = c.r / 255.f, cg = c.g / 255.f;
float cb = c.b / 255.f, ca = c.a / 255.f;
UI_IconInfo *info = &g_icons[ri->icon_id];
emit_quad_rotated(&batch,
bb.x, bb.y, bb.x + bb.width, bb.y + bb.height,
info->u0, info->v0, info->u1, info->v1,
cr, cg, cb, ca,
ri->angle_rad);
}
}
} break;

View File

@@ -401,6 +401,63 @@ static void emit_quad(DrawBatch *batch,
batch->index_count += 6;
}
static void emit_quad_rotated(DrawBatch *batch,
float x0, float y0, float x1, float y1,
float u0, float v0, float u1, float v1,
float cr, float cg, float cb, float ca,
float angle_rad)
{
if (batch->vertex_count + 4 > MAX_VERTICES || batch->index_count + 6 > MAX_INDICES)
return;
U32 base = batch->vertex_count;
UIVertex *v = &batch->vertices[base];
float cx = (x0 + x1) * 0.5f;
float cy = (y0 + y1) * 0.5f;
float cosA = cosf(angle_rad);
float sinA = sinf(angle_rad);
// Corner offsets from center
float dx0 = x0 - cx, dy0 = y0 - cy;
float dx1 = x1 - cx, dy1 = y1 - cy;
// Rotate each corner around center
v[0].pos[0] = cx + dx0 * cosA - dy0 * sinA;
v[0].pos[1] = cy + dx0 * sinA + dy0 * cosA;
v[0].uv[0] = u0; v[0].uv[1] = v0;
v[1].pos[0] = cx + dx1 * cosA - dy0 * sinA;
v[1].pos[1] = cy + dx1 * sinA + dy0 * cosA;
v[1].uv[0] = u1; v[1].uv[1] = v0;
v[2].pos[0] = cx + dx1 * cosA - dy1 * sinA;
v[2].pos[1] = cy + dx1 * sinA + dy1 * cosA;
v[2].uv[0] = u1; v[2].uv[1] = v1;
v[3].pos[0] = cx + dx0 * cosA - dy1 * sinA;
v[3].pos[1] = cy + dx0 * sinA + dy1 * cosA;
v[3].uv[0] = u0; v[3].uv[1] = v1;
for (int i = 0; i < 4; i++) {
v[i].col[0] = cr; v[i].col[1] = cg; v[i].col[2] = cb; v[i].col[3] = ca;
v[i].rect_min[0] = 0; v[i].rect_min[1] = 0;
v[i].rect_max[0] = 0; v[i].rect_max[1] = 0;
v[i].corner_radii[0] = 0; v[i].corner_radii[1] = 0;
v[i].corner_radii[2] = 0; v[i].corner_radii[3] = 0;
v[i].border_thickness = 0;
v[i].softness = 0;
v[i].mode = 1.0f; // textured
}
U32 *idx = &batch->indices[batch->index_count];
idx[0] = base; idx[1] = base + 1; idx[2] = base + 2;
idx[3] = base; idx[4] = base + 2; idx[5] = base + 3;
batch->vertex_count += 4;
batch->index_count += 6;
}
static void emit_rect(DrawBatch *batch,
float x0, float y0, float x1, float y1,
float cr, float cg, float cb, float ca,
@@ -845,6 +902,18 @@ void renderer_end_frame(Renderer *r, Clay_RenderCommandArray render_commands) {
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 1.0f);
} else if (type == CUSTOM_RENDER_ROTATED_ICON) {
bind_icon_texture();
CustomRotatedIconData *ri = (CustomRotatedIconData *)custom->customData;
Clay_Color c = ri->color;
float cr = c.r / 255.f, cg = c.g / 255.f;
float cb = c.b / 255.f, ca = c.a / 255.f;
UI_IconInfo *info = &g_icons[ri->icon_id];
emit_quad_rotated(&batch,
bb.x, bb.y, bb.x + bb.width, bb.y + bb.height,
info->u0, info->v0, info->u1, info->v1,
cr, cg, cb, ca,
ri->angle_rad);
}
}
} break;

View File

@@ -118,6 +118,7 @@ static inline uint16_t uifs(float x) { return (uint16_t)(x * g_ui_scale + 0.5f);
enum CustomRenderType {
CUSTOM_RENDER_VGRADIENT = 1,
CUSTOM_RENDER_ICON = 2,
CUSTOM_RENDER_ROTATED_ICON = 3,
};
struct CustomGradientData {
@@ -132,6 +133,13 @@ struct CustomIconData {
Clay_Color color;
};
struct CustomRotatedIconData {
CustomRenderType type; // CUSTOM_RENDER_ROTATED_ICON
S32 icon_id;
Clay_Color color;
F32 angle_rad;
};
////////////////////////////////
// Font sizes
@@ -149,6 +157,8 @@ struct CustomIconData {
#define WIDGET_INPUT_HEIGHT uis(30)
#define WIDGET_DROPDOWN_HEIGHT uis(30)
#define WIDGET_DROPDOWN_ITEM_H uis(28)
#define WIDGET_KNOB_SIZE uis(48)
#define WIDGET_KNOB_LABEL_GAP uip(4)
////////////////////////////////
// Corner radius (from theme)

View File

@@ -23,6 +23,12 @@ static const char *g_icon_svgs[UI_ICON_COUNT] = {
R"(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M7 10 L12 15 L17 10" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>)",
// UI_ICON_KNOB - filled circle with indicator line pointing up (12 o'clock)
R"(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" fill="white" opacity="0.25"/>
<line x1="12" y1="12" x2="12" y2="3" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
</svg>)",
};
U8 *ui_icons_rasterize_atlas(S32 *out_w, S32 *out_h, S32 icon_size) {

View File

@@ -7,6 +7,7 @@ enum UI_IconID {
UI_ICON_CLOSE,
UI_ICON_CHECK,
UI_ICON_CHEVRON_DOWN,
UI_ICON_KNOB,
UI_ICON_COUNT
};

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;
}

View File

@@ -31,6 +31,15 @@ struct UI_WindowSlot {
int16_t z_order;
};
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 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)
S32 last_click_frame; // Frame number of last click
};
struct UI_DragState {
uint32_t dragging_id; // Window ID currently being dragged (0 = none)
Vec2F32 drag_anchor; // Mouse position when drag started
@@ -69,6 +78,16 @@ struct UI_WidgetState {
S32 window_count;
int16_t next_z;
UI_DragState drag;
// Knob drag state
UI_KnobDragState knob_drag;
// Knob text edit state
uint32_t knob_edit_id; // Hash of knob in text edit mode (0 = none)
char knob_edit_buf[32]; // Text buffer for numeric entry
S32 knob_edit_cursor; // Cursor position in edit buffer
S32 knob_edit_sel_start; // Selection anchor
S32 knob_edit_sel_end; // Selection extent
};
extern UI_WidgetState g_wstate;
@@ -130,3 +149,12 @@ typedef void (*UI_WindowContentFn)(void *user_data);
B32 ui_window(const char *id, const char *title, B32 *open,
Vec2F32 initial_pos, Vec2F32 initial_size,
UI_WindowContentFn content_fn, void *user_data);
// Knob / potentiometer. Vertical drag to change value.
// unsigned (is_signed=0): value in [0, max_val]
// signed (is_signed=1): value in [-max_val, +max_val]
// default_val: value restored on double-click.
// editable: if true, clicking the value text opens a text input for direct entry.
// 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);