// The MIT License (MIT)
//
// Copyright (C) 2026 Scott Duensing
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.

#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 "../../../libs/kpunch/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

// Slack added to lineOffsets/lineVisLens capacity when growing.  Bigger
// values = fewer reallocs, smaller = less wasted space.  256 empirically
// keeps realloc traffic low on edits to medium-sized files.
#define TEXTAREA_LINE_CAP_GROWTH 256
#define MAX_COLORIZE_LEN   1024
// Match the ANSI terminal cursor blink rate (CURSOR_MS in widgetAnsiTerm.c)
#define CURSOR_BLINK_MS    250

// sCursorBlinkOn is defined in widgetCore.c (shared state)


// ============================================================
// Prototypes
// ============================================================

static void basColorize(const char *line, int32_t lineLen, uint8_t *colors, void *ctx);
static const char *basGetWordAtCursor(const WidgetT *w);
static void basSetSyntaxMode(WidgetT *w, const char *mode);
static void dhsColorize(const char *line, int32_t lineLen, uint8_t *colors, void *ctx);
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 bool maskCharValid(char slot, char ch);
static void maskedInputOnKey(WidgetT *w, int32_t key, int32_t mod);
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 uint32_t syntaxColor(const DisplayT *d, uint8_t idx, uint32_t defaultFg, const uint32_t *custom);
static void textAreaCacheDelete(WidgetT *w, int32_t off, int32_t deleteLen);
static void textAreaCacheInsert(WidgetT *w, int32_t off, int32_t insertLen);
static int32_t textAreaCursorToOff(const char *buf, int32_t len, int32_t row, int32_t col);
static inline void textAreaDirtyCache(WidgetT *w);
static void textAreaEnsureCache(WidgetT *w);
static void textAreaEnsureVisible(WidgetT *w, int32_t visRows, int32_t visCols);
static int32_t textAreaGetLineCount(WidgetT *w);
static int32_t textAreaGetMaxLineLen(WidgetT *w);
static int32_t textAreaGutterWidth(WidgetT *w, const BitmapFontT *font);
static int32_t textAreaLineLen(const char *buf, int32_t len, int32_t row);
static int32_t textAreaLineLenCached(WidgetT *w, 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 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 void textAreaRebuildCache(WidgetT *w);
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);
WidgetT *wgtMaskedInput(WidgetT *parent, const char *mask);
WidgetT *wgtPasswordInput(WidgetT *parent, int32_t maxLen);
WidgetT *wgtTextArea(WidgetT *parent, int32_t maxLen);
void wgtTextAreaAppendText(WidgetT *w, const char *text);
bool wgtTextAreaFindNext(WidgetT *w, const char *needle, bool caseSensitive, bool forward);
int32_t wgtTextAreaGetCursorLine(const WidgetT *w);
int32_t wgtTextAreaGetWordAtCursor(const WidgetT *w, char *buf, int32_t bufSize);
void wgtTextAreaGoToLine(WidgetT *w, int32_t line);
int32_t wgtTextAreaReplaceAll(WidgetT *w, const char *needle, const char *replacement, bool caseSensitive);
void wgtTextAreaSetAutoIndent(WidgetT *w, bool enable);
void wgtTextAreaSetCaptureTabs(WidgetT *w, bool capture);
void wgtTextAreaSetColorize(WidgetT *w, void (*fn)(const char *, int32_t, uint8_t *, void *), void *ctx);
void wgtTextAreaSetGutterClickCallback(WidgetT *w, void (*fn)(WidgetT *, int32_t));
void wgtTextAreaSetLineDecorator(WidgetT *w, uint32_t (*fn)(int32_t, uint32_t *, void *), void *ctx);
void wgtTextAreaSetShowLineNumbers(WidgetT *w, bool show);
void wgtTextAreaSetSyntaxColors(WidgetT *w, const uint32_t *colors, int32_t count);
void wgtTextAreaSetTabWidth(WidgetT *w, int32_t width);
void wgtTextAreaSetUseTabChar(WidgetT *w, bool useChar);
WidgetT *wgtTextInput(WidgetT *parent, int32_t maxLen);
void widgetTextAreaCalcMinSize(WidgetT *w, const BitmapFontT *font);
static bool widgetTextAreaClearSelection(WidgetT *w);
void widgetTextAreaDestroy(WidgetT *w);
static void widgetTextAreaDragSelect(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
const char *widgetTextAreaGetText(const WidgetT *w);
static void widgetTextAreaOnDragUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y);
void widgetTextAreaOnKey(WidgetT *w, int32_t key, int32_t mod);
void widgetTextAreaOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
void widgetTextAreaPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
static void widgetTextAreaScrollDragUpdate(WidgetT *w, int32_t orient, int32_t dragOff, int32_t mouseX, int32_t mouseY);
void widgetTextAreaSetText(WidgetT *w, const char *text);
void widgetTextInputCalcMinSize(WidgetT *w, const BitmapFontT *font);
static bool widgetTextInputClearSelection(WidgetT *w);
void widgetTextInputDestroy(WidgetT *w);
const char *widgetTextInputGetText(const WidgetT *w);
static void widgetTextInputOnDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
void widgetTextInputOnKey(WidgetT *w, int32_t key, int32_t mod);
void widgetTextInputOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
void widgetTextInputPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
void widgetTextInputSetText(WidgetT *w, const char *text);


