// 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
// widgetAnsiTerm.c  -- ANSI BBS terminal emulator widget
//
// Implements a VT100/ANSI-compatible terminal emulator widget designed for
// connecting to BBS systems over the serial link. The terminal uses a
// traditional text-mode cell buffer (character + attribute byte pairs) that
// mirrors the layout of CGA/VGA text mode memory. This representation was
// chosen because:
//   1. It maps directly to the BBS/ANSI art paradigm (CP437 character set,
//      16-color CGA palette, blink attribute)
//   2. Cell-based storage is extremely compact  -- 2 bytes per cell means an
//      80x25 screen is only 4000 bytes, fitting in L1 cache on a 486
//   3. Dirty-row tracking via a per-row bitmask allows sub-millisecond
//      incremental repaints without scanning the entire buffer
//
// The ANSI parser is a 3-state machine (NORMAL -> ESC -> CSI) that handles
// the subset of sequences commonly used by DOS BBS software: cursor movement,
// screen/line erase, scrolling regions, SGR colors, and a few DEC private modes.
// Full VT100 conformance is explicitly NOT a goal  -- only sequences actually
// emitted by real BBS systems are implemented.
//
// Scrollback is implemented as a circular buffer of row snapshots. Only
// full-screen scroll operations push lines into scrollback; scroll-region
// operations (used by split-screen chat, status bars) do not, matching
// the behavior users expect from DOS terminal programs.
//
// The widget supports two paint paths:
//   - Full paint (widgetAnsiTermPaint): used during normal widget repaints
//   - Fast repaint (wgtAnsiTermRepaint): bypasses the widget pipeline
//     entirely, rendering dirty rows directly into the window's content
//     buffer. This is critical for serial communication where ACK turnaround
//     time matters  -- the fewer milliseconds between receiving data and
//     displaying it, the higher the effective throughput.
//
// Communication is abstracted through read/write function pointers, allowing
// the terminal to work with raw serial ports, the secLink encrypted channel,
// or any other byte-oriented transport.

#include "dvxWgtP.h"
#include "../../../libs/kpunch/texthelp/textHelp.h"

static int32_t sTypeId = -1;

#define ANSI_BORDER              2
#define ANSI_MAX_PARAMS          8
// Upper bound for an accumulated CSI parameter value.  Caps the
// digit accumulator so a hostile sequence like ESC[999999999S can
// neither overflow the int nor drive a billion-iteration scroll loop.
#define ANSI_MAX_PARAM_VALUE     9999
#define ANSI_SB_W               14
#define ANSI_DEFAULT_SCROLLBACK 500
#define PARSE_NORMAL  0
#define PARSE_ESC     1
#define PARSE_CSI     2
#define ANSI_DEFAULT_ATTR  0x07
#define ATTR_BLINK_BIT     0x80
#define ATTR_FG_MASK       0x0F
#define BLINK_MS           500
#define CURSOR_MS          250

// dirtyRows is a multi-word bitmask sized at ANSI_DIRTY_WORDS(rows)
// uint32_t words, so terminals taller than 32 rows keep per-row dirty
// tracking instead of losing updates for rows past bit 31.
#define ANSI_DIRTY_WORD_BITS   32
#define ANSI_DIRTY_WORD_ALL    0xFFFFFFFFU
#define ANSI_DIRTY_WORDS(rows) (((rows) + ANSI_DIRTY_WORD_BITS - 1) / ANSI_DIRTY_WORD_BITS)

// Geometry and scrollback caps.  wgtAnsiTerm and wgtAnsiTermSetScrollback
// are public API; without these caps cols * rows * 2 (or maxLines * cols
// * 2) can overflow int32_t and wrap to a tiny allocation that cursor
// writes and scroll memmoves then overrun.
#define ANSI_MAX_COLS       512
#define ANSI_MAX_ROWS       512
#define ANSI_MAX_SCROLLBACK 32768

// Clipboard buffer size for selection copy.
#define ANSI_CLIPBOARD_BUF    4096

typedef struct {
    // Fields are ordered by alignment to minimize struct padding.
    uint8_t *cells;
    int32_t  cols;
    int32_t  rows;
    int32_t  cursorRow;
    int32_t  cursorCol;
    int32_t  params[8];
    int32_t  paramCount;
    int32_t  savedRow;
    int32_t  savedCol;
    int32_t  scrollTop;
    int32_t  scrollBot;
    uint8_t *scrollback;
    int32_t  scrollbackMax;
    int32_t  scrollbackCount;
    int32_t  scrollbackHead;
    int32_t  scrollPos;
    clock_t  blinkTime;
    clock_t  cursorTime;
    uint32_t *dirtyRows;    // ANSI_DIRTY_WORDS(rows) words, one bit per row
    int32_t  lastCursorRow;
    int32_t  lastCursorCol;
    uint32_t packedPalette[16];
    int32_t  selStartLine;
    int32_t  selStartCol;
    int32_t  selEndLine;
    int32_t  selEndCol;
    void    *commCtx;
    int32_t (*commRead)(void *ctx, uint8_t *buf, int32_t maxLen);
    int32_t (*commWrite)(void *ctx, const uint8_t *buf, int32_t len);
    bool     cursorVisible;
    bool     wrapMode;
    bool     bold;
    bool     originMode;
    bool     csiPrivate;
    uint8_t  curAttr;
    uint8_t  parseState;
    bool     blinkVisible;
    bool     cursorOn;
    bool     paletteValid;
    bool     hasBlinkCells;     // tracked to skip the per-blink O(rows*cols) scan
} AnsiTermDataT;

static const uint8_t sCgaPalette[16][3] = {
    {  0,   0,   0}, {  0,   0, 170}, {  0, 170,   0}, {  0, 170, 170},
    {170,   0,   0}, {170,   0, 170}, {170,  85,   0}, {170, 170, 170},
    { 85,  85,  85}, { 85,  85, 255}, { 85, 255,  85}, { 85, 255, 255},
    {255,  85,  85}, {255,  85, 255}, {255, 255,  85}, {255, 255, 255},
};

static const int32_t sAnsiToCga[8] = { 0, 4, 2, 6, 1, 5, 3, 7 };


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

