update widgets, add copy paste buffer

This commit is contained in:
2026-02-26 00:04:02 -05:00
parent 68235b57ce
commit 7a5c1c5159
5 changed files with 365 additions and 49 deletions

View File

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