// 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++] = SYNTAX_COMMENT;
            }

            return;
        }

        // String literal
        if (ch == '"') {
            colors[i++] = SYNTAX_STRING;

            while (i < lineLen && line[i] != '"') {
                colors[i++] = SYNTAX_STRING;
            }

            if (i < lineLen) {
                colors[i++] = SYNTAX_STRING;
            }

            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++] = SYNTAX_NUMBER;
            }

            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] = SYNTAX_COMMENT;
                }

                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 = SYNTAX_DEFAULT;

            for (int32_t j = 0; sBasKeywords[j]; j++) {
                if (strcmp(upper, sBasKeywords[j]) == 0) {
                    c = SYNTAX_KEYWORD;
                    break;
                }
            }

            if (c == SYNTAX_DEFAULT) {
                for (int32_t j = 0; sBasTypes[j]; j++) {
                    if (strcmp(upper, sBasTypes[j]) == 0) {
                        c = SYNTAX_TYPE;
                        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++] = SYNTAX_OPERATOR;
            continue;
        }

        colors[i++] = SYNTAX_DEFAULT;
    }
}


// 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;
}


// 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);
    }
}


// .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
}


// 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;
    }
}


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


// 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 == KEY_CTRL_C) {
        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 == KEY_CTRL_V) {
        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 == KEY_CTRL_X) {
        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 == KEY_CTRL_Z && 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 >= KEY_ASCII_PRINT_FIRST && key <= KEY_ASCII_PRINT_LAST) {
        // 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 == KEY_DELETE) {
        // 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 == KEY_LEFT) {
        // 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 == KEY_RIGHT) {
        // 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 == KEY_HOME) {
        // 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 == KEY_END) {
        // 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);
}


static int32_t maskFirstSlot(const char *mask) {
    for (int32_t i = 0; mask[i]; i++) {
        if (maskIsSlot(mask[i])) {
            return i;
        }
    }

    return 0;
}


static bool maskIsSlot(char ch) {
    return ch == '#' || ch == 'A' || ch == '*';
}


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


static uint32_t syntaxColor(const DisplayT *d, uint8_t idx, uint32_t defaultFg, const uint32_t *custom) {
    if (idx != SYNTAX_DEFAULT && 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;
    }
}


// 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;
}


// 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;
}


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 inline void textAreaDirtyCache(WidgetT *w) {
    TextAreaDataT *ta = (TextAreaDataT *)w->data;
    ta->cachedLines      = -1;
    ta->cachedMaxLL      = -1;
    ta->cachedGutterW    = 0;
}


static void textAreaEnsureCache(WidgetT *w) {
    TextAreaDataT *ta = (TextAreaDataT *)w->data;

    if (ta->cachedLines < 0) {
        textAreaRebuildCache(w);
    }
}


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


static int32_t textAreaGetLineCount(WidgetT *w) {
    textAreaEnsureCache(w);
    return ((TextAreaDataT *)w->data)->cachedLines;
}


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


// 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 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];
}


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];
}


// 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 + TEXTAREA_LINE_CAP_GROWTH;
        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 + TEXTAREA_LINE_CAP_GROWTH;
        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;
}


// 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;
}


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 = WGT_WEIGHT_FILL;
    }

    return w;
}