static void            ansiTermAddToScrollback(WidgetT *w, int32_t screenRow);
static void            ansiTermBuildPalette(WidgetT *w, const DisplayT *d);
static void            ansiTermClearSelection(WidgetT *w);
static void            ansiTermCopySelection(WidgetT *w);
static void            ansiTermDeleteLines(WidgetT *w, int32_t count);
static void            ansiTermDirtyAll(WidgetT *w);
static bool            ansiTermDirtyAny(const WidgetT *w);
static void            ansiTermDirtyClear(WidgetT *w);
static void            ansiTermDirtyRange(WidgetT *w, int32_t startCell, int32_t count);
static void            ansiTermDirtyRow(WidgetT *w, int32_t row);
static bool            ansiTermDirtyTest(const WidgetT *w, int32_t row);
static void            ansiTermDispatchCsi(WidgetT *w, uint8_t cmd);
static void            ansiTermEraseDisplay(WidgetT *w, int32_t mode);
static void            ansiTermEraseLine(WidgetT *w, int32_t mode);
static void            ansiTermFillCells(WidgetT *w, int32_t start, int32_t count);
static const uint8_t  *ansiTermGetLine(WidgetT *w, int32_t lineIndex);
static bool            ansiTermHasSelection(const WidgetT *w);
static void            ansiTermInsertLines(WidgetT *w, int32_t count);
static void            ansiTermNewline(WidgetT *w);
static void            ansiTermPaintSelRow(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t screenRow, int32_t baseX, int32_t baseY);
static void            ansiTermPasteToComm(WidgetT *w);
static void            ansiTermProcessByte(WidgetT *w, uint8_t ch);
static void            ansiTermProcessSgr(WidgetT *w);
static void            ansiTermPutChar(WidgetT *w, uint8_t ch);
static void            ansiTermScrollDown(WidgetT *w);
static void            ansiTermScrollUp(WidgetT *w);
static void            ansiTermSelectionRange(const WidgetT *w, int32_t *startLine, int32_t *startCol, int32_t *endLine, int32_t *endCol);
WidgetT               *wgtAnsiTerm(WidgetT *parent, int32_t cols, int32_t rows);
void                   wgtAnsiTermClear(WidgetT *w);
static int32_t         wgtAnsiTermGetCols(const WidgetT *w);
static int32_t         wgtAnsiTermGetRows(const WidgetT *w);
static int32_t         wgtAnsiTermGetScrollback(const WidgetT *w);
int32_t                wgtAnsiTermPoll(WidgetT *w);
int32_t                wgtAnsiTermRepaint(WidgetT *w, int32_t *outY, int32_t *outH);
void                   wgtAnsiTermSetComm(WidgetT *w, void *ctx, int32_t (*readFn)(void *, uint8_t *, int32_t), int32_t (*writeFn)(void *, const uint8_t *, int32_t));
void                   wgtAnsiTermSetScrollback(WidgetT *w, int32_t maxLines);
void                   wgtAnsiTermWrite(WidgetT *w, const uint8_t *data, int32_t len);
static void            wgtAnsiTermWriteString(WidgetT *w, const char *text);
void                   wgtRegister(void);
void                   widgetAnsiTermCalcMinSize(WidgetT *w, const BitmapFontT *font);
static bool            widgetAnsiTermClearSelection(WidgetT *w);
void                   widgetAnsiTermDestroy(WidgetT *w);
static void            widgetAnsiTermOnDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
void                   widgetAnsiTermOnKey(WidgetT *w, int32_t key, int32_t mod);
void                   widgetAnsiTermOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy);
void                   widgetAnsiTermPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
void                   widgetAnsiTermPollVtable(WidgetT *w, WindowT *win);


static void ansiTermAddToScrollback(WidgetT *w, int32_t screenRow) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (!at->scrollback || at->scrollbackMax <= 0) { return; }
    int32_t cols        = at->cols;
    int32_t bytesPerRow = cols * 2;
    int32_t head        = at->scrollbackHead;
    memcpy(at->scrollback + head * bytesPerRow, at->cells + screenRow * bytesPerRow, bytesPerRow);
    at->scrollbackHead = (head + 1) % at->scrollbackMax;
    if (at->scrollbackCount < at->scrollbackMax) { at->scrollbackCount++; }
}


static void ansiTermBuildPalette(WidgetT *w, const DisplayT *d) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (at->paletteValid) { return; }
    for (int32_t i = 0; i < 16; i++) {
        at->packedPalette[i] = packColor(d, sCgaPalette[i][0], sCgaPalette[i][1], sCgaPalette[i][2]);
    }
    at->paletteValid = true;
}


static void ansiTermClearSelection(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (ansiTermHasSelection(w)) { ansiTermDirtyAll(w); }
    at->selStartLine = -1;
    at->selStartCol  = -1;
    at->selEndLine   = -1;
    at->selEndCol    = -1;
}


static void ansiTermCopySelection(WidgetT *w) {
    if (!ansiTermHasSelection(w)) { return; }
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t sLine, sCol, eLine, eCol;
    ansiTermSelectionRange(w, &sLine, &sCol, &eLine, &eCol);
    int32_t cols = at->cols;
    char buf[ANSI_CLIPBOARD_BUF];
    int32_t pos = 0;
    for (int32_t line = sLine; line <= eLine && pos < (int32_t)sizeof(buf) - 1; line++) {
        const uint8_t *lineData = ansiTermGetLine(w, line);
        int32_t colStart = (line == sLine) ? sCol : 0;
        int32_t colEnd   = (line == eLine) ? eCol : cols;
        int32_t lastNonSpace = colStart - 1;
        for (int32_t c = colStart; c < colEnd; c++) {
            if (lineData[c * 2] != ' ') { lastNonSpace = c; }
        }
        for (int32_t c = colStart; c <= lastNonSpace && pos < (int32_t)sizeof(buf) - 1; c++) {
            buf[pos++] = (char)lineData[c * 2];
        }
        if (line < eLine && pos < (int32_t)sizeof(buf) - 1) { buf[pos++] = '\n'; }
    }
    buf[pos] = '\0';
    if (pos > 0) { clipboardCopy(buf, pos); }
}


static void ansiTermDeleteLines(WidgetT *w, int32_t count) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols = at->cols;
    int32_t bot  = at->scrollBot;
    int32_t row  = at->cursorRow;
    if (count > bot - row + 1) { count = bot - row + 1; }
    if (count <= 0) { return; }
    int32_t bytesPerRow = cols * 2;
    if (row + count <= bot) {
        memmove(at->cells + row * bytesPerRow, at->cells + (row + count) * bytesPerRow, (bot - row - count + 1) * bytesPerRow);
    }
    for (int32_t r = bot - count + 1; r <= bot; r++) { ansiTermFillCells(w, r * cols, cols); }
    for (int32_t r = row; r <= bot; r++) { ansiTermDirtyRow(w, r); }
}


static void ansiTermDirtyAll(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t words = ANSI_DIRTY_WORDS(at->rows);
    for (int32_t i = 0; i < words; i++) {
        at->dirtyRows[i] = ANSI_DIRTY_WORD_ALL;
    }
}


static bool ansiTermDirtyAny(const WidgetT *w) {
    const AnsiTermDataT *at = (const AnsiTermDataT *)w->data;
    int32_t words = ANSI_DIRTY_WORDS(at->rows);
    for (int32_t i = 0; i < words; i++) {
        if (at->dirtyRows[i] != 0) {
            return true;
        }
    }
    return false;
}


static void ansiTermDirtyClear(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t words = ANSI_DIRTY_WORDS(at->rows);
    for (int32_t i = 0; i < words; i++) {
        at->dirtyRows[i] = 0;
    }
}


static void ansiTermDirtyRange(WidgetT *w, int32_t startCell, int32_t count) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t startRow = startCell / at->cols;
    int32_t endRow   = (startCell + count - 1) / at->cols;
    for (int32_t r = startRow; r <= endRow; r++) {
        ansiTermDirtyRow(w, r);
    }
}


static void ansiTermDirtyRow(WidgetT *w, int32_t row) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (row >= 0 && row < at->rows) {
        at->dirtyRows[row / ANSI_DIRTY_WORD_BITS] |= (1U << (row % ANSI_DIRTY_WORD_BITS));
    }
}


static bool ansiTermDirtyTest(const WidgetT *w, int32_t row) {
    const AnsiTermDataT *at = (const AnsiTermDataT *)w->data;
    return (at->dirtyRows[row / ANSI_DIRTY_WORD_BITS] & (1U << (row % ANSI_DIRTY_WORD_BITS))) != 0;
}


