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

// textHelp.c -- Shared text editing infrastructure
//
// Provides clipboard, multi-click detection, word boundary logic,
// cross-widget selection clearing, and the single-line text editing
// engine. Shared across multiple widget DXEs (TextInput, TextArea,
// ComboBox, Spinner, AnsiTerm) via the texthelp.lib DXE.

#include "textHelp.h"

#include <ctype.h>
#include <time.h>

// ============================================================
// Static state
// ============================================================

// Cursor blink state
#define CURSOR_BLINK_MS 250
static clock_t sCursorBlinkTime = 0;

// Slack added to line cache capacity when growing. Bigger values
// mean fewer reallocs at the cost of wasted space; 256 empirically
// keeps realloc traffic low on medium-sized file edits.
#define TEXT_EDIT_LINE_CAP_GROWTH 256

// Default tab width when a cache has been left at 0 (callers should
// always initialize with textEditLineCacheInit, but guard defensively).
#define TEXT_EDIT_DEFAULT_TAB_WIDTH 3

// Maximum number of leading whitespace bytes copied when auto-indenting
// a new line; auto-indent silently truncates past this.
#define TEXT_EDIT_MAX_AUTO_INDENT 64

// Track the widget that last had an active selection so we can
// clear it in O(1) instead of walking every widget in every window.
static WidgetT *sLastSelectedWidget = NULL;

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

void           clearOtherSelections(WidgetT *except);
void           textEditClearWidgetRef(WidgetT *w);
bool           isWordChar(char c);
static void    textEditDeleteSelection(char *buf, int32_t *pLen, int32_t *pCursor, int32_t *pSelStart, int32_t *pSelEnd);
void           textEditDrawColorizedText(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);
void           textEditEnsureVisible(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t cursorRow, int32_t cursorCol, int32_t tabW, int32_t visRows, int32_t visCols, int32_t *pScrollRow, int32_t *pScrollCol);
bool           textEditFindNext(TextEditLineCacheT *lc, const char *buf, int32_t len, const char *needle, bool caseSensitive, bool forward, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor);
int32_t        textEditGetWordAtCursor(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t cursorRow, int32_t cursorCol, char *out, int32_t outSize);
void           textEditGoToLine(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t line, int32_t visRows, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pScrollRow, int32_t *pScrollCol, int32_t *pSelAnchor, int32_t *pSelCursor);
void           textEditLineCacheDirty(TextEditLineCacheT *lc);
void           textEditLineCacheEnsure(TextEditLineCacheT *lc, const char *buf, int32_t len);
void           textEditLineCacheFree(TextEditLineCacheT *lc);
void           textEditLineCacheInit(TextEditLineCacheT *lc, int32_t tabWidth);
void           textEditLineCacheNotifyDelete(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t deleteLen);
void           textEditLineCacheNotifyInsert(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t insertLen);
static void    textEditLineCacheRebuild(TextEditLineCacheT *lc, const char *buf, int32_t len);
void           textEditLineCacheSetTabWidth(TextEditLineCacheT *lc, int32_t tabWidth);
void           textEditMultiFree(TextEditMultiT *te);
bool           textEditMultiInit(TextEditMultiT *te, int32_t maxLen, int32_t tabWidth);
int32_t        textEditLineCount(TextEditLineCacheT *lc, const char *buf, int32_t len);
int32_t        textEditLineLen(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row);
int32_t        textEditLineStart(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row);
int32_t        textEditMaxLineLen(TextEditLineCacheT *lc, const char *buf, int32_t len);
void           textEditOffToRowCol(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t *row, int32_t *col);
int32_t        textEditReplaceAll(TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, const char *needle, const char *replacement, bool caseSensitive, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor);
bool           textEditReplaceSelectionMulti(TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, const char *needle, const char *replacement, bool caseSensitive, bool forward, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor);
int32_t        textEditRowColToOff(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row, int32_t col);
void           textEditSaveUndo(char *buf, int32_t len, int32_t cursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t bufSize);
uint32_t       textEditSyntaxColor(const DisplayT *d, uint8_t idx, uint32_t defaultFg, const uint32_t *custom);
int32_t        textEditVisualCol(const char *buf, int32_t lineStart, int32_t off, int32_t tabW);
int32_t        textEditVisualColToOff(const char *buf, int32_t len, int32_t lineStart, int32_t targetVC, int32_t tabW);
static void    textHelpInit(void) __attribute__((constructor));
void           wgtUpdateCursorBlink(void);
void           widgetTextEditDragUpdateLine(int32_t vx, int32_t leftEdge, int32_t maxChars, const BitmapFontT *font, int32_t len, int32_t *pCursorPos, int32_t *pScrollOff, int32_t *pSelEnd);
void           widgetTextEditMouseClick(WidgetT *w, int32_t vx, int32_t vy, int32_t textLeftX, const BitmapFontT *font, const char *buf, int32_t len, int32_t scrollOff, int32_t *pCursorPos, int32_t *pSelStart, int32_t *pSelEnd, bool wordSelect, bool dragSelect);
void           widgetTextEditMultiDragUpdateArea(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t vx, int32_t vy, int32_t textX, int32_t textY, const BitmapFontT *font, int32_t *pScrollRow, int32_t scrollCol, int32_t visRows, int32_t tabW, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelCursor);
void           widgetTextEditMultiMouseClick(WidgetT *w, TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t vx, int32_t vy, int32_t textX, int32_t textY, const BitmapFontT *font, int32_t scrollRow, int32_t scrollCol, int32_t tabW, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor);
void           widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pScrollRow, int32_t *pScrollCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t visRows, int32_t visCols, const TextEditMultiOptionsT *opts);
void           widgetTextEditMultiPaintArea(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t textX, int32_t textY, int32_t innerW, int32_t visCols, int32_t visRows, int32_t scrollRow, int32_t scrollCol, int32_t cursorRow, int32_t cursorCol, int32_t selAnchor, int32_t selCursor, int32_t tabW, uint32_t fg, uint32_t bg, bool showCursor, const TextEditPaintHooksT *hooks);
void           widgetTextScrollbarDraw(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, bool vertical, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll);
int32_t        widgetTextScrollbarDragToScroll(bool vertical, int32_t mouseCoord, int32_t sbStart, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t dragOff);
int32_t        widgetTextScrollbarHitTest(bool vertical, int32_t vx, int32_t vy, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll, int32_t *pDragOff);
void           widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursor, int32_t *pScrollOff, int32_t *pSelStart, int32_t *pSelEnd, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t fieldWidth);
void           widgetTextEditPaintLine(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t textX, int32_t textY, const char *buf, int32_t visLen, int32_t scrollOff, int32_t cursorPos, int32_t selStart, int32_t selEnd, uint32_t fg, uint32_t bg, bool showCursor, int32_t cursorMinX, int32_t cursorMaxX);
int32_t wordBoundaryLeft(const char *buf, int32_t pos);
int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos);
int32_t        wordEnd(const char *buf, int32_t len, int32_t pos);
int32_t        wordStart(const char *buf, int32_t pos);


// Clears selection on the previously-selected widget (if different
// from the newly-focused one). Validates that the previous widget's
// window is still in the window stack before touching it -- the
// window may have been closed since sLastSelectedWidget was set.
// If a selection was cleared, the previous widget is invalidated so
// the stale highlight is erased -- the deferred paint flush handles
// both same-window and cross-window widgets.
void clearOtherSelections(WidgetT *except) {
    if (!except || !except->window || !except->window->widgetRoot) {
        return;
    }

    WidgetT *prev = sLastSelectedWidget;
    sLastSelectedWidget = except;

    if (!prev || prev == except) {
        return;
    }

    // Verify the widget is still alive (its window still in the stack)
    WindowT *prevWin = prev->window;

    if (!prevWin) {
        return;
    }

    AppContextT *ctx = wgtGetContext(except);

    if (!ctx) {
        return;
    }

    bool found = false;

    for (int32_t i = 0; i < ctx->stack.count; i++) {
        if (ctx->stack.windows[i] == prevWin) {
            found = true;
            break;
        }
    }

    if (!found) {
        return;
    }

    if (wclsClearSelection(prev)) {
        wgtInvalidatePaint(prev);
    }
}


bool isWordChar(char c) {
    return isalnum((unsigned char)c) || c == '_';
}


// Nulls the last-selected tracker when its widget is being destroyed.
// Registered as a libdvx widget-destroy subscriber (widgetRegisterDestroyFn)
// so the static can never dangle past a wgtDestroy (this lives in a
// different TU than widgetClearReferences, which cannot reach this static
// directly).
void textEditClearWidgetRef(WidgetT *w) {
    if (sLastSelectedWidget == w) {
        sLastSelectedWidget = NULL;
    }
}


static void textEditDeleteSelection(char *buf, int32_t *pLen, int32_t *pCursor, int32_t *pSelStart, int32_t *pSelEnd) {
    int32_t lo = *pSelStart < *pSelEnd ? *pSelStart : *pSelEnd;
    int32_t hi = *pSelStart < *pSelEnd ? *pSelEnd : *pSelStart;

    if (lo < 0) {
        lo = 0;
    }

    if (hi > *pLen) {
        hi = *pLen;
    }

    if (lo >= hi) {
        *pSelStart = -1;
        *pSelEnd   = -1;
        return;
    }

    memmove(buf + lo, buf + hi, *pLen - hi + 1);
    *pLen    -= (hi - lo);
    *pCursor  = lo;
    *pSelStart = -1;
    *pSelEnd   = -1;
}


// Paint `text` as a run of single-color regions defined by
// syntaxColors[textOff..textOff+len). Coalesces adjacent same-color
// bytes to minimize drawTextN calls.
void textEditDrawColorizedText(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 = textEditSyntaxColor(d, curColor, defaultFg, customColors);
        drawTextN(d, ops, font, x + runStart * font->charWidth, y, text + runStart, runEnd - runStart, fg, bg, true);
        runStart = runEnd;
    }
}


