DVX_GUI/dvx/widgets/widgetTextInput.c

2969 lines
94 KiB
C

// widgetTextInput.c -- TextInput and TextArea widgets
//
// This file implements three text editing widgets plus shared
// infrastructure:
//
// 1. TextInput -- single-line text field with scroll, selection,
// undo, password masking, and masked input (e.g., phone/SSN).
// 2. TextArea -- multi-line text editor with row/col cursor,
// dual-axis scrolling, and full selection/clipboard support.
// 3. Shared infrastructure -- clipboard, multi-click detection,
// word boundary logic, cross-widget selection clearing, and
// the single-line text editing engine (widgetTextEditOnKey).
//
// All text editing is done in-place in fixed-size char buffers
// allocated at widget creation. No dynamic resizing -- this keeps
// memory management simple and predictable on DOS where heap
// fragmentation is a real concern.
//
// The single-line editing engine (widgetTextEditOnKey) is factored
// out as a separate function that takes buffer/cursor/selection
// pointers as parameters, so it can be shared between TextInput,
// Spinner, and ComboBox without code duplication. Each widget
// passes its own state fields.
//
// Undo is single-level swap: before each mutation, the current
// buffer is copied to undoBuf. Ctrl+Z swaps current<->undo, so
// a second Ctrl+Z is "redo". This is simpler than a multi-level
// undo stack and sufficient for typical DOS text entry.
//
// Selection model: selStart/selEnd (single-line) or selAnchor/
// selCursor (multi-line) form a directed range. selStart is where
// the user began selecting, selEnd is where they stopped. The
// "low" end for deletion/copy is always min(start, end). The
// -1 sentinel means "no selection".
//
// Clipboard is a simple static buffer (4KB). This is a process-wide
// clipboard, not per-widget and not OS-integrated (DOS has no
// clipboard API). Text cut/copied from any widget is available to
// paste in any other widget.
//
// Multi-click detection uses clock() timestamps with a 500ms
// threshold and 4px spatial tolerance. Double-click selects word,
// triple-click selects line (TextArea) or all (TextInput).
//
// Cross-widget selection clearing: when a widget gains selection,
// clearOtherSelections() deselects any other widget that had an
// active selection. This prevents the confusing visual state of
// multiple selected ranges across different widgets. The tracking
// uses sLastSelectedWidget to achieve O(1) clearing rather than
// walking the entire widget tree.
//
// Masked input: a special TextInput mode where the buffer is
// pre-filled from a mask pattern (e.g., "###-##-####" for SSN).
// '#' accepts digits, 'A' accepts letters, '*' accepts any
// printable char. Literal characters in the mask are fixed and the
// cursor skips over them. This provides constrained input without
// needing a separate widget type.
//
// Password mode: renders bullets instead of characters (CP437 0xF9)
// and blocks copy/cut operations for security.
#include "widgetInternal.h"
#include <ctype.h>
#include <time.h>
#define TEXTAREA_BORDER 2
#define TEXTAREA_PAD 2
#define TEXTAREA_SB_W 14
#define TEXTAREA_MIN_ROWS 4
#define TEXTAREA_MIN_COLS 20
#define CLIPBOARD_MAX 4096
#define DBLCLICK_TICKS (CLOCKS_PER_SEC / 2)
// Match the ANSI terminal cursor blink rate (CURSOR_MS in widgetAnsiTerm.c)
#define CURSOR_BLINK_MS 250
// ============================================================
// Prototypes
// ============================================================
static bool maskCharValid(char slot, char ch);
static int32_t maskFirstSlot(const char *mask);
static bool maskIsSlot(char ch);
static int32_t maskNextSlot(const char *mask, int32_t pos);
static int32_t maskPrevSlot(const char *mask, int32_t pos);
static void maskedInputOnKey(WidgetT *w, int32_t key, int32_t mod);
static int32_t textAreaCountLines(const char *buf, int32_t len);
static int32_t textAreaCursorToOff(const char *buf, int32_t len, int32_t row, int32_t col);
static inline void textAreaDirtyCache(WidgetT *w);
static void textAreaEnsureVisible(WidgetT *w, int32_t visRows, int32_t visCols);
static int32_t textAreaGetLineCount(WidgetT *w);
static int32_t textAreaGetMaxLineLen(WidgetT *w);
static int32_t textAreaLineLen(const char *buf, int32_t len, int32_t row);
static int32_t textAreaLineStart(const char *buf, int32_t len, int32_t row);
static int32_t textAreaMaxLineLen(const char *buf, int32_t len);
static void textAreaOffToRowCol(const char *buf, int32_t off, int32_t *row, int32_t *col);
static void textEditDeleteSelection(char *buf, int32_t *pLen, int32_t *pCursor, int32_t *pSelStart, int32_t *pSelEnd);
static void textEditSaveUndo(char *buf, int32_t len, int32_t cursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t bufSize);
static int32_t wordBoundaryLeft(const char *buf, int32_t pos);
static int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos);
// ============================================================
// Shared clipboard
// ============================================================
static char sClipboard[CLIPBOARD_MAX];
static int32_t sClipboardLen = 0;
// ============================================================
// Cursor blink state
// ============================================================
// Shared across all text-editing widgets (TextInput, TextArea,
// ComboBox, Spinner). Matches the ANSI terminal's 250ms rate.
bool sCursorBlinkOn = true;
static clock_t sCursorBlinkTime = 0;
void clipboardCopy(const char *text, int32_t len) {
if (!text || len <= 0) {
return;
}
if (len > CLIPBOARD_MAX - 1) {
len = CLIPBOARD_MAX - 1;
}
memcpy(sClipboard, text, len);
sClipboard[len] = '\0';
sClipboardLen = len;
}
const char *clipboardGet(int32_t *outLen) {
if (outLen) {
*outLen = sClipboardLen;
}
return sClipboard;
}
// ============================================================
// Cursor blink
// ============================================================
void wgtUpdateCursorBlink(void) {
clock_t now = clock();
clock_t interval = (clock_t)CURSOR_BLINK_MS * CLOCKS_PER_SEC / 1000;
if ((now - sCursorBlinkTime) >= interval) {
sCursorBlinkTime = now;
sCursorBlinkOn = !sCursorBlinkOn;
// Invalidate the focused widget so its cursor redraws
if (sFocusedWidget) {
wgtInvalidatePaint(sFocusedWidget);
}
}
}
// ============================================================
// Shared single-line text editing: mouse click
// ============================================================
//
// Computes cursor position from pixel coordinates, handles multi-click
// (double = word select, triple = select all), and optionally starts
// drag-select. Used by TextInput, ComboBox, and Spinner mouse handlers
// so click-to-cursor behavior is consistent across all text widgets.
//
// wordSelect: if true, double-click selects the word under the cursor;
// if false, double-click selects all (used by Spinner).
// dragSelect: if true, single-click registers the widget for drag-select
// tracking (TextInput/ComboBox); false for Spinner.
void widgetTextEditMouseClick(WidgetT *w, int32_t vx, int32_t vy, int32_t textLeftX, const BitmapFontT *font, const char *buf, int32_t len, int32_t scrollOff, int32_t *pCursorPos, int32_t *pSelStart, int32_t *pSelEnd, bool wordSelect, bool dragSelect) {
int32_t relX = vx - textLeftX;
int32_t charPos = relX / font->charWidth + scrollOff;
if (charPos < 0) {
charPos = 0;
}
if (charPos > len) {
charPos = len;
}
int32_t clicks = multiClickDetect(vx, vy);
if (clicks >= 3) {
*pSelStart = 0;
*pSelEnd = len;
*pCursorPos = len;
sDragTextSelect = NULL;
return;
}
if (clicks == 2) {
if (wordSelect && buf) {
int32_t ws = wordStart(buf, charPos);
int32_t we = wordEnd(buf, len, charPos);
*pSelStart = ws;
*pSelEnd = we;
*pCursorPos = we;
} else {
*pSelStart = 0;
*pSelEnd = len;
*pCursorPos = len;
}
sDragTextSelect = NULL;
return;
}
// Single click: place cursor
*pCursorPos = charPos;
*pSelStart = charPos;
*pSelEnd = charPos;
sDragTextSelect = dragSelect ? w : NULL;
}
// ============================================================
// Shared single-line text editing: drag update
// ============================================================
//
// Called during mouse drag to extend the selection. Auto-scrolls
// when the mouse moves past the visible text edges. Used by
// widgetTextDragUpdate for TextInput and ComboBox.
void widgetTextEditDragUpdateLine(int32_t vx, int32_t leftEdge, int32_t maxChars, const BitmapFontT *font, int32_t len, int32_t *pCursorPos, int32_t *pScrollOff, int32_t *pSelEnd) {
int32_t rightEdge = leftEdge + maxChars * font->charWidth;
if (vx < leftEdge && *pScrollOff > 0) {
(*pScrollOff)--;
} else if (vx >= rightEdge && *pScrollOff + maxChars < len) {
(*pScrollOff)++;
}
int32_t relX = vx - leftEdge;
int32_t charPos = relX / font->charWidth + *pScrollOff;
if (charPos < 0) {
charPos = 0;
}
if (charPos > len) {
charPos = len;
}
*pCursorPos = charPos;
*pSelEnd = charPos;
}
// ============================================================
// Shared single-line text editing: paint
// ============================================================
//
// Renders a single line of text with optional selection highlighting
// and a blinking cursor. Draws up to 3 runs (before/during/after
// selection) to avoid overdraw. Used by TextInput, ComboBox, and
// Spinner paint functions so selection rendering is identical.
//
// buf points to the already-scrolled display text (buf + scrollOff),
// and visLen is the number of visible characters. For password mode,
// the caller passes a bullet-filled display buffer.
void widgetTextEditPaintLine(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t textX, int32_t textY, const char *buf, int32_t visLen, int32_t scrollOff, int32_t cursorPos, int32_t selStart, int32_t selEnd, uint32_t fg, uint32_t bg, bool showCursor, int32_t cursorMinX, int32_t cursorMaxX) {
// Normalize selection to low/high
int32_t selLo = -1;
int32_t selHi = -1;
if (selStart >= 0 && selEnd >= 0 && selStart != selEnd) {
selLo = selStart < selEnd ? selStart : selEnd;
selHi = selStart < selEnd ? selEnd : selStart;
}
// Map selection to visible range
int32_t visSelLo = selLo - scrollOff;
int32_t visSelHi = selHi - scrollOff;
if (visSelLo < 0) { visSelLo = 0; }
if (visSelHi > visLen) { visSelHi = visLen; }
if (selLo >= 0 && visSelLo < visSelHi) {
if (visSelLo > 0) {
drawTextN(d, ops, font, textX, textY, buf, visSelLo, fg, bg, true);
}
drawTextN(d, ops, font, textX + visSelLo * font->charWidth, textY, buf + visSelLo, visSelHi - visSelLo, colors->menuHighlightFg, colors->menuHighlightBg, true);
if (visSelHi < visLen) {
drawTextN(d, ops, font, textX + visSelHi * font->charWidth, textY, buf + visSelHi, visLen - visSelHi, fg, bg, true);
}
} else if (visLen > 0) {
drawTextN(d, ops, font, textX, textY, buf, visLen, fg, bg, true);
}
// Blinking cursor
if (showCursor && sCursorBlinkOn) {
int32_t cursorX = textX + (cursorPos - scrollOff) * font->charWidth;
if (cursorX >= cursorMinX && cursorX < cursorMaxX) {
drawVLine(d, ops, cursorX, textY, font->charHeight, fg);
}
}
}
// ============================================================
// Multi-click tracking
// ============================================================
static clock_t sLastClickTime = 0;
static int32_t sLastClickX = -1;
static int32_t sLastClickY = -1;
static int32_t sClickCount = 0;
int32_t multiClickDetect(int32_t vx, int32_t vy) {
clock_t now = clock();
if ((now - sLastClickTime) < sDblClickTicks &&
abs(vx - sLastClickX) < 4 && abs(vy - sLastClickY) < 4) {
sClickCount++;
} else {
sClickCount = 1;
}
sLastClickTime = now;
sLastClickX = vx;
sLastClickY = vy;
return sClickCount;
}
bool isWordChar(char c) {
return isalnum((unsigned char)c) || c == '_';
}
int32_t wordStart(const char *buf, int32_t pos) {
while (pos > 0 && isWordChar(buf[pos - 1])) {
pos--;
}
return pos;
}
int32_t wordEnd(const char *buf, int32_t len, int32_t pos) {
while (pos < len && isWordChar(buf[pos])) {
pos++;
}
return pos;
}
// ============================================================
// Clear selection on all text widgets except 'except'
// ============================================================
// Track the widget that last had an active selection so we can
// clear it in O(1) instead of walking every widget in every window.
// This is critical for performance on 486 -- a brute-force walk
// across all widgets in all windows on every focus change would be
// prohibitive.
static WidgetT *sLastSelectedWidget = NULL;
static bool clearSelectionOnWidget(WidgetT *w) {
if (w->type == WidgetTextInputE) {
if (w->as.textInput.selStart != w->as.textInput.selEnd) {
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
return true;
}
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
} else if (w->type == WidgetTextAreaE) {
if (w->as.textArea.selAnchor != w->as.textArea.selCursor) {
w->as.textArea.selAnchor = -1;
w->as.textArea.selCursor = -1;
return true;
}
w->as.textArea.selAnchor = -1;
w->as.textArea.selCursor = -1;
} else if (w->type == WidgetComboBoxE) {
if (w->as.comboBox.selStart != w->as.comboBox.selEnd) {
w->as.comboBox.selStart = -1;
w->as.comboBox.selEnd = -1;
return true;
}
w->as.comboBox.selStart = -1;
w->as.comboBox.selEnd = -1;
} else if (w->type == WidgetAnsiTermE) {
if (w->as.ansiTerm->selStartLine >= 0 &&
(w->as.ansiTerm->selStartLine != w->as.ansiTerm->selEndLine ||
w->as.ansiTerm->selStartCol != w->as.ansiTerm->selEndCol)) {
w->as.ansiTerm->dirtyRows = 0xFFFFFFFF;
w->as.ansiTerm->selStartLine = -1;
w->as.ansiTerm->selStartCol = -1;
w->as.ansiTerm->selEndLine = -1;
w->as.ansiTerm->selEndCol = -1;
w->as.ansiTerm->selecting = false;
return true;
}
w->as.ansiTerm->selStartLine = -1;
w->as.ansiTerm->selStartCol = -1;
w->as.ansiTerm->selEndLine = -1;
w->as.ansiTerm->selEndCol = -1;
w->as.ansiTerm->selecting = false;
}
return false;
}
// Clears selection on the previously-selected widget (if different
// from the newly-focused one). Validates that the previous widget's
// window is still in the window stack before touching it -- the
// window may have been closed since sLastSelectedWidget was set.
// If the previous widget was in a different window, that window
// gets a full repaint to clear the stale selection highlight.
void clearOtherSelections(WidgetT *except) {
if (!except || !except->window || !except->window->widgetRoot) {
return;
}
WidgetT *prev = sLastSelectedWidget;
sLastSelectedWidget = except;
if (!prev || prev == except) {
return;
}
// Verify the widget is still alive (its window still in the stack)
WindowT *prevWin = prev->window;
if (!prevWin) {
return;
}
AppContextT *ctx = wgtGetContext(except);
if (!ctx) {
return;
}
bool found = false;
for (int32_t i = 0; i < ctx->stack.count; i++) {
if (ctx->stack.windows[i] == prevWin) {
found = true;
break;
}
}
if (!found) {
return;
}
if (clearSelectionOnWidget(prev) && prevWin != except->window) {
dvxInvalidateWindow(ctx, prevWin);
}
}
// ============================================================
// Shared undo helpers
// ============================================================
static void textEditSaveUndo(char *buf, int32_t len, int32_t cursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t bufSize) {
if (!undoBuf) {
return;
}
int32_t copyLen = len < bufSize ? len : bufSize - 1;
memcpy(undoBuf, buf, copyLen);
undoBuf[copyLen] = '\0';
*pUndoLen = copyLen;
*pUndoCursor = cursor;
}
// ============================================================
// Shared selection helpers
// ============================================================
static void textEditDeleteSelection(char *buf, int32_t *pLen, int32_t *pCursor, int32_t *pSelStart, int32_t *pSelEnd) {
int32_t lo = *pSelStart < *pSelEnd ? *pSelStart : *pSelEnd;
int32_t hi = *pSelStart < *pSelEnd ? *pSelEnd : *pSelStart;
if (lo < 0) {
lo = 0;
}
if (hi > *pLen) {
hi = *pLen;
}
if (lo >= hi) {
*pSelStart = -1;
*pSelEnd = -1;
return;
}
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
*pCursor = lo;
*pSelStart = -1;
*pSelEnd = -1;
}
// ============================================================
// wordBoundaryLeft
// ============================================================
//
// From position pos, skip non-word chars left, then skip word chars left.
// Returns the position at the start of the word (or 0).
static int32_t wordBoundaryLeft(const char *buf, int32_t pos) {
if (pos <= 0) {
return 0;
}
// Skip non-word characters
while (pos > 0 && !isalnum((unsigned char)buf[pos - 1]) && buf[pos - 1] != '_') {
pos--;
}
// Skip word characters
while (pos > 0 && (isalnum((unsigned char)buf[pos - 1]) || buf[pos - 1] == '_')) {
pos--;
}
return pos;
}
// ============================================================
// wordBoundaryRight
// ============================================================
//
// From position pos, skip word chars right, then skip non-word chars right.
// Returns the position at the end of the word (or len).
static int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos) {
if (pos >= len) {
return len;
}
// Skip word characters
while (pos < len && (isalnum((unsigned char)buf[pos]) || buf[pos] == '_')) {
pos++;
}
// Skip non-word characters
while (pos < len && !isalnum((unsigned char)buf[pos]) && buf[pos] != '_') {
pos++;
}
return pos;
}
// ============================================================
// wgtTextArea
// ============================================================
// TextArea allocates heap buffers for content and undo. maxLen controls
// the buffer size; 0 defaults to 256 bytes. The undo buffer is the
// same size as the content buffer to support full-buffer undo. Line
// count and max line length are cached (invalidated by textAreaDirtyCache)
// to avoid re-scanning the entire buffer on every paint/layout call.
WidgetT *wgtTextArea(WidgetT *parent, int32_t maxLen) {
WidgetT *w = widgetAlloc(parent, WidgetTextAreaE);
if (w) {
int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;
w->as.textArea.buf = (char *)malloc(bufSize);
w->as.textArea.undoBuf = (char *)malloc(bufSize);
w->as.textArea.bufSize = bufSize;
if (!w->as.textArea.buf || !w->as.textArea.undoBuf) {
free(w->as.textArea.buf);
free(w->as.textArea.undoBuf);
w->as.textArea.buf = NULL;
w->as.textArea.undoBuf = NULL;
} else {
w->as.textArea.buf[0] = '\0';
}
w->as.textArea.selAnchor = -1;
w->as.textArea.selCursor = -1;
w->as.textArea.desiredCol = 0;
w->as.textArea.cachedLines = -1;
w->as.textArea.cachedMaxLL = -1;
w->weight = 100;
}
return w;
}
// ============================================================
// wgtTextInput
// ============================================================
// TextInput also heap-allocates buffers. The weight=100 default makes
// text inputs stretch to fill available width, which is the expected
// behavior in form layouts. Selection uses a start/end pair rather
// than anchor/cursor -- for single-line, the concepts are equivalent.
WidgetT *wgtTextInput(WidgetT *parent, int32_t maxLen) {
WidgetT *w = widgetAlloc(parent, WidgetTextInputE);
if (w) {
int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;
w->as.textInput.buf = (char *)malloc(bufSize);
w->as.textInput.bufSize = bufSize;
if (w->as.textInput.buf) {
w->as.textInput.buf[0] = '\0';
}
w->as.textInput.undoBuf = (char *)malloc(bufSize);
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
w->weight = 100;
}
return w;
}
WidgetT *wgtPasswordInput(WidgetT *parent, int32_t maxLen) {
WidgetT *w = wgtTextInput(parent, maxLen);
if (w) {
w->as.textInput.inputMode = InputPasswordE;
}
return w;
}
// Masked input pre-fills the buffer from the mask pattern: literal
// characters are placed directly, editable slots get '_' placeholder.
// The cursor starts at the first editable slot. The mask string is
// NOT copied -- it must remain valid for the widget's lifetime (caller
// typically passes a string literal).
WidgetT *wgtMaskedInput(WidgetT *parent, const char *mask) {
if (!mask) {
return NULL;
}
int32_t maskLen = (int32_t)strlen(mask);
WidgetT *w = wgtTextInput(parent, maskLen);
if (w) {
w->as.textInput.inputMode = InputMaskedE;
w->as.textInput.mask = mask;
// Pre-fill buffer: literals copied as-is, slots filled with '_'
for (int32_t i = 0; i < maskLen; i++) {
if (maskIsSlot(mask[i])) {
w->as.textInput.buf[i] = '_';
} else {
w->as.textInput.buf[i] = mask[i];
}
}
w->as.textInput.buf[maskLen] = '\0';
w->as.textInput.len = maskLen;
w->as.textInput.cursorPos = maskFirstSlot(mask);
}
return w;
}
// ============================================================
// Mask helpers
// ============================================================
static bool maskIsSlot(char ch) {
return ch == '#' || ch == 'A' || ch == '*';
}
static bool maskCharValid(char slot, char ch) {
switch (slot) {
case '#':
return ch >= '0' && ch <= '9';
case 'A':
return isalpha((unsigned char)ch);
case '*':
return ch >= 32 && ch < 127;
default:
return false;
}
}
static int32_t maskFirstSlot(const char *mask) {
for (int32_t i = 0; mask[i]; i++) {
if (maskIsSlot(mask[i])) {
return i;
}
}
return 0;
}
static int32_t maskNextSlot(const char *mask, int32_t pos) {
for (int32_t i = pos + 1; mask[i]; i++) {
if (maskIsSlot(mask[i])) {
return i;
}
}
return (int32_t)strlen(mask);
}
static int32_t maskPrevSlot(const char *mask, int32_t pos) {
for (int32_t i = pos - 1; i >= 0; i--) {
if (maskIsSlot(mask[i])) {
return i;
}
}
return pos;
}
// Masked input key handling is entirely separate from the shared
// text editor because the editing semantics are fundamentally different:
// the buffer length is fixed (it's always maskLen), characters can
// only be placed in slot positions, and backspace clears a slot rather
// than removing a character. The cursor skips over literal characters
// when moving left/right. Cut clears slots to '_' rather than removing
// text. Paste fills consecutive slots, skipping non-matching clipboard
// characters.
static void maskedInputOnKey(WidgetT *w, int32_t key, int32_t mod) {
char *buf = w->as.textInput.buf;
const char *mask = w->as.textInput.mask;
int32_t *pCur = &w->as.textInput.cursorPos;
int32_t maskLen = w->as.textInput.len;
bool shift = (mod & KEY_MOD_SHIFT) != 0;
(void)shift;
// Ctrl+A -- select all
if (key == 1) {
w->as.textInput.selStart = 0;
w->as.textInput.selEnd = maskLen;
*pCur = maskLen;
goto done;
}
// Ctrl+C -- copy formatted text
if (key == 3) {
if (w->as.textInput.selStart >= 0 && w->as.textInput.selEnd >= 0 && w->as.textInput.selStart != w->as.textInput.selEnd) {
int32_t selLo = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selStart : w->as.textInput.selEnd;
int32_t selHi = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selEnd : w->as.textInput.selStart;
if (selLo < 0) {
selLo = 0;
}
if (selHi > maskLen) {
selHi = maskLen;
}
if (selHi > selLo) {
clipboardCopy(buf + selLo, selHi - selLo);
}
}
return;
}
// Ctrl+V -- paste valid chars into slots
if (key == 22) {
int32_t clipLen;
const char *clip = clipboardGet(&clipLen);
if (clipLen > 0) {
if (w->as.textInput.undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize);
}
int32_t slotPos = *pCur;
bool changed = false;
for (int32_t i = 0; i < clipLen && slotPos < maskLen; i++) {
// Skip to next slot if not on one
while (slotPos < maskLen && !maskIsSlot(mask[slotPos])) {
slotPos++;
}
if (slotPos >= maskLen) {
break;
}
// Skip non-matching clipboard chars
if (maskCharValid(mask[slotPos], clip[i])) {
buf[slotPos] = clip[i];
slotPos = maskNextSlot(mask, slotPos);
changed = true;
}
}
if (changed) {
*pCur = slotPos <= maskLen ? slotPos : maskLen;
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
}
}
goto done;
}
// Ctrl+X -- copy and clear selected slots
if (key == 24) {
if (w->as.textInput.selStart >= 0 && w->as.textInput.selEnd >= 0 && w->as.textInput.selStart != w->as.textInput.selEnd) {
int32_t selLo = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selStart : w->as.textInput.selEnd;
int32_t selHi = w->as.textInput.selStart < w->as.textInput.selEnd ? w->as.textInput.selEnd : w->as.textInput.selStart;
if (selLo < 0) {
selLo = 0;
}
if (selHi > maskLen) {
selHi = maskLen;
}
clipboardCopy(buf + selLo, selHi - selLo);
if (w->as.textInput.undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize);
}
for (int32_t i = selLo; i < selHi; i++) {
if (maskIsSlot(mask[i])) {
buf[i] = '_';
}
}
*pCur = selLo;
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
}
goto done;
}
// Ctrl+Z -- undo
if (key == 26 && w->as.textInput.undoBuf) {
char tmpBuf[256];
int32_t tmpLen = maskLen + 1 < (int32_t)sizeof(tmpBuf) ? maskLen + 1 : (int32_t)sizeof(tmpBuf);
int32_t tmpCursor = *pCur;
memcpy(tmpBuf, buf, tmpLen);
memcpy(buf, w->as.textInput.undoBuf, maskLen + 1);
*pCur = w->as.textInput.undoCursor < maskLen ? w->as.textInput.undoCursor : maskLen;
memcpy(w->as.textInput.undoBuf, tmpBuf, tmpLen);
w->as.textInput.undoCursor = tmpCursor;
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
goto done;
}
if (key >= 32 && key < 127) {
// Printable character -- place at current slot if valid
if (*pCur < maskLen && maskIsSlot(mask[*pCur]) && maskCharValid(mask[*pCur], (char)key)) {
if (w->as.textInput.undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize);
}
buf[*pCur] = (char)key;
*pCur = maskNextSlot(mask, *pCur);
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
}
} else if (key == 8) {
// Backspace -- clear previous slot
int32_t prev = maskPrevSlot(mask, *pCur);
if (prev != *pCur) {
if (w->as.textInput.undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize);
}
buf[prev] = '_';
*pCur = prev;
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
}
} else if (key == (0x53 | 0x100)) {
// Delete -- clear current slot
if (*pCur < maskLen && maskIsSlot(mask[*pCur])) {
if (w->as.textInput.undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, w->as.textInput.undoBuf, &w->as.textInput.undoLen, &w->as.textInput.undoCursor, w->as.textInput.bufSize);
}
buf[*pCur] = '_';
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
}
} else if (key == (0x4B | 0x100)) {
// Left arrow -- move to previous slot
int32_t prev = maskPrevSlot(mask, *pCur);
if (shift) {
if (w->as.textInput.selStart < 0) {
w->as.textInput.selStart = *pCur;
w->as.textInput.selEnd = *pCur;
}
*pCur = prev;
w->as.textInput.selEnd = *pCur;
} else {
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
*pCur = prev;
}
} else if (key == (0x4D | 0x100)) {
// Right arrow -- move to next slot
int32_t next = maskNextSlot(mask, *pCur);
if (shift) {
if (w->as.textInput.selStart < 0) {
w->as.textInput.selStart = *pCur;
w->as.textInput.selEnd = *pCur;
}
*pCur = next;
w->as.textInput.selEnd = *pCur;
} else {
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
*pCur = next;
}
} else if (key == (0x47 | 0x100)) {
// Home -- first slot
if (shift) {
if (w->as.textInput.selStart < 0) {
w->as.textInput.selStart = *pCur;
w->as.textInput.selEnd = *pCur;
}
*pCur = maskFirstSlot(mask);
w->as.textInput.selEnd = *pCur;
} else {
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
*pCur = maskFirstSlot(mask);
}
} else if (key == (0x4F | 0x100)) {
// End -- past last slot
int32_t last = maskLen;
if (shift) {
if (w->as.textInput.selStart < 0) {
w->as.textInput.selStart = *pCur;
w->as.textInput.selEnd = *pCur;
}
*pCur = last;
w->as.textInput.selEnd = *pCur;
} else {
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
*pCur = last;
}
} else {
return;
}
done:
// Adjust scroll
{
AppContextT *ctx = wgtGetContext(w);
const BitmapFontT *font = &ctx->font;
int32_t visibleChars = (w->w - TEXT_INPUT_PAD * 2) / font->charWidth;
if (*pCur < w->as.textInput.scrollOff) {
w->as.textInput.scrollOff = *pCur;
}
if (*pCur >= w->as.textInput.scrollOff + visibleChars) {
w->as.textInput.scrollOff = *pCur - visibleChars + 1;
}
}
wgtInvalidatePaint(w);
}
// ============================================================
// TextArea line helpers
// ============================================================
static int32_t textAreaCountLines(const char *buf, int32_t len) {
int32_t lines = 1;
for (int32_t i = 0; i < len; i++) {
if (buf[i] == '\n') {
lines++;
}
}
return lines;
}
// Cached line count -- sentinel value -1 means "dirty, recompute".
// This avoids O(N) buffer scans on every paint frame. The cache is
// invalidated (set to -1) by textAreaDirtyCache() after any buffer
// mutation. The same pattern is used for max line length.
static int32_t textAreaGetLineCount(WidgetT *w) {
if (w->as.textArea.cachedLines < 0) {
w->as.textArea.cachedLines = textAreaCountLines(w->as.textArea.buf, w->as.textArea.len);
}
return w->as.textArea.cachedLines;
}
static int32_t textAreaLineStart(const char *buf, int32_t len, int32_t row) {
(void)len;
int32_t off = 0;
for (int32_t r = 0; r < row; r++) {
while (off < len && buf[off] != '\n') {
off++;
}
if (off < len) {
off++;
}
}
return off;
}
static int32_t textAreaLineLen(const char *buf, int32_t len, int32_t row) {
int32_t start = textAreaLineStart(buf, len, row);
int32_t end = start;
while (end < len && buf[end] != '\n') {
end++;
}
return end - start;
}
static int32_t textAreaMaxLineLen(const char *buf, int32_t len) {
int32_t maxLen = 0;
int32_t curLen = 0;
for (int32_t i = 0; i < len; i++) {
if (buf[i] == '\n') {
if (curLen > maxLen) {
maxLen = curLen;
}
curLen = 0;
} else {
curLen++;
}
}
if (curLen > maxLen) {
maxLen = curLen;
}
return maxLen;
}
static int32_t textAreaGetMaxLineLen(WidgetT *w) {
if (w->as.textArea.cachedMaxLL < 0) {
w->as.textArea.cachedMaxLL = textAreaMaxLineLen(w->as.textArea.buf, w->as.textArea.len);
}
return w->as.textArea.cachedMaxLL;
}
static inline void textAreaDirtyCache(WidgetT *w) {
w->as.textArea.cachedLines = -1;
w->as.textArea.cachedMaxLL = -1;
}
static int32_t textAreaCursorToOff(const char *buf, int32_t len, int32_t row, int32_t col) {
int32_t start = textAreaLineStart(buf, len, row);
int32_t lineL = textAreaLineLen(buf, len, row);
int32_t clampC = col < lineL ? col : lineL;
return start + clampC;
}
static void textAreaOffToRowCol(const char *buf, int32_t off, int32_t *row, int32_t *col) {
int32_t r = 0;
int32_t c = 0;
for (int32_t i = 0; i < off; i++) {
if (buf[i] == '\n') {
r++;
c = 0;
} else {
c++;
}
}
*row = r;
*col = c;
}
static void textAreaEnsureVisible(WidgetT *w, int32_t visRows, int32_t visCols) {
int32_t row = w->as.textArea.cursorRow;
int32_t col = w->as.textArea.cursorCol;
if (row < w->as.textArea.scrollRow) {
w->as.textArea.scrollRow = row;
}
if (row >= w->as.textArea.scrollRow + visRows) {
w->as.textArea.scrollRow = row - visRows + 1;
}
if (col < w->as.textArea.scrollCol) {
w->as.textArea.scrollCol = col;
}
if (col >= w->as.textArea.scrollCol + visCols) {
w->as.textArea.scrollCol = col - visCols + 1;
}
}
// ============================================================
// widgetTextAreaCalcMinSize
// ============================================================
void widgetTextAreaCalcMinSize(WidgetT *w, const BitmapFontT *font) {
w->calcMinW = font->charWidth * TEXTAREA_MIN_COLS + TEXTAREA_PAD * 2 + TEXTAREA_BORDER * 2 + TEXTAREA_SB_W;
w->calcMinH = font->charHeight * TEXTAREA_MIN_ROWS + TEXTAREA_BORDER * 2;
}
// ============================================================
// widgetTextAreaDestroy
// ============================================================
void widgetTextAreaDestroy(WidgetT *w) {
free(w->as.textArea.buf);
free(w->as.textArea.undoBuf);
}
// ============================================================
// widgetTextAreaGetText
// ============================================================
const char *widgetTextAreaGetText(const WidgetT *w) {
return w->as.textArea.buf ? w->as.textArea.buf : "";
}
// ============================================================
// widgetTextAreaOnKey
// ============================================================
// TextArea key handling is inline (not using widgetTextEditOnKey)
// because multi-line editing has fundamentally different cursor
// semantics: row/col instead of linear offset, desiredCol for
// vertical movement (so moving down from a long line to a short
// line remembers the original column), and Enter inserts newlines.
//
// The SEL_BEGIN/SEL_END/HAS_SEL macros factor out the repetitive
// selection-start/selection-extend pattern: SEL_BEGIN initializes
// the anchor at the current offset if Shift is held and no selection
// exists yet. SEL_END updates the selection cursor to the new
// position (or clears selection if Shift isn't held). This keeps
// the per-key handler code manageable despite the large number of
// key combinations.
//
// textAreaEnsureVisible() is called after every cursor movement to
// auto-scroll the viewport. It adjusts scrollRow/scrollCol so the
// cursor is within the visible range.
void widgetTextAreaOnKey(WidgetT *w, int32_t key, int32_t mod) {
if (!w->as.textArea.buf) {
return;
}
clearOtherSelections(w);
char *buf = w->as.textArea.buf;
int32_t bufSize = w->as.textArea.bufSize;
int32_t *pLen = &w->as.textArea.len;
int32_t *pRow = &w->as.textArea.cursorRow;
int32_t *pCol = &w->as.textArea.cursorCol;
int32_t *pSA = &w->as.textArea.selAnchor;
int32_t *pSC = &w->as.textArea.selCursor;
bool shift = (mod & KEY_MOD_SHIFT) != 0;
AppContextT *ctx = wgtGetContext(w);
const BitmapFontT *font = &ctx->font;
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W;
int32_t visCols = innerW / font->charWidth;
int32_t maxLL = textAreaGetMaxLineLen(w);
bool needHSb = (maxLL > visCols);
int32_t innerH = w->h - TEXTAREA_BORDER * 2 - (needHSb ? TEXTAREA_SB_W : 0);
int32_t visRows = innerH / font->charHeight;
if (visRows < 1) {
visRows = 1;
}
if (visCols < 1) {
visCols = 1;
}
int32_t totalLines = textAreaGetLineCount(w);
// Helper macros for cursor offset
#define CUR_OFF() textAreaCursorToOff(buf, *pLen, *pRow, *pCol)
// Start/extend selection
#define SEL_BEGIN() do { \
if (shift && *pSA < 0) { *pSA = CUR_OFF(); *pSC = *pSA; } \
} while (0)
#define SEL_END() do { \
if (shift) { *pSC = CUR_OFF(); } \
else { *pSA = -1; *pSC = -1; } \
} while (0)
#define HAS_SEL() (*pSA >= 0 && *pSC >= 0 && *pSA != *pSC)
#define SEL_LO() (*pSA < *pSC ? *pSA : *pSC)
#define SEL_HI() (*pSA < *pSC ? *pSC : *pSA)
// Clamp selection to buffer bounds
if (HAS_SEL()) {
if (*pSA > *pLen) {
*pSA = *pLen;
}
if (*pSC > *pLen) {
*pSC = *pLen;
}
}
// Ctrl+A -- select all
if (key == 1) {
*pSA = 0;
*pSC = *pLen;
textAreaOffToRowCol(buf, *pLen, pRow, pCol);
w->as.textArea.desiredCol = *pCol;
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+C -- copy
if (key == 3) {
if (HAS_SEL()) {
clipboardCopy(buf + SEL_LO(), SEL_HI() - SEL_LO());
}
return;
}
// Read-only: allow select-all, copy, and navigation but block editing
if (w->readOnly) {
goto navigation;
}
// Ctrl+V -- paste
if (key == 22) {
if (sClipboardLen > 0) {
textEditSaveUndo(buf, *pLen, CUR_OFF(), w->as.textArea.undoBuf, &w->as.textArea.undoLen, &w->as.textArea.undoCursor, bufSize);
if (HAS_SEL()) {
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowCol(buf, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
}
int32_t off = CUR_OFF();
int32_t canFit = bufSize - 1 - *pLen;
int32_t paste = sClipboardLen < canFit ? sClipboardLen : canFit;
if (paste > 0) {
memmove(buf + off + paste, buf + off, *pLen - off + 1);
memcpy(buf + off, sClipboard, paste);
*pLen += paste;
textAreaOffToRowCol(buf, off + paste, pRow, pCol);
w->as.textArea.desiredCol = *pCol;
}
if (w->onChange) {
w->onChange(w);
}
textAreaDirtyCache(w);
}
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+X -- cut
if (key == 24) {
if (HAS_SEL()) {
clipboardCopy(buf + SEL_LO(), SEL_HI() - SEL_LO());
textEditSaveUndo(buf, *pLen, CUR_OFF(), w->as.textArea.undoBuf, &w->as.textArea.undoLen, &w->as.textArea.undoCursor, bufSize);
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowCol(buf, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
w->as.textArea.desiredCol = *pCol;
if (w->onChange) {
w->onChange(w);
}
textAreaDirtyCache(w);
}
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+Z -- undo
if (key == 26) {
if (w->as.textArea.undoBuf && w->as.textArea.undoLen >= 0) {
// Swap current and undo
char tmpBuf[CLIPBOARD_MAX];
int32_t tmpLen = *pLen;
int32_t tmpCursor = CUR_OFF();
int32_t copyLen = tmpLen < (int32_t)sizeof(tmpBuf) - 1 ? tmpLen : (int32_t)sizeof(tmpBuf) - 1;
memcpy(tmpBuf, buf, copyLen);
tmpBuf[copyLen] = '\0';
int32_t restLen = w->as.textArea.undoLen < bufSize - 1 ? w->as.textArea.undoLen : bufSize - 1;
memcpy(buf, w->as.textArea.undoBuf, restLen);
buf[restLen] = '\0';
*pLen = restLen;
// Save current as new undo
int32_t saveLen = copyLen < bufSize - 1 ? copyLen : bufSize - 1;
memcpy(w->as.textArea.undoBuf, tmpBuf, saveLen);
w->as.textArea.undoBuf[saveLen] = '\0';
w->as.textArea.undoLen = saveLen;
w->as.textArea.undoCursor = tmpCursor;
// Restore cursor
int32_t restoreOff = w->as.textArea.undoCursor < *pLen ? w->as.textArea.undoCursor : *pLen;
w->as.textArea.undoCursor = tmpCursor;
textAreaOffToRowCol(buf, restoreOff, pRow, pCol);
w->as.textArea.desiredCol = *pCol;
*pSA = -1;
*pSC = -1;
if (w->onChange) {
w->onChange(w);
}
textAreaDirtyCache(w);
}
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Enter -- insert newline
if (key == 0x0D) {
if (*pLen < bufSize - 1) {
textEditSaveUndo(buf, *pLen, CUR_OFF(), w->as.textArea.undoBuf, &w->as.textArea.undoLen, &w->as.textArea.undoCursor, bufSize);
if (HAS_SEL()) {
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowCol(buf, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
}
int32_t off = CUR_OFF();
if (*pLen < bufSize - 1) {
memmove(buf + off + 1, buf + off, *pLen - off + 1);
buf[off] = '\n';
(*pLen)++;
(*pRow)++;
*pCol = 0;
w->as.textArea.desiredCol = 0;
}
if (w->onChange) {
w->onChange(w);
}
textAreaDirtyCache(w);
}
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Backspace
if (key == 8) {
if (HAS_SEL()) {
textEditSaveUndo(buf, *pLen, CUR_OFF(), w->as.textArea.undoBuf, &w->as.textArea.undoLen, &w->as.textArea.undoCursor, bufSize);
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowCol(buf, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
w->as.textArea.desiredCol = *pCol;
textAreaDirtyCache(w);
if (w->onChange) {
w->onChange(w);
}
} else {
int32_t off = CUR_OFF();
if (off > 0) {
textEditSaveUndo(buf, *pLen, off, w->as.textArea.undoBuf, &w->as.textArea.undoLen, &w->as.textArea.undoCursor, bufSize);
memmove(buf + off - 1, buf + off, *pLen - off + 1);
(*pLen)--;
textAreaOffToRowCol(buf, off - 1, pRow, pCol);
w->as.textArea.desiredCol = *pCol;
textAreaDirtyCache(w);
if (w->onChange) {
w->onChange(w);
}
}
}
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Delete
if (key == (0x53 | 0x100)) {
if (HAS_SEL()) {
textEditSaveUndo(buf, *pLen, CUR_OFF(), w->as.textArea.undoBuf, &w->as.textArea.undoLen, &w->as.textArea.undoCursor, bufSize);
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowCol(buf, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
w->as.textArea.desiredCol = *pCol;
textAreaDirtyCache(w);
if (w->onChange) {
w->onChange(w);
}
} else {
int32_t off = CUR_OFF();
if (off < *pLen) {
textEditSaveUndo(buf, *pLen, off, w->as.textArea.undoBuf, &w->as.textArea.undoLen, &w->as.textArea.undoCursor, bufSize);
memmove(buf + off, buf + off + 1, *pLen - off);
(*pLen)--;
textAreaDirtyCache(w);
if (w->onChange) {
w->onChange(w);
}
}
}
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
navigation:
// Left arrow
if (key == (0x4B | 0x100)) {
SEL_BEGIN();
int32_t off = CUR_OFF();
if (off > 0) {
textAreaOffToRowCol(buf, off - 1, pRow, pCol);
}
w->as.textArea.desiredCol = *pCol;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Right arrow
if (key == (0x4D | 0x100)) {
SEL_BEGIN();
int32_t off = CUR_OFF();
if (off < *pLen) {
textAreaOffToRowCol(buf, off + 1, pRow, pCol);
}
w->as.textArea.desiredCol = *pCol;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+Left -- word left
if (key == (0x73 | 0x100)) {
SEL_BEGIN();
int32_t off = CUR_OFF();
int32_t newOff = wordBoundaryLeft(buf, off);
textAreaOffToRowCol(buf, newOff, pRow, pCol);
w->as.textArea.desiredCol = *pCol;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+Right -- word right
if (key == (0x74 | 0x100)) {
SEL_BEGIN();
int32_t off = CUR_OFF();
int32_t newOff = wordBoundaryRight(buf, *pLen, off);
textAreaOffToRowCol(buf, newOff, pRow, pCol);
w->as.textArea.desiredCol = *pCol;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Up arrow
if (key == (0x48 | 0x100)) {
SEL_BEGIN();
if (*pRow > 0) {
(*pRow)--;
int32_t lineL = textAreaLineLen(buf, *pLen, *pRow);
*pCol = w->as.textArea.desiredCol < lineL ? w->as.textArea.desiredCol : lineL;
}
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Down arrow
if (key == (0x50 | 0x100)) {
SEL_BEGIN();
if (*pRow < totalLines - 1) {
(*pRow)++;
int32_t lineL = textAreaLineLen(buf, *pLen, *pRow);
*pCol = w->as.textArea.desiredCol < lineL ? w->as.textArea.desiredCol : lineL;
}
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Home
if (key == (0x47 | 0x100)) {
SEL_BEGIN();
*pCol = 0;
w->as.textArea.desiredCol = 0;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// End
if (key == (0x4F | 0x100)) {
SEL_BEGIN();
*pCol = textAreaLineLen(buf, *pLen, *pRow);
w->as.textArea.desiredCol = *pCol;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Page Up
if (key == (0x49 | 0x100)) {
SEL_BEGIN();
*pRow -= visRows;
if (*pRow < 0) {
*pRow = 0;
}
int32_t lineL = textAreaLineLen(buf, *pLen, *pRow);
*pCol = w->as.textArea.desiredCol < lineL ? w->as.textArea.desiredCol : lineL;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Page Down
if (key == (0x51 | 0x100)) {
SEL_BEGIN();
*pRow += visRows;
if (*pRow >= totalLines) {
*pRow = totalLines - 1;
}
int32_t lineL = textAreaLineLen(buf, *pLen, *pRow);
*pCol = w->as.textArea.desiredCol < lineL ? w->as.textArea.desiredCol : lineL;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+Home (scancode 0x77)
if (key == (0x77 | 0x100)) {
SEL_BEGIN();
*pRow = 0;
*pCol = 0;
w->as.textArea.desiredCol = 0;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+End (scancode 0x75)
if (key == (0x75 | 0x100)) {
SEL_BEGIN();
textAreaOffToRowCol(buf, *pLen, pRow, pCol);
w->as.textArea.desiredCol = *pCol;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Printable character (blocked in read-only mode)
if (key >= 32 && key < 127 && !w->readOnly) {
if (*pLen < bufSize - 1) {
textEditSaveUndo(buf, *pLen, CUR_OFF(), w->as.textArea.undoBuf, &w->as.textArea.undoLen, &w->as.textArea.undoCursor, bufSize);
if (HAS_SEL()) {
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowCol(buf, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
}
int32_t off = CUR_OFF();
if (*pLen < bufSize - 1) {
memmove(buf + off + 1, buf + off, *pLen - off + 1);
buf[off] = (char)key;
(*pLen)++;
(*pCol)++;
w->as.textArea.desiredCol = *pCol;
}
textAreaDirtyCache(w);
if (w->onChange) {
w->onChange(w);
}
}
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
#undef CUR_OFF
#undef SEL_BEGIN
#undef SEL_END
#undef HAS_SEL
#undef SEL_LO
#undef SEL_HI
}
// ============================================================
// widgetTextAreaOnMouse
// ============================================================
// Mouse handling: scrollbar clicks (both V and H), then content area
// clicks. Content clicks convert pixel coordinates to row/col using
// font metrics and scroll offset. Multi-click: double-click selects
// word, triple-click selects entire line. Single click starts a
// drag-select (sets sDragTextSelect which the event loop monitors
// on mouse-move to extend the selection). The drag-select global
// is cleared on double/triple click since the selection is already
// complete.
void widgetTextAreaOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
w->focused = true;
clearOtherSelections(w);
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
int32_t innerX = w->x + TEXTAREA_BORDER + TEXTAREA_PAD;
int32_t innerY = w->y + TEXTAREA_BORDER;
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W;
int32_t visCols = innerW / font->charWidth;
int32_t maxLL = textAreaGetMaxLineLen(w);
bool needHSb = (maxLL > visCols);
int32_t innerH = w->h - TEXTAREA_BORDER * 2 - (needHSb ? TEXTAREA_SB_W : 0);
int32_t visRows = innerH / font->charHeight;
if (visRows < 1) {
visRows = 1;
}
if (visCols < 1) {
visCols = 1;
}
int32_t totalLines = textAreaCountLines(w->as.textArea.buf, w->as.textArea.len);
int32_t maxScroll = totalLines - visRows;
if (maxScroll < 0) {
maxScroll = 0;
}
w->as.textArea.scrollRow = clampInt(w->as.textArea.scrollRow, 0, maxScroll);
int32_t maxHScroll = maxLL - visCols;
if (maxHScroll < 0) {
maxHScroll = 0;
}
w->as.textArea.scrollCol = clampInt(w->as.textArea.scrollCol, 0, maxHScroll);
// Check horizontal scrollbar click
if (needHSb) {
int32_t hsbY = w->y + w->h - TEXTAREA_BORDER - TEXTAREA_SB_W;
if (vy >= hsbY && vx < w->x + w->w - TEXTAREA_BORDER - TEXTAREA_SB_W) {
int32_t hsbX = w->x + TEXTAREA_BORDER;
int32_t hsbW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_SB_W;
int32_t relX = vx - hsbX;
int32_t trackLen = hsbW - TEXTAREA_SB_W * 2;
if (relX < TEXTAREA_SB_W) {
// Left arrow
if (w->as.textArea.scrollCol > 0) {
w->as.textArea.scrollCol--;
}
} else if (relX >= hsbW - TEXTAREA_SB_W) {
// Right arrow
if (w->as.textArea.scrollCol < maxHScroll) {
w->as.textArea.scrollCol++;
}
} else if (trackLen > 0) {
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, maxLL, visCols, w->as.textArea.scrollCol, &thumbPos, &thumbSize);
int32_t trackRelX = relX - TEXTAREA_SB_W;
if (trackRelX < thumbPos) {
w->as.textArea.scrollCol -= visCols;
w->as.textArea.scrollCol = clampInt(w->as.textArea.scrollCol, 0, maxHScroll);
} else if (trackRelX >= thumbPos + thumbSize) {
w->as.textArea.scrollCol += visCols;
w->as.textArea.scrollCol = clampInt(w->as.textArea.scrollCol, 0, maxHScroll);
} else {
sDragScrollbar = w;
sDragScrollbarOrient = 1;
sDragScrollbarOff = trackRelX - thumbPos;
return;
}
}
return;
}
}
// Check vertical scrollbar click
int32_t sbX = w->x + w->w - TEXTAREA_BORDER - TEXTAREA_SB_W;
if (vx >= sbX) {
int32_t sbY = w->y + TEXTAREA_BORDER;
int32_t sbH = innerH;
int32_t relY = vy - sbY;
int32_t trackLen = sbH - TEXTAREA_SB_W * 2;
if (relY < TEXTAREA_SB_W) {
// Up arrow
if (w->as.textArea.scrollRow > 0) {
w->as.textArea.scrollRow--;
}
} else if (relY >= sbH - TEXTAREA_SB_W) {
// Down arrow
if (w->as.textArea.scrollRow < maxScroll) {
w->as.textArea.scrollRow++;
}
} else if (trackLen > 0) {
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, totalLines, visRows, w->as.textArea.scrollRow, &thumbPos, &thumbSize);
int32_t trackRelY = relY - TEXTAREA_SB_W;
if (trackRelY < thumbPos) {
w->as.textArea.scrollRow -= visRows;
w->as.textArea.scrollRow = clampInt(w->as.textArea.scrollRow, 0, maxScroll);
} else if (trackRelY >= thumbPos + thumbSize) {
w->as.textArea.scrollRow += visRows;
w->as.textArea.scrollRow = clampInt(w->as.textArea.scrollRow, 0, maxScroll);
} else {
sDragScrollbar = w;
sDragScrollbarOrient = 0;
sDragScrollbarOff = trackRelY - thumbPos;
return;
}
}
return;
}
// Click on text area -- place cursor
int32_t relX = vx - innerX;
int32_t relY = vy - innerY;
int32_t clickRow = w->as.textArea.scrollRow + relY / font->charHeight;
int32_t clickCol = w->as.textArea.scrollCol + relX / font->charWidth;
if (clickRow < 0) {
clickRow = 0;
}
if (clickRow >= totalLines) {
clickRow = totalLines - 1;
}
if (clickCol < 0) {
clickCol = 0;
}
int32_t lineL = textAreaLineLen(w->as.textArea.buf, w->as.textArea.len, clickRow);
if (clickCol > lineL) {
clickCol = lineL;
}
int32_t clicks = multiClickDetect(vx, vy);
if (clicks >= 3) {
// Triple-click: select entire line
int32_t lineStart = textAreaCursorToOff(w->as.textArea.buf, w->as.textArea.len, clickRow, 0);
int32_t lineEnd = textAreaCursorToOff(w->as.textArea.buf, w->as.textArea.len, clickRow, lineL);
w->as.textArea.cursorRow = clickRow;
w->as.textArea.cursorCol = lineL;
w->as.textArea.desiredCol = lineL;
w->as.textArea.selAnchor = lineStart;
w->as.textArea.selCursor = lineEnd;
sDragTextSelect = NULL;
return;
}
if (clicks == 2 && w->as.textArea.buf) {
// Double-click: select word
int32_t off = textAreaCursorToOff(w->as.textArea.buf, w->as.textArea.len, clickRow, clickCol);
int32_t ws = wordStart(w->as.textArea.buf, off);
int32_t we = wordEnd(w->as.textArea.buf, w->as.textArea.len, off);
int32_t weRow;
int32_t weCol;
textAreaOffToRowCol(w->as.textArea.buf, we, &weRow, &weCol);
w->as.textArea.cursorRow = weRow;
w->as.textArea.cursorCol = weCol;
w->as.textArea.desiredCol = weCol;
w->as.textArea.selAnchor = ws;
w->as.textArea.selCursor = we;
sDragTextSelect = NULL;
return;
}
// Single click: place cursor + start drag-select
w->as.textArea.cursorRow = clickRow;
w->as.textArea.cursorCol = clickCol;
w->as.textArea.desiredCol = clickCol;
int32_t anchorOff = textAreaCursorToOff(w->as.textArea.buf, w->as.textArea.len, clickRow, clickCol);
w->as.textArea.selAnchor = anchorOff;
w->as.textArea.selCursor = anchorOff;
sDragTextSelect = w;
}
// ============================================================
// widgetTextDragUpdate -- update selection during mouse drag
// ============================================================
// Called by the event loop on mouse-move while sDragTextSelect is set.
// Extends the selection from the anchor to the current mouse position.
// Handles auto-scroll: when the mouse is past the widget edges, the
// scroll offset is nudged by one unit per event, creating a smooth
// scroll-while-dragging effect. This function handles TextInput,
// TextArea, ComboBox, and AnsiTerm -- all widgets that support
// drag-selection. The type switch is slightly ugly but avoids
// needing a virtual method for what is essentially the same operation
// with different field names.
void widgetTextDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
if (w->type == WidgetTextInputE) {
int32_t leftEdge = w->x + TEXT_INPUT_PAD;
int32_t maxChars = (w->w - TEXT_INPUT_PAD * 2) / font->charWidth;
widgetTextEditDragUpdateLine(vx, leftEdge, maxChars, font, w->as.textInput.len, &w->as.textInput.cursorPos, &w->as.textInput.scrollOff, &w->as.textInput.selEnd);
} else if (w->type == WidgetTextAreaE) {
int32_t innerX = w->x + TEXTAREA_BORDER + TEXTAREA_PAD;
int32_t innerY = w->y + TEXTAREA_BORDER;
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W;
int32_t visCols = innerW / font->charWidth;
int32_t maxLL = textAreaGetMaxLineLen(w);
bool needHSb = (maxLL > visCols);
int32_t innerH = w->h - TEXTAREA_BORDER * 2 - (needHSb ? TEXTAREA_SB_W : 0);
int32_t visRows = innerH / font->charHeight;
int32_t totalLines = textAreaGetLineCount(w);
if (visRows < 1) {
visRows = 1;
}
if (visCols < 1) {
visCols = 1;
}
// Auto-scroll vertically
if (vy < innerY && w->as.textArea.scrollRow > 0) {
w->as.textArea.scrollRow--;
} else if (vy >= innerY + visRows * font->charHeight && w->as.textArea.scrollRow + visRows < totalLines) {
w->as.textArea.scrollRow++;
}
// Auto-scroll horizontally
int32_t rightEdge = innerX + visCols * font->charWidth;
if (vx < innerX && w->as.textArea.scrollCol > 0) {
w->as.textArea.scrollCol--;
} else if (vx >= rightEdge && w->as.textArea.scrollCol < maxLL - visCols) {
w->as.textArea.scrollCol++;
}
int32_t relX = vx - innerX;
int32_t relY = vy - innerY;
int32_t clickRow = w->as.textArea.scrollRow + relY / font->charHeight;
int32_t clickCol = w->as.textArea.scrollCol + relX / font->charWidth;
if (clickRow < 0) {
clickRow = 0;
}
if (clickRow >= totalLines) {
clickRow = totalLines - 1;
}
if (clickCol < 0) {
clickCol = 0;
}
int32_t lineL = textAreaLineLen(w->as.textArea.buf, w->as.textArea.len, clickRow);
if (clickCol > lineL) {
clickCol = lineL;
}
w->as.textArea.cursorRow = clickRow;
w->as.textArea.cursorCol = clickCol;
w->as.textArea.desiredCol = clickCol;
w->as.textArea.selCursor = textAreaCursorToOff(w->as.textArea.buf, w->as.textArea.len, clickRow, clickCol);
} else if (w->type == WidgetComboBoxE) {
int32_t leftEdge = w->x + TEXT_INPUT_PAD;
int32_t maxChars = (w->w - TEXT_INPUT_PAD * 2 - 16) / font->charWidth;
widgetTextEditDragUpdateLine(vx, leftEdge, maxChars, font, w->as.comboBox.len, &w->as.comboBox.cursorPos, &w->as.comboBox.scrollOff, &w->as.comboBox.selEnd);
} else if (w->type == WidgetAnsiTermE) {
int32_t baseX = w->x + 2; // ANSI_BORDER
int32_t baseY = w->y + 2;
int32_t cols = w->as.ansiTerm->cols;
int32_t rows = w->as.ansiTerm->rows;
int32_t clickRow = (vy - baseY) / font->charHeight;
int32_t clickCol = (vx - baseX) / font->charWidth;
if (clickRow < 0) {
clickRow = 0;
}
if (clickRow >= rows) {
clickRow = rows - 1;
}
if (clickCol < 0) {
clickCol = 0;
}
if (clickCol >= cols) {
clickCol = cols;
}
w->as.ansiTerm->selEndLine = w->as.ansiTerm->scrollPos + clickRow;
w->as.ansiTerm->selEndCol = clickCol;
w->as.ansiTerm->dirtyRows = 0xFFFFFFFF;
}
}
// ============================================================
// widgetTextAreaPaint
// ============================================================
// TextArea paint uses an optimized incremental line-offset approach:
// instead of calling textAreaLineStart() for each visible row (which
// would re-scan from the buffer start each time), we compute the
// starting offset of the first visible line once, then advance it
// line by line. This makes the paint cost O(visRows * maxLineLen)
// rather than O(visRows * totalLines).
//
// Each line is drawn in up to 3 runs (before-selection, selection,
// after-selection) to avoid overdraw. Selection highlighting that
// extends past the end of a line (the newline itself is "selected")
// is rendered as a highlight-colored rectFill past the text.
//
// Scrollbars are drawn inline (not using the shared scrollbar
// functions) because TextArea scrolling is in rows/cols (logical
// units) rather than pixels, so the thumb calculation parameters
// differ. The dead corner between scrollbars is filled with
// windowFace color.
void widgetTextAreaPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
char *buf = w->as.textArea.buf;
int32_t len = w->as.textArea.len;
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W;
int32_t visCols = innerW / font->charWidth;
int32_t maxLL = textAreaGetMaxLineLen(w);
bool needHSb = (maxLL > visCols);
int32_t innerH = w->h - TEXTAREA_BORDER * 2 - (needHSb ? TEXTAREA_SB_W : 0);
int32_t visRows = innerH / font->charHeight;
int32_t totalLines = textAreaGetLineCount(w);
bool needVSb = (totalLines > visRows);
// Sunken border
BevelStyleT bevel = BEVEL_SUNKEN(colors, bg, 2);
drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
// Clamp vertical scroll
int32_t maxScroll = totalLines - visRows;
if (maxScroll < 0) {
maxScroll = 0;
}
w->as.textArea.scrollRow = clampInt(w->as.textArea.scrollRow, 0, maxScroll);
// Clamp horizontal scroll
int32_t maxHScroll = maxLL - visCols;
if (maxHScroll < 0) {
maxHScroll = 0;
}
w->as.textArea.scrollCol = clampInt(w->as.textArea.scrollCol, 0, maxHScroll);
// Selection range
int32_t selLo = -1;
int32_t selHi = -1;
if (w->as.textArea.selAnchor >= 0 && w->as.textArea.selCursor >= 0 && w->as.textArea.selAnchor != w->as.textArea.selCursor) {
selLo = w->as.textArea.selAnchor < w->as.textArea.selCursor ? w->as.textArea.selAnchor : w->as.textArea.selCursor;
selHi = w->as.textArea.selAnchor < w->as.textArea.selCursor ? w->as.textArea.selCursor : w->as.textArea.selAnchor;
}
// Draw lines -- compute first visible line offset once, then advance incrementally
int32_t textX = w->x + TEXTAREA_BORDER + TEXTAREA_PAD;
int32_t textY = w->y + TEXTAREA_BORDER;
int32_t lineOff = textAreaLineStart(buf, len, w->as.textArea.scrollRow);
for (int32_t i = 0; i < visRows; i++) {
int32_t row = w->as.textArea.scrollRow + i;
if (row >= totalLines) {
break;
}
// Compute line length by scanning from lineOff (not from buffer start)
int32_t lineL = 0;
while (lineOff + lineL < len && buf[lineOff + lineL] != '\n') {
lineL++;
}
int32_t drawY = textY + i * font->charHeight;
// Visible range within line
int32_t scrollCol = w->as.textArea.scrollCol;
int32_t visStart = scrollCol;
int32_t visEnd = scrollCol + visCols;
int32_t textEnd = lineL; // chars in this line
// Clamp visible range to actual line content for text drawing
int32_t drawStart = visStart < textEnd ? visStart : textEnd;
int32_t drawEnd = visEnd < textEnd ? visEnd : textEnd;
// Determine selection intersection with this line
int32_t lineSelLo = -1;
int32_t lineSelHi = -1;
if (selLo >= 0) {
// Selection range in column-space for this line
if (selLo < lineOff + lineL + 1 && selHi > lineOff) {
lineSelLo = selLo - lineOff;
lineSelHi = selHi - lineOff;
if (lineSelLo < 0) { lineSelLo = 0; }
// selHi can extend past line (newline selected)
}
}
if (lineSelLo >= 0 && lineSelLo < lineSelHi) {
// Clamp selection to visible columns for text runs
int32_t vSelLo = lineSelLo < drawStart ? drawStart : lineSelLo;
int32_t vSelHi = lineSelHi < drawEnd ? lineSelHi : drawEnd;
if (vSelLo > vSelHi) { vSelLo = vSelHi; }
// Before selection
if (drawStart < vSelLo) {
drawTextN(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, buf + lineOff + drawStart, vSelLo - drawStart, fg, bg, true);
}
// Selection (text portion)
if (vSelLo < vSelHi) {
drawTextN(d, ops, font, textX + (vSelLo - scrollCol) * font->charWidth, drawY, buf + lineOff + vSelLo, vSelHi - vSelLo, colors->menuHighlightFg, colors->menuHighlightBg, true);
}
// After selection
if (vSelHi < drawEnd) {
drawTextN(d, ops, font, textX + (vSelHi - scrollCol) * font->charWidth, drawY, buf + lineOff + vSelHi, drawEnd - vSelHi, fg, bg, true);
}
// Past end of text: fill selected area with highlight bg
int32_t nlOff = lineOff + lineL;
bool pastEolSelected = (nlOff >= selLo && nlOff < selHi);
if (pastEolSelected && drawEnd < visEnd) {
int32_t selPastStart = drawEnd < lineSelLo ? lineSelLo : drawEnd;
int32_t selPastEnd = visEnd;
if (selPastStart < visStart) { selPastStart = visStart; }
if (selPastStart < selPastEnd) {
rectFill(d, ops, textX + (selPastStart - scrollCol) * font->charWidth, drawY, (selPastEnd - selPastStart) * font->charWidth, font->charHeight, colors->menuHighlightBg);
}
}
} else {
// No selection on this line -- single run
if (drawStart < drawEnd) {
drawTextN(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, buf + lineOff + drawStart, drawEnd - drawStart, fg, bg, true);
}
}
// Advance lineOff to the next line
lineOff += lineL;
if (lineOff < len && buf[lineOff] == '\n') {
lineOff++;
}
}
// Draw cursor (blinks at same rate as terminal cursor)
if (w->focused && sCursorBlinkOn) {
int32_t curDrawCol = w->as.textArea.cursorCol - w->as.textArea.scrollCol;
int32_t curDrawRow = w->as.textArea.cursorRow - w->as.textArea.scrollRow;
if (curDrawCol >= 0 && curDrawCol <= visCols && curDrawRow >= 0 && curDrawRow < visRows) {
int32_t cursorX = textX + curDrawCol * font->charWidth;
int32_t cursorY = textY + curDrawRow * font->charHeight;
drawVLine(d, ops, cursorX, cursorY, font->charHeight, fg);
}
}
BevelStyleT btnBevel = BEVEL_RAISED(colors, 1);
// Draw vertical scrollbar
if (needVSb) {
int32_t sbX = w->x + w->w - TEXTAREA_BORDER - TEXTAREA_SB_W;
int32_t sbY = w->y + TEXTAREA_BORDER;
int32_t sbH = innerH;
// Trough
BevelStyleT troughBevel = BEVEL_TROUGH(colors);
drawBevel(d, ops, sbX, sbY, TEXTAREA_SB_W, sbH, &troughBevel);
// Up arrow button
drawBevel(d, ops, sbX, sbY, TEXTAREA_SB_W, TEXTAREA_SB_W, &btnBevel);
// Up arrow triangle
{
int32_t cx = sbX + TEXTAREA_SB_W / 2;
int32_t cy = sbY + TEXTAREA_SB_W / 2;
for (int32_t i = 0; i < 4; i++) {
drawHLine(d, ops, cx - i, cy - 2 + i, 1 + i * 2, colors->contentFg);
}
}
// Down arrow button
int32_t downY = sbY + sbH - TEXTAREA_SB_W;
drawBevel(d, ops, sbX, downY, TEXTAREA_SB_W, TEXTAREA_SB_W, &btnBevel);
// Down arrow triangle
{
int32_t cx = sbX + TEXTAREA_SB_W / 2;
int32_t cy = downY + TEXTAREA_SB_W / 2;
for (int32_t i = 0; i < 4; i++) {
drawHLine(d, ops, cx - i, cy + 2 - i, 1 + i * 2, colors->contentFg);
}
}
// Thumb
int32_t trackLen = sbH - TEXTAREA_SB_W * 2;
if (trackLen > 0) {
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, totalLines, visRows, w->as.textArea.scrollRow, &thumbPos, &thumbSize);
drawBevel(d, ops, sbX, sbY + TEXTAREA_SB_W + thumbPos, TEXTAREA_SB_W, thumbSize, &btnBevel);
}
}
// Draw horizontal scrollbar
if (needHSb) {
int32_t hsbX = w->x + TEXTAREA_BORDER;
int32_t hsbY = w->y + w->h - TEXTAREA_BORDER - TEXTAREA_SB_W;
int32_t hsbW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_SB_W;
// Trough
BevelStyleT troughBevel = BEVEL_TROUGH(colors);
drawBevel(d, ops, hsbX, hsbY, hsbW, TEXTAREA_SB_W, &troughBevel);
// Left arrow button
drawBevel(d, ops, hsbX, hsbY, TEXTAREA_SB_W, TEXTAREA_SB_W, &btnBevel);
// Left arrow triangle
{
int32_t cx = hsbX + TEXTAREA_SB_W / 2;
int32_t cy = hsbY + TEXTAREA_SB_W / 2;
for (int32_t i = 0; i < 4; i++) {
drawVLine(d, ops, cx - 2 + i, cy - i, 1 + i * 2, colors->contentFg);
}
}
// Right arrow button
int32_t rightX = hsbX + hsbW - TEXTAREA_SB_W;
drawBevel(d, ops, rightX, hsbY, TEXTAREA_SB_W, TEXTAREA_SB_W, &btnBevel);
// Right arrow triangle
{
int32_t cx = rightX + TEXTAREA_SB_W / 2;
int32_t cy = hsbY + TEXTAREA_SB_W / 2;
for (int32_t i = 0; i < 4; i++) {
drawVLine(d, ops, cx + 2 - i, cy - i, 1 + i * 2, colors->contentFg);
}
}
// Thumb
int32_t trackLen = hsbW - TEXTAREA_SB_W * 2;
if (trackLen > 0) {
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, maxLL, visCols, w->as.textArea.scrollCol, &thumbPos, &thumbSize);
drawBevel(d, ops, hsbX + TEXTAREA_SB_W + thumbPos, hsbY, thumbSize, TEXTAREA_SB_W, &btnBevel);
}
// Dead corner between scrollbars
if (needVSb) {
int32_t cornerX = w->x + w->w - TEXTAREA_BORDER - TEXTAREA_SB_W;
rectFill(d, ops, cornerX, hsbY, TEXTAREA_SB_W, TEXTAREA_SB_W, colors->windowFace);
}
}
// Focus rect
if (w->focused) {
drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg);
}
}
// ============================================================
// widgetTextAreaSetText
// ============================================================
void widgetTextAreaSetText(WidgetT *w, const char *text) {
if (w->as.textArea.buf) {
strncpy(w->as.textArea.buf, text, w->as.textArea.bufSize - 1);
w->as.textArea.buf[w->as.textArea.bufSize - 1] = '\0';
w->as.textArea.len = (int32_t)strlen(w->as.textArea.buf);
w->as.textArea.cursorRow = 0;
w->as.textArea.cursorCol = 0;
w->as.textArea.scrollRow = 0;
w->as.textArea.scrollCol = 0;
w->as.textArea.desiredCol = 0;
w->as.textArea.selAnchor = -1;
w->as.textArea.selCursor = -1;
w->as.textArea.cachedLines = -1;
w->as.textArea.cachedMaxLL = -1;
}
}
// ============================================================
// widgetTextInputCalcMinSize
// ============================================================
void widgetTextInputCalcMinSize(WidgetT *w, const BitmapFontT *font) {
w->calcMinW = font->charWidth * 8 + TEXT_INPUT_PAD * 2;
w->calcMinH = font->charHeight + TEXT_INPUT_PAD * 2;
}
// ============================================================
// widgetTextInputDestroy
// ============================================================
void widgetTextInputDestroy(WidgetT *w) {
free(w->as.textInput.buf);
free(w->as.textInput.undoBuf);
}
// ============================================================
// widgetTextInputGetText
// ============================================================
const char *widgetTextInputGetText(const WidgetT *w) {
return w->as.textInput.buf ? w->as.textInput.buf : "";
}
// ============================================================
// widgetTextInputOnKey
// ============================================================
// TextInput key handling delegates to the shared widgetTextEditOnKey
// engine, passing pointers to its state fields. Masked input mode
// gets its own handler since the editing semantics are completely
// different. Password mode blocks copy/cut at this level before
// reaching the shared engine.
void widgetTextInputOnKey(WidgetT *w, int32_t key, int32_t mod) {
if (!w->as.textInput.buf) {
return;
}
clearOtherSelections(w);
if (w->as.textInput.inputMode == InputMaskedE) {
maskedInputOnKey(w, key, mod);
return;
}
// Password mode: block copy (Ctrl+C) and cut (Ctrl+X)
if (w->as.textInput.inputMode == InputPasswordE) {
if (key == 3 || key == 24) {
return;
}
}
widgetTextEditOnKey(w, key, mod, w->as.textInput.buf, w->as.textInput.bufSize,
&w->as.textInput.len, &w->as.textInput.cursorPos,
&w->as.textInput.scrollOff,
&w->as.textInput.selStart, &w->as.textInput.selEnd,
w->as.textInput.undoBuf, &w->as.textInput.undoLen,
&w->as.textInput.undoCursor);
}
// ============================================================
// widgetTextInputOnMouse
// ============================================================
// Mouse handling for single-line input. Cursor position is computed
// from pixel offset using the fixed-width font (relX / charWidth +
// scrollOff). Multi-click: double-click selects word (using
// wordStart/wordEnd), triple-click selects all. Single click starts
// drag-select by setting both selStart and selEnd to the click
// position and registering sDragTextSelect.
void widgetTextInputOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
w->focused = true;
clearOtherSelections(w);
AppContextT *ctx = (AppContextT *)root->userData;
widgetTextEditMouseClick(w, vx, vy, w->x + TEXT_INPUT_PAD, &ctx->font, w->as.textInput.buf, w->as.textInput.len, w->as.textInput.scrollOff, &w->as.textInput.cursorPos, &w->as.textInput.selStart, &w->as.textInput.selEnd, true, true);
}
// ============================================================
// widgetTextInputPaint
// ============================================================
// TextInput paint: sunken 2px bevel, then text with optional selection
// highlighting, then cursor line. Text is drawn from a display buffer
// that may be either the actual text or bullets (password mode, using
// CP437 character 0xF9 which renders as a small centered dot).
//
// The 3-run approach (before/during/after selection) draws text in a
// single pass without overdraw. The scroll offset ensures only the
// visible portion of the text is drawn. The cursor is a 1px-wide
// vertical line drawn at the character boundary.
void widgetTextInputPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->enabled ? (w->fgColor ? w->fgColor : colors->contentFg) : colors->windowShadow;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
// Sunken border
BevelStyleT bevel;
bevel.highlight = colors->windowShadow;
bevel.shadow = colors->windowHighlight;
bevel.face = bg;
bevel.width = 2;
drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
// Draw text
if (w->as.textInput.buf) {
int32_t textX = w->x + TEXT_INPUT_PAD;
int32_t textY = w->y + (w->h - font->charHeight) / 2;
int32_t maxChars = (w->w - TEXT_INPUT_PAD * 2) / font->charWidth;
int32_t off = w->as.textInput.scrollOff;
int32_t len = w->as.textInput.len - off;
if (len > maxChars) {
len = maxChars;
}
bool isPassword = (w->as.textInput.inputMode == InputPasswordE);
// Build display buffer (password masking)
char dispBuf[256];
int32_t dispLen = len > 255 ? 255 : len;
if (isPassword) {
memset(dispBuf, '\xF9', dispLen); // CP437 bullet
} else {
memcpy(dispBuf, w->as.textInput.buf + off, dispLen);
}
widgetTextEditPaintLine(d, ops, font, colors, textX, textY, dispBuf, dispLen, off, w->as.textInput.cursorPos, w->as.textInput.selStart, w->as.textInput.selEnd, fg, bg, w->focused && w->enabled, w->x + TEXT_INPUT_PAD, w->x + w->w - TEXT_INPUT_PAD);
}
}
// ============================================================
// widgetTextInputSetText
// ============================================================
void widgetTextInputSetText(WidgetT *w, const char *text) {
if (w->as.textInput.buf) {
strncpy(w->as.textInput.buf, text, w->as.textInput.bufSize - 1);
w->as.textInput.buf[w->as.textInput.bufSize - 1] = '\0';
w->as.textInput.len = (int32_t)strlen(w->as.textInput.buf);
w->as.textInput.cursorPos = w->as.textInput.len;
w->as.textInput.scrollOff = 0;
w->as.textInput.selStart = -1;
w->as.textInput.selEnd = -1;
}
}
// ============================================================
// widgetTextEditOnKey -- shared single-line text editing logic
// ============================================================
// This is the core single-line text editing engine, parameterized by
// pointer to allow reuse across TextInput, Spinner, and ComboBox.
// All buffer manipulation (insert, delete, cursor movement, selection,
// clipboard, undo) is handled here. The caller passes pointers to
// its own state fields so this function can modify them directly.
//
// Key mapping follows DOS conventions for extended keys (scancode |
// 0x100): 0x4B=Left, 0x4D=Right, 0x47=Home, 0x4F=End, 0x53=Delete,
// 0x73=Ctrl+Left, 0x74=Ctrl+Right. Control characters: 1=Ctrl+A,
// 3=Ctrl+C, 22=Ctrl+V, 24=Ctrl+X, 26=Ctrl+Z.
//
// The "goto adjustScroll" pattern consolidates the scroll-offset
// adjustment and repaint that most key handlers need, reducing
// code duplication. The scroll offset keeps the cursor visible
// within the visible character range.
//
// Single-line paste strips newlines from clipboard content, which
// is important when pasting from TextArea to TextInput.
void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursor, int32_t *pScrollOff, int32_t *pSelStart, int32_t *pSelEnd, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor) {
bool shift = (mod & KEY_MOD_SHIFT) != 0;
bool hasSel = (pSelStart && pSelEnd && *pSelStart >= 0 && *pSelEnd >= 0 && *pSelStart != *pSelEnd);
int32_t selLo = hasSel ? (*pSelStart < *pSelEnd ? *pSelStart : *pSelEnd) : -1;
int32_t selHi = hasSel ? (*pSelStart < *pSelEnd ? *pSelEnd : *pSelStart) : -1;
// Clamp selection to buffer bounds
if (hasSel) {
if (selLo < 0) {
selLo = 0;
}
if (selHi > *pLen) {
selHi = *pLen;
}
if (selLo >= selHi) {
hasSel = false;
selLo = -1;
selHi = -1;
}
}
// Ctrl+A -- select all
if (key == 1 && pSelStart && pSelEnd) {
*pSelStart = 0;
*pSelEnd = *pLen;
*pCursor = *pLen;
goto adjustScroll;
}
// Ctrl+C -- copy
if (key == 3) {
if (hasSel) {
clipboardCopy(buf + selLo, selHi - selLo);
}
return;
}
// Ctrl+V -- paste
if (key == 22) {
if (sClipboardLen > 0) {
if (undoBuf) {
textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
}
if (hasSel) {
textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
}
int32_t canFit = bufSize - 1 - *pLen;
// For single-line, skip newlines in clipboard
int32_t paste = 0;
for (int32_t i = 0; i < sClipboardLen && paste < canFit; i++) {
if (sClipboard[i] != '\n' && sClipboard[i] != '\r') {
paste++;
}
}
if (paste > 0) {
int32_t pos = *pCursor;
memmove(buf + pos + paste, buf + pos, *pLen - pos + 1);
int32_t j = 0;
for (int32_t i = 0; i < sClipboardLen && j < paste; i++) {
if (sClipboard[i] != '\n' && sClipboard[i] != '\r') {
buf[pos + j] = sClipboard[i];
j++;
}
}
*pLen += paste;
*pCursor += paste;
}
if (w->onChange) {
w->onChange(w);
}
}
goto adjustScroll;
}
// Ctrl+X -- cut
if (key == 24) {
if (hasSel) {
clipboardCopy(buf + selLo, selHi - selLo);
if (undoBuf) {
textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
}
textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
if (w->onChange) {
w->onChange(w);
}
}
goto adjustScroll;
}
// Ctrl+Z -- undo
if (key == 26 && undoBuf && pUndoLen && pUndoCursor) {
// Swap current and undo
char tmpBuf[CLIPBOARD_MAX];
int32_t tmpLen = *pLen;
int32_t tmpCursor = *pCursor;
int32_t copyLen = tmpLen < (int32_t)sizeof(tmpBuf) - 1 ? tmpLen : (int32_t)sizeof(tmpBuf) - 1;
memcpy(tmpBuf, buf, copyLen);
tmpBuf[copyLen] = '\0';
int32_t restLen = *pUndoLen < bufSize - 1 ? *pUndoLen : bufSize - 1;
memcpy(buf, undoBuf, restLen);
buf[restLen] = '\0';
*pLen = restLen;
int32_t restoreCursor = *pUndoCursor < *pLen ? *pUndoCursor : *pLen;
// Save old as new undo
int32_t saveLen = copyLen < bufSize - 1 ? copyLen : bufSize - 1;
memcpy(undoBuf, tmpBuf, saveLen);
undoBuf[saveLen] = '\0';
*pUndoLen = saveLen;
*pUndoCursor = tmpCursor;
*pCursor = restoreCursor;
if (pSelStart) {
*pSelStart = -1;
}
if (pSelEnd) {
*pSelEnd = -1;
}
if (w->onChange) {
w->onChange(w);
}
goto adjustScroll;
}
if (key >= 32 && key < 127) {
// Printable character
if (undoBuf) {
textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
}
if (hasSel) {
textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
}
if (*pLen < bufSize - 1) {
int32_t pos = *pCursor;
memmove(buf + pos + 1, buf + pos, *pLen - pos + 1);
buf[pos] = (char)key;
(*pLen)++;
(*pCursor)++;
if (w->onChange) {
w->onChange(w);
}
}
} else if (key == 8) {
// Backspace
if (hasSel) {
if (undoBuf) {
textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
}
textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
if (w->onChange) {
w->onChange(w);
}
} else if (*pCursor > 0) {
if (undoBuf) {
textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
}
int32_t pos = *pCursor;
memmove(buf + pos - 1, buf + pos, *pLen - pos + 1);
(*pLen)--;
(*pCursor)--;
if (w->onChange) {
w->onChange(w);
}
}
} else if (key == (0x4B | 0x100)) {
// Left arrow
if (shift && pSelStart && pSelEnd) {
if (*pSelStart < 0) {
*pSelStart = *pCursor;
*pSelEnd = *pCursor;
}
if (*pCursor > 0) {
(*pCursor)--;
}
*pSelEnd = *pCursor;
} else {
if (pSelStart) {
*pSelStart = -1;
}
if (pSelEnd) {
*pSelEnd = -1;
}
if (*pCursor > 0) {
(*pCursor)--;
}
}
} else if (key == (0x4D | 0x100)) {
// Right arrow
if (shift && pSelStart && pSelEnd) {
if (*pSelStart < 0) {
*pSelStart = *pCursor;
*pSelEnd = *pCursor;
}
if (*pCursor < *pLen) {
(*pCursor)++;
}
*pSelEnd = *pCursor;
} else {
if (pSelStart) {
*pSelStart = -1;
}
if (pSelEnd) {
*pSelEnd = -1;
}
if (*pCursor < *pLen) {
(*pCursor)++;
}
}
} else if (key == (0x73 | 0x100)) {
// Ctrl+Left -- word left
int32_t newPos = wordBoundaryLeft(buf, *pCursor);
if (shift && pSelStart && pSelEnd) {
if (*pSelStart < 0) {
*pSelStart = *pCursor;
*pSelEnd = *pCursor;
}
*pCursor = newPos;
*pSelEnd = newPos;
} else {
if (pSelStart) {
*pSelStart = -1;
}
if (pSelEnd) {
*pSelEnd = -1;
}
*pCursor = newPos;
}
} else if (key == (0x74 | 0x100)) {
// Ctrl+Right -- word right
int32_t newPos = wordBoundaryRight(buf, *pLen, *pCursor);
if (shift && pSelStart && pSelEnd) {
if (*pSelStart < 0) {
*pSelStart = *pCursor;
*pSelEnd = *pCursor;
}
*pCursor = newPos;
*pSelEnd = newPos;
} else {
if (pSelStart) {
*pSelStart = -1;
}
if (pSelEnd) {
*pSelEnd = -1;
}
*pCursor = newPos;
}
} else if (key == (0x47 | 0x100)) {
// Home
if (shift && pSelStart && pSelEnd) {
if (*pSelStart < 0) {
*pSelStart = *pCursor;
*pSelEnd = *pCursor;
}
*pCursor = 0;
*pSelEnd = 0;
} else {
if (pSelStart) {
*pSelStart = -1;
}
if (pSelEnd) {
*pSelEnd = -1;
}
*pCursor = 0;
}
} else if (key == (0x4F | 0x100)) {
// End
if (shift && pSelStart && pSelEnd) {
if (*pSelStart < 0) {
*pSelStart = *pCursor;
*pSelEnd = *pCursor;
}
*pCursor = *pLen;
*pSelEnd = *pLen;
} else {
if (pSelStart) {
*pSelStart = -1;
}
if (pSelEnd) {
*pSelEnd = -1;
}
*pCursor = *pLen;
}
} else if (key == (0x53 | 0x100)) {
// Delete
if (hasSel) {
if (undoBuf) {
textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
}
textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
if (w->onChange) {
w->onChange(w);
}
} else if (*pCursor < *pLen) {
if (undoBuf) {
textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
}
int32_t pos = *pCursor;
memmove(buf + pos, buf + pos + 1, *pLen - pos);
(*pLen)--;
if (w->onChange) {
w->onChange(w);
}
}
} else {
return;
}
adjustScroll:
// Adjust scroll offset to keep cursor visible
{
AppContextT *ctx = wgtGetContext(w);
const BitmapFontT *font = &ctx->font;
int32_t fieldW = w->w;
if (w->type == WidgetComboBoxE) {
fieldW -= DROPDOWN_BTN_WIDTH;
}
int32_t visibleChars = (fieldW - TEXT_INPUT_PAD * 2) / font->charWidth;
if (*pCursor < *pScrollOff) {
*pScrollOff = *pCursor;
}
if (*pCursor >= *pScrollOff + visibleChars) {
*pScrollOff = *pCursor - visibleChars + 1;
}
}
wgtInvalidatePaint(w);
}