static void ansiTermDispatchCsi(WidgetT *w, uint8_t cmd) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t *p = at->params;
    int32_t  n = at->paramCount;

    if (at->csiPrivate) {
        int32_t mode = (n >= 1) ? p[0] : 0;
        if (cmd == 'h') {
            if (mode == 6) { at->originMode = true; at->cursorRow = at->scrollTop; at->cursorCol = 0; }
            if (mode == 7) { at->wrapMode = true; }
            if (mode == 25) { at->cursorVisible = true; }
        } else if (cmd == 'l') {
            if (mode == 6) { at->originMode = false; at->cursorRow = 0; at->cursorCol = 0; }
            if (mode == 7) { at->wrapMode = false; }
            if (mode == 25) { at->cursorVisible = false; }
        }
        return;
    }

    switch (cmd) {
        case '@': {
            int32_t count = (n >= 1 && p[0]) ? p[0] : 1;
            int32_t cols  = at->cols;
            int32_t row   = at->cursorRow;
            int32_t col   = at->cursorCol;
            if (count > cols - col) { count = cols - col; }
            if (count > 0) {
                uint8_t *base = at->cells + row * cols * 2;
                memmove(base + (col + count) * 2, base + col * 2, (cols - col - count) * 2);
                for (int32_t i = col; i < col + count; i++) { base[i * 2] = ' '; base[i * 2 + 1] = at->curAttr; }
                ansiTermDirtyRow(w, row);
            }
            break;
        }
        case 'A': {
            int32_t count  = (n >= 1 && p[0]) ? p[0] : 1;
            int32_t minRow = at->originMode ? at->scrollTop : 0;
            at->cursorRow -= count;
            if (at->cursorRow < minRow) { at->cursorRow = minRow; }
            break;
        }
        case 'B': {
            int32_t count  = (n >= 1 && p[0]) ? p[0] : 1;
            int32_t maxRow = at->originMode ? at->scrollBot : at->rows - 1;
            at->cursorRow += count;
            if (at->cursorRow > maxRow) { at->cursorRow = maxRow; }
            break;
        }
        case 'C': {
            int32_t count = (n >= 1 && p[0]) ? p[0] : 1;
            at->cursorCol += count;
            if (at->cursorCol >= at->cols) { at->cursorCol = at->cols - 1; }
            break;
        }
        case 'D': {
            int32_t count = (n >= 1 && p[0]) ? p[0] : 1;
            at->cursorCol -= count;
            if (at->cursorCol < 0) { at->cursorCol = 0; }
            break;
        }
        case 'c': {
            if (at->commWrite) {
                const uint8_t reply[] = "\033[?1;2c";
                at->commWrite(at->commCtx, reply, sizeof(reply) - 1);
            }
            break;
        }
        case 'E': {
            int32_t count = (n >= 1 && p[0]) ? p[0] : 1;
            if (count > at->rows) { count = at->rows; }
            for (int32_t i = 0; i < count; i++) { ansiTermNewline(w); }
            at->cursorCol = 0;
            break;
        }
        case 'H':
        case 'f': {
            int32_t row = (n >= 1 && p[0]) ? p[0] - 1 : 0;
            int32_t col = (n >= 2 && p[1]) ? p[1] - 1 : 0;
            if (at->originMode) { row += at->scrollTop; if (row > at->scrollBot) { row = at->scrollBot; } }
            if (row < 0) { row = 0; }
            if (row >= at->rows) { row = at->rows - 1; }
            if (col < 0) { col = 0; }
            if (col >= at->cols) { col = at->cols - 1; }
            at->cursorRow = row;
            at->cursorCol = col;
            break;
        }
        case 'J': { ansiTermEraseDisplay(w, (n >= 1) ? p[0] : 0); break; }
        case 'K': { ansiTermEraseLine(w, (n >= 1) ? p[0] : 0); break; }
        case 'L': { ansiTermInsertLines(w, (n >= 1 && p[0]) ? p[0] : 1); break; }
        case 'M': { ansiTermDeleteLines(w, (n >= 1 && p[0]) ? p[0] : 1); break; }
        case 'P': {
            int32_t count = (n >= 1 && p[0]) ? p[0] : 1;
            int32_t cols  = at->cols;
            int32_t row   = at->cursorRow;
            int32_t col   = at->cursorCol;
            if (count > cols - col) { count = cols - col; }
            if (count > 0) {
                uint8_t *base = at->cells + row * cols * 2;
                memmove(base + col * 2, base + (col + count) * 2, (cols - col - count) * 2);
                for (int32_t i = cols - count; i < cols; i++) { base[i * 2] = ' '; base[i * 2 + 1] = at->curAttr; }
                ansiTermDirtyRow(w, row);
            }
            break;
        }
        case 'S': {
            int32_t count = (n >= 1 && p[0]) ? p[0] : 1;
            if (count > at->rows) { count = at->rows; }  // more clears the screen anyway
            for (int32_t i = 0; i < count; i++) { ansiTermScrollUp(w); }
            break;
        }
        case 'T': {
            int32_t count = (n >= 1 && p[0]) ? p[0] : 1;
            if (count > at->rows) { count = at->rows; }
            for (int32_t i = 0; i < count; i++) { ansiTermScrollDown(w); }
            break;
        }
        case 'Z': {
            int32_t col = at->cursorCol;
            if (col > 0) { col = ((col - 1) / 8) * 8; }
            at->cursorCol = col;
            break;
        }
        case 'm': ansiTermProcessSgr(w); break;
        case 'n': {
            int32_t mode = (n >= 1) ? p[0] : 0;
            if (at->commWrite) {
                if (mode == 6) {
                    char reply[16];
                    int32_t len = snprintf(reply, sizeof(reply), "\033[%ld;%ldR", (long)(at->cursorRow + 1), (long)(at->cursorCol + 1));
                    at->commWrite(at->commCtx, (const uint8_t *)reply, len);
                } else if (mode == 255) {
                    char reply[16];
                    int32_t len = snprintf(reply, sizeof(reply), "\033[%ld;%ldR", (long)at->rows, (long)at->cols);
                    at->commWrite(at->commCtx, (const uint8_t *)reply, len);
                }
            }
            break;
        }
        case 'r': {
            int32_t rows = at->rows;
            int32_t top  = (n >= 1 && p[0]) ? p[0] - 1 : 0;
            int32_t bot  = (n >= 2 && p[1]) ? p[1] - 1 : rows - 1;
            if (top < 0) { top = 0; }
            if (bot >= rows) { bot = rows - 1; }
            if (top < bot) { at->scrollTop = top; at->scrollBot = bot; }
            else { at->scrollTop = 0; at->scrollBot = rows - 1; }
            at->cursorRow = at->originMode ? at->scrollTop : 0;
            at->cursorCol = 0;
            break;
        }
        case 's': at->savedRow = at->cursorRow; at->savedCol = at->cursorCol; break;
        case 'u': at->cursorRow = at->savedRow; at->cursorCol = at->savedCol; break;
        default: break;
    }
}


static void ansiTermEraseDisplay(WidgetT *w, int32_t mode) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols = at->cols;
    int32_t rows = at->rows;
    int32_t cur  = at->cursorRow * cols + at->cursorCol;
    if (mode == 0) {
        ansiTermFillCells(w, cur, cols * rows - cur);
    } else if (mode == 1) {
        ansiTermFillCells(w, 0, cur + 1);
    } else if (mode == 2) {
        bool wasAtBottom = (at->scrollPos == at->scrollbackCount);
        for (int32_t r = 0; r < rows; r++) { ansiTermAddToScrollback(w, r); }
        if (wasAtBottom) { at->scrollPos = at->scrollbackCount; }
        ansiTermFillCells(w, 0, cols * rows);
    }
}


