diff --git a/autosample.raddbg b/autosample.raddbg index 7a74b0b..5015cb5 100644 --- a/autosample.raddbg +++ b/autosample.raddbg @@ -17,7 +17,7 @@ target: debug_info: { path: "C:/Users/mta/projects/autosample/build/autosample.pdb" - timestamp: 66207544041290 + timestamp: 66207573824751 } target: { diff --git a/src/platform/platform.h b/src/platform/platform.h index f158fd4..59cf913 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -79,3 +79,9 @@ 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); + +// Clipboard operations (null-terminated UTF-8 strings). +// platform_clipboard_set copies text to the system clipboard. +// platform_clipboard_get returns a pointer to a static buffer (valid until next call), or nullptr. +void platform_clipboard_set(const char *text); +const char *platform_clipboard_get(); diff --git a/src/platform/platform_win32.cpp b/src/platform/platform_win32.cpp index 79646d3..6c23a75 100644 --- a/src/platform/platform_win32.cpp +++ b/src/platform/platform_win32.cpp @@ -206,3 +206,51 @@ PlatformInputEvents platform_get_input_events(PlatformWindow *window) { window->input_events = {}; return result; } + +void platform_clipboard_set(const char *text) { + if (!text) return; + int len = (int)strlen(text); + if (len == 0) return; + + // Convert UTF-8 to wide string for Windows clipboard + int wlen = MultiByteToWideChar(CP_UTF8, 0, text, len, nullptr, 0); + if (wlen == 0) return; + + HGLOBAL hmem = GlobalAlloc(GMEM_MOVEABLE, (wlen + 1) * sizeof(wchar_t)); + if (!hmem) return; + + wchar_t *wbuf = (wchar_t *)GlobalLock(hmem); + MultiByteToWideChar(CP_UTF8, 0, text, len, wbuf, wlen); + wbuf[wlen] = L'\0'; + GlobalUnlock(hmem); + + HWND hwnd = g_current_window ? g_current_window->hwnd : nullptr; + if (OpenClipboard(hwnd)) { + EmptyClipboard(); + SetClipboardData(CF_UNICODETEXT, hmem); + CloseClipboard(); + } else { + GlobalFree(hmem); + } +} + +const char *platform_clipboard_get() { + static char buf[4096]; + buf[0] = '\0'; + + HWND hwnd = g_current_window ? g_current_window->hwnd : nullptr; + if (!OpenClipboard(hwnd)) return nullptr; + + HGLOBAL hmem = GetClipboardData(CF_UNICODETEXT); + if (hmem) { + wchar_t *wbuf = (wchar_t *)GlobalLock(hmem); + if (wbuf) { + int len = WideCharToMultiByte(CP_UTF8, 0, wbuf, -1, buf, sizeof(buf) - 1, nullptr, nullptr); + buf[len > 0 ? len - 1 : 0] = '\0'; // WideCharToMultiByte includes null in count + GlobalUnlock(hmem); + } + } + + CloseClipboard(); + return buf[0] ? buf : nullptr; +} diff --git a/src/ui/ui_widgets.cpp b/src/ui/ui_widgets.cpp index 33fac13..de1ccca 100644 --- a/src/ui/ui_widgets.cpp +++ b/src/ui/ui_widgets.cpp @@ -20,6 +20,17 @@ void ui_widgets_begin_frame(PlatformInputEvents input, B32 mouse_down, B32 was_m 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; + g_wstate.text_input_count = 0; + g_wstate.tab_pressed = 0; + + // Detect Tab key press this frame + for (S32 k = 0; k < input.key_count; k++) { + if (input.keys[k] == PKEY_TAB) { + g_wstate.tab_pressed = 1; + break; + } + } + ui_text_input_reset_display_bufs(); } @@ -28,6 +39,7 @@ void ui_widgets_begin_frame(PlatformInputEvents input, B32 mouse_down, B32 was_m static Clay_TextElementConfig g_widget_text_config; static Clay_TextElementConfig g_widget_text_config_dim; +static Clay_TextElementConfig g_widget_text_config_sel; static bool g_widget_text_configs_init = false; static void ensure_widget_text_configs() { @@ -43,6 +55,12 @@ static void ensure_widget_text_configs() { 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; + + // Selected text: white on accent background (background set on parent element) + g_widget_text_config_sel = {}; + g_widget_text_config_sel.textColor = Clay_Color{255, 255, 255, 255}; + g_widget_text_config_sel.fontSize = 15; + g_widget_text_config_sel.wrapMode = CLAY_TEXT_WRAP_NONE; } static Clay_String clay_str(const char *s) { @@ -214,39 +232,106 @@ B32 ui_radio_group(const char *id, const char **options, S32 count, S32 *selecte //////////////////////////////// // Text input // -// Each text input gets its own display buffer so Clay string pointers +// Each text input gets its own display buffer(s) so Clay string pointers // remain valid until the end of the frame. We support up to // MAX_TEXT_INPUTS simultaneous text inputs per frame. +// Each text input may need up to 3 display buffers (before/sel/after). #define MAX_TEXT_INPUTS 8 -static char g_text_display_bufs[MAX_TEXT_INPUTS][512]; +#define DISPLAY_BUF_SIZE 512 +// 3 buffers per input: [0]=before selection, [1]=selected text, [2]=after selection +static char g_text_display_bufs[MAX_TEXT_INPUTS * 3][DISPLAY_BUF_SIZE]; static S32 g_text_display_buf_idx = 0; void ui_text_input_reset_display_bufs() { g_text_display_buf_idx = 0; } +// Helper: get selection range in normalized order (lo <= hi) +static void text_input_get_sel(S32 *lo, S32 *hi) { + S32 a = g_wstate.sel_start; + S32 b = g_wstate.sel_end; + if (a <= b) { *lo = a; *hi = b; } + else { *lo = b; *hi = a; } +} + +// Helper: true if there's an active selection +static B32 text_input_has_sel() { + return (g_wstate.sel_start != g_wstate.sel_end); +} + +// Helper: delete selected range from buf, update cursor, clear selection +// Returns new string length. +static S32 text_input_delete_sel(char *buf, S32 len) { + S32 lo, hi; + text_input_get_sel(&lo, &hi); + if (lo == hi) return len; + memmove(&buf[lo], &buf[hi], len - hi + 1); + g_wstate.cursor_pos = lo; + g_wstate.sel_start = lo; + g_wstate.sel_end = lo; + return len - (hi - lo); +} + +// Helper: copy selected text to clipboard +static void text_input_copy_sel(const char *buf, S32 len) { + S32 lo, hi; + text_input_get_sel(&lo, &hi); + if (lo == hi || lo >= len) return; + if (hi > len) hi = len; + // Use a temp buffer to null-terminate the selection + char tmp[DISPLAY_BUF_SIZE]; + S32 sel_len = hi - lo; + if (sel_len > (S32)sizeof(tmp) - 1) sel_len = (S32)sizeof(tmp) - 1; + memcpy(tmp, &buf[lo], sel_len); + tmp[sel_len] = '\0'; + platform_clipboard_set(tmp); +} + 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]; - + // Register this text input for Tab cycling Clay_ElementId eid = WID(id); + if (g_wstate.text_input_count < UI_WIDGET_MAX_TEXT_INPUTS) { + g_wstate.text_input_ids[g_wstate.text_input_count++] = eid.id; + } + + // Grab unique display buffers for this text input (up to 3) + S32 my_buf_base = g_text_display_buf_idx; + g_text_display_buf_idx += 3; + if (my_buf_base + 2 >= MAX_TEXT_INPUTS * 3) my_buf_base = 0; // safety + char *dbuf_before = g_text_display_bufs[my_buf_base + 0]; + char *dbuf_sel = g_text_display_bufs[my_buf_base + 1]; + char *dbuf_after = g_text_display_bufs[my_buf_base + 2]; + B32 hovered = Clay_PointerOver(eid); B32 is_focused = (g_wstate.focused_id == eid.id); + // Tab cycling: if Tab was pressed and we're focused, move to next input. + // We handle this BEFORE processing other input so the Tab key doesn't + // get processed as a regular key. The actual focus change is deferred + // to after all text inputs have registered (see end of function). + B32 wants_tab = 0; + if (is_focused && g_wstate.tab_pressed) { + wants_tab = 1; + } + // 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.sel_start = g_wstate.cursor_pos; + g_wstate.sel_end = g_wstate.cursor_pos; g_wstate.cursor_blink = 0; is_focused = 1; + } else { + // Already focused, clicking clears selection + g_wstate.sel_start = g_wstate.cursor_pos; + g_wstate.sel_end = g_wstate.cursor_pos; } } else if (is_focused) { g_wstate.focused_id = 0; @@ -257,45 +342,133 @@ B32 ui_text_input(const char *id, char *buf, S32 buf_size) { // Process keyboard input if focused if (is_focused) { S32 len = (S32)strlen(buf); + B32 ctrl = g_wstate.input.ctrl_held; - // Clamp cursor + // Clamp cursor and selection if (g_wstate.cursor_pos > len) g_wstate.cursor_pos = len; if (g_wstate.cursor_pos < 0) g_wstate.cursor_pos = 0; + if (g_wstate.sel_start > len) g_wstate.sel_start = len; + if (g_wstate.sel_end > len) g_wstate.sel_end = len; - // Key events first (backspace generates both WM_KEYDOWN and WM_CHAR; - // we handle backspace/delete/arrows via keys, printable chars via chars) + // Key events for (S32 k = 0; k < g_wstate.input.key_count; k++) { uint8_t key = g_wstate.input.keys[k]; + + // Skip Tab — handled via tab cycling + if (key == PKEY_TAB) continue; + + // Ctrl shortcuts + if (ctrl) { + if (key == PKEY_A) { + // Select all + g_wstate.sel_start = 0; + g_wstate.sel_end = len; + g_wstate.cursor_pos = len; + continue; + } + if (key == PKEY_C) { + // Copy + text_input_copy_sel(buf, len); + continue; + } + if (key == PKEY_X) { + // Cut + if (text_input_has_sel()) { + text_input_copy_sel(buf, len); + len = text_input_delete_sel(buf, len); + text_changed = 1; + } + continue; + } + if (key == PKEY_V) { + // Paste + const char *clip = platform_clipboard_get(); + if (clip) { + // Delete selection first if any + if (text_input_has_sel()) { + len = text_input_delete_sel(buf, len); + } + S32 clip_len = (S32)strlen(clip); + // Filter to single line (stop at newline) + for (S32 i = 0; i < clip_len; i++) { + if (clip[i] == '\n' || clip[i] == '\r') { clip_len = i; break; } + } + S32 space = buf_size - 1 - len; + if (clip_len > space) clip_len = space; + if (clip_len > 0) { + memmove(&buf[g_wstate.cursor_pos + clip_len], &buf[g_wstate.cursor_pos], len - g_wstate.cursor_pos + 1); + memcpy(&buf[g_wstate.cursor_pos], clip, clip_len); + g_wstate.cursor_pos += clip_len; + len += clip_len; + text_changed = 1; + } + g_wstate.sel_start = g_wstate.cursor_pos; + g_wstate.sel_end = g_wstate.cursor_pos; + } + continue; + } + // Other Ctrl combos: ignore + continue; + } + switch (key) { case PKEY_BACKSPACE: - if (g_wstate.cursor_pos > 0) { + if (text_input_has_sel()) { + len = text_input_delete_sel(buf, len); + text_changed = 1; + } else 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; } + g_wstate.sel_start = g_wstate.cursor_pos; + g_wstate.sel_end = g_wstate.cursor_pos; break; case PKEY_DELETE: - if (g_wstate.cursor_pos < len) { + if (text_input_has_sel()) { + len = text_input_delete_sel(buf, len); + text_changed = 1; + } else 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; } + g_wstate.sel_start = g_wstate.cursor_pos; + g_wstate.sel_end = g_wstate.cursor_pos; break; case PKEY_LEFT: - if (g_wstate.cursor_pos > 0) g_wstate.cursor_pos--; + if (text_input_has_sel()) { + S32 lo, hi; text_input_get_sel(&lo, &hi); + g_wstate.cursor_pos = lo; + } else if (g_wstate.cursor_pos > 0) { + g_wstate.cursor_pos--; + } + g_wstate.sel_start = g_wstate.cursor_pos; + g_wstate.sel_end = g_wstate.cursor_pos; g_wstate.cursor_blink = 0; break; case PKEY_RIGHT: - if (g_wstate.cursor_pos < len) g_wstate.cursor_pos++; + if (text_input_has_sel()) { + S32 lo, hi; text_input_get_sel(&lo, &hi); + g_wstate.cursor_pos = hi; + } else if (g_wstate.cursor_pos < len) { + g_wstate.cursor_pos++; + } + g_wstate.sel_start = g_wstate.cursor_pos; + g_wstate.sel_end = g_wstate.cursor_pos; g_wstate.cursor_blink = 0; break; case PKEY_HOME: g_wstate.cursor_pos = 0; + g_wstate.sel_start = 0; + g_wstate.sel_end = 0; g_wstate.cursor_blink = 0; break; case PKEY_END: g_wstate.cursor_pos = len; + g_wstate.sel_start = len; + g_wstate.sel_end = len; g_wstate.cursor_blink = 0; break; case PKEY_RETURN: @@ -310,46 +483,69 @@ B32 ui_text_input(const char *id, char *buf, S32 buf_size) { 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; + if (ch >= 32 && ch < 127) { + // Delete selection first if any + if (text_input_has_sel()) { + len = text_input_delete_sel(buf, len); + } + if (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; + } + g_wstate.sel_start = g_wstate.cursor_pos; + g_wstate.sel_end = g_wstate.cursor_pos; } } } } - // 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'; + // Tab cycling: find next text input after this one + if (wants_tab && is_focused) { + // We need to find our index. Since text inputs register in order, + // our ID was just added above. Find it and advance to next. + S32 my_idx = -1; + for (S32 i = 0; i < g_wstate.text_input_count; i++) { + if (g_wstate.text_input_ids[i] == eid.id) { my_idx = i; break; } + } + if (my_idx >= 0) { + // Focus next (wrapping). But we might not have all inputs registered + // yet this frame. Store the "next index" request; actual focus change + // happens via a simple approach: just focus the next ID if known, + // otherwise wrap to first. + S32 next_idx = my_idx + 1; + // We can't know total count yet (more inputs may register after us). + // Instead, unfocus ourselves and set a "pending tab focus" that will + // be resolved. Simpler approach: Tab from last input wraps to first, + // Tab from others goes to next. Since we know the IDs registered so + // far, and inputs are registered in declaration order: + // If there IS a next already-registered input, go to it. + // Otherwise, wrap to index 0. + if (next_idx < g_wstate.text_input_count) { + g_wstate.focused_id = g_wstate.text_input_ids[next_idx]; + } else { + // Wrap to first + g_wstate.focused_id = g_wstate.text_input_ids[0]; + } + g_wstate.cursor_pos = 0; + g_wstate.sel_start = 0; + g_wstate.sel_end = 0; + is_focused = 0; // no longer focused on THIS input + } } - 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; + // Build display + S32 len = (S32)strlen(buf); + S32 sel_lo = 0, sel_hi = 0; + B32 has_sel = 0; + if (is_focused) { + text_input_get_sel(&sel_lo, &sel_hi); + if (sel_lo < 0) sel_lo = 0; + if (sel_hi > len) sel_hi = len; + has_sel = (sel_lo != sel_hi); } Clay_Color bg = is_focused ? g_theme.bg_dark : g_theme.bg_medium; @@ -360,12 +556,68 @@ B32 ui_text_input(const char *id, char *buf, S32 buf_size) { .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 = border_color, .width = { 1, 1, 1, 1 } } ) { - CLAY_TEXT(clay_str(show_text), text_cfg); + if (len == 0 && !is_focused) { + // Placeholder + CLAY_TEXT(CLAY_STRING("..."), &g_widget_text_config_dim); + } else if (is_focused && has_sel) { + // Three segments: before | selected | after + // Before selection + if (sel_lo > 0) { + S32 n = sel_lo < (S32)DISPLAY_BUF_SIZE - 1 ? sel_lo : (S32)DISPLAY_BUF_SIZE - 1; + memcpy(dbuf_before, buf, n); + dbuf_before[n] = '\0'; + CLAY_TEXT(clay_str(dbuf_before), &g_widget_text_config); + } + // Selected text with highlight background + { + S32 slen = sel_hi - sel_lo; + if (slen > (S32)DISPLAY_BUF_SIZE - 1) slen = (S32)DISPLAY_BUF_SIZE - 1; + memcpy(dbuf_sel, &buf[sel_lo], slen); + dbuf_sel[slen] = '\0'; + CLAY(WIDI(id, 900), + .layout = { + .sizing = { .width = CLAY_SIZING_FIT(), .height = CLAY_SIZING_FIT() }, + }, + .backgroundColor = g_theme.accent + ) { + CLAY_TEXT(clay_str(dbuf_sel), &g_widget_text_config_sel); + } + } + // After selection + if (sel_hi < len) { + S32 n = len - sel_hi; + if (n > (S32)DISPLAY_BUF_SIZE - 1) n = (S32)DISPLAY_BUF_SIZE - 1; + memcpy(dbuf_after, &buf[sel_hi], n); + dbuf_after[n] = '\0'; + CLAY_TEXT(clay_str(dbuf_after), &g_widget_text_config); + } + } else { + // No selection: show text with cursor '|' + if (is_focused) { + S32 cp = g_wstate.cursor_pos; + if (cp > len) cp = len; + S32 total = len + 1; // +1 for cursor char + if (total > (S32)DISPLAY_BUF_SIZE - 1) total = (S32)DISPLAY_BUF_SIZE - 1; + S32 before = cp < total ? cp : total; + memcpy(dbuf_before, buf, before); + dbuf_before[before] = '|'; + S32 after = total - before - 1; + if (after > 0) memcpy(dbuf_before + before + 1, buf + cp, after); + dbuf_before[total] = '\0'; + CLAY_TEXT(clay_str(dbuf_before), &g_widget_text_config); + } else { + S32 n = len < (S32)DISPLAY_BUF_SIZE - 1 ? len : (S32)DISPLAY_BUF_SIZE - 1; + memcpy(dbuf_before, buf, n); + dbuf_before[n] = '\0'; + CLAY_TEXT(clay_str(dbuf_before), &g_widget_text_config); + } + } } return text_changed; diff --git a/src/ui/ui_widgets.h b/src/ui/ui_widgets.h index 63dd7ec..4f89811 100644 --- a/src/ui/ui_widgets.h +++ b/src/ui/ui_widgets.h @@ -13,6 +13,7 @@ // Widget state (global, managed by widget layer) #define UI_WIDGET_MAX_DROPDOWN_ITEMS 32 +#define UI_WIDGET_MAX_TEXT_INPUTS 16 struct UI_WidgetState { // Text input focus @@ -20,6 +21,15 @@ struct UI_WidgetState { int32_t cursor_pos; // Cursor position in focused text input F32 cursor_blink; // Blink timer (seconds) + // Text selection (sel_start == sel_end means no selection) + int32_t sel_start; // Selection anchor (where selection began) + int32_t sel_end; // Selection extent (moves with cursor) + + // Tab cycling: registered text input IDs in order of declaration + uint32_t text_input_ids[UI_WIDGET_MAX_TEXT_INPUTS]; + int32_t text_input_count; + B32 tab_pressed; // True on the frame Tab was pressed + // Dropdown uint32_t open_dropdown_id; // Clay element ID hash of the open dropdown (0 = none)