void textEditEnsureVisible(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t cursorRow, int32_t cursorCol, int32_t tabW, int32_t visRows, int32_t visCols, int32_t *pScrollRow, int32_t *pScrollCol) {
    int32_t curLineOff = textEditLineStart(lc, buf, len, cursorRow);
    int32_t curOff     = curLineOff + cursorCol;

    if (curOff > len) {
        curOff = len;
    }

    int32_t col = textEditVisualCol(buf, curLineOff, curOff, tabW);

    if (cursorRow < *pScrollRow) {
        *pScrollRow = cursorRow;
    }

    if (cursorRow >= *pScrollRow + visRows) {
        *pScrollRow = cursorRow - visRows + 1;
    }

    if (col < *pScrollCol) {
        *pScrollCol = col;
    }

    if (col >= *pScrollCol + visCols) {
        *pScrollCol = col - visCols + 1;
    }
}


// Searches forward (or backward) from the cursor for `needle`. On
// match, selects it and moves the cursor to match start (forward)
// or match end (backward). No wrap-around.
bool textEditFindNext(TextEditLineCacheT *lc, const char *buf, int32_t len, const char *needle, bool caseSensitive, bool forward, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor) {
    if (!needle || !needle[0]) {
        return false;
    }

    int32_t needleLen = strlen(needle);

    if (needleLen > len) {
        return false;
    }

    int32_t cursorByte = textEditRowColToOff(lc, buf, len, *pCursorRow, *pCursorCol);
    int32_t searchLen  = len - needleLen + 1;  // >= 1 given the needleLen > len check above
    int32_t startPos   = forward ? cursorByte + 1 : cursorByte - 1;

    // Backward: a match at pos requires pos + needleLen <= len, so the
    // highest valid start is searchLen - 1.  Without this clamp, a cursor
    // within the last (needleLen - 1) bytes starts past every valid
    // position and the loop breaks before testing anything.
    if (!forward && startPos > searchLen - 1) {
        startPos = searchLen - 1;
    }

    int32_t count = forward ? (searchLen - startPos) : (startPos + 1);

    if (count <= 0) {
        return false;
    }

    for (int32_t attempt = 0; attempt < count; attempt++) {
        int32_t pos = forward ? startPos + attempt : startPos - attempt;

        if (pos < 0 || pos >= searchLen) {
            break;
        }

        bool match;

        if (caseSensitive) {
            match = (memcmp(buf + pos, needle, needleLen) == 0);
        } else {
            match = (strncasecmp(buf + pos, needle, needleLen) == 0);
        }

        if (match) {
            *pSelAnchor = pos;
            *pSelCursor = pos + needleLen;

            int32_t cursorOff = forward ? pos : pos + needleLen;
            textEditOffToRowCol(lc, buf, len, cursorOff, pCursorRow, pCursorCol);
            *pDesiredCol = *pCursorCol;
            return true;
        }
    }

    return false;
}


// Writes the word under the cursor (if any) into out and returns
// its length. Empty string if the cursor is not on a word.
int32_t textEditGetWordAtCursor(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t cursorRow, int32_t cursorCol, char *out, int32_t outSize) {
    out[0] = '\0';

    if (!buf || len == 0 || outSize < 2) {
        return 0;
    }

    int32_t off    = textEditRowColToOff(lc, buf, len, cursorRow, cursorCol);
    int32_t ws     = wordStart(buf, off);
    int32_t we     = wordEnd(buf, len, off);
    int32_t wdLen  = we - ws;

    if (wdLen <= 0) {
        return 0;
    }

    if (wdLen >= outSize) {
        wdLen = outSize - 1;
    }

    memcpy(out, buf + ws, wdLen);
    out[wdLen] = '\0';
    return wdLen;
}


// Jumps to the given 1-based line, selects the whole line, and
// scrolls so the line sits ~1/4 from the top of the visible area.
void textEditGoToLine(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t line, int32_t visRows, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pScrollRow, int32_t *pScrollCol, int32_t *pSelAnchor, int32_t *pSelCursor) {
    int32_t totalLines = textEditLineCount(lc, buf, len);
    int32_t row        = line - 1;

    if (row < 0) {
        row = 0;
    }

    if (row >= totalLines) {
        row = totalLines - 1;
    }

    *pCursorRow  = row;
    *pCursorCol  = 0;
    *pDesiredCol = 0;

    int32_t lineStart = textEditLineStart(lc, buf, len, row);
    int32_t lineL     = textEditLineLen(lc, buf, len, row);
    *pSelAnchor = lineStart;
    *pSelCursor = lineStart + lineL;

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

    *pScrollRow = targetScroll;
    *pScrollCol = 0;
}


void textEditLineCacheDirty(TextEditLineCacheT *lc) {
    lc->cachedLines = -1;
    lc->cachedMaxLL = -1;
}


void textEditLineCacheEnsure(TextEditLineCacheT *lc, const char *buf, int32_t len) {
    if (lc->cachedLines < 0) {
        textEditLineCacheRebuild(lc, buf, len);
    }
}


void textEditLineCacheFree(TextEditLineCacheT *lc) {
    free(lc->lineOffsets);
    free(lc->lineVisLens);
    lc->lineOffsets   = NULL;
    lc->lineVisLens   = NULL;
    lc->lineOffsetCap = 0;
    lc->lineVisLenCap = 0;
    lc->cachedLines   = -1;
    lc->cachedMaxLL   = -1;
}


void textEditLineCacheInit(TextEditLineCacheT *lc, int32_t tabWidth) {
    lc->lineOffsets   = NULL;
    lc->lineVisLens   = NULL;
    lc->lineOffsetCap = 0;
    lc->lineVisLenCap = 0;
    lc->cachedLines   = -1;
    lc->cachedMaxLL   = -1;
    lc->tabWidth      = tabWidth > 0 ? tabWidth : TEXT_EDIT_DEFAULT_TAB_WIDTH;
}


// Incrementally update the cache after deleting bytes at `off`.
// Caller passes the POST-delete buffer and length. Falls back to a
// full rebuild if the deletion may have spanned a newline.
void textEditLineCacheNotifyDelete(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t deleteLen) {
    if (lc->cachedLines < 0 || deleteLen <= 0) {
        textEditLineCacheDirty(lc);
        return;
    }

    // We only trust the incremental path for single-byte deletes; any
    // longer run could have swallowed a newline, and the post-delete
    // buffer can't tell us whether it did.
    if (deleteLen > 1) {
        textEditLineCacheDirty(lc);
        return;
    }

    int32_t line = 0;

    for (line = lc->cachedLines - 1; line > 0; line--) {
        if (lc->lineOffsets[line] <= off) {
            break;
        }
    }

    for (int32_t i = line + 1; i <= lc->cachedLines; i++) {
        lc->lineOffsets[i] -= deleteLen;
    }

    int32_t lineOff = lc->lineOffsets[line];
    int32_t nextOff = (line + 1 <= lc->cachedLines) ? lc->lineOffsets[line + 1] : len;

    if (line + 1 <= lc->cachedLines && nextOff <= lineOff) {
        textEditLineCacheDirty(lc);
        return;
    }

    for (int32_t i = lineOff; i < nextOff && i < len; i++) {
        if (buf[i] == '\n') {
            if (i < nextOff - 1) {
                textEditLineCacheDirty(lc);
                return;
            }
        }
    }

    int32_t tabW = lc->tabWidth > 0 ? lc->tabWidth : TEXT_EDIT_DEFAULT_TAB_WIDTH;
    int32_t vc   = 0;

    for (int32_t i = lineOff; i < nextOff && i < len && buf[i] != '\n'; i++) {
        if (buf[i] == '\t') {
            vc += tabW - (vc % tabW);
        } else {
            vc++;
        }
    }

    lc->lineVisLens[line] = vc;
    lc->cachedMaxLL       = -1;
}


// Incrementally update the cache after inserting bytes at `off`.
// Caller passes the POST-insert buffer and length. Falls back to a
// full rebuild if the insertion contains newlines.
void textEditLineCacheNotifyInsert(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t insertLen) {
    if (lc->cachedLines < 0 || insertLen <= 0) {
        textEditLineCacheDirty(lc);
        return;
    }

    for (int32_t i = off; i < off + insertLen && i < len; i++) {
        if (buf[i] == '\n') {
            textEditLineCacheDirty(lc);
            return;
        }
    }

    int32_t line = 0;

    for (line = lc->cachedLines - 1; line > 0; line--) {
        if (lc->lineOffsets[line] <= off) {
            break;
        }
    }

    for (int32_t i = line + 1; i <= lc->cachedLines; i++) {
        lc->lineOffsets[i] += insertLen;
    }

    int32_t lineOff = lc->lineOffsets[line];
    int32_t lineEnd = (line + 1 <= lc->cachedLines) ? lc->lineOffsets[line + 1] : len;
    int32_t tabW    = lc->tabWidth > 0 ? lc->tabWidth : TEXT_EDIT_DEFAULT_TAB_WIDTH;
    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++;
        }
    }

    lc->lineVisLens[line] = vc;

    // cachedMaxLL == -1 is the "unknown/needs-recompute" sentinel.  An insert
    // can only grow the edited line's visual length, so only grow a KNOWN max
    // and never clobber the sentinel with a single line's length.
    if (lc->cachedMaxLL >= 0 && vc > lc->cachedMaxLL) {
        lc->cachedMaxLL = vc;
    }
}