static void ansiTermEraseLine(WidgetT *w, int32_t mode) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols = at->cols;
    int32_t row  = at->cursorRow;
    int32_t col  = at->cursorCol;
    int32_t base = row * cols;
    if (mode == 0) { ansiTermFillCells(w, base + col, cols - col); }
    else if (mode == 1) { ansiTermFillCells(w, base, col + 1); }
    else if (mode == 2) { ansiTermFillCells(w, base, cols); }
}


static void ansiTermFillCells(WidgetT *w, int32_t start, int32_t count) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    uint8_t *cells = at->cells;
    uint8_t  attr  = at->curAttr;
    int32_t  total = at->cols * at->rows;
    if (start < 0) { count += start; start = 0; }
    if (start + count > total) { count = total - start; }
    for (int32_t i = 0; i < count; i++) {
        cells[(start + i) * 2]     = ' ';
        cells[(start + i) * 2 + 1] = attr;
    }
    ansiTermDirtyRange(w, start, count);
}


static const uint8_t *ansiTermGetLine(WidgetT *w, int32_t lineIndex) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols        = at->cols;
    int32_t bytesPerRow = cols * 2;
    int32_t sbCount     = at->scrollbackCount;
    if (lineIndex < sbCount) {
        int32_t sbMax  = at->scrollbackMax;
        int32_t actual = (at->scrollbackHead - sbCount + lineIndex + sbMax) % sbMax;
        return at->scrollback + actual * bytesPerRow;
    }
    return at->cells + (lineIndex - sbCount) * bytesPerRow;
}


static bool ansiTermHasSelection(const WidgetT *w) {
    const AnsiTermDataT *at = (const AnsiTermDataT *)w->data;
    if (at->selStartLine < 0) { return false; }
    if (at->selStartLine == at->selEndLine && at->selStartCol == at->selEndCol) { return false; }
    return true;
}


static void ansiTermInsertLines(WidgetT *w, int32_t count) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols = at->cols;
    int32_t bot  = at->scrollBot;
    int32_t row  = at->cursorRow;
    if (count > bot - row + 1) { count = bot - row + 1; }
    if (count <= 0) { return; }
    int32_t bytesPerRow = cols * 2;
    if (row + count <= bot) {
        memmove(at->cells + (row + count) * bytesPerRow, at->cells + row * bytesPerRow, (bot - row - count + 1) * bytesPerRow);
    }
    for (int32_t r = row; r < row + count; r++) { ansiTermFillCells(w, r * cols, cols); }
    for (int32_t r = row; r <= bot; r++) { ansiTermDirtyRow(w, r); }
}


static void ansiTermNewline(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (at->cursorRow == at->scrollBot) { ansiTermScrollUp(w); }
    else if (at->cursorRow < at->rows - 1) { at->cursorRow++; }
}


static void ansiTermPaintSelRow(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t screenRow, int32_t baseX, int32_t baseY) {
    if (!ansiTermHasSelection(w)) { return; }
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t sLine, sCol, eLine, eCol;
    ansiTermSelectionRange(w, &sLine, &sCol, &eLine, &eCol);
    int32_t cols = at->cols;
    int32_t lineIndex = at->scrollPos + screenRow;
    if (lineIndex < sLine || lineIndex > eLine) { return; }
    int32_t colStart = (lineIndex == sLine) ? sCol : 0;
    int32_t colEnd   = (lineIndex == eLine) ? eCol : cols;
    if (colStart >= colEnd) { return; }
    const uint8_t *lineData = ansiTermGetLine(w, lineIndex);
    const uint32_t *palette = at->packedPalette;
    int32_t cellH = font->charHeight;
    int32_t cellW = font->charWidth;
    for (int32_t col = colStart; col < colEnd; col++) {
        uint8_t ch   = lineData[col * 2];
        uint8_t attr = lineData[col * 2 + 1];
        uint32_t fg  = palette[(attr >> 4) & 0x07];
        uint32_t bg  = palette[attr & 0x0F];
        drawChar(d, ops, font, baseX + col * cellW, baseY + screenRow * cellH, ch, fg, bg, true);
    }
}


static void ansiTermPasteToComm(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (!at->commWrite) { return; }
    int32_t clipLen;
    const char *clip = clipboardGet(&clipLen);
    if (clipLen <= 0) { return; }
    for (int32_t i = 0; i < clipLen; i++) {
        uint8_t ch = (uint8_t)clip[i];
        if (ch == '\n') { ch = '\r'; }
        at->commWrite(at->commCtx, &ch, 1);
    }
}


static void ansiTermProcessByte(WidgetT *w, uint8_t ch) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    switch (at->parseState) {
        case PARSE_NORMAL:
            if (ch == KEY_ESCAPE) { at->parseState = PARSE_ESC; }
            else if (ch == '\r') { at->cursorCol = 0; }
            else if (ch == '\n') { ansiTermNewline(w); }
            else if (ch == '\b') { if (at->cursorCol > 0) { at->cursorCol--; } }
            else if (ch == '\t') {
                at->cursorCol = (at->cursorCol + 8) & ~7;
                if (at->cursorCol >= at->cols) { at->cursorCol = at->cols - 1; }
            }
            else if (ch == '\f') { ansiTermEraseDisplay(w, 2); at->cursorRow = 0; at->cursorCol = 0; }
            else if (ch == '\a') { /* Bell -- ignored */ }
            else { ansiTermPutChar(w, ch); }
            break;
        case PARSE_ESC:
            if (ch == '[') {
                at->parseState = PARSE_CSI;
                at->paramCount = 0;
                at->csiPrivate = false;
                memset(at->params, 0, sizeof(at->params));
            } else if (ch == 'D') { ansiTermScrollUp(w); at->parseState = PARSE_NORMAL; }
            else if (ch == 'M') { ansiTermScrollDown(w); at->parseState = PARSE_NORMAL; }
            else if (ch == 'c') {
                at->cursorRow = 0; at->cursorCol = 0; at->curAttr = ANSI_DEFAULT_ATTR;
                at->bold = false; at->wrapMode = true; at->originMode = false;
                at->cursorVisible = true; at->scrollTop = 0; at->scrollBot = at->rows - 1;
                ansiTermEraseDisplay(w, 2); at->parseState = PARSE_NORMAL;
            }
            else { at->parseState = PARSE_NORMAL; }
            break;
        case PARSE_CSI:
            if (ch == '?') { at->csiPrivate = true; }
            else if (ch >= '0' && ch <= '9') {
                if (at->paramCount == 0) { at->paramCount = 1; }
                int32_t idx = at->paramCount - 1;
                if (idx < ANSI_MAX_PARAMS) {
                    at->params[idx] = at->params[idx] * 10 + (ch - '0');
                    if (at->params[idx] > ANSI_MAX_PARAM_VALUE) { at->params[idx] = ANSI_MAX_PARAM_VALUE; }
                }
            }
            else if (ch == ';') {
                // A leading ';' means an omitted (default) first parameter,
                // so reserve slot 0 as well as the slot for what follows;
                // otherwise the next digit overwrites params[0] and every
                // later parameter is shifted one slot left (e.g. ESC[;5H).
                // params[] was zeroed on entering PARSE_CSI, so slot 0 reads
                // as the default value 0.
                if (at->paramCount == 0) { at->paramCount = 2; }
                else if (at->paramCount < ANSI_MAX_PARAMS) { at->paramCount++; }
            }
            else if (ch >= 0x40 && ch <= 0x7E) { ansiTermDispatchCsi(w, ch); at->parseState = PARSE_NORMAL; }
            else { at->parseState = PARSE_NORMAL; }
            break;
    }
}


