DVX_GUI/widgets/textInput/widgetTextInput.c

3780 lines
118 KiB
C

#define DVX_WIDGET_IMPL
// widgetTextInput.c -- TextInput and TextArea widgets
//
// This file implements two text editing widgets:
//
// 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.
//
// Shared infrastructure (clipboard, multi-click detection, word
// boundary logic, cross-widget selection clearing, and the
// single-line editing engine widgetTextEditOnKey) lives in
// widgetCore.c so it can be linked from any widget DXE.
//
// 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.
//
// 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 "dvxWgtP.h"
#include "../texthelp/textHelp.h"
static int32_t sTextInputTypeId = -1;
static int32_t sTextAreaTypeId = -1;
// Syntax color indices (returned by colorize callback)
#define SYNTAX_DEFAULT 0
#define SYNTAX_KEYWORD 1
#define SYNTAX_STRING 2
#define SYNTAX_COMMENT 3
#define SYNTAX_NUMBER 4
#define SYNTAX_OPERATOR 5
#define SYNTAX_TYPE 6
#define SYNTAX_MAX 7
typedef enum {
InputNormalE,
InputPasswordE,
InputMaskedE
} InputModeE;
typedef struct {
char *buf;
int32_t bufSize;
int32_t len;
int32_t cursorPos;
int32_t scrollOff;
int32_t selStart;
int32_t selEnd;
char *undoBuf;
int32_t undoLen;
int32_t undoCursor;
InputModeE inputMode;
const char *mask;
} TextInputDataT;
typedef struct {
char *buf;
int32_t bufSize;
int32_t len;
int32_t cursorRow;
int32_t cursorCol;
int32_t scrollRow;
int32_t scrollCol;
int32_t desiredCol;
int32_t selAnchor;
int32_t selCursor;
char *undoBuf;
int32_t undoLen;
int32_t undoCursor;
int32_t cachedLines;
int32_t cachedMaxLL;
// Line offset cache: lineOffsets[i] = byte offset of start of line i.
// lineOffsets[lineCount] = past-end sentinel (len or len+1).
// Rebuilt lazily when cachedLines == -1.
int32_t *lineOffsets;
int32_t lineOffsetCap;
// Per-line visual length cache (tab-expanded). -1 = dirty.
int32_t *lineVisLens;
int32_t lineVisLenCap;
// Cached cursor byte offset (avoids O(N) recomputation per keystroke)
int32_t cursorOff;
int32_t sbDragOrient;
int32_t sbDragOff;
bool sbDragging;
bool showLineNumbers;
bool autoIndent;
bool captureTabs; // true = Tab key inserts tab/spaces; false = Tab moves focus
bool useTabChar; // true = insert '\t'; false = insert spaces
int32_t tabWidth; // display width and space count (default 3)
// Syntax colorizer callback (optional). Called for each visible line.
// line: text of the line (NOT null-terminated, use lineLen).
// colors: output array of color indices (0=default, 1-7=syntax colors).
// The callback fills colors[0..lineLen-1].
void (*colorize)(const char *line, int32_t lineLen, uint8_t *colors, void *ctx);
void *colorizeCtx;
// Line decorator callback (optional). Called for each visible line during paint.
// Returns background color override (0 = use default). Sets *gutterColor to a
// non-zero color to draw a filled circle in the gutter for breakpoints.
uint32_t (*lineDecorator)(int32_t lineNum, uint32_t *gutterColor, void *ctx);
void *lineDecoratorCtx;
// Gutter click callback (optional). Fired when user clicks in the gutter.
void (*onGutterClick)(WidgetT *w, int32_t lineNum);
// Custom syntax colors (0x00RRGGBB; 0 = use default hardcoded color)
uint32_t customSyntaxColors[SYNTAX_MAX];
// Pre-allocated paint buffers (avoid 3KB stack alloc per visible line per frame)
uint8_t *rawSyntax; // syntax color buffer (MAX_COLORIZE_LEN)
char *expandBuf; // tab-expanded text (MAX_COLORIZE_LEN)
uint8_t *syntaxBuf; // tab-expanded syntax colors (MAX_COLORIZE_LEN)
// Cached gutter width (recomputed only when line count changes)
int32_t cachedGutterW;
int32_t cachedGutterLines; // line count when cachedGutterW was computed
} TextAreaDataT;
#include <ctype.h>
#include <strings.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 MAX_COLORIZE_LEN 1024
// Match the ANSI terminal cursor blink rate (CURSOR_MS in widgetAnsiTerm.c)
#define CURSOR_BLINK_MS 250
// ============================================================
// Prototypes
// ============================================================
static void drawColorizedText(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t len, const uint8_t *syntaxColors, int32_t textOff, uint32_t defaultFg, uint32_t bg, const uint32_t *customColors);
static uint32_t syntaxColor(const DisplayT *d, uint8_t idx, uint32_t defaultFg, const uint32_t *custom);
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 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 textAreaGutterWidth(WidgetT *w, const BitmapFontT *font);
static int32_t textAreaGetMaxLineLen(WidgetT *w);
static int32_t textAreaLineLenCached(WidgetT *w, int32_t row);
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 textAreaLineStartCached(WidgetT *w, int32_t row);
static void textAreaRebuildCache(WidgetT *w);
static void textAreaOffToRowCol(const char *buf, int32_t off, int32_t *row, int32_t *col);
static void textAreaOffToRowColFast(TextAreaDataT *ta, int32_t off, int32_t *row, int32_t *col);
static int32_t visualCol(const char *buf, int32_t lineStart, int32_t off, int32_t tabW);
static int32_t visualColToOff(const char *buf, int32_t len, int32_t lineStart, int32_t targetVC, int32_t tabW);
static void textEditSaveUndo(char *buf, int32_t len, int32_t cursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t bufSize);
static void widgetTextAreaDragSelect(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
static void widgetTextAreaOnDragUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y);
static int32_t wordBoundaryLeft(const char *buf, int32_t pos);
static int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos);
bool wgtTextAreaFindNext(WidgetT *w, const char *needle, bool caseSensitive, bool forward);
int32_t wgtTextAreaReplaceAll(WidgetT *w, const char *needle, const char *replacement, bool caseSensitive);
WidgetT *wgtTextInput(WidgetT *parent, int32_t maxLen);
// sCursorBlinkOn is defined in widgetCore.c (shared state)
// ============================================================
// 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;
}
// ============================================================
// 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;
}
// ============================================================
// 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) {
TextInputDataT *ti = (TextInputDataT *)w->data;
char *buf = ti->buf;
const char *mask = ti->mask;
int32_t *pCur = &ti->cursorPos;
int32_t maskLen = ti->len;
bool shift = (mod & KEY_MOD_SHIFT) != 0;
(void)shift;
// Ctrl+A -- select all
if (key == 1) {
ti->selStart = 0;
ti->selEnd = maskLen;
*pCur = maskLen;
goto done;
}
// Ctrl+C -- copy formatted text
if (key == 3) {
if (ti->selStart >= 0 && ti->selEnd >= 0 && ti->selStart != ti->selEnd) {
int32_t selLo = ti->selStart < ti->selEnd ? ti->selStart : ti->selEnd;
int32_t selHi = ti->selStart < ti->selEnd ? ti->selEnd : ti->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 (ti->undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, ti->undoBuf, &ti->undoLen, &ti->undoCursor, ti->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;
ti->selStart = -1;
ti->selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
}
}
goto done;
}
// Ctrl+X -- copy and clear selected slots
if (key == 24) {
if (ti->selStart >= 0 && ti->selEnd >= 0 && ti->selStart != ti->selEnd) {
int32_t selLo = ti->selStart < ti->selEnd ? ti->selStart : ti->selEnd;
int32_t selHi = ti->selStart < ti->selEnd ? ti->selEnd : ti->selStart;
if (selLo < 0) {
selLo = 0;
}
if (selHi > maskLen) {
selHi = maskLen;
}
clipboardCopy(buf + selLo, selHi - selLo);
if (ti->undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, ti->undoBuf, &ti->undoLen, &ti->undoCursor, ti->bufSize);
}
for (int32_t i = selLo; i < selHi; i++) {
if (maskIsSlot(mask[i])) {
buf[i] = '_';
}
}
*pCur = selLo;
ti->selStart = -1;
ti->selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
}
goto done;
}
// Ctrl+Z -- undo
if (key == 26 && ti->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, ti->undoBuf, maskLen + 1);
*pCur = ti->undoCursor < maskLen ? ti->undoCursor : maskLen;
memcpy(ti->undoBuf, tmpBuf, tmpLen);
ti->undoCursor = tmpCursor;
ti->selStart = -1;
ti->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 (ti->undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, ti->undoBuf, &ti->undoLen, &ti->undoCursor, ti->bufSize);
}
buf[*pCur] = (char)key;
*pCur = maskNextSlot(mask, *pCur);
ti->selStart = -1;
ti->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 (ti->undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, ti->undoBuf, &ti->undoLen, &ti->undoCursor, ti->bufSize);
}
buf[prev] = '_';
*pCur = prev;
ti->selStart = -1;
ti->selEnd = -1;
if (w->onChange) {
w->onChange(w);
}
}
} else if (key == (0x53 | 0x100)) {
// Delete -- clear current slot
if (*pCur < maskLen && maskIsSlot(mask[*pCur])) {
if (ti->undoBuf) {
textEditSaveUndo(buf, maskLen, *pCur, ti->undoBuf, &ti->undoLen, &ti->undoCursor, ti->bufSize);
}
buf[*pCur] = '_';
ti->selStart = -1;
ti->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 (ti->selStart < 0) {
ti->selStart = *pCur;
ti->selEnd = *pCur;
}
*pCur = prev;
ti->selEnd = *pCur;
} else {
ti->selStart = -1;
ti->selEnd = -1;
*pCur = prev;
}
} else if (key == (0x4D | 0x100)) {
// Right arrow -- move to next slot
int32_t next = maskNextSlot(mask, *pCur);
if (shift) {
if (ti->selStart < 0) {
ti->selStart = *pCur;
ti->selEnd = *pCur;
}
*pCur = next;
ti->selEnd = *pCur;
} else {
ti->selStart = -1;
ti->selEnd = -1;
*pCur = next;
}
} else if (key == (0x47 | 0x100)) {
// Home -- first slot
if (shift) {
if (ti->selStart < 0) {
ti->selStart = *pCur;
ti->selEnd = *pCur;
}
*pCur = maskFirstSlot(mask);
ti->selEnd = *pCur;
} else {
ti->selStart = -1;
ti->selEnd = -1;
*pCur = maskFirstSlot(mask);
}
} else if (key == (0x4F | 0x100)) {
// End -- past last slot
int32_t last = maskLen;
if (shift) {
if (ti->selStart < 0) {
ti->selStart = *pCur;
ti->selEnd = *pCur;
}
*pCur = last;
ti->selEnd = *pCur;
} else {
ti->selStart = -1;
ti->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 < ti->scrollOff) {
ti->scrollOff = *pCur;
}
if (*pCur >= ti->scrollOff + visibleChars) {
ti->scrollOff = *pCur - visibleChars + 1;
}
}
wgtInvalidatePaint(w);
}
// ============================================================
// TextArea line helpers
// ============================================================
// ============================================================
// Line offset cache
// ============================================================
//
// lineOffsets[i] = byte offset where line i starts.
// lineOffsets[lineCount] = len (sentinel past last line).
// Built lazily on first access after invalidation (cachedLines == -1).
// Single O(N) scan builds both the line offset table and per-line
// visual length cache simultaneously.
static void textAreaRebuildCache(WidgetT *w) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
char *buf = ta->buf;
int32_t len = ta->len;
int32_t tabW = ta->tabWidth > 0 ? ta->tabWidth : 3;
// Count lines first
int32_t lineCount = 1;
for (int32_t i = 0; i < len; i++) {
if (buf[i] == '\n') {
lineCount++;
}
}
// Grow line offset array if needed (+1 for sentinel)
int32_t needed = lineCount + 1;
if (needed > ta->lineOffsetCap) {
int32_t newCap = needed + 256;
ta->lineOffsets = (int32_t *)realloc(ta->lineOffsets, newCap * sizeof(int32_t));
ta->lineOffsetCap = newCap;
}
// Grow visual length array if needed
if (lineCount > ta->lineVisLenCap) {
int32_t newCap = lineCount + 256;
ta->lineVisLens = (int32_t *)realloc(ta->lineVisLens, newCap * sizeof(int32_t));
ta->lineVisLenCap = newCap;
}
// Single pass: record offsets and compute visual lengths
int32_t line = 0;
int32_t vc = 0;
int32_t maxVL = 0;
ta->lineOffsets[0] = 0;
for (int32_t i = 0; i < len; i++) {
if (buf[i] == '\n') {
ta->lineVisLens[line] = vc;
if (vc > maxVL) {
maxVL = vc;
}
line++;
ta->lineOffsets[line] = i + 1;
vc = 0;
} else if (buf[i] == '\t') {
vc += tabW - (vc % tabW);
} else {
vc++;
}
}
// Last line (may not end with newline)
ta->lineVisLens[line] = vc;
if (vc > maxVL) {
maxVL = vc;
}
ta->lineOffsets[lineCount] = len;
ta->cachedLines = lineCount;
ta->cachedMaxLL = maxVL;
}
static void textAreaEnsureCache(WidgetT *w) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (ta->cachedLines < 0) {
textAreaRebuildCache(w);
}
}
static int32_t textAreaGetLineCount(WidgetT *w) {
textAreaEnsureCache(w);
return ((TextAreaDataT *)w->data)->cachedLines;
}
static int32_t textAreaGutterWidth(WidgetT *w, const BitmapFontT *font) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (!ta->showLineNumbers) {
return 0;
}
int32_t totalLines = textAreaGetLineCount(w);
// Return cached value if line count hasn't changed
if (ta->cachedGutterW > 0 && ta->cachedGutterLines == totalLines) {
return ta->cachedGutterW;
}
int32_t digits = 1;
int32_t temp = totalLines;
while (temp >= 10) {
temp /= 10;
digits++;
}
if (digits < 3) {
digits = 3;
}
ta->cachedGutterW = (digits + 1) * font->charWidth;
ta->cachedGutterLines = totalLines;
return ta->cachedGutterW;
}
static int32_t textAreaLineStart(const char *buf, int32_t len, int32_t row) {
(void)buf;
(void)len;
(void)row;
// Should not be called — use textAreaLineStartCached() instead.
// Fallback: linear scan (only reachable if cache isn't built yet)
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;
}
// O(1) line start via cache
static int32_t textAreaLineStartCached(WidgetT *w, int32_t row) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
textAreaEnsureCache(w);
if (row < 0) {
return 0;
}
if (row >= ta->cachedLines) {
return ta->len;
}
return ta->lineOffsets[row];
}
// O(1) line length via cache
static int32_t textAreaLineLenCached(WidgetT *w, int32_t row) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
textAreaEnsureCache(w);
if (row < 0 || row >= ta->cachedLines) {
return 0;
}
int32_t start = ta->lineOffsets[row];
int32_t next = ta->lineOffsets[row + 1];
// Subtract newline if present
if (next > start && ta->buf[next - 1] == '\n') {
next--;
}
return next - start;
}
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 textAreaGetMaxLineLen(WidgetT *w) {
textAreaEnsureCache(w);
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (ta->cachedMaxLL < 0 && ta->cachedLines >= 0) {
// Recompute max from cached visual lengths
int32_t maxVL = 0;
for (int32_t i = 0; i < ta->cachedLines; i++) {
if (ta->lineVisLens[i] > maxVL) {
maxVL = ta->lineVisLens[i];
}
}
ta->cachedMaxLL = maxVL;
}
return ta->cachedMaxLL;
}
static inline void textAreaDirtyCache(WidgetT *w) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->cachedLines = -1;
ta->cachedMaxLL = -1;
ta->cachedGutterW = 0;
}
// Incrementally update the cache after inserting bytes at `off`.
// If the insertion contains newlines, falls back to a full rebuild.
// Otherwise, adjusts offsets and visual lengths in O(lines_after_cursor).
static void textAreaCacheInsert(WidgetT *w, int32_t off, int32_t insertLen) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (ta->cachedLines < 0 || insertLen <= 0) {
textAreaDirtyCache(w);
return;
}
// Check if inserted bytes contain newlines — if so, full rebuild
const char *buf = ta->buf;
for (int32_t i = off; i < off + insertLen && i < ta->len; i++) {
if (buf[i] == '\n') {
textAreaDirtyCache(w);
return;
}
}
// Find which line the insertion is on
int32_t line = 0;
for (line = ta->cachedLines - 1; line > 0; line--) {
if (ta->lineOffsets[line] <= off) {
break;
}
}
// Shift all subsequent line offsets
for (int32_t i = line + 1; i <= ta->cachedLines; i++) {
ta->lineOffsets[i] += insertLen;
}
// Recompute visual length of the affected line
int32_t lineOff = ta->lineOffsets[line];
int32_t lineEnd = (line + 1 <= ta->cachedLines) ? ta->lineOffsets[line + 1] : ta->len;
int32_t tabW = ta->tabWidth > 0 ? ta->tabWidth : 4;
int32_t vc = 0;
for (int32_t i = lineOff; i < lineEnd && buf[i] != '\n'; i++) {
if (buf[i] == '\t') {
vc += tabW - (vc % tabW);
} else {
vc++;
}
}
ta->lineVisLens[line] = vc;
// Update max line length
if (vc > ta->cachedMaxLL) {
ta->cachedMaxLL = vc;
} else {
ta->cachedMaxLL = -1; // unknown, will recompute lazily
}
ta->cachedGutterW = 0;
}
// Incrementally update the cache after deleting bytes at `off`.
// If the deletion spans newlines, falls back to a full rebuild.
static void textAreaCacheDelete(WidgetT *w, int32_t off, int32_t deleteLen) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (ta->cachedLines < 0 || deleteLen <= 0) {
textAreaDirtyCache(w);
return;
}
// Check if deleted bytes contained newlines — check the buffer BEFORE deletion
// Since deletion already happened, we can't check. Fall back to dirty for safety
// unless we know it was a single non-newline character.
if (deleteLen > 1) {
textAreaDirtyCache(w);
return;
}
// Single character delete — check if a line was removed by seeing if
// line count decreased. Quick check: the character that WAS at `off`
// is now gone. If the current char at `off` merged two lines, rebuild.
// Simple heuristic: if the line count from offsets doesn't match after
// adjusting, rebuild.
// Find which line the deletion was on
int32_t line = 0;
for (line = ta->cachedLines - 1; line > 0; line--) {
if (ta->lineOffsets[line] <= off) {
break;
}
}
// Shift subsequent offsets
for (int32_t i = line + 1; i <= ta->cachedLines; i++) {
ta->lineOffsets[i] -= deleteLen;
}
// Verify the line structure is still valid
int32_t lineOff = ta->lineOffsets[line];
int32_t nextOff = (line + 1 <= ta->cachedLines) ? ta->lineOffsets[line + 1] : ta->len;
// If the next line's offset is now <= this line's, a newline was deleted
if (line + 1 <= ta->cachedLines && nextOff <= lineOff) {
textAreaDirtyCache(w);
return;
}
// Check that no newline appears before the expected boundary
const char *buf = ta->buf;
for (int32_t i = lineOff; i < nextOff && i < ta->len; i++) {
if (buf[i] == '\n') {
if (i < nextOff - 1) {
// Newline in the middle — structure changed
textAreaDirtyCache(w);
return;
}
}
}
// Recompute visual length of the affected line
int32_t tabW = ta->tabWidth > 0 ? ta->tabWidth : 4;
int32_t vc = 0;
for (int32_t i = lineOff; i < nextOff && i < ta->len && buf[i] != '\n'; i++) {
if (buf[i] == '\t') {
vc += tabW - (vc % tabW);
} else {
vc++;
}
}
ta->lineVisLens[line] = vc;
ta->cachedMaxLL = -1; // recompute lazily
ta->cachedGutterW = 0;
}
// Tab-aware visual column from buffer offset within a line.
// tabW <= 0 means tabs are 1 column (no expansion).
static int32_t visualCol(const char *buf, int32_t lineStart, int32_t off, int32_t tabW) {
int32_t vc = 0;
for (int32_t i = lineStart; i < off; i++) {
if (buf[i] == '\t' && tabW > 0) {
vc += tabW - (vc % tabW);
} else {
vc++;
}
}
return vc;
}
// Tab-aware: convert visual column to buffer offset within a line.
static int32_t visualColToOff(const char *buf, int32_t len, int32_t lineStart, int32_t targetVC, int32_t tabW) {
int32_t vc = 0;
int32_t i = lineStart;
while (i < len && buf[i] != '\n') {
int32_t w = 1;
if (buf[i] == '\t' && tabW > 0) {
w = tabW - (vc % tabW);
}
if (vc + w > targetVC) {
break;
}
vc += w;
i++;
}
return i;
}
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;
}
// O(log N) offset-to-row/col using binary search on cached line offsets.
// Falls back to linear scan if cache is not available.
static void textAreaOffToRowColFast(TextAreaDataT *ta, int32_t off, int32_t *row, int32_t *col) {
if (!ta->lineOffsets || ta->cachedLines < 0) {
textAreaOffToRowCol(ta->buf, off, row, col);
return;
}
// Binary search for the row containing 'off'
int32_t lo = 0;
int32_t hi = ta->cachedLines;
while (lo < hi) {
int32_t mid = (lo + hi + 1) / 2;
if (ta->lineOffsets[mid] <= off) {
lo = mid;
} else {
hi = mid - 1;
}
}
*row = lo;
*col = off - ta->lineOffsets[lo];
}
static void textAreaEnsureVisible(WidgetT *w, int32_t visRows, int32_t visCols) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
int32_t row = ta->cursorRow;
// Use cached cursor offset to avoid O(N) recomputation
int32_t curLineOff = textAreaLineStartCached(w, row);
int32_t curOff = curLineOff + ta->cursorCol;
if (curOff > ta->len) {
curOff = ta->len;
}
int32_t col = visualCol(ta->buf, curLineOff, curOff, ta->tabWidth);
if (row < ta->scrollRow) {
ta->scrollRow = row;
}
if (row >= ta->scrollRow + visRows) {
ta->scrollRow = row - visRows + 1;
}
if (col < ta->scrollCol) {
ta->scrollCol = col;
}
if (col >= ta->scrollCol + visCols) {
ta->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) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (ta) {
free(ta->buf);
free(ta->undoBuf);
free(ta->lineOffsets);
free(ta->lineVisLens);
free(ta->rawSyntax);
free(ta->expandBuf);
free(ta->syntaxBuf);
free(ta);
w->data = NULL;
}
}
// ============================================================
// widgetTextAreaGetText
// ============================================================
const char *widgetTextAreaGetText(const WidgetT *w) {
const TextAreaDataT *ta = (const TextAreaDataT *)w->data;
return ta->buf ? ta->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) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (!ta->buf) {
return;
}
clearOtherSelections(w);
char *buf = ta->buf;
int32_t bufSize = ta->bufSize;
int32_t *pLen = &ta->len;
int32_t *pRow = &ta->cursorRow;
int32_t *pCol = &ta->cursorCol;
int32_t *pSA = &ta->selAnchor;
int32_t *pSC = &ta->selCursor;
bool shift = (mod & KEY_MOD_SHIFT) != 0;
AppContextT *ctx = wgtGetContext(w);
const BitmapFontT *font = &ctx->font;
int32_t gutterW = textAreaGutterWidth(w, font);
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W - gutterW;
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;
textAreaOffToRowColFast(ta, *pLen, pRow, pCol);
ta->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) {
int32_t clipLen = 0;
const char *clip = clipboardGet(&clipLen);
if (clipLen > 0) {
textEditSaveUndo(buf, *pLen, CUR_OFF(), ta->undoBuf, &ta->undoLen, &ta->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);
textAreaOffToRowColFast(ta, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
}
int32_t off = CUR_OFF();
int32_t canFit = bufSize - 1 - *pLen;
int32_t paste = clipLen < canFit ? clipLen : canFit;
if (paste > 0) {
memmove(buf + off + paste, buf + off, *pLen - off + 1);
memcpy(buf + off, clip, paste);
*pLen += paste;
textAreaOffToRowColFast(ta, off + paste, pRow, pCol);
ta->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(), ta->undoBuf, &ta->undoLen, &ta->undoCursor, bufSize);
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowColFast(ta, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
ta->desiredCol = *pCol;
if (w->onChange) {
w->onChange(w);
}
textAreaDirtyCache(w);
}
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+Z -- undo
if (key == 26) {
if (ta->undoBuf && ta->undoLen >= 0) {
// Swap current and undo
char tmpBuf[*pLen + 1];
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 = ta->undoLen < bufSize - 1 ? ta->undoLen : bufSize - 1;
memcpy(buf, ta->undoBuf, restLen);
buf[restLen] = '\0';
*pLen = restLen;
// Save current as new undo
int32_t saveLen = copyLen < bufSize - 1 ? copyLen : bufSize - 1;
memcpy(ta->undoBuf, tmpBuf, saveLen);
ta->undoBuf[saveLen] = '\0';
ta->undoLen = saveLen;
ta->undoCursor = tmpCursor;
// Restore cursor
int32_t restoreOff = ta->undoCursor < *pLen ? ta->undoCursor : *pLen;
ta->undoCursor = tmpCursor;
textAreaOffToRowColFast(ta, restoreOff, pRow, pCol);
ta->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(), ta->undoBuf, &ta->undoLen, &ta->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);
textAreaOffToRowColFast(ta, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
}
int32_t off = CUR_OFF();
// Measure indent of current line before inserting newline
int32_t indent = 0;
char indentBuf[64];
if (ta->autoIndent) {
int32_t lineStart = textAreaLineStart(buf, *pLen, *pRow);
while (lineStart + indent < off && indent < 63 && (buf[lineStart + indent] == ' ' || buf[lineStart + indent] == '\t')) {
indentBuf[indent] = buf[lineStart + indent];
indent++;
}
}
if (*pLen + 1 + indent < bufSize) {
memmove(buf + off + 1 + indent, buf + off, *pLen - off + 1);
buf[off] = '\n';
memcpy(buf + off + 1, indentBuf, indent);
*pLen += 1 + indent;
(*pRow)++;
*pCol = indent;
ta->desiredCol = indent;
}
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(), ta->undoBuf, &ta->undoLen, &ta->undoCursor, bufSize);
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowColFast(ta, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
ta->desiredCol = *pCol;
textAreaDirtyCache(w);
if (w->onChange) {
w->onChange(w);
}
} else {
int32_t off = CUR_OFF();
if (off > 0) {
char deleted = buf[off - 1];
textEditSaveUndo(buf, *pLen, off, ta->undoBuf, &ta->undoLen, &ta->undoCursor, bufSize);
memmove(buf + off - 1, buf + off, *pLen - off + 1);
(*pLen)--;
textAreaOffToRowColFast(ta, off - 1, pRow, pCol);
ta->desiredCol = *pCol;
if (deleted == '\n') {
textAreaDirtyCache(w);
} else {
textAreaCacheDelete(w, off - 1, 1);
}
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(), ta->undoBuf, &ta->undoLen, &ta->undoCursor, bufSize);
int32_t lo = SEL_LO();
int32_t hi = SEL_HI();
memmove(buf + lo, buf + hi, *pLen - hi + 1);
*pLen -= (hi - lo);
textAreaOffToRowColFast(ta, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
ta->desiredCol = *pCol;
textAreaDirtyCache(w);
if (w->onChange) {
w->onChange(w);
}
} else {
int32_t off = CUR_OFF();
if (off < *pLen) {
char deleted = buf[off];
textEditSaveUndo(buf, *pLen, off, ta->undoBuf, &ta->undoLen, &ta->undoCursor, bufSize);
memmove(buf + off, buf + off + 1, *pLen - off);
(*pLen)--;
if (deleted == '\n') {
textAreaDirtyCache(w);
} else {
textAreaCacheDelete(w, off, 1);
}
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) {
textAreaOffToRowColFast(ta, off - 1, pRow, pCol);
}
ta->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) {
textAreaOffToRowColFast(ta, off + 1, pRow, pCol);
}
ta->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);
textAreaOffToRowColFast(ta, newOff, pRow, pCol);
ta->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);
textAreaOffToRowColFast(ta, newOff, pRow, pCol);
ta->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 = ta->desiredCol < lineL ? ta->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 = ta->desiredCol < lineL ? ta->desiredCol : lineL;
}
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Home
if (key == (0x47 | 0x100)) {
SEL_BEGIN();
*pCol = 0;
ta->desiredCol = 0;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// End
if (key == (0x4F | 0x100)) {
SEL_BEGIN();
*pCol = textAreaLineLen(buf, *pLen, *pRow);
ta->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 = ta->desiredCol < lineL ? ta->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 = ta->desiredCol < lineL ? ta->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;
ta->desiredCol = 0;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Ctrl+End (scancode 0x75)
if (key == (0x75 | 0x100)) {
SEL_BEGIN();
textAreaOffToRowColFast(ta, *pLen, pRow, pCol);
ta->desiredCol = *pCol;
SEL_END();
textAreaEnsureVisible(w, visRows, visCols);
wgtInvalidatePaint(w);
return;
}
// Tab key -- insert tab character or spaces if captureTabs is enabled
if (key == 9 && ta->captureTabs && !w->readOnly) {
if (*pLen < bufSize - 1) {
textEditSaveUndo(buf, *pLen, CUR_OFF(), ta->undoBuf, &ta->undoLen, &ta->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);
textAreaOffToRowColFast(ta, lo, pRow, pCol);
*pSA = -1;
*pSC = -1;
}
int32_t off = CUR_OFF();
if (ta->useTabChar) {
// Insert a single tab character
if (*pLen < bufSize - 1) {
memmove(buf + off + 1, buf + off, *pLen - off + 1);
buf[off] = '\t';
(*pLen)++;
(*pCol)++;
ta->desiredCol = *pCol;
}
} else {
// Insert spaces to next tab stop
int32_t tw = ta->tabWidth > 0 ? ta->tabWidth : 3;
int32_t spaces = tw - (*pCol % tw);
for (int32_t s = 0; s < spaces && *pLen < bufSize - 1; s++) {
memmove(buf + off + 1, buf + off, *pLen - off + 1);
buf[off] = ' ';
off++;
(*pLen)++;
(*pCol)++;
}
ta->desiredCol = *pCol;
}
textAreaDirtyCache(w);
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(), ta->undoBuf, &ta->undoLen, &ta->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);
textAreaOffToRowColFast(ta, 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)++;
ta->desiredCol = *pCol;
textAreaCacheInsert(w, off, 1);
} else {
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 sDragWidget 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) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
sFocusedWidget = w;
clearOtherSelections(w);
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
int32_t gutterW = textAreaGutterWidth(w, font);
int32_t innerX = w->x + TEXTAREA_BORDER + TEXTAREA_PAD + gutterW;
int32_t innerY = w->y + TEXTAREA_BORDER;
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W - gutterW;
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);
int32_t maxScroll = totalLines - visRows;
if (maxScroll < 0) {
maxScroll = 0;
}
ta->scrollRow = clampInt(ta->scrollRow, 0, maxScroll);
int32_t maxHScroll = maxLL - visCols;
if (maxHScroll < 0) {
maxHScroll = 0;
}
ta->scrollCol = clampInt(ta->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 (ta->scrollCol > 0) {
ta->scrollCol--;
}
} else if (relX >= hsbW - TEXTAREA_SB_W) {
// Right arrow
if (ta->scrollCol < maxHScroll) {
ta->scrollCol++;
}
} else if (trackLen > 0) {
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, maxLL, visCols, ta->scrollCol, &thumbPos, &thumbSize);
int32_t trackRelX = relX - TEXTAREA_SB_W;
if (trackRelX < thumbPos) {
ta->scrollCol -= visCols;
ta->scrollCol = clampInt(ta->scrollCol, 0, maxHScroll);
} else if (trackRelX >= thumbPos + thumbSize) {
ta->scrollCol += visCols;
ta->scrollCol = clampInt(ta->scrollCol, 0, maxHScroll);
} else {
sDragWidget = w;
ta->sbDragOrient = 1;
ta->sbDragging = true;
ta->sbDragOff = 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 (ta->scrollRow > 0) {
ta->scrollRow--;
}
} else if (relY >= sbH - TEXTAREA_SB_W) {
// Down arrow
if (ta->scrollRow < maxScroll) {
ta->scrollRow++;
}
} else if (trackLen > 0) {
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, totalLines, visRows, ta->scrollRow, &thumbPos, &thumbSize);
int32_t trackRelY = relY - TEXTAREA_SB_W;
if (trackRelY < thumbPos) {
ta->scrollRow -= visRows;
ta->scrollRow = clampInt(ta->scrollRow, 0, maxScroll);
} else if (trackRelY >= thumbPos + thumbSize) {
ta->scrollRow += visRows;
ta->scrollRow = clampInt(ta->scrollRow, 0, maxScroll);
} else {
sDragWidget = w;
ta->sbDragOrient = 0;
ta->sbDragging = true;
ta->sbDragOff = trackRelY - thumbPos;
return;
}
}
return;
}
// Click on gutter — toggle breakpoint
if (gutterW > 0 && vx < innerX && ta->onGutterClick) {
int32_t relY = vy - innerY;
int32_t clickRow = ta->scrollRow + relY / font->charHeight;
if (clickRow >= 0 && clickRow < totalLines) {
ta->onGutterClick(w, clickRow + 1); // 1-based line number
}
wgtInvalidatePaint(w);
return;
}
// Click on text area -- place cursor
int32_t relX = vx - innerX;
int32_t relY = vy - innerY;
int32_t clickRow = ta->scrollRow + relY / font->charHeight;
int32_t clickVisCol = ta->scrollCol + relX / font->charWidth;
if (clickRow < 0) {
clickRow = 0;
}
if (clickRow >= totalLines) {
clickRow = totalLines - 1;
}
if (clickVisCol < 0) {
clickVisCol = 0;
}
int32_t clkLineStart = textAreaLineStartCached(w, clickRow);
int32_t clkByteOff = visualColToOff(ta->buf, ta->len, clkLineStart, clickVisCol, ta->tabWidth);
int32_t clickCol = clkByteOff - clkLineStart;
int32_t lineL = textAreaLineLenCached(w, clickRow);
if (clickCol > lineL) {
clickCol = lineL;
}
int32_t clicks = multiClickDetect(vx, vy);
if (clicks >= 3) {
// Triple-click: select entire line
int32_t lineStart = textAreaCursorToOff(ta->buf, ta->len, clickRow, 0);
int32_t lineEnd = textAreaCursorToOff(ta->buf, ta->len, clickRow, lineL);
ta->cursorRow = clickRow;
ta->cursorCol = lineL;
ta->desiredCol = lineL;
ta->selAnchor = lineStart;
ta->selCursor = lineEnd;
sDragWidget = NULL;
return;
}
if (clicks == 2 && ta->buf) {
// Double-click: select word
int32_t off = textAreaCursorToOff(ta->buf, ta->len, clickRow, clickCol);
int32_t ws = wordStart(ta->buf, off);
int32_t we = wordEnd(ta->buf, ta->len, off);
int32_t weRow;
int32_t weCol;
textAreaOffToRowColFast(ta, we, &weRow, &weCol);
ta->cursorRow = weRow;
ta->cursorCol = weCol;
ta->desiredCol = weCol;
ta->selAnchor = ws;
ta->selCursor = we;
sDragWidget = NULL;
return;
}
// Single click: place cursor + start drag-select
ta->cursorRow = clickRow;
ta->cursorCol = clickCol;
ta->desiredCol = clickCol;
int32_t anchorOff = textAreaCursorToOff(ta->buf, ta->len, clickRow, clickCol);
ta->selAnchor = anchorOff;
ta->selCursor = anchorOff;
sDragWidget = w;
ta->sbDragging = false;
}
// ============================================================
// syntaxColor
// ============================================================
static uint32_t syntaxColor(const DisplayT *d, uint8_t idx, uint32_t defaultFg, const uint32_t *custom) {
if (idx > 0 && idx < SYNTAX_MAX && custom && custom[idx]) {
uint32_t c = custom[idx];
return packColor(d, (c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF);
}
switch (idx) {
case SYNTAX_KEYWORD: return packColor(d, 0, 0, 128);
case SYNTAX_STRING: return packColor(d, 128, 0, 0);
case SYNTAX_COMMENT: return packColor(d, 0, 128, 0);
case SYNTAX_NUMBER: return packColor(d, 128, 0, 128);
case SYNTAX_OPERATOR: return packColor(d, 128, 128, 0);
case SYNTAX_TYPE: return packColor(d, 0, 128, 128);
default: return defaultFg;
}
}
// ============================================================
// drawColorizedText
// ============================================================
//
// Draw text with per-character syntax coloring. Batches consecutive
// characters of the same color into single drawTextN calls.
static void drawColorizedText(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t len, const uint8_t *syntaxColors, int32_t textOff, uint32_t defaultFg, uint32_t bg, const uint32_t *customColors) {
int32_t runStart = 0;
while (runStart < len) {
uint8_t curColor = syntaxColors[textOff + runStart];
int32_t runEnd = runStart + 1;
while (runEnd < len && syntaxColors[textOff + runEnd] == curColor) {
runEnd++;
}
uint32_t fg = syntaxColor(d, curColor, defaultFg, customColors);
drawTextN(d, ops, font, x + runStart * font->charWidth, y, text + runStart, runEnd - runStart, fg, bg, true);
runStart = runEnd;
}
}
// ============================================================
// 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) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
char *buf = ta->buf;
int32_t len = ta->len;
int32_t gutterW = textAreaGutterWidth(w, font);
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W - gutterW;
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;
}
ta->scrollRow = clampInt(ta->scrollRow, 0, maxScroll);
// Clamp horizontal scroll
int32_t maxHScroll = maxLL - visCols;
if (maxHScroll < 0) {
maxHScroll = 0;
}
ta->scrollCol = clampInt(ta->scrollCol, 0, maxHScroll);
// Selection range
int32_t selLo = -1;
int32_t selHi = -1;
if (ta->selAnchor >= 0 && ta->selCursor >= 0 && ta->selAnchor != ta->selCursor) {
selLo = ta->selAnchor < ta->selCursor ? ta->selAnchor : ta->selCursor;
selHi = ta->selAnchor < ta->selCursor ? ta->selCursor : ta->selAnchor;
}
// Draw lines -- compute first visible line offset once, then advance incrementally
int32_t gutterX = w->x + TEXTAREA_BORDER + TEXTAREA_PAD;
int32_t textX = gutterX + gutterW;
int32_t textY = w->y + TEXTAREA_BORDER;
int32_t lineOff = textAreaLineStartCached(w, ta->scrollRow);
// Draw gutter background
if (gutterW > 0) {
rectFill(d, ops, gutterX, textY, gutterW, innerH, colors->windowFace);
drawVLine(d, ops, gutterX + gutterW - 1, textY, innerH, colors->windowShadow);
}
for (int32_t i = 0; i < visRows; i++) {
int32_t row = ta->scrollRow + i;
if (row >= totalLines) {
break;
}
// Use cached line length (O(1) lookup instead of O(lineLen) scan)
int32_t lineL = textAreaLineLenCached(w, row);
int32_t drawY = textY + i * font->charHeight;
// Line decorator: background highlight and gutter indicators
uint32_t lineBg = bg;
uint32_t gutterColor = 0;
if (ta->lineDecorator) {
uint32_t decBg = ta->lineDecorator(row + 1, &gutterColor, ta->lineDecoratorCtx);
if (decBg) {
lineBg = decBg;
}
// Always fill the line background when a decorator is active
// to clear stale highlights from previous paints.
rectFill(d, ops, textX, drawY, innerW, font->charHeight, lineBg);
}
// Draw line number in gutter
if (gutterW > 0) {
// Gutter indicator (breakpoint dot)
if (gutterColor) {
// Draw a filled circle using rectFill scanlines.
// Radius 3 gives a clean 7x7 circle at any font size.
int32_t cx = gutterX + font->charWidth / 2 + 1;
int32_t cy = drawY + font->charHeight / 2;
// dy: -3 -2 -1 0 1 2 3
static const int32_t hw[] = { 1, 2, 3, 3, 3, 2, 1 };
for (int32_t dy = -3; dy <= 3; dy++) {
int32_t w = hw[dy + 3];
rectFill(d, ops, cx - w, cy + dy, w * 2 + 1, 1, gutterColor);
}
}
char numBuf[12];
int32_t numLen = snprintf(numBuf, sizeof(numBuf), "%d", (int)(row + 1));
int32_t numX = gutterX + gutterW - (numLen + 1) * font->charWidth;
drawTextN(d, ops, font, numX, drawY, numBuf, numLen, colors->windowShadow, colors->windowFace, true);
}
// Override bg for this line if the decorator set a custom background.
// This ensures text character backgrounds match the highlighted line.
uint32_t savedBg = bg;
bg = lineBg;
// Expand tabs in this line into a temporary buffer for drawing.
// Also expand syntax colors so each visual column has a color byte.
int32_t tabW = ta->tabWidth > 0 ? ta->tabWidth : 3;
// Compute syntax colors for the raw line first (before expansion)
uint8_t *rawSyntax = ta->rawSyntax;
char *expandBuf = ta->expandBuf;
uint8_t *syntaxBuf = ta->syntaxBuf;
bool hasSyntax = false;
if (ta->colorize && lineL > 0) {
int32_t colorLen = lineL < MAX_COLORIZE_LEN ? lineL : MAX_COLORIZE_LEN;
memset(rawSyntax, 0, colorLen);
ta->colorize(buf + lineOff, colorLen, rawSyntax, ta->colorizeCtx);
hasSyntax = true;
}
int32_t expandLen = 0;
int32_t vc = 0;
for (int32_t j = 0; j < lineL && expandLen < MAX_COLORIZE_LEN - tabW; j++) {
if (buf[lineOff + j] == '\t') {
int32_t spaces = tabW - (vc % tabW);
uint8_t sc = hasSyntax && j < MAX_COLORIZE_LEN ? rawSyntax[j] : 0;
for (int32_t s = 0; s < spaces && expandLen < MAX_COLORIZE_LEN; s++) {
expandBuf[expandLen] = ' ';
syntaxBuf[expandLen] = sc;
expandLen++;
vc++;
}
} else {
expandBuf[expandLen] = buf[lineOff + j];
syntaxBuf[expandLen] = hasSyntax && j < MAX_COLORIZE_LEN ? rawSyntax[j] : 0;
expandLen++;
vc++;
}
}
// Visible range within expanded line (visual columns)
int32_t scrollCol = ta->scrollCol;
int32_t visStart = scrollCol;
int32_t visEnd = scrollCol + visCols;
int32_t textEnd = expandLen;
// Clamp visible range to actual expanded content
int32_t drawStart = visStart < textEnd ? visStart : textEnd;
int32_t drawEnd = visEnd < textEnd ? visEnd : textEnd;
// Determine selection intersection with this line.
// Selection offsets are byte-based; convert to visual columns in the expanded buffer.
int32_t lineSelLo = -1;
int32_t lineSelHi = -1;
if (selLo >= 0) {
if (selLo < lineOff + lineL + 1 && selHi > lineOff) {
int32_t byteSelLo = selLo - lineOff;
int32_t byteSelHi = selHi - lineOff;
if (byteSelLo < 0) { byteSelLo = 0; }
lineSelLo = visualCol(buf, lineOff, lineOff + byteSelLo, tabW);
lineSelHi = visualCol(buf, lineOff, lineOff + (byteSelHi < lineL ? byteSelHi : lineL), tabW);
if (byteSelHi > lineL) {
lineSelHi = expandLen + 1; // extends past EOL
}
}
}
if (lineSelLo >= 0 && lineSelLo < lineSelHi) {
int32_t vSelLo = lineSelLo < drawStart ? drawStart : lineSelLo;
int32_t vSelHi = lineSelHi < drawEnd ? lineSelHi : drawEnd;
if (vSelLo > vSelHi) { vSelLo = vSelHi; }
// Before selection
if (drawStart < vSelLo) {
if (hasSyntax) {
drawColorizedText(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, expandBuf + drawStart, vSelLo - drawStart, syntaxBuf, drawStart, fg, bg, ta->customSyntaxColors);
} else {
drawTextN(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, expandBuf + drawStart, vSelLo - drawStart, fg, bg, true);
}
}
// Selection (always uses highlight colors, no syntax coloring)
if (vSelLo < vSelHi) {
drawTextN(d, ops, font, textX + (vSelLo - scrollCol) * font->charWidth, drawY, expandBuf + vSelLo, vSelHi - vSelLo, colors->menuHighlightFg, colors->menuHighlightBg, true);
}
// After selection
if (vSelHi < drawEnd) {
if (hasSyntax) {
drawColorizedText(d, ops, font, textX + (vSelHi - scrollCol) * font->charWidth, drawY, expandBuf + vSelHi, drawEnd - vSelHi, syntaxBuf, vSelHi, fg, bg, ta->customSyntaxColors);
} else {
drawTextN(d, ops, font, textX + (vSelHi - scrollCol) * font->charWidth, drawY, expandBuf + 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
if (drawStart < drawEnd) {
if (hasSyntax) {
drawColorizedText(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, expandBuf + drawStart, drawEnd - drawStart, syntaxBuf, drawStart, fg, bg, ta->customSyntaxColors);
} else {
drawTextN(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, expandBuf + drawStart, drawEnd - drawStart, fg, bg, true);
}
}
}
// Restore bg for next line
bg = savedBg;
// 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 == sFocusedWidget && sCursorBlinkOn) {
int32_t curLineOff = textAreaLineStartCached(w, ta->cursorRow);
int32_t curOff = curLineOff + ta->cursorCol;
if (curOff > len) { curOff = len; }
int32_t curVisCol = visualCol(buf, curLineOff, curOff, ta->tabWidth);
int32_t curDrawCol = curVisCol - ta->scrollCol;
int32_t curDrawRow = ta->cursorRow - ta->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, ta->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, ta->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 == sFocusedWidget) {
drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg);
}
}
// ============================================================
// widgetTextAreaSetText
// ============================================================
void widgetTextAreaSetText(WidgetT *w, const char *text) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (ta->buf) {
strncpy(ta->buf, text, ta->bufSize - 1);
ta->buf[ta->bufSize - 1] = '\0';
ta->len = (int32_t)strlen(ta->buf);
ta->cursorRow = 0;
ta->cursorCol = 0;
ta->scrollRow = 0;
ta->scrollCol = 0;
ta->desiredCol = 0;
ta->selAnchor = -1;
ta->selCursor = -1;
ta->cachedLines = -1;
ta->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) {
TextInputDataT *ti = (TextInputDataT *)w->data;
if (ti) {
free(ti->buf);
free(ti->undoBuf);
free(ti);
w->data = NULL;
}
}
// ============================================================
// widgetTextInputGetText
// ============================================================
const char *widgetTextInputGetText(const WidgetT *w) {
const TextInputDataT *ti = (const TextInputDataT *)w->data;
return ti->buf ? ti->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) {
TextInputDataT *ti = (TextInputDataT *)w->data;
if (!ti->buf) {
return;
}
clearOtherSelections(w);
if (ti->inputMode == InputMaskedE) {
maskedInputOnKey(w, key, mod);
return;
}
// Password mode: block copy (Ctrl+C) and cut (Ctrl+X)
if (ti->inputMode == InputPasswordE) {
if (key == 3 || key == 24) {
return;
}
}
widgetTextEditOnKey(w, key, mod, ti->buf, ti->bufSize,
&ti->len, &ti->cursorPos,
&ti->scrollOff,
&ti->selStart, &ti->selEnd,
ti->undoBuf, &ti->undoLen,
&ti->undoCursor,
w->w - TEXT_INPUT_PAD * 2);
}
// ============================================================
// 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 sDragWidget.
void widgetTextInputOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
TextInputDataT *ti = (TextInputDataT *)w->data;
sFocusedWidget = w;
clearOtherSelections(w);
AppContextT *ctx = (AppContextT *)root->userData;
widgetTextEditMouseClick(w, vx, vy, w->x + TEXT_INPUT_PAD, &ctx->font, ti->buf, ti->len, ti->scrollOff, &ti->cursorPos, &ti->selStart, &ti->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) {
TextInputDataT *ti = (TextInputDataT *)w->data;
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 (ti->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 = ti->scrollOff;
int32_t len = ti->len - off;
if (len > maxChars) {
len = maxChars;
}
bool isPassword = (ti->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, ti->buf + off, dispLen);
}
widgetTextEditPaintLine(d, ops, font, colors, textX, textY, dispBuf, dispLen, off, ti->cursorPos, ti->selStart, ti->selEnd, fg, bg, w == sFocusedWidget && w->enabled, w->x + TEXT_INPUT_PAD, w->x + w->w - TEXT_INPUT_PAD);
}
}
// ============================================================
// widgetTextInputSetText
// ============================================================
void widgetTextInputSetText(WidgetT *w, const char *text) {
TextInputDataT *ti = (TextInputDataT *)w->data;
if (ti->buf) {
strncpy(ti->buf, text, ti->bufSize - 1);
ti->buf[ti->bufSize - 1] = '\0';
ti->len = (int32_t)strlen(ti->buf);
ti->cursorPos = ti->len;
ti->scrollOff = 0;
ti->selStart = -1;
ti->selEnd = -1;
}
}
// ============================================================
// DXE registration
// ============================================================
static bool widgetTextInputClearSelection(WidgetT *w) {
TextInputDataT *ti = (TextInputDataT *)w->data;
if (ti->selStart >= 0 && ti->selEnd >= 0 && ti->selStart != ti->selEnd) {
ti->selStart = -1;
ti->selEnd = -1;
return true;
}
return false;
}
static void widgetTextInputOnDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
(void)root;
(void)vy;
TextInputDataT *ti = (TextInputDataT *)w->data;
AppContextT *ctx = wgtGetContext(w);
int32_t maxChars = (w->w - TEXT_INPUT_PAD * 2) / ctx->font.charWidth;
widgetTextEditDragUpdateLine(vx, w->x + TEXT_INPUT_PAD, maxChars, &ctx->font, ti->len, &ti->cursorPos, &ti->scrollOff, &ti->selEnd);
}
static const WidgetClassT sClassTextInput = {
.version = WGT_CLASS_VERSION,
.flags = WCLASS_FOCUSABLE,
.handlers = {
[WGT_METHOD_PAINT] = (void *)widgetTextInputPaint,
[WGT_METHOD_CALC_MIN_SIZE] = (void *)widgetTextInputCalcMinSize,
[WGT_METHOD_ON_MOUSE] = (void *)widgetTextInputOnMouse,
[WGT_METHOD_ON_KEY] = (void *)widgetTextInputOnKey,
[WGT_METHOD_DESTROY] = (void *)widgetTextInputDestroy,
[WGT_METHOD_GET_TEXT] = (void *)widgetTextInputGetText,
[WGT_METHOD_SET_TEXT] = (void *)widgetTextInputSetText,
[WGT_METHOD_CLEAR_SELECTION] = (void *)widgetTextInputClearSelection,
[WGT_METHOD_ON_DRAG_UPDATE] = (void *)widgetTextInputOnDragUpdate,
}
};
// ============================================================
// widgetTextAreaScrollDragUpdate
// ============================================================
// Handle scrollbar thumb drag for TextArea vertical and horizontal scrollbars.
// The TextArea always reserves space for the V scrollbar on the right.
// The H scrollbar appears at the bottom only when the longest line exceeds
// visible columns.
static void widgetTextAreaScrollDragUpdate(WidgetT *w, int32_t orient, int32_t dragOff, int32_t mouseX, int32_t mouseY) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
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;
}
if (orient == 0) {
// Vertical scrollbar drag
int32_t totalLines = textAreaGetLineCount(w);
int32_t maxScroll = totalLines - visRows;
if (maxScroll <= 0) {
return;
}
int32_t trackLen = innerH - TEXTAREA_SB_W * 2;
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, totalLines, visRows, ta->scrollRow, &thumbPos, &thumbSize);
int32_t sbY = w->y + TEXTAREA_BORDER;
int32_t relMouse = mouseY - sbY - TEXTAREA_SB_W - dragOff;
int32_t newScroll = (trackLen > thumbSize) ? (maxScroll * relMouse) / (trackLen - thumbSize) : 0;
ta->scrollRow = clampInt(newScroll, 0, maxScroll);
} else if (orient == 1) {
// Horizontal scrollbar drag
int32_t maxHScroll = maxLL - visCols;
if (maxHScroll <= 0) {
return;
}
int32_t hsbW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_SB_W;
int32_t trackLen = hsbW - TEXTAREA_SB_W * 2;
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, maxLL, visCols, ta->scrollCol, &thumbPos, &thumbSize);
int32_t sbX = w->x + TEXTAREA_BORDER;
int32_t relMouse = mouseX - sbX - TEXTAREA_SB_W - dragOff;
int32_t newScroll = (trackLen > thumbSize) ? (maxHScroll * relMouse) / (trackLen - thumbSize) : 0;
ta->scrollCol = clampInt(newScroll, 0, maxHScroll);
}
}
static void widgetTextAreaOnDragUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (ta->sbDragging) {
widgetTextAreaScrollDragUpdate(w, ta->sbDragOrient, ta->sbDragOff, x, y);
} else {
widgetTextAreaDragSelect(w, root, x, y);
}
}
static bool widgetTextAreaClearSelection(WidgetT *w) {
TextAreaDataT *ta = (TextAreaDataT *)w->data;
if (ta->selAnchor >= 0 && ta->selCursor >= 0 && ta->selAnchor != ta->selCursor) {
ta->selAnchor = -1;
ta->selCursor = -1;
return true;
}
return false;
}
static void widgetTextAreaDragSelect(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
(void)root;
TextAreaDataT *ta = (TextAreaDataT *)w->data;
AppContextT *ctx = wgtGetContext(w);
const BitmapFontT *font = &ctx->font;
int32_t gutterW = textAreaGutterWidth(w, font);
int32_t innerX = w->x + TEXTAREA_BORDER + TEXTAREA_PAD + gutterW;
int32_t innerY = w->y + TEXTAREA_BORDER;
int32_t relX = vx - innerX;
int32_t relY = vy - innerY;
int32_t totalLines = textAreaGetLineCount(w);
int32_t visRows = (w->h - TEXTAREA_BORDER * 2) / font->charHeight;
// Auto-scroll when dragging past edges
if (relY < 0 && ta->scrollRow > 0) {
ta->scrollRow--;
} else if (relY >= visRows * font->charHeight && ta->scrollRow < totalLines - visRows) {
ta->scrollRow++;
}
int32_t clickRow = ta->scrollRow + relY / font->charHeight;
int32_t clickVisCol = ta->scrollCol + relX / font->charWidth;
if (clickRow < 0) {
clickRow = 0;
}
if (clickRow >= totalLines) {
clickRow = totalLines - 1;
}
if (clickVisCol < 0) {
clickVisCol = 0;
}
int32_t dragLineStart = textAreaLineStartCached(w, clickRow);
int32_t dragByteOff = visualColToOff(ta->buf, ta->len, dragLineStart, clickVisCol, ta->tabWidth);
int32_t clickCol = dragByteOff - dragLineStart;
int32_t lineL = textAreaLineLenCached(w, clickRow);
if (clickCol > lineL) {
clickCol = lineL;
}
ta->cursorRow = clickRow;
ta->cursorCol = clickCol;
ta->desiredCol = clickCol;
ta->selCursor = textAreaCursorToOff(ta->buf, ta->len, clickRow, clickCol);
}
static const WidgetClassT sClassTextArea = {
.version = WGT_CLASS_VERSION,
.flags = WCLASS_FOCUSABLE | WCLASS_SCROLLABLE,
.handlers = {
[WGT_METHOD_PAINT] = (void *)widgetTextAreaPaint,
[WGT_METHOD_CALC_MIN_SIZE] = (void *)widgetTextAreaCalcMinSize,
[WGT_METHOD_ON_MOUSE] = (void *)widgetTextAreaOnMouse,
[WGT_METHOD_ON_KEY] = (void *)widgetTextAreaOnKey,
[WGT_METHOD_DESTROY] = (void *)widgetTextAreaDestroy,
[WGT_METHOD_GET_TEXT] = (void *)widgetTextAreaGetText,
[WGT_METHOD_SET_TEXT] = (void *)widgetTextAreaSetText,
[WGT_METHOD_CLEAR_SELECTION] = (void *)widgetTextAreaClearSelection,
[WGT_METHOD_ON_DRAG_UPDATE] = (void *)widgetTextAreaOnDragUpdate,
}
};
// ============================================================
// Widget creation functions
// ============================================================
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) {
TextInputDataT *ti = (TextInputDataT *)w->data;
ti->inputMode = InputMaskedE;
ti->mask = mask;
// Pre-fill buffer: literals copied as-is, slots filled with '_'
for (int32_t i = 0; i < maskLen; i++) {
char ch = mask[i];
if (ch == '#' || ch == 'A' || ch == '*') {
ti->buf[i] = '_';
} else {
ti->buf[i] = ch;
}
}
ti->buf[maskLen] = '\0';
ti->len = maskLen;
// Position cursor at first editable slot
ti->cursorPos = 0;
for (int32_t i = 0; mask[i]; i++) {
char ch = mask[i];
if (ch == '#' || ch == 'A' || ch == '*') {
ti->cursorPos = i;
break;
}
}
}
return w;
}
WidgetT *wgtPasswordInput(WidgetT *parent, int32_t maxLen) {
WidgetT *w = wgtTextInput(parent, maxLen);
if (w) {
TextInputDataT *ti = (TextInputDataT *)w->data;
ti->inputMode = InputPasswordE;
}
return w;
}
WidgetT *wgtTextArea(WidgetT *parent, int32_t maxLen) {
WidgetT *w = widgetAlloc(parent, sTextAreaTypeId);
if (w) {
TextAreaDataT *ta = (TextAreaDataT *)calloc(1, sizeof(TextAreaDataT));
if (!ta) {
return w;
}
w->data = ta;
int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;
ta->buf = (char *)malloc(bufSize);
ta->undoBuf = (char *)malloc(bufSize);
ta->bufSize = bufSize;
if (!ta->buf || !ta->undoBuf) {
free(ta->buf);
free(ta->undoBuf);
ta->buf = NULL;
ta->undoBuf = NULL;
} else {
ta->buf[0] = '\0';
}
ta->selAnchor = -1;
ta->selCursor = -1;
ta->desiredCol = 0;
ta->cachedLines = -1;
ta->cachedMaxLL = -1;
ta->captureTabs = false; // default: Tab moves focus
ta->useTabChar = true; // default: insert actual tab character
ta->tabWidth = 3; // default: 3-space tab stops
// Pre-allocate paint buffers (avoids 3KB stack alloc per visible line per frame)
ta->rawSyntax = (uint8_t *)malloc(MAX_COLORIZE_LEN);
ta->expandBuf = (char *)malloc(MAX_COLORIZE_LEN);
ta->syntaxBuf = (uint8_t *)malloc(MAX_COLORIZE_LEN);
w->weight = 100;
}
return w;
}
WidgetT *wgtTextInput(WidgetT *parent, int32_t maxLen) {
WidgetT *w = widgetAlloc(parent, sTextInputTypeId);
if (w) {
TextInputDataT *ti = (TextInputDataT *)calloc(1, sizeof(TextInputDataT));
if (!ti) {
return w;
}
w->data = ti;
int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;
ti->buf = (char *)malloc(bufSize);
ti->bufSize = bufSize;
if (ti->buf) {
ti->buf[0] = '\0';
}
ti->undoBuf = (char *)malloc(bufSize);
ti->selStart = -1;
ti->selEnd = -1;
w->weight = 100;
}
return w;
}
// ============================================================
// wgtTextAreaSetColorize
// ============================================================
void wgtTextAreaGoToLine(WidgetT *w, int32_t line) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
int32_t totalLines = textAreaGetLineCount(w);
int32_t row = line - 1; // 1-based to 0-based
if (row < 0) {
row = 0;
}
if (row >= totalLines) {
row = totalLines - 1;
}
ta->cursorRow = row;
ta->cursorCol = 0;
ta->desiredCol = 0;
// Select the entire line for visual highlight
int32_t lineStart = textAreaLineStartCached(w, row);
int32_t lineL = textAreaLineLenCached(w, row);
ta->selAnchor = lineStart;
ta->selCursor = lineStart + lineL;
// Scroll to put the target line near the top of the visible area
AppContextT *ctx = wgtGetContext(w);
if (ctx) {
const BitmapFontT *font = &ctx->font;
int32_t gutterW = textAreaGutterWidth(w, font);
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W - gutterW;
int32_t visCols = innerW / font->charWidth;
int32_t innerH = w->h - TEXTAREA_BORDER * 2;
int32_t visRows = innerH / font->charHeight;
if (visCols < 1) { visCols = 1; }
if (visRows < 1) { visRows = 1; }
// Place target line 1/4 from top for context, then clamp
int32_t targetScroll = row - visRows / 4;
if (targetScroll < 0) {
targetScroll = 0;
}
int32_t maxScroll = totalLines - visRows;
if (maxScroll < 0) {
maxScroll = 0;
}
if (targetScroll > maxScroll) {
targetScroll = maxScroll;
}
ta->scrollRow = targetScroll;
ta->scrollCol = 0;
}
wgtInvalidatePaint(w);
}
void wgtTextAreaSetAutoIndent(WidgetT *w, bool enable) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->autoIndent = enable;
}
void wgtTextAreaSetColorize(WidgetT *w, void (*fn)(const char *, int32_t, uint8_t *, void *), void *ctx) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->colorize = fn;
ta->colorizeCtx = ctx;
}
void wgtTextAreaSetGutterClickCallback(WidgetT *w, void (*fn)(WidgetT *, int32_t)) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->onGutterClick = fn;
}
void wgtTextAreaSetSyntaxColors(WidgetT *w, const uint32_t *colors, int32_t count) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
memset(ta->customSyntaxColors, 0, sizeof(ta->customSyntaxColors));
if (colors && count > 0) {
if (count > SYNTAX_MAX) {
count = SYNTAX_MAX;
}
memcpy(ta->customSyntaxColors, colors, count * sizeof(uint32_t));
}
wgtInvalidatePaint(w);
}
int32_t wgtTextAreaGetCursorLine(const WidgetT *w) {
if (!w || w->type != sTextAreaTypeId) {
return 1;
}
const TextAreaDataT *ta = (const TextAreaDataT *)w->data;
return ta->cursorRow + 1; // 0-based to 1-based
}
int32_t wgtTextAreaGetWordAtCursor(const WidgetT *w, char *buf, int32_t bufSize) {
buf[0] = '\0';
if (!w || w->type != sTextAreaTypeId || bufSize < 2) {
return 0;
}
const TextAreaDataT *ta = (const TextAreaDataT *)w->data;
if (!ta || !ta->buf || ta->len == 0) {
return 0;
}
int32_t off = textAreaCursorToOff(ta->buf, ta->len, ta->cursorRow, ta->cursorCol);
int32_t ws = wordStart(ta->buf, off);
int32_t we = wordEnd(ta->buf, ta->len, off);
int32_t len = we - ws;
if (len <= 0) {
return 0;
}
if (len >= bufSize) {
len = bufSize - 1;
}
memcpy(buf, ta->buf + ws, len);
buf[len] = '\0';
return len;
}
void wgtTextAreaSetLineDecorator(WidgetT *w, uint32_t (*fn)(int32_t, uint32_t *, void *), void *ctx) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->lineDecorator = fn;
ta->lineDecoratorCtx = ctx;
}
void wgtTextAreaSetShowLineNumbers(WidgetT *w, bool show) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->showLineNumbers = show;
wgtInvalidatePaint(w);
}
void wgtTextAreaSetCaptureTabs(WidgetT *w, bool capture) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->captureTabs = capture;
w->swallowTab = capture;
}
void wgtTextAreaSetTabWidth(WidgetT *w, int32_t width) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->tabWidth = width > 0 ? width : 1;
}
void wgtTextAreaSetUseTabChar(WidgetT *w, bool useChar) {
if (!w || w->type != sTextAreaTypeId) {
return;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
ta->useTabChar = useChar;
}
bool wgtTextAreaFindNext(WidgetT *w, const char *needle, bool caseSensitive, bool forward) {
if (!w || w->type != sTextAreaTypeId || !needle || !needle[0]) {
return false;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
int32_t needleLen = strlen(needle);
if (needleLen > ta->len) {
return false;
}
// Get current cursor byte position
int32_t cursorByte = textAreaCursorToOff(ta->buf, ta->len, ta->cursorRow, ta->cursorCol);
// Search forward from cursor+1 (or backward from cursor-1).
// No wrap-around: returns false if the end/start of the buffer
// is reached without finding a match.
int32_t startPos = forward ? cursorByte + 1 : cursorByte - 1;
int32_t searchLen = ta->len - needleLen + 1;
if (searchLen <= 0) {
return false;
}
int32_t count = forward ? (searchLen - startPos) : (startPos + 1);
if (count <= 0) {
return false;
}
for (int32_t attempt = 0; attempt < count; attempt++) {
int32_t pos;
if (forward) {
pos = startPos + attempt;
} else {
pos = startPos - attempt;
}
if (pos < 0 || pos >= searchLen) {
break;
}
bool match;
if (caseSensitive) {
match = (memcmp(ta->buf + pos, needle, needleLen) == 0);
} else {
match = (strncasecmp(ta->buf + pos, needle, needleLen) == 0);
}
if (match) {
// Select the found text
ta->selAnchor = pos;
ta->selCursor = pos + needleLen;
// Move cursor to start of match (forward) or end of match (backward)
int32_t cursorOff = forward ? pos : pos + needleLen;
textAreaOffToRowColFast(ta, cursorOff, &ta->cursorRow, &ta->cursorCol);
ta->desiredCol = ta->cursorCol;
// Scroll to show the match
AppContextT *ctx = wgtGetContext(w);
if (ctx) {
const BitmapFontT *font = &ctx->font;
int32_t gutterW = textAreaGutterWidth(w, font);
int32_t innerW = w->w - TEXTAREA_BORDER * 2 - TEXTAREA_PAD * 2 - TEXTAREA_SB_W - gutterW;
int32_t visCols = innerW / font->charWidth;
int32_t innerH = w->h - TEXTAREA_BORDER * 2;
int32_t visRows = innerH / font->charHeight;
if (visCols < 1) { visCols = 1; }
if (visRows < 1) { visRows = 1; }
textAreaEnsureVisible(w, visRows, visCols);
}
wgtInvalidatePaint(w);
return true;
}
}
return false;
}
int32_t wgtTextAreaReplaceAll(WidgetT *w, const char *needle, const char *replacement, bool caseSensitive) {
if (!w || w->type != sTextAreaTypeId || !needle || !needle[0] || !replacement) {
return 0;
}
TextAreaDataT *ta = (TextAreaDataT *)w->data;
int32_t needleLen = strlen(needle);
int32_t replLen = strlen(replacement);
int32_t delta = replLen - needleLen;
int32_t count = 0;
if (needleLen > ta->len) {
return 0;
}
// Save undo before any modifications
textEditSaveUndo(ta->buf, ta->len, textAreaCursorToOff(ta->buf, ta->len, ta->cursorRow, ta->cursorCol), ta->undoBuf, &ta->undoLen, &ta->undoCursor, ta->bufSize);
int32_t pos = 0;
while (pos + needleLen <= ta->len) {
bool match;
if (caseSensitive) {
match = (memcmp(ta->buf + pos, needle, needleLen) == 0);
} else {
match = (strncasecmp(ta->buf + pos, needle, needleLen) == 0);
}
if (match) {
// Check if replacement fits in buffer
if (ta->len + delta >= ta->bufSize) {
break;
}
// Shift text after match to make room (or close gap)
if (delta != 0) {
memmove(ta->buf + pos + replLen, ta->buf + pos + needleLen, ta->len - pos - needleLen);
}
// Copy replacement in
memcpy(ta->buf + pos, replacement, replLen);
ta->len += delta;
ta->buf[ta->len] = '\0';
count++;
pos += replLen;
} else {
pos++;
}
}
if (count > 0) {
// Clear selection
ta->selAnchor = -1;
ta->selCursor = -1;
// Clamp cursor if it's past the end
int32_t cursorOff = textAreaCursorToOff(ta->buf, ta->len, ta->cursorRow, ta->cursorCol);
if (cursorOff > ta->len) {
textAreaOffToRowColFast(ta, ta->len, &ta->cursorRow, &ta->cursorCol);
}
ta->desiredCol = ta->cursorCol;
textAreaDirtyCache(w);
wgtInvalidatePaint(w);
if (w->onChange) {
w->onChange(w);
}
}
return count;
}
// ============================================================
// DXE registration
// ============================================================
static const struct {
WidgetT *(*create)(WidgetT *parent, int32_t maxLen);
WidgetT *(*password)(WidgetT *parent, int32_t maxLen);
WidgetT *(*masked)(WidgetT *parent, const char *mask);
WidgetT *(*textArea)(WidgetT *parent, int32_t maxLen);
void (*setColorize)(WidgetT *w, void (*fn)(const char *, int32_t, uint8_t *, void *), void *ctx);
void (*goToLine)(WidgetT *w, int32_t line);
void (*setAutoIndent)(WidgetT *w, bool enable);
void (*setShowLineNumbers)(WidgetT *w, bool show);
void (*setCaptureTabs)(WidgetT *w, bool capture);
void (*setTabWidth)(WidgetT *w, int32_t width);
void (*setUseTabChar)(WidgetT *w, bool useChar);
bool (*findNext)(WidgetT *w, const char *needle, bool caseSensitive, bool forward);
int32_t (*replaceAll)(WidgetT *w, const char *needle, const char *replacement, bool caseSensitive);
void (*setLineDecorator)(WidgetT *w, uint32_t (*fn)(int32_t, uint32_t *, void *), void *ctx);
int32_t (*getCursorLine)(const WidgetT *w);
void (*setGutterClick)(WidgetT *w, void (*fn)(WidgetT *, int32_t));
int32_t (*getWordAtCursor)(const WidgetT *w, char *buf, int32_t bufSize);
void (*setSyntaxColors)(WidgetT *w, const uint32_t *colors, int32_t count);
} sApi = {
.create = wgtTextInput,
.password = wgtPasswordInput,
.masked = wgtMaskedInput,
.textArea = wgtTextArea,
.setColorize = wgtTextAreaSetColorize,
.goToLine = wgtTextAreaGoToLine,
.setAutoIndent = wgtTextAreaSetAutoIndent,
.setShowLineNumbers = wgtTextAreaSetShowLineNumbers,
.setCaptureTabs = wgtTextAreaSetCaptureTabs,
.setTabWidth = wgtTextAreaSetTabWidth,
.setUseTabChar = wgtTextAreaSetUseTabChar,
.findNext = wgtTextAreaFindNext,
.replaceAll = wgtTextAreaReplaceAll,
.setLineDecorator = wgtTextAreaSetLineDecorator,
.getCursorLine = wgtTextAreaGetCursorLine,
.setGutterClick = wgtTextAreaSetGutterClickCallback,
.getWordAtCursor = wgtTextAreaGetWordAtCursor,
.setSyntaxColors = wgtTextAreaSetSyntaxColors
};
// Per-type APIs for the designer
static const struct { WidgetT *(*create)(WidgetT *, int32_t); } sTextInputApi = { .create = wgtTextInput };
static const struct { WidgetT *(*create)(WidgetT *, int32_t); } sTextAreaApi = { .create = wgtTextArea };
// ============================================================
// Built-in syntax colorizers for SetSyntaxMode
// ============================================================
// Color indices (match wgtTextAreaSetSyntaxColors slots):
// 0=default, 1=keyword, 2=string, 3=comment, 4=number, 5=operator, 6=type, 7=reserved
// .dhs help source colorizer
static void dhsColorize(const char *line, int32_t lineLen, uint8_t *colors, void *ctx) {
(void)ctx;
if (lineLen == 0) {
return;
}
// Skip leading whitespace
int32_t i = 0;
while (i < lineLen && (line[i] == ' ' || line[i] == '\t')) {
i++;
}
// Lines starting with . are directives
if (i < lineLen && line[i] == '.') {
int32_t dirStart = i;
i++;
// Directive name
while (i < lineLen && line[i] != ' ' && line[i] != '\t') {
i++;
}
// Classify the directive
int32_t dirLen = i - dirStart;
const char *dir = line + dirStart;
// Block directives: .code, .endcode, .table, .endtable, .list, .endlist, .note, .endnote
bool isBlock = false;
if (dirLen >= 4 && (strncasecmp(dir, ".code", 5) == 0 || strncasecmp(dir, ".table", 6) == 0 || strncasecmp(dir, ".list", 5) == 0 || strncasecmp(dir, ".note", 5) == 0 || strncasecmp(dir, ".endcode", 8) == 0 || strncasecmp(dir, ".endtable", 9) == 0 || strncasecmp(dir, ".endlist", 8) == 0 || strncasecmp(dir, ".endnote", 8) == 0 || strncasecmp(dir, ".item", 5) == 0 || strncasecmp(dir, ".hr", 3) == 0)) {
isBlock = true;
}
// Heading directives: .h1, .h2, .h3
bool isHeading = (dirLen >= 3 && dir[0] == '.' && (dir[1] == 'h' || dir[1] == 'H') && dir[2] >= '1' && dir[2] <= '3');
// Link directive: .link
bool isLink = (dirLen >= 5 && strncasecmp(dir, ".link", 5) == 0);
// Topic/index/toc/title directives
bool isMeta = false;
if (dirLen >= 4 && (strncasecmp(dir, ".topic", 6) == 0 || strncasecmp(dir, ".title", 6) == 0 || strncasecmp(dir, ".toc", 4) == 0 || strncasecmp(dir, ".index", 6) == 0 || strncasecmp(dir, ".default", 8) == 0 || strncasecmp(dir, ".section", 8) == 0 || strncasecmp(dir, ".image", 6) == 0)) {
isMeta = true;
}
// Color the directive name
uint8_t dirColor = 1; // keyword
if (isHeading) {
dirColor = 6; // type (stands out more)
} else if (isLink) {
dirColor = 5; // operator
} else if (isMeta) {
dirColor = 7; // reserved
} else if (isBlock) {
dirColor = 1; // keyword
}
for (int32_t j = dirStart; j < i; j++) {
colors[j] = dirColor;
}
// Color the rest of the line as the directive's argument
if (isHeading) {
// Heading text gets heading color
for (int32_t j = i; j < lineLen; j++) {
colors[j] = 6;
}
} else if (isLink) {
// .link topicId displayText -- topicId is string-colored, rest is default
while (i < lineLen && (line[i] == ' ' || line[i] == '\t')) {
i++;
}
int32_t idStart = i;
while (i < lineLen && line[i] != ' ' && line[i] != '\t') {
i++;
}
for (int32_t j = idStart; j < i; j++) {
colors[j] = 2; // string
}
} else if (isMeta) {
// Meta argument in string color
for (int32_t j = i; j < lineLen; j++) {
colors[j] = 2;
}
}
return;
}
// Non-directive lines are body text (default color), no special highlighting
}
// BASIC keyword colorizer (simplified version of IDE's basicColorize)
static void basColorize(const char *line, int32_t lineLen, uint8_t *colors, void *ctx) {
(void)ctx;
static const char *sBasKeywords[] = {
"AND", "AS", "BYVAL", "CALL", "CASE", "CLOSE", "CONST",
"CREATECONTROL", "CREATEFORM",
"DATA", "DECLARE", "DIM", "DO", "DOEVENTS",
"ELSE", "ELSEIF", "END", "ERASE", "EXIT",
"FOR", "FUNCTION", "GET", "GOSUB", "GOTO",
"HIDE", "IF", "INPUT", "IS",
"LET", "LIBRARY", "LINE", "LOAD", "LOOP",
"ME", "MOD", "MSGBOX",
"NEXT", "NOTHING", "NOT",
"ON", "OPEN", "OPTIONAL", "OPTION", "OR",
"PRINT", "PUT",
"RANDOMIZE", "READ", "REDIM", "REMOVECONTROL", "RESTORE", "RESUME", "RETURN",
"SEEK", "SELECT", "SET", "SETEVENT", "SHARED", "SHELL", "SHOW", "SLEEP",
"STATIC", "STEP", "SUB", "SWAP",
"THEN", "TO", "TYPE",
"UNLOAD", "UNTIL",
"WEND", "WHILE", "WRITE",
"XOR",
NULL
};
static const char *sBasTypes[] = {
"BOOLEAN", "DOUBLE", "FALSE", "INTEGER", "LONG", "SINGLE", "STRING", "TRUE",
NULL
};
int32_t i = 0;
while (i < lineLen) {
char ch = line[i];
// Comment
if (ch == '\'') {
while (i < lineLen) {
colors[i++] = 3;
}
return;
}
// String literal
if (ch == '"') {
colors[i++] = 2;
while (i < lineLen && line[i] != '"') {
colors[i++] = 2;
}
if (i < lineLen) {
colors[i++] = 2;
}
continue;
}
// Number
if (isdigit((unsigned char)ch) || (ch == '&' && i + 1 < lineLen && (line[i + 1] == 'H' || line[i + 1] == 'h'))) {
while (i < lineLen && (isxdigit((unsigned char)line[i]) || line[i] == '.' || line[i] == '&' || line[i] == 'H' || line[i] == 'h')) {
colors[i++] = 4;
}
continue;
}
// Identifier or keyword
if (isalpha((unsigned char)ch) || ch == '_') {
int32_t start = i;
while (i < lineLen && (isalnum((unsigned char)line[i]) || line[i] == '_' || line[i] == '$' || line[i] == '%' || line[i] == '&' || line[i] == '!' || line[i] == '#')) {
i++;
}
int32_t wordLen = i - start;
// Check for REM
if (wordLen == 3 && (line[start] == 'R' || line[start] == 'r') && (line[start + 1] == 'E' || line[start + 1] == 'e') && (line[start + 2] == 'M' || line[start + 2] == 'm')) {
for (int32_t j = start; j < lineLen; j++) {
colors[j] = 3;
}
return;
}
// Uppercase for comparison
char upper[48];
int32_t uLen = wordLen < 47 ? wordLen : 47;
for (int32_t j = 0; j < uLen; j++) {
upper[j] = (char)toupper((unsigned char)line[start + j]);
}
// Strip type suffix for matching
while (uLen > 0 && (upper[uLen - 1] == '$' || upper[uLen - 1] == '%' || upper[uLen - 1] == '&' || upper[uLen - 1] == '!' || upper[uLen - 1] == '#')) {
uLen--;
}
upper[uLen] = '\0';
// Check keywords
uint8_t c = 0;
for (int32_t j = 0; sBasKeywords[j]; j++) {
if (strcmp(upper, sBasKeywords[j]) == 0) {
c = 1;
break;
}
}
if (c == 0) {
for (int32_t j = 0; sBasTypes[j]; j++) {
if (strcmp(upper, sBasTypes[j]) == 0) {
c = 6;
break;
}
}
}
for (int32_t j = start; j < i; j++) {
colors[j] = c;
}
continue;
}
// Operator
if (ch == '=' || ch == '<' || ch == '>' || ch == '+' || ch == '-' || ch == '*' || ch == '/' || ch == '\\') {
colors[i++] = 5;
continue;
}
colors[i++] = 0;
}
}
// SetSyntaxMode method: activate a built-in colorizer
static void basSetSyntaxMode(WidgetT *w, const char *mode) {
if (!mode || !*mode) {
wgtTextAreaSetColorize(w, NULL, NULL);
return;
}
if (strcasecmp(mode, "dhs") == 0 || strcasecmp(mode, "help") == 0) {
wgtTextAreaSetColorize(w, dhsColorize, NULL);
} else if (strcasecmp(mode, "bas") == 0 || strcasecmp(mode, "basic") == 0) {
wgtTextAreaSetColorize(w, basColorize, NULL);
} else {
wgtTextAreaSetColorize(w, NULL, NULL);
}
}
// BASIC wrapper: GetWordAtCursor returns the word as a string
static const char *basGetWordAtCursor(const WidgetT *w) {
static char buf[256];
wgtTextAreaGetWordAtCursor(w, buf, sizeof(buf));
return buf;
}
// TextArea BASIC interface
static const WgtPropDescT sTextAreaProps[] = {
{ "CursorLine", WGT_IFACE_INT, (void *)wgtTextAreaGetCursorLine, NULL, NULL }
};
static const WgtMethodDescT sTextAreaMethods[] = {
{ "FindNext", WGT_SIG_STR_BOOL_BOOL, (void *)wgtTextAreaFindNext },
{ "GetWordAtCursor", WGT_SIG_RET_STR, (void *)basGetWordAtCursor },
{ "GoToLine", WGT_SIG_INT, (void *)wgtTextAreaGoToLine },
{ "ReplaceAll", WGT_SIG_STR_STR_BOOL, (void *)wgtTextAreaReplaceAll },
{ "SetAutoIndent", WGT_SIG_BOOL, (void *)wgtTextAreaSetAutoIndent },
{ "SetCaptureTabs", WGT_SIG_BOOL, (void *)wgtTextAreaSetCaptureTabs },
{ "SetShowLineNumbers", WGT_SIG_BOOL, (void *)wgtTextAreaSetShowLineNumbers },
{ "SetSyntaxMode", WGT_SIG_STR, (void *)basSetSyntaxMode },
{ "SetTabWidth", WGT_SIG_INT, (void *)wgtTextAreaSetTabWidth },
{ "SetUseTabChar", WGT_SIG_BOOL, (void *)wgtTextAreaSetUseTabChar },
};
static const WgtIfaceT sIfaceTextInput = {
.basName = "TextBox",
.props = NULL,
.propCount = 0,
.methods = NULL,
.methodCount = 0,
.events = NULL,
.eventCount = 0,
.createSig = WGT_CREATE_PARENT_INT,
.createArgs = { 256 },
.defaultEvent = "Change"
};
static const WgtIfaceT sIfaceTextArea = {
.basName = "TextArea",
.props = sTextAreaProps,
.propCount = 1,
.methods = sTextAreaMethods,
.methodCount = 10,
.events = NULL,
.eventCount = 0,
.createSig = WGT_CREATE_PARENT_INT,
.createArgs = { 65536 },
.defaultEvent = "Change"
};
void wgtRegister(void) {
sTextInputTypeId = wgtRegisterClass(&sClassTextInput);
sTextAreaTypeId = wgtRegisterClass(&sClassTextArea);
// Original combined API (for widgetTextInput.h compatibility)
wgtRegisterApi("textinput", &sApi);
// Per-type APIs and ifaces (for designer/toolbox)
wgtRegisterApi("textbox", &sTextInputApi);
wgtRegisterIface("textbox", &sIfaceTextInput);
wgtRegisterApi("textarea", &sTextAreaApi);
wgtRegisterIface("textarea", &sIfaceTextArea);
}