// Single O(N) scan populates line-offset table plus per-line
// visual lengths. lineOffsets[lineCount] is the buffer-end sentinel.
static void textEditLineCacheRebuild(TextEditLineCacheT *lc, const char *buf, int32_t len) {
    int32_t tabW      = lc->tabWidth > 0 ? lc->tabWidth : TEXT_EDIT_DEFAULT_TAB_WIDTH;
    int32_t lineCount = 1;

    for (int32_t i = 0; i < len; i++) {
        if (buf[i] == '\n') {
            lineCount++;
        }
    }

    int32_t needed = lineCount + 1;

    if (needed > lc->lineOffsetCap) {
        int32_t  newCap = needed + TEXT_EDIT_LINE_CAP_GROWTH;
        int32_t *tmp    = (int32_t *)realloc(lc->lineOffsets, newCap * sizeof(int32_t));

        if (!tmp) {
            // Leave the cache marked invalid so a later call retries.
            lc->cachedLines = -1;
            return;
        }

        lc->lineOffsets   = tmp;
        lc->lineOffsetCap = newCap;
    }

    if (lineCount > lc->lineVisLenCap) {
        int32_t  newCap = lineCount + TEXT_EDIT_LINE_CAP_GROWTH;
        int32_t *tmp2   = (int32_t *)realloc(lc->lineVisLens, newCap * sizeof(int32_t));

        if (!tmp2) {
            lc->cachedLines = -1;
            return;
        }

        lc->lineVisLens   = tmp2;
        lc->lineVisLenCap = newCap;
    }

    int32_t line  = 0;
    int32_t vc    = 0;
    int32_t maxVL = 0;

    lc->lineOffsets[0] = 0;

    for (int32_t i = 0; i < len; i++) {
        if (buf[i] == '\n') {
            lc->lineVisLens[line] = vc;

            if (vc > maxVL) {
                maxVL = vc;
            }

            line++;
            lc->lineOffsets[line] = i + 1;
            vc = 0;
        } else if (buf[i] == '\t') {
            vc += tabW - (vc % tabW);
        } else {
            vc++;
        }
    }

    lc->lineVisLens[line] = vc;

    if (vc > maxVL) {
        maxVL = vc;
    }

    lc->lineOffsets[lineCount] = len;
    lc->cachedLines            = lineCount;
    lc->cachedMaxLL            = maxVL;
}


void textEditLineCacheSetTabWidth(TextEditLineCacheT *lc, int32_t tabWidth) {
    int32_t newW = tabWidth > 0 ? tabWidth : TEXT_EDIT_DEFAULT_TAB_WIDTH;

    if (lc->tabWidth == newW) {
        return;
    }

    lc->tabWidth = newW;
    textEditLineCacheDirty(lc);
}


void textEditMultiFree(TextEditMultiT *te) {
    free(te->buf);
    free(te->undoBuf);
    textEditLineCacheFree(&te->lines);
    te->buf     = NULL;
    te->undoBuf = NULL;
    te->bufSize = 0;
    te->len     = 0;
}


// Allocates the buf/undoBuf pair sized for maxLen characters plus
// NUL, and initializes cursor/scroll/selection/undo state.  Returns
// false (leaving te->buf == NULL) if allocation fails.
bool textEditMultiInit(TextEditMultiT *te, int32_t maxLen, int32_t tabWidth) {
    memset(te, 0, sizeof(*te));

    int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;

    te->buf     = (char *)malloc(bufSize);
    te->undoBuf = (char *)malloc(bufSize);
    te->bufSize = bufSize;

    if (!te->buf || !te->undoBuf) {
        free(te->buf);
        free(te->undoBuf);
        te->buf     = NULL;
        te->undoBuf = NULL;
        return false;
    }

    te->buf[0]     = '\0';
    te->undoBuf[0] = '\0';
    te->selAnchor  = -1;
    te->selCursor  = -1;
    textEditLineCacheInit(&te->lines, tabWidth);
    return true;
}


int32_t textEditLineCount(TextEditLineCacheT *lc, const char *buf, int32_t len) {
    textEditLineCacheEnsure(lc, buf, len);
    return lc->cachedLines;
}


int32_t textEditLineLen(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row) {
    textEditLineCacheEnsure(lc, buf, len);

    if (row < 0 || row >= lc->cachedLines) {
        return 0;
    }

    int32_t start = lc->lineOffsets[row];
    int32_t next  = lc->lineOffsets[row + 1];

    if (next > start && buf[next - 1] == '\n') {
        next--;
    }

    return next - start;
}


int32_t textEditLineStart(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row) {
    textEditLineCacheEnsure(lc, buf, len);

    if (row < 0) {
        return 0;
    }

    if (row >= lc->cachedLines) {
        return len;
    }

    return lc->lineOffsets[row];
}


int32_t textEditMaxLineLen(TextEditLineCacheT *lc, const char *buf, int32_t len) {
    textEditLineCacheEnsure(lc, buf, len);

    if (lc->cachedMaxLL < 0 && lc->cachedLines >= 0) {
        int32_t maxVL = 0;

        for (int32_t i = 0; i < lc->cachedLines; i++) {
            if (lc->lineVisLens[i] > maxVL) {
                maxVL = lc->lineVisLens[i];
            }
        }

        lc->cachedMaxLL = maxVL;
    }

    return lc->cachedMaxLL;
}


void textEditOffToRowCol(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t *row, int32_t *col) {
    textEditLineCacheEnsure(lc, buf, len);

    if (!lc->lineOffsets || lc->cachedLines < 0) {
        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;
        return;
    }

    int32_t lo = 0;
    int32_t hi = lc->cachedLines;

    while (lo < hi) {
        int32_t mid = (lo + hi + 1) / 2;

        if (lc->lineOffsets[mid] <= off) {
            lo = mid;
        } else {
            hi = mid - 1;
        }
    }

    *row = lo;
    *col = off - lc->lineOffsets[lo];
}


// Replaces every occurrence of needle with replacement. Records a
// single undo snapshot before mutating. Clamps the cursor to the
// post-replace buffer length and clears any selection. Returns the
// count of replacements made.
int32_t textEditReplaceAll(TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, const char *needle, const char *replacement, bool caseSensitive, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor) {
    if (!needle || !needle[0] || !replacement) {
        return 0;
    }

    int32_t needleLen = strlen(needle);
    int32_t replLen   = strlen(replacement);
    int32_t delta     = replLen - needleLen;
    int32_t count     = 0;

    if (needleLen > *pLen) {
        return 0;
    }

    textEditSaveUndo(buf, *pLen, textEditRowColToOff(lc, buf, *pLen, *pCursorRow, *pCursorCol), undoBuf, pUndoLen, pUndoCursor, bufSize);

    int32_t pos = 0;

    while (pos + needleLen <= *pLen) {
        bool match;

        if (caseSensitive) {
            match = (memcmp(buf + pos, needle, needleLen) == 0);
        } else {
            match = (strncasecmp(buf + pos, needle, needleLen) == 0);
        }

        if (match) {
            if (*pLen + delta >= bufSize) {
                break;
            }

            if (delta != 0) {
                memmove(buf + pos + replLen, buf + pos + needleLen, *pLen - pos - needleLen);
            }

            memcpy(buf + pos, replacement, replLen);
            *pLen += delta;
            buf[*pLen] = '\0';
            count++;
            pos += replLen;
        } else {
            pos++;
        }
    }

    if (count > 0) {
        *pSelAnchor = -1;
        *pSelCursor = -1;
        // Dirty the cache BEFORE converting the cursor: replaceAll changed the
        // buffer, so the row/col conversions below must rebuild from it.
        textEditLineCacheDirty(lc);

        int32_t cursorOff = textEditRowColToOff(lc, buf, *pLen, *pCursorRow, *pCursorCol);

        if (cursorOff > *pLen) {
            textEditOffToRowCol(lc, buf, *pLen, *pLen, pCursorRow, pCursorCol);
        }

        *pDesiredCol = *pCursorCol;
    }

    return count;
}


// textEditReplaceSelectionMulti -- if the current selection exactly
// matches needle, splice replacement in its place, record a single
// undo snapshot, update cursor/selection/length, and dirty the line
// cache.  Mirrors textEditReplaceAll's undo/cursor/line-cache policy.
// After a successful replace the cursor is left at the end of the
// inserted text when searching forward, or at its start when
// searching backward, so a subsequent textEditFindNext continues
// past the replacement in the chosen direction.  Returns true only
// if a replacement was performed.
bool textEditReplaceSelectionMulti(TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, const char *needle, const char *replacement, bool caseSensitive, bool forward, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor) {
    if (!needle || !needle[0] || !replacement) {
        return false;
    }

    if (*pSelAnchor < 0 || *pSelCursor < 0 || *pSelAnchor == *pSelCursor) {
        return false;
    }

    int32_t selStart = *pSelAnchor < *pSelCursor ? *pSelAnchor : *pSelCursor;
    int32_t selEnd   = *pSelAnchor < *pSelCursor ? *pSelCursor : *pSelAnchor;
    int32_t selLen   = selEnd - selStart;
    int32_t needleLen = (int32_t)strlen(needle);

    if (selLen != needleLen) {
        return false;
    }

    if (selStart < 0 || selEnd > *pLen) {
        return false;
    }

    bool match;

    if (caseSensitive) {
        match = (memcmp(buf + selStart, needle, needleLen) == 0);
    } else {
        match = (strncasecmp(buf + selStart, needle, needleLen) == 0);
    }

    if (!match) {
        return false;
    }

    int32_t replLen = (int32_t)strlen(replacement);
    int32_t delta   = replLen - needleLen;

    if (*pLen + delta >= bufSize) {
        return false;
    }

    textEditSaveUndo(buf, *pLen, textEditRowColToOff(lc, buf, *pLen, *pCursorRow, *pCursorCol), undoBuf, pUndoLen, pUndoCursor, bufSize);

    if (delta != 0) {
        memmove(buf + selStart + replLen, buf + selStart + needleLen, *pLen - selStart - needleLen);
    }

    memcpy(buf + selStart, replacement, replLen);
    *pLen += delta;
    buf[*pLen] = '\0';

    *pSelAnchor = -1;
    *pSelCursor = -1;

    int32_t cursorOff = forward ? selStart + replLen : selStart;
    textEditLineCacheDirty(lc);
    textEditOffToRowCol(lc, buf, *pLen, cursorOff, pCursorRow, pCursorCol);
    *pDesiredCol = *pCursorCol;

    return true;
}