static void ansiTermProcessSgr(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (at->paramCount == 0) { at->curAttr = ANSI_DEFAULT_ATTR; at->bold = false; return; }
    for (int32_t i = 0; i < at->paramCount; i++) {
        int32_t code = at->params[i];
        uint8_t fg = at->curAttr & 0x0F;
        uint8_t bg = (at->curAttr >> 4) & 0x0F;
        if (code == 0) { fg = 7; bg = 0; at->bold = false; }
        else if (code == 1) { at->bold = true; fg |= 8; }
        else if (code == 5) { bg |= 8; }
        else if (code == 25) { bg &= 7; }
        else if (code == 7) { uint8_t tmp = fg; fg = bg; bg = tmp; }
        else if (code == 8) { fg = bg & 0x07; }
        else if (code == 22) { at->bold = false; fg &= 7; }
        else if (code >= 30 && code <= 37) { fg = (uint8_t)sAnsiToCga[code - 30]; if (at->bold) { fg |= 8; } }
        else if (code >= 40 && code <= 47) { bg = (uint8_t)(sAnsiToCga[code - 40] | (bg & 8)); }
        else if (code >= 90 && code <= 97) { fg = (uint8_t)(sAnsiToCga[code - 90] | 8); }
        else if (code >= 100 && code <= 107) { bg = (uint8_t)(sAnsiToCga[code - 100] | 8); }
        at->curAttr = (uint8_t)((bg << 4) | fg);
    }
}


static void ansiTermPutChar(WidgetT *w, uint8_t ch) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols = at->cols;
    int32_t rows = at->rows;
    int32_t row  = at->cursorRow;
    int32_t col  = at->cursorCol;
    if (row >= 0 && row < rows && col >= 0 && col < cols) {
        int32_t idx = (row * cols + col) * 2;
        at->cells[idx]     = ch;
        at->cells[idx + 1] = at->curAttr;
        if (at->curAttr & ATTR_BLINK_BIT) {
            at->hasBlinkCells = true;
        }
        ansiTermDirtyRow(w, row);
    }
    at->cursorCol++;
    if (at->cursorCol >= cols) {
        if (at->wrapMode) { at->cursorCol = 0; ansiTermNewline(w); }
        else { at->cursorCol = cols - 1; }
    }
}


static void ansiTermScrollDown(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols = at->cols;
    int32_t top  = at->scrollTop;
    int32_t bot  = at->scrollBot;
    int32_t bytesPerRow = cols * 2;
    if (bot > top) { memmove(at->cells + (top + 1) * bytesPerRow, at->cells + top * bytesPerRow, (bot - top) * bytesPerRow); }
    ansiTermFillCells(w, top * cols, cols);
    for (int32_t r = top; r <= bot; r++) { ansiTermDirtyRow(w, r); }
}


static void ansiTermScrollUp(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols = at->cols;
    int32_t top  = at->scrollTop;
    int32_t bot  = at->scrollBot;
    int32_t bytesPerRow = cols * 2;
    if (top == 0 && bot == at->rows - 1) {
        bool wasAtBottom = (at->scrollPos == at->scrollbackCount);
        ansiTermAddToScrollback(w, 0);
        if (wasAtBottom) { at->scrollPos = at->scrollbackCount; }
    }
    if (bot > top) { memmove(at->cells + top * bytesPerRow, at->cells + (top + 1) * bytesPerRow, (bot - top) * bytesPerRow); }
    ansiTermFillCells(w, bot * cols, cols);
    for (int32_t r = top; r <= bot; r++) { ansiTermDirtyRow(w, r); }
}


static void ansiTermSelectionRange(const WidgetT *w, int32_t *startLine, int32_t *startCol, int32_t *endLine, int32_t *endCol) {
    const AnsiTermDataT *at = (const AnsiTermDataT *)w->data;
    int32_t sl = at->selStartLine, sc = at->selStartCol;
    int32_t el = at->selEndLine, ec = at->selEndCol;
    if (sl > el || (sl == el && sc > ec)) { *startLine = el; *startCol = ec; *endLine = sl; *endCol = sc; }
    else { *startLine = sl; *startCol = sc; *endLine = el; *endCol = ec; }
}


WidgetT *wgtAnsiTerm(WidgetT *parent, int32_t cols, int32_t rows) {
    if (!parent) { return NULL; }
    if (cols <= 0) { cols = 80; }
    if (rows <= 0) { rows = 25; }
    if (cols > ANSI_MAX_COLS) { cols = ANSI_MAX_COLS; }
    if (rows > ANSI_MAX_ROWS) { rows = ANSI_MAX_ROWS; }
    WidgetT *w = widgetAlloc(parent, sTypeId);
    if (!w) { return NULL; }
    // Use wgtDestroy (not bare free) on every failure path: widgetAlloc
    // already linked w into the parent's child list and the poll list, so
    // freeing it directly leaves dangling pointers there.  The destroy
    // method is NULL-safe and frees any partially-allocated data.
    AnsiTermDataT *at = (AnsiTermDataT *)calloc(1, sizeof(AnsiTermDataT));
    if (!at) {
        wgtDestroy(w);
        return NULL;
    }
    w->data = at;
    int32_t cellCount = cols * rows;
    at->cells = (uint8_t *)malloc(cellCount * 2);
    if (!at->cells) {
        wgtDestroy(w);
        return NULL;
    }
    int32_t sbMax = ANSI_DEFAULT_SCROLLBACK;
    at->scrollback = (uint8_t *)malloc(sbMax * cols * 2);
    if (!at->scrollback) {
        wgtDestroy(w);
        return NULL;
    }
    at->dirtyRows = (uint32_t *)malloc(ANSI_DIRTY_WORDS(rows) * sizeof(uint32_t));
    if (!at->dirtyRows) {
        wgtDestroy(w);
        return NULL;
    }
    at->cols = cols; at->rows = rows;
    at->cursorVisible = true; at->wrapMode = true;
    at->curAttr = ANSI_DEFAULT_ATTR; at->parseState = PARSE_NORMAL;
    at->scrollBot = rows - 1; at->scrollbackMax = sbMax;
    at->selStartLine = -1; at->selStartCol = -1;
    at->selEndLine = -1; at->selEndCol = -1;
    at->blinkVisible = true; at->blinkTime = clock();
    at->cursorOn = true; at->cursorTime = clock();
    ansiTermDirtyAll(w); at->lastCursorRow = -1; at->lastCursorCol = -1;
    for (int32_t i = 0; i < cellCount; i++) { at->cells[i * 2] = ' '; at->cells[i * 2 + 1] = ANSI_DEFAULT_ATTR; }
    return w;
}


void wgtAnsiTermClear(WidgetT *w) {
    VALIDATE_WIDGET_VOID(w, sTypeId);
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t rows = at->rows;
    int32_t cols = at->cols;
    bool wasAtBottom = (at->scrollPos == at->scrollbackCount);
    for (int32_t r = 0; r < rows; r++) { ansiTermAddToScrollback(w, r); }
    if (wasAtBottom) { at->scrollPos = at->scrollbackCount; }
    int32_t cellCount = cols * rows;
    for (int32_t i = 0; i < cellCount; i++) { at->cells[i * 2] = ' '; at->cells[i * 2 + 1] = ANSI_DEFAULT_ATTR; }
    at->cursorRow = 0; at->cursorCol = 0; at->curAttr = ANSI_DEFAULT_ATTR;
    at->bold = false; at->parseState = PARSE_NORMAL;
    // Mark every row dirty or the row-skipping paint leaves stale cells
    // until each happens to be re-dirtied by later output.
    ansiTermDirtyAll(w);
}