void wgtTextAreaAppendText(WidgetT *w, const char *text) {
    if (!w || w->type != sTextAreaTypeId || !text) {
        return;
    }

    TextAreaDataT *ta = (TextAreaDataT *)w->data;

    if (!ta->buf) {
        return;
    }

    int32_t addLen = (int32_t)strlen(text);
    int32_t room   = ta->bufSize - 1 - ta->len;

    if (addLen > room) {
        addLen = room;
    }

    if (addLen <= 0) {
        return;
    }

    memcpy(ta->buf + ta->len, text, addLen);
    ta->len             += addLen;
    ta->buf[ta->len]     = '\0';
    ta->cachedLines      = -1;
    ta->cachedMaxLL      = -1;
    wgtInvalidate(w);
}


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 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 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);
}


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


void wgtTextAreaSetAutoIndent(WidgetT *w, bool enable) {
    if (!w || w->type != sTextAreaTypeId) {
        return;
    }

    TextAreaDataT *ta = (TextAreaDataT *)w->data;
    ta->autoIndent = enable;
}


void wgtTextAreaSetCaptureTabs(WidgetT *w, bool capture) {
    if (!w || w->type != sTextAreaTypeId) {
        return;
    }

    TextAreaDataT *ta = (TextAreaDataT *)w->data;
    ta->captureTabs = capture;
    w->swallowTab   = capture;
}


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 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 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);
}


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


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 = WGT_WEIGHT_FILL;
    }

    return w;
}


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


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


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


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);
}


const char *widgetTextAreaGetText(const WidgetT *w) {
    const TextAreaDataT *ta = (const TextAreaDataT *)w->data;
    return ta->buf ? ta->buf : "";
}


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);
    }
}


// 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 == KEY_CTRL_C) {
        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 == KEY_CTRL_V) {
        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 == KEY_CTRL_X) {
        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 == KEY_CTRL_Z) {
        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 == KEY_DELETE) {
        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 == KEY_LEFT) {
        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 == KEY_RIGHT) {
        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 == KEY_UP) {
        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 == KEY_DOWN) {
        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 == KEY_HOME) {
        SEL_BEGIN();
        *pCol = 0;
        ta->desiredCol = 0;
        SEL_END();
        textAreaEnsureVisible(w, visRows, visCols);
        wgtInvalidatePaint(w);
        return;
    }

    // End
    if (key == KEY_END) {
        SEL_BEGIN();
        *pCol = textAreaLineLen(buf, *pLen, *pRow);
        ta->desiredCol = *pCol;
        SEL_END();
        textAreaEnsureVisible(w, visRows, visCols);
        wgtInvalidatePaint(w);
        return;
    }

    // Page Up
    if (key == KEY_PGUP) {
        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 == KEY_PGDN) {
        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 >= KEY_ASCII_PRINT_FIRST && key <= KEY_ASCII_PRINT_LAST && !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
}


// 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;
}


// 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);
    }
}


// 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);
    }
}


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


void widgetTextInputCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    w->calcMinW = font->charWidth * 8 + TEXT_INPUT_PAD * 2;
    w->calcMinH = font->charHeight + TEXT_INPUT_PAD * 2;
}


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


void widgetTextInputDestroy(WidgetT *w) {
    TextInputDataT *ti = (TextInputDataT *)w->data;

    if (ti) {
        free(ti->buf);
        free(ti->undoBuf);
        free(ti);
        w->data = NULL;
    }
}


const char *widgetTextInputGetText(const WidgetT *w) {
    const TextInputDataT *ti = (const TextInputDataT *)w->data;
    return ti->buf ? ti->buf : "";
}


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);
}


// 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 == KEY_CTRL_C || key == KEY_CTRL_X) {
            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);
}


// 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);
}


// 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);
    }
}


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


// 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 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,
    }
};

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,
    }
};


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


// TextArea BASIC interface
static const WgtPropDescT sTextAreaProps[] = {
    { "CursorLine",      WGT_IFACE_INT, (void *)wgtTextAreaGetCursorLine, NULL, NULL }
};

static const WgtMethodDescT sTextAreaMethods[] = {
    { "AppendText",         WGT_SIG_STR,            (void *)wgtTextAreaAppendText },
    { "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  = 11,
    .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);
}