int32_t textEditRowColToOff(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row, int32_t col) {
    int32_t start = textEditLineStart(lc, buf, len, row);
    int32_t lineL = textEditLineLen(lc, buf, len, row);
    int32_t clamp = col < lineL ? col : lineL;

    return start + clamp;
}


void textEditSaveUndo(char *buf, int32_t len, int32_t cursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t bufSize) {
    if (!undoBuf) {
        return;
    }

    int32_t copyLen = len < bufSize ? len : bufSize - 1;
    memcpy(undoBuf, buf, copyLen);
    undoBuf[copyLen] = '\0';
    *pUndoLen    = copyLen;
    *pUndoCursor = cursor;
}


// Resolve a syntax color index to a packed-pixel foreground color.
// Falls back to defaultFg for TEXT_SYNTAX_DEFAULT or unknown indices.
// A non-zero entry in custom[idx] (0x00RRGGBB) overrides the default.
uint32_t textEditSyntaxColor(const DisplayT *d, uint8_t idx, uint32_t defaultFg, const uint32_t *custom) {
    if (idx != TEXT_SYNTAX_DEFAULT && idx < TEXT_SYNTAX_MAX && custom && custom[idx]) {
        uint32_t c = custom[idx];
        return packColor(d, (c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF);
    }

    switch (idx) {
        case TEXT_SYNTAX_KEYWORD:  return packColor(d,   0,   0, 128);
        case TEXT_SYNTAX_STRING:   return packColor(d, 128,   0,   0);
        case TEXT_SYNTAX_COMMENT:  return packColor(d,   0, 128,   0);
        case TEXT_SYNTAX_NUMBER:   return packColor(d, 128,   0, 128);
        case TEXT_SYNTAX_OPERATOR: return packColor(d, 128, 128,   0);
        case TEXT_SYNTAX_TYPE:     return packColor(d,   0, 128, 128);
        default:                   return defaultFg;
    }
}


// Tab-aware visual column from buffer offset within a line.
// tabW <= 0 means tabs are 1 column (no expansion).
int32_t textEditVisualCol(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.
int32_t textEditVisualColToOff(const char *buf, int32_t len, int32_t lineStart, int32_t targetVC, int32_t tabW) {
    int32_t vc = 0;
    int32_t i  = lineStart;

    while (i < len && buf[i] != '\n') {
        int32_t w = 1;

        if (buf[i] == '\t' && tabW > 0) {
            w = tabW - (vc % tabW);
        }

        if (vc + w > targetVC) {
            break;
        }

        vc += w;
        i++;
    }

    return i;
}


static void textHelpInit(void) {
    sCursorBlinkFn = wgtUpdateCursorBlink;
    widgetRegisterDestroyFn(textEditClearWidgetRef);
}


void wgtUpdateCursorBlink(void) {
    clock_t now      = clock();
    clock_t interval = (clock_t)CURSOR_BLINK_MS * CLOCKS_PER_SEC / 1000;

    if ((now - sCursorBlinkTime) >= interval) {
        sCursorBlinkTime = now;
        sCursorBlinkOn   = !sCursorBlinkOn;

        if (sFocusedWidget) {
            wgtInvalidatePaint(sFocusedWidget);
        }
    }
}


// Called during mouse drag to extend the selection for single-line
// text widgets. Auto-scrolls when the mouse moves past the visible
// text edges.
void widgetTextEditDragUpdateLine(int32_t vx, int32_t leftEdge, int32_t maxChars, const BitmapFontT *font, int32_t len, int32_t *pCursorPos, int32_t *pScrollOff, int32_t *pSelEnd) {
    int32_t rightEdge = leftEdge + maxChars * font->charWidth;

    if (vx < leftEdge && *pScrollOff > 0) {
        (*pScrollOff)--;
    } else if (vx >= rightEdge && *pScrollOff + maxChars < len) {
        (*pScrollOff)++;
    }

    int32_t relX    = vx - leftEdge;
    int32_t charPos = relX / font->charWidth + *pScrollOff;

    if (charPos < 0) {
        charPos = 0;
    }

    if (charPos > len) {
        charPos = len;
    }

    *pCursorPos = charPos;
    *pSelEnd    = charPos;
}


// Computes cursor position from pixel coordinates, handles multi-click
// (double = word select, triple = select all), and optionally starts
// drag-select.
void widgetTextEditMouseClick(WidgetT *w, int32_t vx, int32_t vy, int32_t textLeftX, const BitmapFontT *font, const char *buf, int32_t len, int32_t scrollOff, int32_t *pCursorPos, int32_t *pSelStart, int32_t *pSelEnd, bool wordSelect, bool dragSelect) {
    int32_t relX    = vx - textLeftX;
    int32_t charPos = relX / font->charWidth + scrollOff;

    if (charPos < 0) {
        charPos = 0;
    }

    if (charPos > len) {
        charPos = len;
    }

    int32_t clicks = multiClickDetect(vx, vy);

    if (clicks >= 3) {
        *pSelStart  = 0;
        *pSelEnd    = len;
        *pCursorPos = len;
        sDragWidget = NULL;
        return;
    }

    if (clicks == 2) {
        if (wordSelect && buf) {
            int32_t ws = wordStart(buf, charPos);
            int32_t we = wordEnd(buf, len, charPos);
            *pSelStart  = ws;
            *pSelEnd    = we;
            *pCursorPos = we;
        } else {
            *pSelStart  = 0;
            *pSelEnd    = len;
            *pCursorPos = len;
        }

        sDragWidget = NULL;
        return;
    }

    // Single click: place cursor
    *pCursorPos = charPos;
    *pSelStart  = charPos;
    *pSelEnd    = charPos;
    sDragWidget = dragSelect ? w : NULL;
}


// Called during a drag to auto-scroll when the mouse moves past the
// top or bottom of the visible area, and to extend the selection to
// the new cursor position.
void widgetTextEditMultiDragUpdateArea(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t vx, int32_t vy, int32_t textX, int32_t textY, const BitmapFontT *font, int32_t *pScrollRow, int32_t scrollCol, int32_t visRows, int32_t tabW, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelCursor) {
    int32_t relX       = vx - textX;
    int32_t relY       = vy - textY;
    int32_t totalLines = textEditLineCount(lc, buf, len);

    if (relY < 0 && *pScrollRow > 0) {
        (*pScrollRow)--;
    } else if (relY >= visRows * font->charHeight && *pScrollRow < totalLines - visRows) {
        (*pScrollRow)++;
    }

    int32_t clickRow    = *pScrollRow + relY / font->charHeight;
    int32_t clickVisCol = scrollCol + relX / font->charWidth;

    if (clickRow < 0) {
        clickRow = 0;
    }

    if (clickRow >= totalLines) {
        clickRow = totalLines - 1;
    }

    if (clickVisCol < 0) {
        clickVisCol = 0;
    }

    int32_t dragLineStart = textEditLineStart(lc, buf, len, clickRow);
    int32_t dragByteOff   = textEditVisualColToOff(buf, len, dragLineStart, clickVisCol, tabW);
    int32_t clickCol      = dragByteOff - dragLineStart;
    int32_t lineL         = textEditLineLen(lc, buf, len, clickRow);

    if (clickCol > lineL) {
        clickCol = lineL;
    }

    *pCursorRow  = clickRow;
    *pCursorCol  = clickCol;
    *pDesiredCol = clickCol;
    *pSelCursor  = textEditRowColToOff(lc, buf, len, clickRow, clickCol);
}


// Handle a mouse click on the text content area. Caller has already
// excluded scrollbar and gutter clicks. Single click places cursor
// and starts a drag anchor; double-click selects the word under
// the cursor; triple-click selects the whole line.
void widgetTextEditMultiMouseClick(WidgetT *w, TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t vx, int32_t vy, int32_t textX, int32_t textY, const BitmapFontT *font, int32_t scrollRow, int32_t scrollCol, int32_t tabW, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor) {
    int32_t totalLines  = textEditLineCount(lc, buf, len);
    int32_t relX        = vx - textX;
    int32_t relY        = vy - textY;
    int32_t clickRow    = scrollRow + relY / font->charHeight;
    int32_t clickVisCol = scrollCol + relX / font->charWidth;

    if (clickRow < 0) {
        clickRow = 0;
    }

    if (clickRow >= totalLines) {
        clickRow = totalLines - 1;
    }

    if (clickVisCol < 0) {
        clickVisCol = 0;
    }

    int32_t clkLineStart = textEditLineStart(lc, buf, len, clickRow);
    int32_t clkByteOff   = textEditVisualColToOff(buf, len, clkLineStart, clickVisCol, tabW);
    int32_t clickCol     = clkByteOff - clkLineStart;
    int32_t lineL        = textEditLineLen(lc, buf, len, clickRow);

    if (clickCol > lineL) {
        clickCol = lineL;
    }

    int32_t clicks = multiClickDetect(vx, vy);

    if (clicks >= 3) {
        int32_t lineStart = textEditRowColToOff(lc, buf, len, clickRow, 0);
        int32_t lineEnd   = textEditRowColToOff(lc, buf, len, clickRow, lineL);

        *pCursorRow  = clickRow;
        *pCursorCol  = lineL;
        *pDesiredCol = lineL;
        *pSelAnchor  = lineStart;
        *pSelCursor  = lineEnd;
        sDragWidget  = NULL;
        return;
    }

    if (clicks == 2 && buf) {
        int32_t off = textEditRowColToOff(lc, buf, len, clickRow, clickCol);
        int32_t ws  = wordStart(buf, off);
        int32_t we  = wordEnd(buf, len, off);
        int32_t weRow;
        int32_t weCol;
        textEditOffToRowCol(lc, buf, len, we, &weRow, &weCol);

        *pCursorRow  = weRow;
        *pCursorCol  = weCol;
        *pDesiredCol = weCol;
        *pSelAnchor  = ws;
        *pSelCursor  = we;
        sDragWidget  = NULL;
        return;
    }

    *pCursorRow  = clickRow;
    *pCursorCol  = clickCol;
    *pDesiredCol = clickCol;

    int32_t anchorOff = textEditRowColToOff(lc, buf, len, clickRow, clickCol);
    *pSelAnchor = anchorOff;
    *pSelCursor = anchorOff;
    sDragWidget = w;
}


// Core multi-line text editing engine. Parameterized on state
// pointers so that the widget's fields remain its own; the library
// never dereferences TextAreaDataT or similar.
void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pScrollRow, int32_t *pScrollCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t visRows, int32_t visCols, const TextEditMultiOptionsT *opts) {
    int32_t *pRow = pCursorRow;
    int32_t *pCol = pCursorCol;
    int32_t *pSA  = pSelAnchor;
    int32_t *pSC  = pSelCursor;
    bool     shift = (mod & KEY_MOD_SHIFT) != 0;
    int32_t  tabW  = opts->tabWidth > 0 ? opts->tabWidth : TEXT_EDIT_DEFAULT_TAB_WIDTH;

    #define CUR_OFF()    textEditRowColToOff(lc, buf, *pLen, *pRow, *pCol)
    #define ENSURE_VIS() textEditEnsureVisible(lc, buf, *pLen, *pRow, *pCol, tabW, visRows, visCols, pScrollRow, pScrollCol)
    #define FROM_OFF(o)  textEditOffToRowCol(lc, buf, *pLen, (o), pRow, pCol)
    #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)

    if (HAS_SEL()) {
        if (*pSA > *pLen) {
            *pSA = *pLen;
        }

        if (*pSC > *pLen) {
            *pSC = *pLen;
        }
    }

    // Ctrl+A  -- select all
    if (key == 1) {
        *pSA = 0;
        *pSC = *pLen;
        FROM_OFF(*pLen);
        *pDesiredCol = *pCol;
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Ctrl+C  -- copy
    if (key == KEY_CTRL_C) {
        if (HAS_SEL()) {
            clipboardCopy(buf + SEL_LO(), SEL_HI() - SEL_LO());
        }

        return;
    }

    if (opts->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(), undoBuf, pUndoLen, pUndoCursor, 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);
                FROM_OFF(lo);
                *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;
                // Buffer geometry changed; rebuild the line cache before
                // converting the post-paste offset, otherwise a multi-line
                // paste maps to a stale row/col.
                textEditLineCacheDirty(lc);
                FROM_OFF(off + paste);
                *pDesiredCol = *pCol;
            }

            if (w->onChange) {
                w->onChange(w);
            }

            textEditLineCacheDirty(lc);
        }

        ENSURE_VIS();
        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(), undoBuf, pUndoLen, pUndoCursor, bufSize);

            int32_t lo = SEL_LO();
            int32_t hi = SEL_HI();
            memmove(buf + lo, buf + hi, *pLen - hi + 1);
            *pLen -= (hi - lo);
            FROM_OFF(lo);
            *pSA = -1;
            *pSC = -1;
            *pDesiredCol = *pCol;

            if (w->onChange) {
                w->onChange(w);
            }

            textEditLineCacheDirty(lc);
        }

        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Ctrl+Z  -- undo (single-slot swap; gives you one level of redo)
    if (key == KEY_CTRL_Z) {
        if (undoBuf && *pUndoLen >= 0) {
            // Heap scratch rather than a ~64KB stack VLA (buf can be the full
            // editor buffer).  Abort the undo cleanly if allocation fails,
            // falling through to the unconditional tail below.
            char *tmpBuf = (char *)malloc((size_t)*pLen + 1);

            if (tmpBuf) {
                int32_t tmpLen    = *pLen;
                int32_t tmpCursor = CUR_OFF();
                int32_t copyLen   = tmpLen;

                memcpy(tmpBuf, buf, copyLen);
                tmpBuf[copyLen] = '\0';

                int32_t restLen = *pUndoLen < bufSize - 1 ? *pUndoLen : bufSize - 1;
                memcpy(buf, undoBuf, restLen);
                buf[restLen] = '\0';
                *pLen = restLen;

                int32_t saveLen = copyLen < bufSize - 1 ? copyLen : bufSize - 1;
                memcpy(undoBuf, tmpBuf, saveLen);
                undoBuf[saveLen] = '\0';
                free(tmpBuf);
                // Compute the restore offset from the snapshot cursor (still in
                // *pUndoCursor) BEFORE overwriting it with the redo cursor.
                int32_t restoreOff = *pUndoCursor < *pLen ? *pUndoCursor : *pLen;
                *pUndoLen    = saveLen;
                *pUndoCursor = tmpCursor;
                // Buffer geometry changed; rebuild the line cache before
                // converting the restore offset, otherwise the cursor maps
                // to a stale row/col from the pre-undo buffer.
                textEditLineCacheDirty(lc);
                FROM_OFF(restoreOff);
                *pDesiredCol = *pCol;
                *pSA = -1;
                *pSC = -1;

                if (w->onChange) {
                    w->onChange(w);
                }

                textEditLineCacheDirty(lc);
            }
        }

        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Enter  -- insert newline (optionally copies leading whitespace)
    if (key == 0x0D) {
        if (*pLen < bufSize - 1) {
            textEditSaveUndo(buf, *pLen, CUR_OFF(), undoBuf, pUndoLen, pUndoCursor, 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);
                FROM_OFF(lo);
                *pSA = -1;
                *pSC = -1;
            }

            int32_t off    = CUR_OFF();
            int32_t indent = 0;
            char indentBuf[TEXT_EDIT_MAX_AUTO_INDENT];

            if (opts->autoIndent) {
                int32_t lineStart = textEditLineStart(lc, buf, *pLen, *pRow);

                while (lineStart + indent < off && indent < (int32_t)sizeof(indentBuf) - 1 && (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;
                *pDesiredCol = indent;
            }

            if (w->onChange) {
                w->onChange(w);
            }

            textEditLineCacheDirty(lc);
        }

        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Backspace
    if (key == 8) {
        if (HAS_SEL()) {
            textEditSaveUndo(buf, *pLen, CUR_OFF(), undoBuf, pUndoLen, pUndoCursor, bufSize);
            int32_t lo = SEL_LO();
            int32_t hi = SEL_HI();
            memmove(buf + lo, buf + hi, *pLen - hi + 1);
            *pLen -= (hi - lo);
            FROM_OFF(lo);
            *pSA = -1;
            *pSC = -1;
            *pDesiredCol = *pCol;
            textEditLineCacheDirty(lc);

            if (w->onChange) {
                w->onChange(w);
            }
        } else {
            int32_t off = CUR_OFF();

            if (off > 0) {
                char deleted = buf[off - 1];
                textEditSaveUndo(buf, *pLen, off, undoBuf, pUndoLen, pUndoCursor, bufSize);
                memmove(buf + off - 1, buf + off, *pLen - off + 1);
                (*pLen)--;
                FROM_OFF(off - 1);
                *pDesiredCol = *pCol;

                if (deleted == '\n') {
                    textEditLineCacheDirty(lc);
                } else {
                    textEditLineCacheNotifyDelete(lc, buf, *pLen, off - 1, 1);
                }

                if (w->onChange) {
                    w->onChange(w);
                }
            }
        }

        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Delete
    if (key == KEY_DELETE) {
        if (HAS_SEL()) {
            textEditSaveUndo(buf, *pLen, CUR_OFF(), undoBuf, pUndoLen, pUndoCursor, bufSize);
            int32_t lo = SEL_LO();
            int32_t hi = SEL_HI();
            memmove(buf + lo, buf + hi, *pLen - hi + 1);
            *pLen -= (hi - lo);
            FROM_OFF(lo);
            *pSA = -1;
            *pSC = -1;
            *pDesiredCol = *pCol;
            textEditLineCacheDirty(lc);

            if (w->onChange) {
                w->onChange(w);
            }
        } else {
            int32_t off = CUR_OFF();

            if (off < *pLen) {
                char deleted = buf[off];
                textEditSaveUndo(buf, *pLen, off, undoBuf, pUndoLen, pUndoCursor, bufSize);
                memmove(buf + off, buf + off + 1, *pLen - off);
                (*pLen)--;

                if (deleted == '\n') {
                    textEditLineCacheDirty(lc);
                } else {
                    textEditLineCacheNotifyDelete(lc, buf, *pLen, off, 1);
                }

                if (w->onChange) {
                    w->onChange(w);
                }
            }
        }

        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

navigation: {
    int32_t totalLines = textEditLineCount(lc, buf, *pLen);

    // Left arrow
    if (key == KEY_LEFT) {
        SEL_BEGIN();
        int32_t off = CUR_OFF();

        if (off > 0) {
            FROM_OFF(off - 1);
        }

        *pDesiredCol = *pCol;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Right arrow
    if (key == KEY_RIGHT) {
        SEL_BEGIN();
        int32_t off = CUR_OFF();

        if (off < *pLen) {
            FROM_OFF(off + 1);
        }

        *pDesiredCol = *pCol;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Ctrl+Left  -- word left
    if (key == (0x73 | 0x100)) {
        SEL_BEGIN();
        int32_t off    = CUR_OFF();
        int32_t newOff = wordBoundaryLeft(buf, off);
        FROM_OFF(newOff);
        *pDesiredCol = *pCol;
        SEL_END();
        ENSURE_VIS();
        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);
        FROM_OFF(newOff);
        *pDesiredCol = *pCol;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Up arrow
    if (key == KEY_UP) {
        SEL_BEGIN();

        if (*pRow > 0) {
            (*pRow)--;
            int32_t lineL = textEditLineLen(lc, buf, *pLen, *pRow);
            *pCol = *pDesiredCol < lineL ? *pDesiredCol : lineL;
        }

        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Down arrow
    if (key == KEY_DOWN) {
        SEL_BEGIN();

        if (*pRow < totalLines - 1) {
            (*pRow)++;
            int32_t lineL = textEditLineLen(lc, buf, *pLen, *pRow);
            *pCol = *pDesiredCol < lineL ? *pDesiredCol : lineL;
        }

        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Home
    if (key == KEY_HOME) {
        SEL_BEGIN();
        *pCol = 0;
        *pDesiredCol = 0;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // End
    if (key == KEY_END) {
        SEL_BEGIN();
        *pCol = textEditLineLen(lc, buf, *pLen, *pRow);
        *pDesiredCol = *pCol;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Page Up
    if (key == KEY_PGUP) {
        SEL_BEGIN();
        *pRow -= visRows;

        if (*pRow < 0) {
            *pRow = 0;
        }

        int32_t lineL = textEditLineLen(lc, buf, *pLen, *pRow);
        *pCol = *pDesiredCol < lineL ? *pDesiredCol : lineL;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Page Down
    if (key == KEY_PGDN) {
        SEL_BEGIN();
        *pRow += visRows;

        if (*pRow >= totalLines) {
            *pRow = totalLines - 1;
        }

        int32_t lineL = textEditLineLen(lc, buf, *pLen, *pRow);
        *pCol = *pDesiredCol < lineL ? *pDesiredCol : lineL;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Ctrl+Home
    if (key == (0x77 | 0x100)) {
        SEL_BEGIN();
        *pRow = 0;
        *pCol = 0;
        *pDesiredCol = 0;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    // Ctrl+End
    if (key == (0x75 | 0x100)) {
        SEL_BEGIN();
        FROM_OFF(*pLen);
        *pDesiredCol = *pCol;
        SEL_END();
        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }
}

    // Tab key
    if (key == 9 && opts->captureTabs && !opts->readOnly) {
        if (*pLen < bufSize - 1) {
            textEditSaveUndo(buf, *pLen, CUR_OFF(), undoBuf, pUndoLen, pUndoCursor, 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);
                FROM_OFF(lo);
                *pSA = -1;
                *pSC = -1;
            }

            int32_t off = CUR_OFF();

            if (opts->useTabChar) {
                if (*pLen < bufSize - 1) {
                    memmove(buf + off + 1, buf + off, *pLen - off + 1);
                    buf[off] = '\t';
                    (*pLen)++;
                    (*pCol)++;
                    *pDesiredCol = *pCol;
                }
            } else {
                // *pCol is a byte column; tab-stop math must use the
                // visual column so cursors on lines containing literal
                // tabs still land on a tab stop.
                int32_t lineStart = textEditLineStart(lc, buf, *pLen, *pRow);
                int32_t visCol    = textEditVisualCol(buf, lineStart, off, tabW);
                int32_t spaces    = tabW - (visCol % tabW);

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

                *pDesiredCol = *pCol;
            }

            textEditLineCacheDirty(lc);
            ENSURE_VIS();
            wgtInvalidatePaint(w);
        }

        return;
    }

    // Printable character
    if (key >= KEY_ASCII_PRINT_FIRST && key <= KEY_ASCII_PRINT_LAST && !opts->readOnly) {
        if (*pLen < bufSize - 1) {
            textEditSaveUndo(buf, *pLen, CUR_OFF(), undoBuf, pUndoLen, pUndoCursor, 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);
                // Buffer geometry changed; rebuild the line cache before
                // converting the offset and before the incremental insert
                // notify below, otherwise it patches a stale line table.
                textEditLineCacheDirty(lc);
                FROM_OFF(lo);
                *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)++;
                *pDesiredCol = *pCol;
                textEditLineCacheNotifyInsert(lc, buf, *pLen, off, 1);
            } else {
                textEditLineCacheDirty(lc);
            }

            if (w->onChange) {
                w->onChange(w);
            }
        }

        ENSURE_VIS();
        wgtInvalidatePaint(w);
        return;
    }

    #undef CUR_OFF
    #undef ENSURE_VIS
    #undef FROM_OFF
    #undef SEL_BEGIN
    #undef SEL_END
    #undef HAS_SEL
    #undef SEL_LO
    #undef SEL_HI
}


// Paints the text content of a multi-line editor inside the box at
// (textX, textY, innerW, visRows*charHeight). Handles tab expansion,
// optional per-character syntax coloring, selection highlighting,
// past-EOL selection fill, cursor draw, and optional per-line
// background overrides via hooks->lineDecorator.
//
// Does NOT touch the surrounding border, gutter, scrollbars, or
// focus rect -- those are caller concerns (widget chrome).
//
// Selection offsets are byte offsets into buf. selAnchor/selCursor
// may be -1 to indicate no selection; unequal values with both >= 0
// produce a highlighted range.
void widgetTextEditMultiPaintArea(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t textX, int32_t textY, int32_t innerW, int32_t visCols, int32_t visRows, int32_t scrollRow, int32_t scrollCol, int32_t cursorRow, int32_t cursorCol, int32_t selAnchor, int32_t selCursor, int32_t tabW, uint32_t fg, uint32_t bg, bool showCursor, const TextEditPaintHooksT *hooks) {
    int32_t totalLines = textEditLineCount(lc, buf, len);
    int32_t selLo = -1;
    int32_t selHi = -1;

    if (selAnchor >= 0 && selCursor >= 0 && selAnchor != selCursor) {
        selLo = selAnchor < selCursor ? selAnchor : selCursor;
        selHi = selAnchor < selCursor ? selCursor : selAnchor;
    }

    int32_t lineOff   = textEditLineStart(lc, buf, len, scrollRow);
    int32_t scratchLen = hooks ? hooks->scratchLen : 0;
    uint8_t *rawSyntax = hooks ? hooks->rawSyntax  : NULL;
    char    *expandBuf = hooks ? hooks->expandBuf  : NULL;
    uint8_t *syntaxBuf = hooks ? hooks->syntaxBuf  : NULL;

    for (int32_t i = 0; i < visRows; i++) {
        int32_t row = scrollRow + i;

        if (row >= totalLines) {
            break;
        }

        int32_t lineL = textEditLineLen(lc, buf, len, row);
        int32_t drawY = textY + i * font->charHeight;

        uint32_t lineBg      = bg;
        uint32_t gutterColor = 0;

        if (hooks && hooks->lineDecorator) {
            uint32_t decBg = hooks->lineDecorator(row + 1, &gutterColor, hooks->lineDecoratorCtx);

            if (decBg) {
                lineBg = decBg;
            }

            rectFill(d, ops, textX, drawY, innerW, font->charHeight, lineBg);
        }

        uint32_t savedBg = bg;
        bg = lineBg;

        bool hasSyntax = false;

        if (hooks && hooks->colorize && lineL > 0 && rawSyntax && scratchLen > 0) {
            int32_t colorLen = lineL < scratchLen ? lineL : scratchLen;
            memset(rawSyntax, 0, colorLen);
            hooks->colorize(buf + lineOff, colorLen, rawSyntax, hooks->colorizeCtx);
            hasSyntax = true;
        }

        int32_t expandLen = 0;
        int32_t vc        = 0;

        if (expandBuf && syntaxBuf && scratchLen > 0) {
            for (int32_t j = 0; j < lineL && expandLen < scratchLen - tabW; j++) {
                if (buf[lineOff + j] == '\t') {
                    int32_t spaces = tabW - (vc % tabW);
                    uint8_t sc     = hasSyntax && j < scratchLen ? rawSyntax[j] : 0;

                    for (int32_t s = 0; s < spaces && expandLen < scratchLen; s++) {
                        expandBuf[expandLen] = ' ';
                        syntaxBuf[expandLen] = sc;
                        expandLen++;
                        vc++;
                    }
                } else {
                    expandBuf[expandLen] = buf[lineOff + j];
                    syntaxBuf[expandLen] = hasSyntax && j < scratchLen ? rawSyntax[j] : 0;
                    expandLen++;
                    vc++;
                }
            }
        }

        int32_t visStart  = scrollCol;
        int32_t visEnd    = scrollCol + visCols;
        int32_t textEnd   = expandLen;
        int32_t drawStart = visStart < textEnd ? visStart : textEnd;
        int32_t drawEnd   = visEnd < textEnd ? visEnd : textEnd;

        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 = textEditVisualCol(buf, lineOff, lineOff + byteSelLo, tabW);
                lineSelHi = textEditVisualCol(buf, lineOff, lineOff + (byteSelHi < lineL ? byteSelHi : lineL), tabW);

                if (byteSelHi > lineL) {
                    lineSelHi = expandLen + 1;
                }
            }
        }

        const uint32_t *custom = hooks ? hooks->customSyntaxColors : NULL;

        if (lineSelLo >= 0 && lineSelLo < lineSelHi) {
            int32_t vSelLo = lineSelLo < drawStart ? drawStart : lineSelLo;
            int32_t vSelHi = lineSelHi < drawEnd ? lineSelHi : drawEnd;

            if (vSelLo > vSelHi) {
                vSelLo = vSelHi;
            }

            if (drawStart < vSelLo) {
                if (hasSyntax) {
                    textEditDrawColorizedText(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, expandBuf + drawStart, vSelLo - drawStart, syntaxBuf, drawStart, fg, bg, custom);
                } else {
                    drawTextN(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, expandBuf + drawStart, vSelLo - drawStart, fg, bg, true);
                }
            }

            if (vSelLo < vSelHi) {
                drawTextN(d, ops, font, textX + (vSelLo - scrollCol) * font->charWidth, drawY, expandBuf + vSelLo, vSelHi - vSelLo, colors->menuHighlightFg, colors->menuHighlightBg, true);
            }

            if (vSelHi < drawEnd) {
                if (hasSyntax) {
                    textEditDrawColorizedText(d, ops, font, textX + (vSelHi - scrollCol) * font->charWidth, drawY, expandBuf + vSelHi, drawEnd - vSelHi, syntaxBuf, vSelHi, fg, bg, custom);
                } else {
                    drawTextN(d, ops, font, textX + (vSelHi - scrollCol) * font->charWidth, drawY, expandBuf + vSelHi, drawEnd - vSelHi, fg, bg, true);
                }
            }

            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 {
            if (drawStart < drawEnd) {
                if (hasSyntax) {
                    textEditDrawColorizedText(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, expandBuf + drawStart, drawEnd - drawStart, syntaxBuf, drawStart, fg, bg, custom);
                } else {
                    drawTextN(d, ops, font, textX + (drawStart - scrollCol) * font->charWidth, drawY, expandBuf + drawStart, drawEnd - drawStart, fg, bg, true);
                }
            }
        }

        bg = savedBg;

        lineOff += lineL;

        if (lineOff < len && buf[lineOff] == '\n') {
            lineOff++;
        }
    }

    if (showCursor) {
        int32_t curLineOff = textEditLineStart(lc, buf, len, cursorRow);
        int32_t curOff     = curLineOff + cursorCol;

        if (curOff > len) {
            curOff = len;
        }

        int32_t curVisCol  = textEditVisualCol(buf, curLineOff, curOff, tabW);
        int32_t curDrawCol = curVisCol - scrollCol;
        int32_t curDrawRow = cursorRow - 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);
        }
    }
}


// This is the core single-line text editing engine, parameterized by
// pointer to allow reuse across TextInput, Spinner, and ComboBox.
void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursor, int32_t *pScrollOff, int32_t *pSelStart, int32_t *pSelEnd, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t fieldWidth) {
    bool shift   = (mod & KEY_MOD_SHIFT) != 0;
    bool hasSel  = (pSelStart && pSelEnd && *pSelStart >= 0 && *pSelEnd >= 0 && *pSelStart != *pSelEnd);
    int32_t selLo = hasSel ? (*pSelStart < *pSelEnd ? *pSelStart : *pSelEnd) : -1;
    int32_t selHi = hasSel ? (*pSelStart < *pSelEnd ? *pSelEnd : *pSelStart) : -1;

    // Clamp selection to buffer bounds
    if (hasSel) {
        if (selLo < 0) {
            selLo = 0;
        }

        if (selHi > *pLen) {
            selHi = *pLen;
        }

        if (selLo >= selHi) {
            hasSel = false;
            selLo  = -1;
            selHi  = -1;
        }
    }

    // Ctrl+A  -- select all
    if (key == 1 && pSelStart && pSelEnd) {
        *pSelStart = 0;
        *pSelEnd   = *pLen;
        *pCursor   = *pLen;
        goto adjustScroll;
    }

    // Ctrl+C  -- copy
    if (key == KEY_CTRL_C) {
        if (hasSel) {
            clipboardCopy(buf + selLo, selHi - selLo);
        }

        return;
    }

    // Ctrl+V  -- paste
    if (key == KEY_CTRL_V) {
        int32_t cbLen     = 0;
        const char *cb    = clipboardGet(&cbLen);

        if (cbLen > 0) {
            if (undoBuf) {
                textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
            }

            if (hasSel) {
                textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
            }

            int32_t canFit = bufSize - 1 - *pLen;
            // For single-line, skip newlines in clipboard
            int32_t paste = 0;

            for (int32_t i = 0; i < cbLen && paste < canFit; i++) {
                if (cb[i] != '\n' && cb[i] != '\r') {
                    paste++;
                }
            }

            if (paste > 0) {
                int32_t pos = *pCursor;
                memmove(buf + pos + paste, buf + pos, *pLen - pos + 1);

                int32_t j = 0;

                for (int32_t i = 0; i < cbLen && j < paste; i++) {
                    if (cb[i] != '\n' && cb[i] != '\r') {
                        buf[pos + j] = cb[i];
                        j++;
                    }
                }

                *pLen    += paste;
                *pCursor += paste;
            }

            if (w->onChange) {
                w->onChange(w);
            }
        }

        goto adjustScroll;
    }

    // Ctrl+X  -- cut
    if (key == KEY_CTRL_X) {
        if (hasSel) {
            clipboardCopy(buf + selLo, selHi - selLo);

            if (undoBuf) {
                textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
            }

            textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);

            if (w->onChange) {
                w->onChange(w);
            }
        }

        goto adjustScroll;
    }

    // Ctrl+Z  -- undo
    if (key == KEY_CTRL_Z && undoBuf && pUndoLen && pUndoCursor) {
        // Swap current and undo
        char tmpBuf[*pLen + 1];
        int32_t tmpLen    = *pLen;
        int32_t tmpCursor = *pCursor;
        int32_t copyLen   = tmpLen < (int32_t)sizeof(tmpBuf) - 1 ? tmpLen : (int32_t)sizeof(tmpBuf) - 1;

        memcpy(tmpBuf, buf, copyLen);
        tmpBuf[copyLen] = '\0';

        int32_t restLen = *pUndoLen < bufSize - 1 ? *pUndoLen : bufSize - 1;
        memcpy(buf, undoBuf, restLen);
        buf[restLen] = '\0';
        *pLen = restLen;

        int32_t restoreCursor = *pUndoCursor < *pLen ? *pUndoCursor : *pLen;

        // Save old as new undo
        int32_t saveLen = copyLen < bufSize - 1 ? copyLen : bufSize - 1;
        memcpy(undoBuf, tmpBuf, saveLen);
        undoBuf[saveLen] = '\0';
        *pUndoLen    = saveLen;
        *pUndoCursor = tmpCursor;

        *pCursor = restoreCursor;

        if (pSelStart) {
            *pSelStart = -1;
        }

        if (pSelEnd) {
            *pSelEnd = -1;
        }

        if (w->onChange) {
            w->onChange(w);
        }

        goto adjustScroll;
    }

    if (key >= KEY_ASCII_PRINT_FIRST && key <= KEY_ASCII_PRINT_LAST) {
        // Printable character.  Only snapshot undo when the edit will
        // actually happen: a full buffer with no selection is a no-op, and
        // saving undo then would discard the real prior state.
        if (undoBuf && (hasSel || *pLen < bufSize - 1)) {
            textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
        }

        if (hasSel) {
            textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
        }

        if (*pLen < bufSize - 1) {
            int32_t pos = *pCursor;
            memmove(buf + pos + 1, buf + pos, *pLen - pos + 1);
            buf[pos] = (char)key;
            (*pLen)++;
            (*pCursor)++;

            if (w->onChange) {
                w->onChange(w);
            }
        }
    } else if (key == 8) {
        // Backspace
        if (hasSel) {
            if (undoBuf) {
                textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
            }

            textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);

            if (w->onChange) {
                w->onChange(w);
            }
        } else if (*pCursor > 0) {
            if (undoBuf) {
                textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
            }

            int32_t pos = *pCursor;
            memmove(buf + pos - 1, buf + pos, *pLen - pos + 1);
            (*pLen)--;
            (*pCursor)--;

            if (w->onChange) {
                w->onChange(w);
            }
        }
    } else if (key == KEY_LEFT) {
        // Left arrow
        if (shift && pSelStart && pSelEnd) {
            if (*pSelStart < 0) {
                *pSelStart = *pCursor;
            }

            if (*pCursor > 0) {
                (*pCursor)--;
            }

            *pSelEnd = *pCursor;
        } else {
            if (pSelStart) {
                *pSelStart = -1;
            }

            if (pSelEnd) {
                *pSelEnd = -1;
            }

            if (*pCursor > 0) {
                (*pCursor)--;
            }
        }
    } else if (key == KEY_RIGHT) {
        // Right arrow
        if (shift && pSelStart && pSelEnd) {
            if (*pSelStart < 0) {
                *pSelStart = *pCursor;
            }

            if (*pCursor < *pLen) {
                (*pCursor)++;
            }

            *pSelEnd = *pCursor;
        } else {
            if (pSelStart) {
                *pSelStart = -1;
            }

            if (pSelEnd) {
                *pSelEnd = -1;
            }

            if (*pCursor < *pLen) {
                (*pCursor)++;
            }
        }
    } else if (key == (0x73 | 0x100)) {
        // Ctrl+Left  -- word left
        int32_t newPos = wordBoundaryLeft(buf, *pCursor);

        if (shift && pSelStart && pSelEnd) {
            if (*pSelStart < 0) {
                *pSelStart = *pCursor;
            }

            *pCursor = newPos;
            *pSelEnd = newPos;
        } else {
            if (pSelStart) {
                *pSelStart = -1;
            }

            if (pSelEnd) {
                *pSelEnd = -1;
            }

            *pCursor = newPos;
        }
    } else if (key == (0x74 | 0x100)) {
        // Ctrl+Right  -- word right
        int32_t newPos = wordBoundaryRight(buf, *pLen, *pCursor);

        if (shift && pSelStart && pSelEnd) {
            if (*pSelStart < 0) {
                *pSelStart = *pCursor;
            }

            *pCursor = newPos;
            *pSelEnd = newPos;
        } else {
            if (pSelStart) {
                *pSelStart = -1;
            }

            if (pSelEnd) {
                *pSelEnd = -1;
            }

            *pCursor = newPos;
        }
    } else if (key == KEY_HOME) {
        // Home
        if (shift && pSelStart && pSelEnd) {
            if (*pSelStart < 0) {
                *pSelStart = *pCursor;
            }

            *pCursor = 0;
            *pSelEnd = 0;
        } else {
            if (pSelStart) {
                *pSelStart = -1;
            }

            if (pSelEnd) {
                *pSelEnd = -1;
            }

            *pCursor = 0;
        }
    } else if (key == KEY_END) {
        // End
        if (shift && pSelStart && pSelEnd) {
            if (*pSelStart < 0) {
                *pSelStart = *pCursor;
            }

            *pCursor = *pLen;
            *pSelEnd = *pLen;
        } else {
            if (pSelStart) {
                *pSelStart = -1;
            }

            if (pSelEnd) {
                *pSelEnd = -1;
            }

            *pCursor = *pLen;
        }
    } else if (key == KEY_DELETE) {
        // Delete
        if (hasSel) {
            if (undoBuf) {
                textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
            }

            textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);

            if (w->onChange) {
                w->onChange(w);
            }
        } else if (*pCursor < *pLen) {
            if (undoBuf) {
                textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize);
            }

            int32_t pos = *pCursor;
            memmove(buf + pos, buf + pos + 1, *pLen - pos);
            (*pLen)--;

            if (w->onChange) {
                w->onChange(w);
            }
        }
    } else {
        return;
    }

adjustScroll:
    // Adjust scroll offset to keep cursor visible
    {
        AppContextT *ctx = wgtGetContext(w);
        const BitmapFontT *font = &ctx->font;
        int32_t fieldW = fieldWidth > 0 ? fieldWidth : w->w;

        int32_t visibleChars = (fieldW - TEXT_INPUT_PAD * 2) / font->charWidth;

        if (*pCursor < *pScrollOff) {
            *pScrollOff = *pCursor;
        }

        if (*pCursor >= *pScrollOff + visibleChars) {
            *pScrollOff = *pCursor - visibleChars + 1;
        }
    }

    wgtInvalidatePaint(w);
}


// Renders a single line of text with optional selection highlighting
// and a blinking cursor.
void widgetTextEditPaintLine(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t textX, int32_t textY, const char *buf, int32_t visLen, int32_t scrollOff, int32_t cursorPos, int32_t selStart, int32_t selEnd, uint32_t fg, uint32_t bg, bool showCursor, int32_t cursorMinX, int32_t cursorMaxX) {
    // Normalize selection to low/high
    int32_t selLo = -1;
    int32_t selHi = -1;

    if (selStart >= 0 && selEnd >= 0 && selStart != selEnd) {
        selLo = selStart < selEnd ? selStart : selEnd;
        selHi = selStart < selEnd ? selEnd : selStart;
    }

    // Map selection to visible range
    int32_t visSelLo = selLo - scrollOff;
    int32_t visSelHi = selHi - scrollOff;

    if (visSelLo < 0) { visSelLo = 0; }
    if (visSelHi > visLen) { visSelHi = visLen; }

    if (selLo >= 0 && visSelLo < visSelHi) {
        if (visSelLo > 0) {
            drawTextN(d, ops, font, textX, textY, buf, visSelLo, fg, bg, true);
        }

        drawTextN(d, ops, font, textX + visSelLo * font->charWidth, textY, buf + visSelLo, visSelHi - visSelLo, colors->menuHighlightFg, colors->menuHighlightBg, true);

        if (visSelHi < visLen) {
            drawTextN(d, ops, font, textX + visSelHi * font->charWidth, textY, buf + visSelHi, visLen - visSelHi, fg, bg, true);
        }
    } else if (visLen > 0) {
        drawTextN(d, ops, font, textX, textY, buf, visLen, fg, bg, true);
    }

    // Blinking cursor
    if (showCursor && sCursorBlinkOn) {
        int32_t cursorX = textX + (cursorPos - scrollOff) * font->charWidth;

        if (cursorX >= cursorMinX && cursorX < cursorMaxX) {
            drawVLine(d, ops, cursorX, textY, font->charHeight, fg);
        }
    }
}


// Paints a single vertical or horizontal text-grid scrollbar:
// trough, end-arrow buttons with direction triangles, and thumb.
// All items are in logical units (lines/cols).
void widgetTextScrollbarDraw(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, bool vertical, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll) {
    BevelStyleT troughBevel = BEVEL_TROUGH(colors);
    BevelStyleT btnBevel    = BEVEL_RAISED(colors, 1);

    if (vertical) {
        drawBevel(d, ops, x, y, thick, len, &troughBevel);
    } else {
        drawBevel(d, ops, x, y, len, thick, &troughBevel);
    }

    // Starting-end arrow button (up / left)
    drawBevel(d, ops, x, y, thick, thick, &btnBevel);

    {
        int32_t cx = x + thick / 2;
        int32_t cy = y + thick / 2;

        if (vertical) {
            for (int32_t i = 0; i < 4; i++) {
                drawHLine(d, ops, cx - i, cy - 2 + i, 1 + i * 2, colors->contentFg);
            }
        } else {
            for (int32_t i = 0; i < 4; i++) {
                drawVLine(d, ops, cx - 2 + i, cy - i, 1 + i * 2, colors->contentFg);
            }
        }
    }

    // Ending-end arrow button (down / right)
    int32_t endX = vertical ? x : x + len - thick;
    int32_t endY = vertical ? y + len - thick : y;
    drawBevel(d, ops, endX, endY, thick, thick, &btnBevel);

    {
        int32_t cx = endX + thick / 2;
        int32_t cy = endY + thick / 2;

        if (vertical) {
            for (int32_t i = 0; i < 4; i++) {
                drawHLine(d, ops, cx - i, cy + 2 - i, 1 + i * 2, colors->contentFg);
            }
        } else {
            for (int32_t i = 0; i < 4; i++) {
                drawVLine(d, ops, cx + 2 - i, cy - i, 1 + i * 2, colors->contentFg);
            }
        }
    }

    int32_t trackLen = len - thick * 2;
    int32_t thumbPos;
    int32_t thumbSize;
    widgetScrollbarThumb(trackLen, total, visible, scroll, &thumbPos, &thumbSize);

    if (thumbSize > 0) {
        if (vertical) {
            drawBevel(d, ops, x, y + thick + thumbPos, thick, thumbSize, &btnBevel);
        } else {
            drawBevel(d, ops, x + thick + thumbPos, y, thumbSize, thick, &btnBevel);
        }
    }
}


// Converts a mouse coordinate (during an active thumb drag) to a
// clamped scroll value. dragOff is the thumb-relative offset captured
// when the drag started (from the hit-test's *pDragOff).
int32_t widgetTextScrollbarDragToScroll(bool vertical, int32_t mouseCoord, int32_t sbStart, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t dragOff) {
    (void)vertical;
    int32_t maxScroll = total - visible;
    int32_t trackLen  = len - thick * 2;
    int32_t rel       = mouseCoord - sbStart - thick - dragOff;
    return widgetScrollbarThumbDragScroll(trackLen, total, visible, rel, maxScroll);
}


// Classifies a mouse click against a text-grid scrollbar. Returns
// one of the TEXT_SB_HIT_* codes. Callers typically translate UP/
// DOWN to single-step scrolls and PAGE_UP/PAGE_DOWN to page-sized
// jumps. THUMB begins a drag; *pDragOff captures the click offset
// relative to the thumb origin for use in subsequent drag updates.
int32_t widgetTextScrollbarHitTest(bool vertical, int32_t vx, int32_t vy, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll, int32_t *pDragOff) {
    int32_t primary = vertical ? (vy - y) : (vx - x);
    int32_t cross   = vertical ? (vx - x) : (vy - y);

    if (cross < 0 || cross >= thick || primary < 0 || primary >= len) {
        return TEXT_SB_HIT_NONE;
    }

    if (primary < thick) {
        return TEXT_SB_HIT_UP;
    }

    if (primary >= len - thick) {
        return TEXT_SB_HIT_DOWN;
    }

    int32_t trackLen = len - thick * 2;

    if (trackLen <= 0) {
        return TEXT_SB_HIT_NONE;
    }

    int32_t thumbPos;
    int32_t thumbSize;
    widgetScrollbarThumb(trackLen, total, visible, scroll, &thumbPos, &thumbSize);

    int32_t trackRel = primary - thick;

    if (trackRel < thumbPos) {
        return TEXT_SB_HIT_PAGE_UP;
    }

    if (trackRel >= thumbPos + thumbSize) {
        return TEXT_SB_HIT_PAGE_DOWN;
    }

    if (pDragOff) {
        *pDragOff = trackRel - thumbPos;
    }

    return TEXT_SB_HIT_THUMB;
}


int32_t wordBoundaryLeft(const char *buf, int32_t pos) {
    if (pos <= 0) {
        return 0;
    }

    // Skip non-word characters
    while (pos > 0 && !isWordChar(buf[pos - 1])) {
        pos--;
    }

    // Skip word characters
    while (pos > 0 && isWordChar(buf[pos - 1])) {
        pos--;
    }

    return pos;
}


int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos) {
    if (pos >= len) {
        return len;
    }

    // Skip word characters
    while (pos < len && isWordChar(buf[pos])) {
        pos++;
    }

    // Skip non-word characters
    while (pos < len && !isWordChar(buf[pos])) {
        pos++;
    }

    return pos;
}


int32_t wordEnd(const char *buf, int32_t len, int32_t pos) {
    while (pos < len && isWordChar(buf[pos])) {
        pos++;
    }

    return pos;
}


int32_t wordStart(const char *buf, int32_t pos) {
    while (pos > 0 && isWordChar(buf[pos - 1])) {
        pos--;
    }

    return pos;
}