static int32_t wgtAnsiTermGetCols(const WidgetT *w) {
    VALIDATE_WIDGET(w, sTypeId, 0);
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    return at->cols;
}


static int32_t wgtAnsiTermGetRows(const WidgetT *w) {
    VALIDATE_WIDGET(w, sTypeId, 0);
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    return at->rows;
}


static int32_t wgtAnsiTermGetScrollback(const WidgetT *w) {
    VALIDATE_WIDGET(w, sTypeId, 0);
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    return at->scrollbackMax;
}


int32_t wgtAnsiTermPoll(WidgetT *w) {
    VALIDATE_WIDGET(w, sTypeId, 0);
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    clock_t now           = clock();
    clock_t blinkInterval = (clock_t)BLINK_MS * CLOCKS_PER_SEC / 1000;
    clock_t curInterval   = (clock_t)CURSOR_MS * CLOCKS_PER_SEC / 1000;
    if ((now - at->blinkTime) >= blinkInterval) {
        at->blinkTime = now; at->blinkVisible = !at->blinkVisible;
        if (at->hasBlinkCells) {
            int32_t cols = at->cols; int32_t rows = at->rows;
            bool    anyBlink = false;
            for (int32_t row = 0; row < rows; row++) {
                for (int32_t col = 0; col < cols; col++) {
                    if (at->cells[(row * cols + col) * 2 + 1] & ATTR_BLINK_BIT) {
                        ansiTermDirtyRow(w, row);
                        anyBlink = true;
                        break;
                    }
                }
            }
            // Clear flag if a full scan found no blink cells (e.g. after a
            // terminal clear).  This keeps subsequent blink ticks free.
            if (!anyBlink) {
                at->hasBlinkCells = false;
            }
        }
    }
    if ((now - at->cursorTime) >= curInterval) {
        at->cursorTime = now; at->cursorOn = !at->cursorOn;
        ansiTermDirtyRow(w, at->cursorRow);
    }
    if (!at->commRead) { return 0; }
    uint8_t buf[256];
    int32_t n = at->commRead(at->commCtx, buf, (int32_t)sizeof(buf));
    if (n > 0) { wgtAnsiTermWrite(w, buf, n); }
    return n > 0 ? n : 0;
}


int32_t wgtAnsiTermRepaint(WidgetT *w, int32_t *outY, int32_t *outH) {
    if (!w || w->type != sTypeId || !w->window) { return 0; }
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t prevRow = at->lastCursorRow;
    int32_t curRow  = at->cursorRow;
    if (prevRow != curRow || at->lastCursorCol != at->cursorCol) {
        ansiTermDirtyRow(w, prevRow);
        ansiTermDirtyRow(w, curRow);
    }
    if (!ansiTermDirtyAny(w)) { return 0; }
    WindowT *win = w->window;
    if (!win->contentBuf || !win->widgetRoot) { return 0; }
    AppContextT *ctx = wgtGetContext(win->widgetRoot);
    if (!ctx) { return 0; }
    DisplayT cd = ctx->display;
    cd.backBuf = win->contentBuf; cd.width = win->contentW; cd.height = win->contentH;
    cd.pitch = win->contentPitch; cd.clipX = 0; cd.clipY = 0; cd.clipW = win->contentW; cd.clipH = win->contentH;
    const BlitOpsT *ops = &ctx->blitOps;
    const BitmapFontT *font = &ctx->font;
    int32_t cols = at->cols; int32_t rows = at->rows;
    int32_t cellH = font->charHeight;
    int32_t baseX = w->x + ANSI_BORDER; int32_t baseY = w->y + ANSI_BORDER;
    ansiTermBuildPalette(w, &cd);
    const uint32_t *palette = at->packedPalette;
    bool viewingLive = (at->scrollPos == at->scrollbackCount);
    int32_t repainted = 0; int32_t minRow = rows; int32_t maxRow = -1;
    for (int32_t row = 0; row < rows; row++) {
        if (!ansiTermDirtyTest(w, row)) { continue; }
        int32_t lineIndex = at->scrollPos + row;
        const uint8_t *lineData = ansiTermGetLine(w, lineIndex);
        int32_t curCol2 = -1;
        if (viewingLive && at->cursorVisible && at->cursorOn && row == at->cursorRow) { curCol2 = at->cursorCol; }
        drawTermRow(&cd, ops, font, baseX, baseY + row * cellH, cols, lineData, palette, at->blinkVisible, curCol2);
        ansiTermPaintSelRow(w, &cd, ops, font, row, baseX, baseY);
        if (row < minRow) { minRow = row; }
        if (row > maxRow) { maxRow = row; }
        repainted++;
    }
    ansiTermDirtyClear(w); at->lastCursorRow = at->cursorRow; at->lastCursorCol = at->cursorCol;
    if (outY) { *outY = baseY + minRow * cellH; }
    if (outH) { *outH = (maxRow - minRow + 1) * cellH; }
    return repainted;
}


void wgtAnsiTermSetComm(WidgetT *w, void *ctx, int32_t (*readFn)(void *, uint8_t *, int32_t), int32_t (*writeFn)(void *, const uint8_t *, int32_t)) {
    VALIDATE_WIDGET_VOID(w, sTypeId);
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    at->commCtx = ctx; at->commRead = readFn; at->commWrite = writeFn;
}


void wgtAnsiTermSetScrollback(WidgetT *w, int32_t maxLines) {
    if (!w || w->type != sTypeId || maxLines <= 0) { return; }
    // Cap the line count so maxLines * cols * 2 cannot overflow int32_t
    // (cols is already capped at ANSI_MAX_COLS on creation).
    if (maxLines > ANSI_MAX_SCROLLBACK) { maxLines = ANSI_MAX_SCROLLBACK; }
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    int32_t cols = at->cols;
    uint8_t *newBuf = (uint8_t *)malloc(maxLines * cols * 2);
    if (!newBuf) { return; }
    free(at->scrollback);
    at->scrollback = newBuf; at->scrollbackMax = maxLines;
    at->scrollbackCount = 0; at->scrollbackHead = 0; at->scrollPos = 0;
}


void wgtAnsiTermWrite(WidgetT *w, const uint8_t *data, int32_t len) {
    if (!w || w->type != sTypeId || !data || len <= 0) { return; }
    ansiTermClearSelection(w);
    for (int32_t i = 0; i < len; i++) { ansiTermProcessByte(w, data[i]); }
}


static void wgtAnsiTermWriteString(WidgetT *w, const char *text) {
    if (!text) {
        return;
    }

    wgtAnsiTermWrite(w, (const uint8_t *)text, (int32_t)strlen(text));
}


void widgetAnsiTermCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    w->calcMinW = at->cols * font->charWidth + ANSI_BORDER * 2 + ANSI_SB_W;
    w->calcMinH = at->rows * font->charHeight + ANSI_BORDER * 2;
}


static bool widgetAnsiTermClearSelection(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;

    if (at->selStartLine >= 0) {
        at->selStartLine = -1;
        at->selStartCol  = -1;
        at->selEndLine   = -1;
        at->selEndCol    = -1;
        ansiTermDirtyAll(w);
        return true;
    }

    return false;
}


