diff --git a/autosample.raddbg b/autosample.raddbg index 92ce204..7a74b0b 100644 --- a/autosample.raddbg +++ b/autosample.raddbg @@ -17,7 +17,7 @@ target: debug_info: { path: "C:/Users/mta/projects/autosample/build/autosample.pdb" - timestamp: 66207540958809 + timestamp: 66207544041290 } target: { diff --git a/src/main.cpp b/src/main.cpp index 9f2d6d1..e48d76f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -75,6 +75,32 @@ static void init_text_configs() { g_text_config_dim.wrapMode = CLAY_TEXT_WRAP_NONE; } +//////////////////////////////// +// App state — all mutable state the frame function needs + +struct AppState { + PlatformWindow *window; + Renderer *renderer; + MidiEngine *midi; + UI_Context *ui; + S32 last_w, last_h; + B32 show_browser; + B32 show_props; + B32 show_log; + B32 show_midi_devices; + LARGE_INTEGER freq; + LARGE_INTEGER last_time; + + // Demo widget state + B32 demo_checkbox_a; + B32 demo_checkbox_b; + int32_t demo_radio_sel; + int32_t demo_dropdown_sel; + char demo_text_a[128]; + char demo_text_b[128]; + int32_t demo_button_count; +}; + //////////////////////////////// // Panel builders @@ -118,7 +144,7 @@ static void build_browser_panel(B32 show) { } } -static void build_main_panel() { +static void build_main_panel(AppState *app) { CLAY(CLAY_ID("MainPanel"), .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_GROW() }, @@ -131,12 +157,104 @@ static void build_main_panel() { CLAY(CLAY_ID("MainContent"), .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_GROW() }, - .padding = { 8, 8, 6, 6 }, - .childGap = 4, + .padding = { 16, 16, 12, 12 }, + .childGap = 12, .layoutDirection = CLAY_TOP_TO_BOTTOM, } ) { - CLAY_TEXT(CLAY_STRING("Main content area"), &g_text_config_normal); + // Section: Buttons + ui_label("LblButtons", "Buttons"); + CLAY(CLAY_ID("ButtonRow"), + .layout = { + .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() }, + .childGap = 8, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + } + ) { + if (ui_button("BtnHello", "Click Me")) { + app->demo_button_count++; + } + if (ui_button("BtnReset", "Reset")) { + app->demo_button_count = 0; + } + } + // Show click count + static char btn_count_buf[64]; + snprintf(btn_count_buf, sizeof(btn_count_buf), "Button clicked %d times", app->demo_button_count); + ui_label("LblBtnCount", btn_count_buf); + + // Separator + CLAY(CLAY_ID("Sep1"), + .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(1) } }, + .backgroundColor = g_theme.border + ) {} + + // Section: Checkboxes + ui_label("LblCheckboxes", "Checkboxes"); + ui_checkbox("ChkA", "Enable feature A", &app->demo_checkbox_a); + ui_checkbox("ChkB", "Enable feature B", &app->demo_checkbox_b); + + CLAY(CLAY_ID("Sep2"), + .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(1) } }, + .backgroundColor = g_theme.border + ) {} + + // Section: Radio buttons + ui_label("LblRadio", "Output Format"); + static const char *radio_options[] = { "WAV", "AIFF", "FLAC" }; + ui_radio_group("RadioFmt", radio_options, 3, &app->demo_radio_sel); + + CLAY(CLAY_ID("Sep3"), + .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(1) } }, + .backgroundColor = g_theme.border + ) {} + + // Section: Text inputs + ui_label("LblText", "Text Inputs"); + CLAY(CLAY_ID("TextRow"), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() }, + .childGap = 8, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + } + ) { + CLAY(CLAY_ID("TextCol1"), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() }, + .childGap = 4, + .layoutDirection = CLAY_TOP_TO_BOTTOM, + } + ) { + ui_label("LblName", "Name:"); + ui_text_input("TxtName", app->demo_text_a, sizeof(app->demo_text_a)); + } + CLAY(CLAY_ID("TextCol2"), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() }, + .childGap = 4, + .layoutDirection = CLAY_TOP_TO_BOTTOM, + } + ) { + ui_label("LblPath", "Output Path:"); + ui_text_input("TxtPath", app->demo_text_b, sizeof(app->demo_text_b)); + } + } + + CLAY(CLAY_ID("Sep4"), + .layout = { .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(1) } }, + .backgroundColor = g_theme.border + ) {} + + // Section: Dropdown + ui_label("LblDropdown", "Sample Rate"); + CLAY(CLAY_ID("DropdownWrapper"), + .layout = { + .sizing = { .width = CLAY_SIZING_FIXED(200), .height = CLAY_SIZING_FIT() }, + } + ) { + static const char *rate_options[] = { "44100 Hz", "48000 Hz", "88200 Hz", "96000 Hz", "192000 Hz" }; + ui_dropdown("DropRate", rate_options, 5, &app->demo_dropdown_sel); + } } } } @@ -249,23 +367,6 @@ static void build_log_panel(B32 show) { } } -//////////////////////////////// -// App state — all mutable state the frame function needs - -struct AppState { - PlatformWindow *window; - Renderer *renderer; - MidiEngine *midi; - UI_Context *ui; - S32 last_w, last_h; - B32 show_browser; - B32 show_props; - B32 show_log; - B32 show_midi_devices; - LARGE_INTEGER freq; - LARGE_INTEGER last_time; -}; - //////////////////////////////// // Build the full UI layout for one frame @@ -284,7 +385,7 @@ static void build_ui(AppState *app) { } ) { build_browser_panel(app->show_browser); - build_main_panel(); + build_main_panel(app); if (app->show_props || app->show_midi_devices) { CLAY(CLAY_ID("RightColumn"), @@ -330,6 +431,8 @@ static void do_frame(AppState *app) { // Gather input input_gather(platform_get_native_handle(app->window)); + PlatformInputEvents input_events = platform_get_input_events(app->window); + ui_widgets_begin_frame(input_events, g_input.mouse_down, g_input.was_mouse_down); // Build UI with Clay ui_begin_frame(app->ui, (F32)w, (F32)h, g_input.mouse, g_input.mouse_down, @@ -382,6 +485,7 @@ int main(int argc, char **argv) { init_text_configs(); setup_menus(window); + ui_widgets_init(); AppState app = {}; app.window = window; @@ -394,6 +498,9 @@ int main(int argc, char **argv) { app.show_props = 1; app.show_log = 1; app.show_midi_devices = 1; + app.demo_dropdown_sel = 1; // default to 48000 Hz + snprintf(app.demo_text_a, sizeof(app.demo_text_a), "My Instrument"); + snprintf(app.demo_text_b, sizeof(app.demo_text_b), "C:\\Samples\\output"); QueryPerformanceFrequency(&app.freq); QueryPerformanceCounter(&app.last_time); diff --git a/src/platform/platform.h b/src/platform/platform.h index 089339d..f158fd4 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -3,6 +3,46 @@ #include #include +//////////////////////////////// +// Input event buffer +// Accumulated per frame, consumed by the app each tick. + +#define PLATFORM_MAX_CHARS_PER_FRAME 64 +#define PLATFORM_MAX_KEYS_PER_FRAME 32 + +// Virtual key codes (subset matching Win32 VK_ codes) +enum { + PKEY_BACKSPACE = 0x08, + PKEY_TAB = 0x09, + PKEY_RETURN = 0x0D, + PKEY_ESCAPE = 0x1B, + PKEY_DELETE = 0x2E, + PKEY_LEFT = 0x25, + PKEY_UP = 0x26, + PKEY_RIGHT = 0x27, + PKEY_DOWN = 0x28, + PKEY_HOME = 0x24, + PKEY_END = 0x23, + PKEY_A = 0x41, + PKEY_C = 0x43, + PKEY_V = 0x56, + PKEY_X = 0x58, +}; + +struct PlatformInputEvents { + // Typed characters (UTF-16 code units, printable only) + uint16_t chars[PLATFORM_MAX_CHARS_PER_FRAME]; + int32_t char_count; + + // Key-down events (virtual key codes) + uint8_t keys[PLATFORM_MAX_KEYS_PER_FRAME]; + int32_t key_count; + + // Modifier state at time of last key event + bool ctrl_held; + bool shift_held; +}; + struct PlatformWindow; struct PlatformWindowDesc { @@ -36,3 +76,6 @@ void *platform_get_native_handle(PlatformWindow *window); void platform_set_frame_callback(PlatformWindow *window, PlatformFrameCallback cb, void *user_data); void platform_set_menu(PlatformWindow *window, PlatformMenu *menus, int32_t menu_count); int32_t platform_poll_menu_command(PlatformWindow *window); + +// Returns accumulated input events since last call, then clears the buffer. +PlatformInputEvents platform_get_input_events(PlatformWindow *window); diff --git a/src/platform/platform_win32.cpp b/src/platform/platform_win32.cpp index 1d0fde7..79646d3 100644 --- a/src/platform/platform_win32.cpp +++ b/src/platform/platform_win32.cpp @@ -14,6 +14,7 @@ struct PlatformWindow { int32_t pending_menu_cmd; PlatformFrameCallback frame_callback; void *frame_callback_user_data; + PlatformInputEvents input_events; }; static PlatformWindow *g_current_window = nullptr; @@ -31,10 +32,36 @@ static LRESULT CALLBACK win32_wndproc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM } } return 0; + case WM_CHAR: + if (g_current_window && wparam >= 32 && wparam < 0xFFFF) { + PlatformInputEvents *ev = &g_current_window->input_events; + if (ev->char_count < PLATFORM_MAX_CHARS_PER_FRAME) + ev->chars[ev->char_count++] = (uint16_t)wparam; + } + return 0; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (g_current_window) { + PlatformInputEvents *ev = &g_current_window->input_events; + if (ev->key_count < PLATFORM_MAX_KEYS_PER_FRAME) + ev->keys[ev->key_count++] = (uint8_t)wparam; + ev->ctrl_held = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + ev->shift_held = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + } + break; // fall through to DefWindowProc for system keys case WM_COMMAND: if (g_current_window && HIWORD(wparam) == 0) g_current_window->pending_menu_cmd = (int32_t)LOWORD(wparam); return 0; + case WM_SETCURSOR: + // When the cursor is in our client area, force it to an arrow. + // Without this, moving from a resize border back into the client + // area would leave the resize cursor shape stuck. + if (LOWORD(lparam) == HTCLIENT) { + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + return TRUE; + } + break; case WM_CLOSE: if (g_current_window) g_current_window->should_close = true; @@ -56,6 +83,7 @@ PlatformWindow *platform_create_window(PlatformWindowDesc *desc) { wc.style = CS_CLASSDC; wc.lpfnWndProc = win32_wndproc; wc.hInstance = GetModuleHandleW(nullptr); + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.lpszClassName = L"autosample_wc"; RegisterClassExW(&wc); @@ -172,3 +200,9 @@ int32_t platform_poll_menu_command(PlatformWindow *window) { window->pending_menu_cmd = 0; return cmd; } + +PlatformInputEvents platform_get_input_events(PlatformWindow *window) { + PlatformInputEvents result = window->input_events; + window->input_events = {}; + return result; +} diff --git a/src/ui/ui_widgets.cpp b/src/ui/ui_widgets.cpp index cbe434a..33fac13 100644 --- a/src/ui/ui_widgets.cpp +++ b/src/ui/ui_widgets.cpp @@ -1,2 +1,483 @@ -// ui_widgets.cpp - Removed: Clay handles all layout and widgets directly. -// This file is kept empty for the unity build include order. +// ui_widgets.cpp - Immediate-mode widget implementations on top of Clay. +// +// IMPORTANT: Clay_Hovered() only works inside CLAY() macro declaration args +// (where the element is the "open" element on the stack). For hover checks +// AFTER a CLAY() block, use Clay_PointerOver(elementId). + +#include "ui/ui_widgets.h" +#include +#include + +UI_WidgetState g_wstate = {}; + +void ui_widgets_init() { + g_wstate = {}; +} + +void ui_widgets_begin_frame(PlatformInputEvents input, B32 mouse_down, B32 was_mouse_down) { + g_wstate.input = input; + g_wstate.was_mouse_down = g_wstate.mouse_down; + g_wstate.mouse_down = mouse_down; + g_wstate.mouse_clicked = (mouse_down && !g_wstate.was_mouse_down); + g_wstate.cursor_blink += 1.0f / 60.0f; + ui_text_input_reset_display_bufs(); +} + +//////////////////////////////// +// Helpers + +static Clay_TextElementConfig g_widget_text_config; +static Clay_TextElementConfig g_widget_text_config_dim; +static bool g_widget_text_configs_init = false; + +static void ensure_widget_text_configs() { + if (g_widget_text_configs_init) return; + g_widget_text_configs_init = true; + + g_widget_text_config = {}; + g_widget_text_config.textColor = g_theme.text; + g_widget_text_config.fontSize = 15; + g_widget_text_config.wrapMode = CLAY_TEXT_WRAP_NONE; + + g_widget_text_config_dim = {}; + g_widget_text_config_dim.textColor = g_theme.text_dim; + g_widget_text_config_dim.fontSize = 15; + g_widget_text_config_dim.wrapMode = CLAY_TEXT_WRAP_NONE; +} + +static Clay_String clay_str(const char *s) { + Clay_String r = {}; + r.isStaticallyAllocated = false; + r.length = (S32)strlen(s); + r.chars = s; + return r; +} + +// Build Clay_ElementId from runtime string (CLAY_ID requires string literals) +#define WID(s) CLAY_SID(clay_str(s)) +#define WIDI(s, i) CLAY_SIDI(clay_str(s), i) + +//////////////////////////////// +// Label + +void ui_label(const char *id, const char *text) { + ensure_widget_text_configs(); + CLAY(WID(id), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() }, + .padding = { 0, 0, 2, 2 }, + } + ) { + CLAY_TEXT(clay_str(text), &g_widget_text_config); + } +} + +//////////////////////////////// +// Button + +B32 ui_button(const char *id, const char *text) { + ensure_widget_text_configs(); + + Clay_ElementId eid = WID(id); + B32 hovered = Clay_PointerOver(eid); + + CLAY(eid, + .layout = { + .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(30) }, + .padding = { 12, 12, 0, 0 }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + }, + .backgroundColor = hovered ? g_theme.accent_hover : g_theme.accent, + .cornerRadius = CLAY_CORNER_RADIUS(3) + ) { + CLAY_TEXT(clay_str(text), &g_widget_text_config); + } + + return (hovered && g_wstate.mouse_clicked) ? 1 : 0; +} + +//////////////////////////////// +// Checkbox + +B32 ui_checkbox(const char *id, const char *label, B32 *value) { + ensure_widget_text_configs(); + B32 changed = 0; + + Clay_ElementId eid = WID(id); + B32 hovered = Clay_PointerOver(eid); + + CLAY(eid, + .layout = { + .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(28) }, + .childGap = 8, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + } + ) { + // Box + Clay_Color box_bg = *value ? g_theme.accent : g_theme.bg_dark; + if (hovered) { + box_bg = *value ? g_theme.accent_hover : g_theme.bg_lighter; + } + CLAY(WIDI(id, 1), + .layout = { + .sizing = { .width = CLAY_SIZING_FIXED(18), .height = CLAY_SIZING_FIXED(18) }, + .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER }, + }, + .backgroundColor = box_bg, + .cornerRadius = CLAY_CORNER_RADIUS(3), + .border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } } + ) { + if (*value) { + CLAY_TEXT(CLAY_STRING("x"), &g_widget_text_config); + } + } + + // Label + CLAY_TEXT(clay_str(label), &g_widget_text_config); + } + + if (hovered && g_wstate.mouse_clicked) { + *value = !(*value); + changed = 1; + } + + return changed; +} + +//////////////////////////////// +// Radio group + +B32 ui_radio_group(const char *id, const char **options, S32 count, S32 *selected) { + ensure_widget_text_configs(); + B32 changed = 0; + + CLAY(WID(id), + .layout = { + .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() }, + .childGap = 4, + .layoutDirection = CLAY_TOP_TO_BOTTOM, + } + ) { + for (S32 i = 0; i < count; i++) { + B32 is_selected = (*selected == i); + Clay_ElementId row_id = WIDI(id, i + 100); + B32 row_hovered = Clay_PointerOver(row_id); + + CLAY(row_id, + .layout = { + .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIXED(26) }, + .childGap = 8, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + } + ) { + // Radio circle + Clay_Color dot_bg = is_selected ? g_theme.accent : g_theme.bg_dark; + if (row_hovered) { + dot_bg = is_selected ? g_theme.accent_hover : g_theme.bg_lighter; + } + CLAY(WIDI(id, i + 200), + .layout = { + .sizing = { .width = CLAY_SIZING_FIXED(16), .height = CLAY_SIZING_FIXED(16) }, + .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER }, + }, + .backgroundColor = dot_bg, + .cornerRadius = CLAY_CORNER_RADIUS(8), + .border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } } + ) { + if (is_selected) { + CLAY(WIDI(id, i + 300), + .layout = { + .sizing = { .width = CLAY_SIZING_FIXED(8), .height = CLAY_SIZING_FIXED(8) }, + }, + .backgroundColor = g_theme.text, + .cornerRadius = CLAY_CORNER_RADIUS(4) + ) {} + } + } + + // Label + CLAY_TEXT(clay_str(options[i]), &g_widget_text_config); + } + + if (row_hovered && g_wstate.mouse_clicked && !is_selected) { + *selected = i; + changed = 1; + } + } + } + + return changed; +} + +//////////////////////////////// +// Text input +// +// Each text input gets its own display buffer so Clay string pointers +// remain valid until the end of the frame. We support up to +// MAX_TEXT_INPUTS simultaneous text inputs per frame. + +#define MAX_TEXT_INPUTS 8 +static char g_text_display_bufs[MAX_TEXT_INPUTS][512]; +static S32 g_text_display_buf_idx = 0; + +void ui_text_input_reset_display_bufs() { + g_text_display_buf_idx = 0; +} + +B32 ui_text_input(const char *id, char *buf, S32 buf_size) { + ensure_widget_text_configs(); + B32 text_changed = 0; + + // Grab a unique display buffer for this text input + S32 my_buf_idx = g_text_display_buf_idx++; + if (my_buf_idx >= MAX_TEXT_INPUTS) my_buf_idx = MAX_TEXT_INPUTS - 1; + char *display_buf = g_text_display_bufs[my_buf_idx]; + + Clay_ElementId eid = WID(id); + B32 hovered = Clay_PointerOver(eid); + B32 is_focused = (g_wstate.focused_id == eid.id); + + // Click to focus / unfocus + if (g_wstate.mouse_clicked) { + if (hovered) { + if (!is_focused) { + g_wstate.focused_id = eid.id; + g_wstate.cursor_pos = (S32)strlen(buf); + g_wstate.cursor_blink = 0; + is_focused = 1; + } + } else if (is_focused) { + g_wstate.focused_id = 0; + is_focused = 0; + } + } + + // Process keyboard input if focused + if (is_focused) { + S32 len = (S32)strlen(buf); + + // Clamp cursor + if (g_wstate.cursor_pos > len) g_wstate.cursor_pos = len; + if (g_wstate.cursor_pos < 0) g_wstate.cursor_pos = 0; + + // Key events first (backspace generates both WM_KEYDOWN and WM_CHAR; + // we handle backspace/delete/arrows via keys, printable chars via chars) + for (S32 k = 0; k < g_wstate.input.key_count; k++) { + uint8_t key = g_wstate.input.keys[k]; + switch (key) { + case PKEY_BACKSPACE: + if (g_wstate.cursor_pos > 0) { + memmove(&buf[g_wstate.cursor_pos - 1], &buf[g_wstate.cursor_pos], len - g_wstate.cursor_pos + 1); + g_wstate.cursor_pos--; + len--; + text_changed = 1; + } + break; + case PKEY_DELETE: + if (g_wstate.cursor_pos < len) { + memmove(&buf[g_wstate.cursor_pos], &buf[g_wstate.cursor_pos + 1], len - g_wstate.cursor_pos); + len--; + text_changed = 1; + } + break; + case PKEY_LEFT: + if (g_wstate.cursor_pos > 0) g_wstate.cursor_pos--; + g_wstate.cursor_blink = 0; + break; + case PKEY_RIGHT: + if (g_wstate.cursor_pos < len) g_wstate.cursor_pos++; + g_wstate.cursor_blink = 0; + break; + case PKEY_HOME: + g_wstate.cursor_pos = 0; + g_wstate.cursor_blink = 0; + break; + case PKEY_END: + g_wstate.cursor_pos = len; + g_wstate.cursor_blink = 0; + break; + case PKEY_RETURN: + case PKEY_ESCAPE: + g_wstate.focused_id = 0; + is_focused = 0; + break; + } + } + + // Character input (printable only, skip control chars) + if (is_focused) { + for (S32 c = 0; c < g_wstate.input.char_count; c++) { + uint16_t ch = g_wstate.input.chars[c]; + if (ch >= 32 && ch < 127 && len < buf_size - 1) { + memmove(&buf[g_wstate.cursor_pos + 1], &buf[g_wstate.cursor_pos], len - g_wstate.cursor_pos + 1); + buf[g_wstate.cursor_pos] = (char)ch; + g_wstate.cursor_pos++; + len++; + text_changed = 1; + g_wstate.cursor_blink = 0; + } + } + } + } + + // Build the display string. + // The cursor is shown as a '|' character when focused. To avoid layout + // flicker from the cursor blinking (which changes text width), we always + // include the cursor character when focused and simply don't blink it. + S32 len = (S32)strlen(buf); + + if (is_focused) { + S32 cp = g_wstate.cursor_pos; + if (cp > len) cp = len; + S32 total = len + 1; // +1 for cursor char + if (total > 510) total = 510; + S32 before = cp < total ? cp : total; + memcpy(display_buf, buf, before); + display_buf[before] = '|'; + S32 after = total - before - 1; + if (after > 0) memcpy(display_buf + before + 1, buf + cp, after); + display_buf[total] = '\0'; + } else { + S32 copy_len = len < 511 ? len : 511; + memcpy(display_buf, buf, copy_len); + display_buf[copy_len] = '\0'; + } + + const char *show_text = display_buf; + Clay_TextElementConfig *text_cfg = &g_widget_text_config; + if (len == 0 && !is_focused) { + show_text = "..."; + text_cfg = &g_widget_text_config_dim; + } + + Clay_Color bg = is_focused ? g_theme.bg_dark : g_theme.bg_medium; + Clay_Color border_color = is_focused ? g_theme.accent : g_theme.border; + + CLAY(eid, + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(30) }, + .padding = { 8, 8, 0, 0 }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + }, + .backgroundColor = bg, + .cornerRadius = CLAY_CORNER_RADIUS(3), + .border = { .color = border_color, .width = { 1, 1, 1, 1 } } + ) { + CLAY_TEXT(clay_str(show_text), text_cfg); + } + + return text_changed; +} + +//////////////////////////////// +// Dropdown + +B32 ui_dropdown(const char *id, const char **options, S32 count, S32 *selected) { + ensure_widget_text_configs(); + B32 changed = 0; + + Clay_ElementId eid = WID(id); + B32 header_hovered = Clay_PointerOver(eid); + B32 is_open = (g_wstate.open_dropdown_id == eid.id); + + // Display current selection + const char *current_label = (*selected >= 0 && *selected < count) ? options[*selected] : "Select..."; + + Clay_Color bg = is_open ? g_theme.bg_dark : g_theme.bg_medium; + if (header_hovered && !is_open) bg = g_theme.bg_lighter; + + CLAY(eid, + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(30) }, + .padding = { 8, 8, 0, 0 }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + }, + .backgroundColor = bg, + .cornerRadius = CLAY_CORNER_RADIUS(3), + .border = { .color = is_open ? g_theme.accent : g_theme.border, .width = { 1, 1, 1, 1 } } + ) { + CLAY(WIDI(id, 500), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() }, + } + ) { + CLAY_TEXT(clay_str(current_label), &g_widget_text_config); + } + + CLAY(WIDI(id, 501), + .layout = { + .sizing = { .width = CLAY_SIZING_FIXED(20), .height = CLAY_SIZING_FIT() }, + .childAlignment = { .x = CLAY_ALIGN_X_CENTER }, + } + ) { + CLAY_TEXT(CLAY_STRING("v"), &g_widget_text_config_dim); + } + } + + // Toggle open on click of header + if (header_hovered && g_wstate.mouse_clicked) { + if (is_open) { + g_wstate.open_dropdown_id = 0; + is_open = 0; + } else { + g_wstate.open_dropdown_id = eid.id; + is_open = 1; + } + } + + // Draw dropdown list if open + if (is_open) { + Clay_ElementId list_id = WIDI(id, 502); + CLAY(list_id, + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIT() }, + .layoutDirection = CLAY_TOP_TO_BOTTOM, + }, + .backgroundColor = g_theme.bg_dark, + .border = { .color = g_theme.border, .width = { 1, 1, 1, 1 } } + ) { + for (S32 i = 0; i < count; i++) { + B32 is_item_selected = (*selected == i); + Clay_ElementId item_id = WIDI(id, i + 600); + B32 item_hovered = Clay_PointerOver(item_id); + + Clay_Color item_bg = is_item_selected ? g_theme.accent : g_theme.bg_dark; + if (item_hovered) item_bg = g_theme.bg_lighter; + + CLAY(item_id, + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(), .height = CLAY_SIZING_FIXED(28) }, + .padding = { 8, 8, 0, 0 }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + }, + .backgroundColor = item_bg + ) { + CLAY_TEXT(clay_str(options[i]), &g_widget_text_config); + } + + if (item_hovered && g_wstate.mouse_clicked) { + *selected = i; + changed = 1; + g_wstate.open_dropdown_id = 0; + } + } + } + + // Close if clicked outside both header and list + if (g_wstate.mouse_clicked && !header_hovered && !Clay_PointerOver(list_id)) { + // Check if any item was clicked (already handled above) + B32 clicked_item = 0; + for (S32 i = 0; i < count; i++) { + if (Clay_PointerOver(WIDI(id, i + 600))) { clicked_item = 1; break; } + } + if (!clicked_item) { + g_wstate.open_dropdown_id = 0; + } + } + } + + return changed; +} diff --git a/src/ui/ui_widgets.h b/src/ui/ui_widgets.h index 0f329fc..63dd7ec 100644 --- a/src/ui/ui_widgets.h +++ b/src/ui/ui_widgets.h @@ -1,3 +1,70 @@ #pragma once -// ui_widgets.h - Removed: Clay handles all layout and widgets directly. -// This file is kept empty for the unity build include order. +// ui_widgets.h - Immediate-mode widgets built on top of Clay. +// +// Each widget function takes a unique string ID, the value to display/edit, +// and returns whether the value was changed (or the widget was activated). +// The caller owns all data — the widget layer only stores transient UI state +// like which text field is focused or which dropdown is open. + +#include "ui/ui_core.h" +#include "platform/platform.h" + +//////////////////////////////// +// Widget state (global, managed by widget layer) + +#define UI_WIDGET_MAX_DROPDOWN_ITEMS 32 + +struct UI_WidgetState { + // Text input focus + uint32_t focused_id; // Clay element ID hash of the focused text input (0 = none) + int32_t cursor_pos; // Cursor position in focused text input + F32 cursor_blink; // Blink timer (seconds) + + // Dropdown + uint32_t open_dropdown_id; // Clay element ID hash of the open dropdown (0 = none) + + // Input events for this frame + PlatformInputEvents input; + + // Click detection + B32 mouse_down; + B32 was_mouse_down; + B32 mouse_clicked; // true on the frame mouse transitions from up->down +}; + +extern UI_WidgetState g_wstate; + +// Call once at startup +void ui_widgets_init(); + +// Call each frame before building widgets. Pass in the frame's input events. +void ui_widgets_begin_frame(PlatformInputEvents input, B32 mouse_down, B32 was_mouse_down); + +// Reset per-frame text input display buffer allocator (called by begin_frame, but +// can also be called manually if needed) +void ui_text_input_reset_display_bufs(); + +//////////////////////////////// +// Widgets +// All IDs must be unique string literals (passed to CLAY_ID internally). + +// Simple label +void ui_label(const char *id, const char *text); + +// Clickable button. Returns true on the frame it was clicked. +B32 ui_button(const char *id, const char *text); + +// Checkbox. Toggles *value on click. Returns true if value changed. +B32 ui_checkbox(const char *id, const char *label, B32 *value); + +// Radio button group. Sets *selected to the clicked index. Returns true if changed. +// options is an array of label strings, count is the number of options. +B32 ui_radio_group(const char *id, const char **options, S32 count, S32 *selected); + +// Single-line text input. Edits buf in-place (null-terminated, max buf_size-1 chars). +// Returns true if the text changed this frame. +B32 ui_text_input(const char *id, char *buf, S32 buf_size); + +// Dropdown / combo box. Sets *selected to chosen index. Returns true if changed. +// options is an array of label strings, count is the number of options. +B32 ui_dropdown(const char *id, const char **options, S32 count, S32 *selected);