void widgetAnsiTermDestroy(WidgetT *w) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (at) { free(at->cells); free(at->scrollback); free(at->dirtyRows); free(at); w->data = NULL; }
}


static void widgetAnsiTermOnDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
    (void)root;
    AnsiTermDataT *at   = (AnsiTermDataT *)w->data;
    AppContextT *ctx    = wgtGetContext(w);
    const BitmapFontT *font = &ctx->font;

    int32_t innerX = w->x + ANSI_BORDER;
    int32_t innerY = w->y + ANSI_BORDER;
    int32_t col    = (vx - innerX) / font->charWidth;
    int32_t row    = (vy - innerY) / font->charHeight;

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

    if (col >= at->cols) {
        col = at->cols - 1;
    }

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

    if (row >= at->rows) {
        row = at->rows - 1;
    }

    int32_t lineIndex = at->scrollPos + row;
    at->selEndLine = lineIndex;
    at->selEndCol  = col;
    ansiTermDirtyAll(w);
}


void widgetAnsiTermOnKey(WidgetT *w, int32_t key, int32_t mod) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    if (key == KEY_CTRL_C && (mod & KEY_MOD_CTRL)) {
        if (ansiTermHasSelection(w)) { ansiTermCopySelection(w); ansiTermClearSelection(w); wgtInvalidatePaint(w); return; }
    }
    if (key == KEY_CTRL_V && (mod & KEY_MOD_CTRL)) {
        ansiTermPasteToComm(w);
        wgtInvalidatePaint(w);
        return;
    }
    if (!at->commWrite) { return; }
    if (ansiTermHasSelection(w)) { ansiTermClearSelection(w); }
    uint8_t buf[8];
    int32_t len = 0;
    if (key >= KEY_ASCII_PRINT_FIRST && key <= KEY_ASCII_PRINT_LAST) { buf[0] = (uint8_t)key; len = 1; }
    else if (key == KEY_ESCAPE) { buf[0] = 0x1B; len = 1; }
    else if (key == 0x09) { buf[0] = 0x09; len = 1; }
    else if (key == 13 || key == 10) { buf[0] = '\r'; len = 1; }
    else if (key == 8) { buf[0] = 0x08; len = 1; }
    else if (key == KEY_UP) { buf[0] = 0x1B; buf[1] = '['; buf[2] = 'A'; len = 3; }
    else if (key == KEY_DOWN) { buf[0] = 0x1B; buf[1] = '['; buf[2] = 'B'; len = 3; }
    else if (key == KEY_RIGHT) { buf[0] = 0x1B; buf[1] = '['; buf[2] = 'C'; len = 3; }
    else if (key == KEY_LEFT) { buf[0] = 0x1B; buf[1] = '['; buf[2] = 'D'; len = 3; }
    else if (key == KEY_HOME) { buf[0] = 0x1B; buf[1] = '['; buf[2] = 'H'; len = 3; }
    else if (key == KEY_END) { buf[0] = 0x1B; buf[1] = '['; buf[2] = 'F'; len = 3; }
    else if (key == KEY_PGUP) { buf[0] = 0x1B; buf[1] = '['; buf[2] = '5'; buf[3] = '~'; len = 4; }
    else if (key == KEY_PGDN) { buf[0] = 0x1B; buf[1] = '['; buf[2] = '6'; buf[3] = '~'; len = 4; }
    else if (key == KEY_DELETE) { buf[0] = 0x1B; buf[1] = '['; buf[2] = '3'; buf[3] = '~'; len = 4; }
    else if (key >= 1 && key < 32) { buf[0] = (uint8_t)key; len = 1; }
    if (len > 0) { at->commWrite(at->commCtx, buf, len); }
    wgtInvalidatePaint(w);
}


void widgetAnsiTermOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
    AnsiTermDataT *at = (AnsiTermDataT *)hit->data;
    AppContextT *actx = (AppContextT *)root->userData;
    const BitmapFontT *font = &actx->font;
    sFocusedWidget = hit;
    clearOtherSelections(hit);
    int32_t cols = at->cols;
    int32_t rows = at->rows;
    int32_t sbX  = hit->x + ANSI_BORDER + cols * font->charWidth;
    int32_t sbY  = hit->y + ANSI_BORDER;
    int32_t sbH  = rows * font->charHeight;
    int32_t arrowH = ANSI_SB_W;

    if (vx < sbX) {
        int32_t baseX = hit->x + ANSI_BORDER;
        int32_t baseY = hit->y + ANSI_BORDER;
        int32_t clickRow = (vy - baseY) / font->charHeight;
        int32_t clickCol = (vx - baseX) / font->charWidth;
        if (clickRow < 0) { clickRow = 0; }
        if (clickRow >= rows) { clickRow = rows - 1; }
        if (clickCol < 0) { clickCol = 0; }
        if (clickCol >= cols) { clickCol = cols - 1; }
        int32_t lineIndex = at->scrollPos + clickRow;
        int32_t clicks = multiClickDetect(vx, vy);
        if (clicks >= 3) {
            at->selStartLine = lineIndex; at->selStartCol = 0;
            at->selEndLine = lineIndex; at->selEndCol = cols;
            sDragWidget = NULL;
        } else if (clicks == 2) {
            const uint8_t *lineData = ansiTermGetLine(hit, lineIndex);
            int32_t ws = clickCol; int32_t we = clickCol;
            while (ws > 0 && isWordChar((char)lineData[(ws - 1) * 2])) { ws--; }
            while (we < cols && isWordChar((char)lineData[we * 2])) { we++; }
            at->selStartLine = lineIndex; at->selStartCol = ws;
            at->selEndLine = lineIndex; at->selEndCol = we;
            sDragWidget = NULL;
        } else {
            at->selStartLine = lineIndex; at->selStartCol = clickCol;
            at->selEndLine = lineIndex; at->selEndCol = clickCol;
            sDragWidget = hit;
        }
        ansiTermDirtyAll(hit);
        return;
    }

    int32_t sbCount = at->scrollbackCount;
    if (sbCount == 0) { return; }
    int32_t maxScroll = sbCount;
    if (vy >= sbY && vy < sbY + arrowH) { at->scrollPos--; }
    else if (vy >= sbY + sbH - arrowH && vy < sbY + sbH) { at->scrollPos++; }
    else if (vy >= sbY + arrowH && vy < sbY + sbH - arrowH) {
        int32_t trackY = sbY + arrowH; int32_t trackH = sbH - arrowH * 2;
        if (trackH <= 0) { return; }
        int32_t totalLines = sbCount + rows;
        int32_t thumbH = (rows * trackH) / totalLines;
        if (thumbH < 8) { thumbH = 8; }
        int32_t thumbRange = trackH - thumbH;
        int32_t thumbY = trackY;
        if (maxScroll > 0 && thumbRange > 0) { thumbY = trackY + (at->scrollPos * thumbRange) / maxScroll; }
        if (vy < thumbY) { at->scrollPos -= rows; }
        else if (vy >= thumbY + thumbH) { at->scrollPos += rows; }
    }
    if (at->scrollPos < 0) { at->scrollPos = 0; }
    if (at->scrollPos > maxScroll) { at->scrollPos = maxScroll; }
    // scrollPos changed every visible row's content; mark all dirty or
    // the row-skipping paint shows a stale mixed display.
    ansiTermDirtyAll(hit);
}


void widgetAnsiTermPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    AnsiTermDataT *at = (AnsiTermDataT *)w->data;
    BevelStyleT bevel;
    bevel.highlight = colors->windowShadow;
    bevel.shadow    = colors->windowHighlight;
    bevel.face      = BEVEL_NO_FILL;
    bevel.width     = ANSI_BORDER;
    drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
    ansiTermBuildPalette(w, d);
    const uint32_t *palette = at->packedPalette;
    int32_t cols = at->cols; int32_t rows = at->rows;
    int32_t cellW = font->charWidth; int32_t cellH = font->charHeight;
    int32_t baseX = w->x + ANSI_BORDER; int32_t baseY = w->y + ANSI_BORDER;
    int32_t sbCount = at->scrollbackCount;
    bool viewingLive = (at->scrollPos == sbCount);
    // A full paint with nothing marked dirty is an expose; repaint all rows.
    bool paintAll = !ansiTermDirtyAny(w);
    for (int32_t row = 0; row < rows; row++) {
        if (!paintAll && !ansiTermDirtyTest(w, row)) { continue; }
        int32_t lineIndex = at->scrollPos + row;
        const uint8_t *lineData = ansiTermGetLine(w, lineIndex);
        int32_t curCol = -1;
        if (viewingLive && at->cursorVisible && at->cursorOn && row == at->cursorRow) { curCol = at->cursorCol; }
        drawTermRow(d, ops, font, baseX, baseY + row * cellH, cols, lineData, palette, at->blinkVisible, curCol);
        ansiTermPaintSelRow(w, d, ops, font, row, baseX, baseY);
    }
    ansiTermDirtyClear(w);

    // Scrollbar
    int32_t sbX = baseX + cols * cellW; int32_t sbY = baseY;
    int32_t sbW = ANSI_SB_W; int32_t sbH = rows * cellH;
    int32_t arrowH = ANSI_SB_W;
    if (sbCount == 0) { rectFill(d, ops, sbX, sbY, sbW, sbH, colors->scrollbarTrough); return; }
    int32_t trackY = sbY + arrowH; int32_t trackH = sbH - arrowH * 2;
    rectFill(d, ops, sbX, trackY, sbW, trackH, colors->scrollbarTrough);
    BevelStyleT btnBevel;
    btnBevel.highlight = colors->windowHighlight; btnBevel.shadow = colors->windowShadow;
    btnBevel.face = colors->scrollbarBg; btnBevel.width = 1;
    drawBevel(d, ops, sbX, sbY, sbW, arrowH, &btnBevel);
    int32_t arrowMidX = sbX + sbW / 2; int32_t arrowMidY = sbY + arrowH / 2;
    for (int32_t i = 0; i < 4; i++) { drawHLine(d, ops, arrowMidX - i, arrowMidY - 1 + i, i * 2 + 1, colors->scrollbarFg); }
    drawBevel(d, ops, sbX, sbY + sbH - arrowH, sbW, arrowH, &btnBevel);
    arrowMidY = sbY + sbH - arrowH + arrowH / 2;
    for (int32_t i = 0; i < 4; i++) { drawHLine(d, ops, arrowMidX - i, arrowMidY + 1 - i, i * 2 + 1, colors->scrollbarFg); }
    if (trackH > 0) {
        int32_t totalLines = sbCount + rows;
        int32_t thumbH = (rows * trackH) / totalLines;
        if (thumbH < 8) { thumbH = 8; }
        int32_t thumbRange = trackH - thumbH; int32_t maxScroll = sbCount;
        int32_t thumbY = trackY;
        if (maxScroll > 0 && thumbRange > 0) { thumbY = trackY + (at->scrollPos * thumbRange) / maxScroll; }
        drawBevel(d, ops, sbX, thumbY, sbW, thumbH, &btnBevel);
    }
    if (w == sFocusedWidget) { drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, colors->contentFg); }
}


void widgetAnsiTermPollVtable(WidgetT *w, WindowT *win) {
    (void)win;
    wgtAnsiTermPoll(w);
}


static const WidgetClassT sClassAnsiTerm = {
    .version = WGT_CLASS_VERSION,
    .flags   = WCLASS_FOCUSABLE | WCLASS_SCROLLABLE | WCLASS_NEEDS_POLL | WCLASS_SWALLOWS_TAB,
    .handlers = {
        [WGT_METHOD_PAINT]           = (void *)widgetAnsiTermPaint,
        [WGT_METHOD_CALC_MIN_SIZE]   = (void *)widgetAnsiTermCalcMinSize,
        [WGT_METHOD_ON_MOUSE]        = (void *)widgetAnsiTermOnMouse,
        [WGT_METHOD_ON_KEY]          = (void *)widgetAnsiTermOnKey,
        [WGT_METHOD_DESTROY]         = (void *)widgetAnsiTermDestroy,
        [WGT_METHOD_CLEAR_SELECTION] = (void *)widgetAnsiTermClearSelection,
        [WGT_METHOD_ON_DRAG_UPDATE]  = (void *)widgetAnsiTermOnDragUpdate,
        [WGT_METHOD_POLL]            = (void *)widgetAnsiTermPollVtable,
        [WGT_METHOD_QUICK_REPAINT]   = (void *)wgtAnsiTermRepaint,
    }
};


static const struct {
    WidgetT *(*create)(WidgetT *parent, int32_t cols, int32_t rows);
    void     (*write)(WidgetT *w, const uint8_t *data, int32_t len);
    void     (*clear)(WidgetT *w);
    void     (*setComm)(WidgetT *w, void *ctx, int32_t (*readFn)(void *, uint8_t *, int32_t), int32_t (*writeFn)(void *, const uint8_t *, int32_t));
    void     (*setScrollback)(WidgetT *w, int32_t maxLines);
    int32_t  (*poll)(WidgetT *w);
    int32_t  (*repaint)(WidgetT *w, int32_t *outY, int32_t *outH);
} sApi = {
    .create        = wgtAnsiTerm,
    .write         = wgtAnsiTermWrite,
    .clear         = wgtAnsiTermClear,
    .setComm       = wgtAnsiTermSetComm,
    .setScrollback = wgtAnsiTermSetScrollback,
    .poll          = wgtAnsiTermPoll,
    .repaint       = wgtAnsiTermRepaint
};

static const WgtPropDescT sProps[] = {
    { "Cols",       WGT_IFACE_INT, (void *)wgtAnsiTermGetCols, NULL, NULL },
    { "Rows",       WGT_IFACE_INT, (void *)wgtAnsiTermGetRows, NULL, NULL },
    { "Scrollback", WGT_IFACE_INT, (void *)wgtAnsiTermGetScrollback, (void *)wgtAnsiTermSetScrollback, NULL }
};

static const WgtMethodDescT sMethods[] = {
    { "Clear", WGT_SIG_VOID, (void *)wgtAnsiTermClear },
    { "Poll",  WGT_SIG_VOID, (void *)wgtAnsiTermPoll },
    { "Write", WGT_SIG_STR,  (void *)wgtAnsiTermWriteString }
};

static const WgtIfaceT sIface = {
    .basName      = "Terminal",
    .props        = sProps,
    .propCount    = 3,
    .methods      = sMethods,
    .methodCount  = 3,
    .events       = NULL,
    .eventCount   = 0,
    .createSig    = WGT_CREATE_PARENT_INT_INT,
    .createArgs   = { 80, 25 }
};

void wgtRegister(void) {
    sTypeId = wgtRegisterClass(&sClassAnsiTerm);
    wgtRegisterApi("ansiterm", &sApi);
    wgtRegisterIface("ansiterm", &sIface);
}
