// 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 32-bit 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 "widgetInternal.h" // ============================================================ // Constants // ============================================================ #define ANSI_BORDER 2 #define ANSI_MAX_PARAMS 8 #define ANSI_SB_W 14 #define ANSI_DEFAULT_SCROLLBACK 500 // Three-state ANSI parser: NORMAL processes printable chars and C0 controls, // ESC waits for the CSI introducer '[' (or standalone ESC sequences like ESC D/M/c), // CSI accumulates numeric parameters until a final byte (0x40-0x7E) dispatches. #define PARSE_NORMAL 0 #define PARSE_ESC 1 #define PARSE_CSI 2 // Default attribute: light gray on black #define ANSI_DEFAULT_ATTR 0x07 // Attribute byte layout matches CGA text mode exactly so that ANSI art // designed for DOS renders correctly without any translation. // Bit 7 = blink, bits 6-4 = bg (0-7), bits 3-0 = fg (0-15) #define ATTR_BLINK_BIT 0x80 #define ATTR_BG_MASK 0x70 #define ATTR_FG_MASK 0x0F // Blink/cursor rates in milliseconds #define BLINK_MS 500 #define CURSOR_MS 250 // ============================================================ // CGA palette (RGB values for 16 standard colors) // ============================================================ static const uint8_t sCgaPalette[16][3] = { { 0, 0, 0}, // 0: black { 0, 0, 170}, // 1: blue { 0, 170, 0}, // 2: green { 0, 170, 170}, // 3: cyan {170, 0, 0}, // 4: red {170, 0, 170}, // 5: magenta {170, 85, 0}, // 6: brown {170, 170, 170}, // 7: light gray { 85, 85, 85}, // 8: dark gray { 85, 85, 255}, // 9: bright blue { 85, 255, 85}, // 10: bright green { 85, 255, 255}, // 11: bright cyan {255, 85, 85}, // 12: bright red {255, 85, 255}, // 13: bright magenta {255, 255, 85}, // 14: bright yellow {255, 255, 255}, // 15: bright white }; // ANSI SGR color index to CGA color index. // ANSI and CGA use different orderings for the base 8 colors. ANSI follows // the RGB bit-field convention (0=black, 1=red, 2=green, 3=yellow, 4=blue, // 5=magenta, 6=cyan, 7=white) while CGA uses (0=black, 1=blue, 2=green, // 3=cyan, 4=red, 5=magenta, 6=brown, 7=light gray). This table maps from // ANSI SGR color numbers (30-37 minus 30) to CGA palette indices. The // high bit (bright) is handled separately via the bold attribute. 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 ansiTermDirtyRange(WidgetT *w, int32_t startCell, int32_t count); static void ansiTermDirtyRow(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); // ============================================================ // ansiTermAddToScrollback // ============================================================ // // Copy a screen row into the scrollback circular buffer. // The circular buffer avoids any need for memmove when the buffer fills -- // the head index simply wraps around, overwriting the oldest entry. This // is O(1) per row regardless of scrollback size, which matters when BBS // software rapidly dumps text (e.g. file listings, ANSI art). static void ansiTermAddToScrollback(WidgetT *w, int32_t screenRow) { if (!w->as.ansiTerm->scrollback || w->as.ansiTerm->scrollbackMax <= 0) { return; } int32_t cols = w->as.ansiTerm->cols; int32_t bytesPerRow = cols * 2; int32_t head = w->as.ansiTerm->scrollbackHead; memcpy(w->as.ansiTerm->scrollback + head * bytesPerRow, w->as.ansiTerm->cells + screenRow * bytesPerRow, bytesPerRow); w->as.ansiTerm->scrollbackHead = (head + 1) % w->as.ansiTerm->scrollbackMax; if (w->as.ansiTerm->scrollbackCount < w->as.ansiTerm->scrollbackMax) { w->as.ansiTerm->scrollbackCount++; } } // ============================================================ // ansiTermBuildPalette // ============================================================ // // Build the packed 16-color palette and cache it in the widget. // Only recomputed when paletteValid is false (first use or // after a display format change). // // Caching avoids calling packColor() 16 times per repaint. Since packColor // involves shifting and masking per the display's pixel format, and the // terminal repaints rows frequently during data reception, this saves // significant overhead on a 486 where each function call costs ~30 cycles. static void ansiTermBuildPalette(WidgetT *w, const DisplayT *d) { if (w->as.ansiTerm->paletteValid) { return; } for (int32_t i = 0; i < 16; i++) { w->as.ansiTerm->packedPalette[i] = packColor(d, sCgaPalette[i][0], sCgaPalette[i][1], sCgaPalette[i][2]); } w->as.ansiTerm->paletteValid = true; } // ============================================================ // ansiTermClearSelection // ============================================================ static void ansiTermClearSelection(WidgetT *w) { // Dirty all rows when clearing a selection so the selection highlight // is erased on the next repaint if (ansiTermHasSelection(w)) { w->as.ansiTerm->dirtyRows = 0xFFFFFFFF; } w->as.ansiTerm->selStartLine = -1; w->as.ansiTerm->selStartCol = -1; w->as.ansiTerm->selEndLine = -1; w->as.ansiTerm->selEndCol = -1; w->as.ansiTerm->selecting = false; } // ============================================================ // ansiTermCopySelection // ============================================================ static void ansiTermCopySelection(WidgetT *w) { if (!ansiTermHasSelection(w)) { return; } int32_t sLine; int32_t sCol; int32_t eLine; int32_t eCol; ansiTermSelectionRange(w, &sLine, &sCol, &eLine, &eCol); int32_t cols = w->as.ansiTerm->cols; // Build text from selected cells (strip trailing spaces per line). // Trailing spaces are stripped because BBS text mode fills the entire // row with spaces -- without stripping, pasting would include many // unwanted trailing blanks. Fixed 4KB buffer is sufficient for typical // terminal selections (80 cols * 50 rows = 4000 chars max). char buf[4096]; int32_t pos = 0; for (int32_t line = sLine; line <= eLine && pos < 4095; line++) { const uint8_t *lineData = ansiTermGetLine(w, line); int32_t colStart = (line == sLine) ? sCol : 0; int32_t colEnd = (line == eLine) ? eCol : cols; // Find last non-space character in this line's selection 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 < 4095; c++) { buf[pos++] = (char)lineData[c * 2]; } // Add newline between lines (not after last) if (line < eLine && pos < 4095) { buf[pos++] = '\n'; } } buf[pos] = '\0'; if (pos > 0) { clipboardCopy(buf, pos); } } // ============================================================ // ansiTermDirtyRange // ============================================================ // // Mark rows dirty that are touched by a cell range [startCell, startCell+count). // Uses a 32-bit bitmask -- one bit per row -- which limits tracking to the first // 32 rows. This is fine because standard terminal sizes are 24-25 rows, and // bitmask operations are single-cycle on the target CPU. The bitmask approach // is much cheaper than maintaining a dirty rect list for per-row tracking. static void ansiTermDirtyRange(WidgetT *w, int32_t startCell, int32_t count) { int32_t cols = w->as.ansiTerm->cols; int32_t startRow = startCell / cols; int32_t endRow = (startCell + count - 1) / cols; for (int32_t r = startRow; r <= endRow && r < 32; r++) { w->as.ansiTerm->dirtyRows |= (1U << r); } } // ============================================================ // ansiTermDirtyRow // ============================================================ static void ansiTermDirtyRow(WidgetT *w, int32_t row) { if (row >= 0 && row < 32) { w->as.ansiTerm->dirtyRows |= (1U << row); } } // ============================================================ // ansiTermDeleteLines // ============================================================ // Delete lines within the scroll region by shifting rows up with memmove. // The vacated lines at the bottom are filled with the current attribute. // This is the CSI 'M' (DL) handler. Used by BBS software for scroll-region // tricks (e.g., split-screen chat windows). static void ansiTermDeleteLines(WidgetT *w, int32_t count) { int32_t cols = w->as.ansiTerm->cols; int32_t bot = w->as.ansiTerm->scrollBot; int32_t row = w->as.ansiTerm->cursorRow; if (count > bot - row + 1) { count = bot - row + 1; } if (count <= 0) { return; } uint8_t *cells = w->as.ansiTerm->cells; int32_t bytesPerRow = cols * 2; // Shift lines up within region if (row + count <= bot) { memmove(cells + row * bytesPerRow, cells + (row + count) * bytesPerRow, (bot - row - count + 1) * bytesPerRow); } // Clear the bottom lines of the region for (int32_t r = bot - count + 1; r <= bot; r++) { ansiTermFillCells(w, r * cols, cols); } // Dirty affected rows for (int32_t r = row; r <= bot; r++) { ansiTermDirtyRow(w, r); } } // ============================================================ // ansiTermDispatchCsi // ============================================================ // Central CSI dispatcher. After the parser accumulates parameters in the CSI // state, the final byte triggers dispatch here. Only sequences commonly used // by BBS software are implemented -- exotic VT220+ sequences are silently ignored. // DEC private modes (ESC[?...) are handled separately since they use a different // parameter namespace than standard ECMA-48 sequences. static void ansiTermDispatchCsi(WidgetT *w, uint8_t cmd) { int32_t *p = w->as.ansiTerm->params; int32_t n = w->as.ansiTerm->paramCount; // DEC private modes (ESC[?...) // Mode 6: origin mode (cursor addressing relative to scroll region) // Mode 7: auto-wrap at right margin // Mode 25: cursor visibility (DECTCEM) if (w->as.ansiTerm->csiPrivate) { int32_t mode = (n >= 1) ? p[0] : 0; if (cmd == 'h') { if (mode == 6) { w->as.ansiTerm->originMode = true; w->as.ansiTerm->cursorRow = w->as.ansiTerm->scrollTop; w->as.ansiTerm->cursorCol = 0; } if (mode == 7) { w->as.ansiTerm->wrapMode = true; } if (mode == 25) { w->as.ansiTerm->cursorVisible = true; } } else if (cmd == 'l') { if (mode == 6) { w->as.ansiTerm->originMode = false; w->as.ansiTerm->cursorRow = 0; w->as.ansiTerm->cursorCol = 0; } if (mode == 7) { w->as.ansiTerm->wrapMode = false; } if (mode == 25) { w->as.ansiTerm->cursorVisible = false; } } return; } switch (cmd) { case '@': // ICH - insert character { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; int32_t cols = w->as.ansiTerm->cols; int32_t row = w->as.ansiTerm->cursorRow; int32_t col = w->as.ansiTerm->cursorCol; if (count > cols - col) { count = cols - col; } if (count > 0) { uint8_t *base = w->as.ansiTerm->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] = w->as.ansiTerm->curAttr; } ansiTermDirtyRow(w, row); } break; } case 'A': // CUU - cursor up { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; int32_t minRow = w->as.ansiTerm->originMode ? w->as.ansiTerm->scrollTop : 0; w->as.ansiTerm->cursorRow -= count; if (w->as.ansiTerm->cursorRow < minRow) { w->as.ansiTerm->cursorRow = minRow; } break; } case 'B': // CUD - cursor down { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; int32_t maxRow = w->as.ansiTerm->originMode ? w->as.ansiTerm->scrollBot : w->as.ansiTerm->rows - 1; w->as.ansiTerm->cursorRow += count; if (w->as.ansiTerm->cursorRow > maxRow) { w->as.ansiTerm->cursorRow = maxRow; } break; } case 'C': // CUF - cursor forward { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; w->as.ansiTerm->cursorCol += count; if (w->as.ansiTerm->cursorCol >= w->as.ansiTerm->cols) { w->as.ansiTerm->cursorCol = w->as.ansiTerm->cols - 1; } break; } case 'D': // CUB - cursor back { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; w->as.ansiTerm->cursorCol -= count; if (w->as.ansiTerm->cursorCol < 0) { w->as.ansiTerm->cursorCol = 0; } break; } case 'c': // DA - device attributes { if (w->as.ansiTerm->commWrite) { // Respond as VT100 with advanced video option (AVO). // Many BBS door games query DA to detect terminal capabilities. // Claiming VT100+AVO is the safest response -- it tells the remote // side we support ANSI color without implying VT220+ features. const uint8_t reply[] = "\033[?1;2c"; w->as.ansiTerm->commWrite(w->as.ansiTerm->commCtx, reply, 7); } break; } case 'E': // CNL - cursor next line { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; for (int32_t i = 0; i < count; i++) { ansiTermNewline(w); } w->as.ansiTerm->cursorCol = 0; break; } case 'H': // CUP - cursor position case 'f': // HVP - same { int32_t row = (n >= 1 && p[0]) ? p[0] - 1 : 0; int32_t col = (n >= 2 && p[1]) ? p[1] - 1 : 0; // Origin mode: row is relative to scroll region if (w->as.ansiTerm->originMode) { row += w->as.ansiTerm->scrollTop; if (row > w->as.ansiTerm->scrollBot) { row = w->as.ansiTerm->scrollBot; } } if (row < 0) { row = 0; } if (row >= w->as.ansiTerm->rows) { row = w->as.ansiTerm->rows - 1; } if (col < 0) { col = 0; } if (col >= w->as.ansiTerm->cols) { col = w->as.ansiTerm->cols - 1; } w->as.ansiTerm->cursorRow = row; w->as.ansiTerm->cursorCol = col; break; } case 'J': // ED - erase display { int32_t mode = (n >= 1) ? p[0] : 0; ansiTermEraseDisplay(w, mode); break; } case 'K': // EL - erase line { int32_t mode = (n >= 1) ? p[0] : 0; ansiTermEraseLine(w, mode); break; } case 'L': // IL - insert lines { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; ansiTermInsertLines(w, count); break; } case 'M': // DL - delete lines { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; ansiTermDeleteLines(w, count); break; } case 'P': // DCH - delete character { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; int32_t cols = w->as.ansiTerm->cols; int32_t row = w->as.ansiTerm->cursorRow; int32_t col = w->as.ansiTerm->cursorCol; if (count > cols - col) { count = cols - col; } if (count > 0) { uint8_t *base = w->as.ansiTerm->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] = w->as.ansiTerm->curAttr; } ansiTermDirtyRow(w, row); } break; } case 'S': // SU - scroll up { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; for (int32_t i = 0; i < count; i++) { ansiTermScrollUp(w); } break; } case 'T': // SD - scroll down { int32_t count = (n >= 1 && p[0]) ? p[0] : 1; for (int32_t i = 0; i < count; i++) { ansiTermScrollDown(w); } break; } case 'Z': // CBT - back tab { // Move cursor back to the previous tab stop (every 8 columns). // Uses integer math: ((col-1)/8)*8 rounds down to the nearest // multiple of 8 strictly before the current position. int32_t col = w->as.ansiTerm->cursorCol; if (col > 0) { col = ((col - 1) / 8) * 8; } w->as.ansiTerm->cursorCol = col; break; } case 'm': // SGR - select graphic rendition ansiTermProcessSgr(w); break; case 'n': // DSR - device status report { int32_t mode = (n >= 1) ? p[0] : 0; if (w->as.ansiTerm->commWrite) { if (mode == 6) { // CPR -- cursor position report: ESC[row;colR (1-based). // BBS software uses this for screen-size detection and // to synchronize cursor positioning in door games. char reply[16]; int32_t len = snprintf(reply, sizeof(reply), "\033[%ld;%ldR", (long)(w->as.ansiTerm->cursorRow + 1), (long)(w->as.ansiTerm->cursorCol + 1)); w->as.ansiTerm->commWrite(w->as.ansiTerm->commCtx, (const uint8_t *)reply, len); } else if (mode == 255) { // Custom extension: screen size report (non-standard). // Allows the remote side to query our terminal dimensions // without relying on NAWS or other Telnet extensions. char reply[16]; int32_t len = snprintf(reply, sizeof(reply), "\033[%ld;%ldR", (long)w->as.ansiTerm->rows, (long)w->as.ansiTerm->cols); w->as.ansiTerm->commWrite(w->as.ansiTerm->commCtx, (const uint8_t *)reply, len); } } break; } case 'r': // DECSTBM - set scrolling region { // Defines the top and bottom rows of the scroll region. Lines // outside this region are not affected by scroll operations. This // is heavily used by BBS split-screen chat (status line at top, // chat scrolling below) and door game scoreboards. int32_t rows = w->as.ansiTerm->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) { w->as.ansiTerm->scrollTop = top; w->as.ansiTerm->scrollBot = bot; } else { // Invalid or reset -- restore full screen w->as.ansiTerm->scrollTop = 0; w->as.ansiTerm->scrollBot = rows - 1; } // Home cursor (relative to region if origin mode) w->as.ansiTerm->cursorRow = w->as.ansiTerm->originMode ? w->as.ansiTerm->scrollTop : 0; w->as.ansiTerm->cursorCol = 0; break; } case 's': // SCP - save cursor position w->as.ansiTerm->savedRow = w->as.ansiTerm->cursorRow; w->as.ansiTerm->savedCol = w->as.ansiTerm->cursorCol; break; case 'u': // RCP - restore cursor position w->as.ansiTerm->cursorRow = w->as.ansiTerm->savedRow; w->as.ansiTerm->cursorCol = w->as.ansiTerm->savedCol; break; default: break; } } // ============================================================ // ansiTermEraseDisplay // ============================================================ // Erase Display (ED): mode 0 = cursor to end, 1 = start to cursor, 2 = all. // Mode 2 pushes all visible lines to scrollback before clearing, preserving // content that was on screen -- this is what users expect when a BBS sends // a clear-screen sequence (they can scroll back to see previous content). // The wasAtBottom check ensures auto-scroll tracking: if the user was // already viewing the latest content, they stay at the bottom after new // scrollback lines are added. static void ansiTermEraseDisplay(WidgetT *w, int32_t mode) { int32_t cols = w->as.ansiTerm->cols; int32_t rows = w->as.ansiTerm->rows; int32_t cur = w->as.ansiTerm->cursorRow * cols + w->as.ansiTerm->cursorCol; if (mode == 0) { // Erase from cursor to end of screen ansiTermFillCells(w, cur, cols * rows - cur); } else if (mode == 1) { // Erase from start to cursor ansiTermFillCells(w, 0, cur + 1); } else if (mode == 2) { // Erase entire screen -- push all lines to scrollback first bool wasAtBottom = (w->as.ansiTerm->scrollPos == w->as.ansiTerm->scrollbackCount); for (int32_t r = 0; r < rows; r++) { ansiTermAddToScrollback(w, r); } if (wasAtBottom) { w->as.ansiTerm->scrollPos = w->as.ansiTerm->scrollbackCount; } ansiTermFillCells(w, 0, cols * rows); } } // ============================================================ // ansiTermEraseLine // ============================================================ static void ansiTermEraseLine(WidgetT *w, int32_t mode) { int32_t cols = w->as.ansiTerm->cols; int32_t row = w->as.ansiTerm->cursorRow; int32_t col = w->as.ansiTerm->cursorCol; int32_t base = row * cols; if (mode == 0) { // Erase from cursor to end of line ansiTermFillCells(w, base + col, cols - col); } else if (mode == 1) { // Erase from start of line to cursor ansiTermFillCells(w, base, col + 1); } else if (mode == 2) { // Erase entire line ansiTermFillCells(w, base, cols); } } // ============================================================ // ansiTermFillCells // ============================================================ // // Fill a range of cells with space + current attribute. // Uses the current attribute (not default) so that erasing respects the // currently active background color -- this is correct per ECMA-48 and // matches what BBS software expects (e.g., colored backgrounds that // persist after a line erase). static void ansiTermFillCells(WidgetT *w, int32_t start, int32_t count) { uint8_t *cells = w->as.ansiTerm->cells; uint8_t attr = w->as.ansiTerm->curAttr; int32_t total = w->as.ansiTerm->cols * w->as.ansiTerm->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); } // ============================================================ // ansiTermGetLine // ============================================================ // // Get a pointer to the cell data for a given line index in the // combined scrollback+screen view. // lineIndex < scrollbackCount -> scrollback line // lineIndex >= scrollbackCount -> screen line // // The unified line index simplifies paint and selection code -- they don't // need to know whether a given line is in scrollback or on screen. The // circular buffer index computation uses modular arithmetic to map from // logical scrollback line number to physical buffer position. static const uint8_t *ansiTermGetLine(WidgetT *w, int32_t lineIndex) { int32_t cols = w->as.ansiTerm->cols; int32_t bytesPerRow = cols * 2; int32_t sbCount = w->as.ansiTerm->scrollbackCount; if (lineIndex < sbCount) { // Scrollback line (circular buffer) int32_t sbMax = w->as.ansiTerm->scrollbackMax; int32_t actual = (w->as.ansiTerm->scrollbackHead - sbCount + lineIndex + sbMax) % sbMax; return w->as.ansiTerm->scrollback + actual * bytesPerRow; } // Screen line int32_t screenRow = lineIndex - sbCount; return w->as.ansiTerm->cells + screenRow * bytesPerRow; } // ============================================================ // ansiTermHasSelection // ============================================================ static bool ansiTermHasSelection(const WidgetT *w) { if (w->as.ansiTerm->selStartLine < 0) { return false; } if (w->as.ansiTerm->selStartLine == w->as.ansiTerm->selEndLine && w->as.ansiTerm->selStartCol == w->as.ansiTerm->selEndCol) { return false; } return true; } // ============================================================ // ansiTermInsertLines // ============================================================ // Insert blank lines at the cursor position within the scroll region by // shifting rows down with memmove. This is the CSI 'L' (IL) handler, // the inverse of ansiTermDeleteLines. static void ansiTermInsertLines(WidgetT *w, int32_t count) { int32_t cols = w->as.ansiTerm->cols; int32_t bot = w->as.ansiTerm->scrollBot; int32_t row = w->as.ansiTerm->cursorRow; if (count > bot - row + 1) { count = bot - row + 1; } if (count <= 0) { return; } uint8_t *cells = w->as.ansiTerm->cells; int32_t bytesPerRow = cols * 2; // Shift lines down within region if (row + count <= bot) { memmove(cells + (row + count) * bytesPerRow, cells + row * bytesPerRow, (bot - row - count + 1) * bytesPerRow); } // Clear the inserted lines for (int32_t r = row; r < row + count; r++) { ansiTermFillCells(w, r * cols, cols); } // Dirty affected rows for (int32_t r = row; r <= bot; r++) { ansiTermDirtyRow(w, r); } } // ============================================================ // ansiTermNewline // ============================================================ // // Move cursor to next line, scrolling if at the bottom. static void ansiTermNewline(WidgetT *w) { int32_t bot = w->as.ansiTerm->scrollBot; if (w->as.ansiTerm->cursorRow == bot) { ansiTermScrollUp(w); } else if (w->as.ansiTerm->cursorRow < w->as.ansiTerm->rows - 1) { w->as.ansiTerm->cursorRow++; } } // ============================================================ // ansiTermProcessByte // ============================================================ // // Feed one byte through the ANSI parser state machine. // This is the hot path for data reception -- called once per byte received // from the serial link. The state machine is kept as simple as possible // (no tables, no indirect calls) because this runs on every incoming byte // and branch prediction on a 486/Pentium benefits from straightforward // if/switch chains. // // In PARSE_NORMAL, C0 control characters (CR, LF, BS, TAB, FF, BEL, ESC) // are handled first. All other bytes -- including CP437 graphic characters // in the 0x01-0x1F range that aren't C0 controls, plus 0x80-0xFF -- are // treated as printable and placed at the cursor via ansiTermPutChar. static void ansiTermProcessByte(WidgetT *w, uint8_t ch) { switch (w->as.ansiTerm->parseState) { case PARSE_NORMAL: if (ch == 0x1B) { w->as.ansiTerm->parseState = PARSE_ESC; } else if (ch == '\r') { w->as.ansiTerm->cursorCol = 0; } else if (ch == '\n') { ansiTermNewline(w); } else if (ch == '\b') { if (w->as.ansiTerm->cursorCol > 0) { w->as.ansiTerm->cursorCol--; } } else if (ch == '\t') { // Advance to next 8-column tab stop using bit-mask trick: // (col+8) & ~7 rounds up to the next multiple of 8 w->as.ansiTerm->cursorCol = (w->as.ansiTerm->cursorCol + 8) & ~7; if (w->as.ansiTerm->cursorCol >= w->as.ansiTerm->cols) { w->as.ansiTerm->cursorCol = w->as.ansiTerm->cols - 1; } } else if (ch == '\f') { // Form feed -- clear screen and home cursor ansiTermEraseDisplay(w, 2); w->as.ansiTerm->cursorRow = 0; w->as.ansiTerm->cursorCol = 0; } else if (ch == '\a') { // Bell -- ignored } else { // CP437 graphic characters (smileys, card suits, etc.) // and all printable characters ansiTermPutChar(w, ch); } break; case PARSE_ESC: if (ch == '[') { w->as.ansiTerm->parseState = PARSE_CSI; w->as.ansiTerm->paramCount = 0; w->as.ansiTerm->csiPrivate = false; memset(w->as.ansiTerm->params, 0, sizeof(w->as.ansiTerm->params)); } else if (ch == 'D') { // IND -- scroll up one line ansiTermScrollUp(w); w->as.ansiTerm->parseState = PARSE_NORMAL; } else if (ch == 'M') { // RI -- scroll down one line ansiTermScrollDown(w); w->as.ansiTerm->parseState = PARSE_NORMAL; } else if (ch == 'c') { // RIS -- terminal reset w->as.ansiTerm->cursorRow = 0; w->as.ansiTerm->cursorCol = 0; w->as.ansiTerm->curAttr = ANSI_DEFAULT_ATTR; w->as.ansiTerm->bold = false; w->as.ansiTerm->wrapMode = true; w->as.ansiTerm->originMode = false; w->as.ansiTerm->cursorVisible = true; w->as.ansiTerm->scrollTop = 0; w->as.ansiTerm->scrollBot = w->as.ansiTerm->rows - 1; ansiTermEraseDisplay(w, 2); w->as.ansiTerm->parseState = PARSE_NORMAL; } else { // Unknown escape -- return to normal w->as.ansiTerm->parseState = PARSE_NORMAL; } break; case PARSE_CSI: // CSI parameter accumulation. Parameters are separated by ';'. // Digits are accumulated into the current parameter slot using // decimal shift (p*10 + digit). The '?' prefix marks DEC private // mode sequences which have separate semantics from standard CSI. // Final bytes (0x40-0x7E) terminate the sequence and dispatch. if (ch == '?') { w->as.ansiTerm->csiPrivate = true; } else if (ch >= '0' && ch <= '9') { if (w->as.ansiTerm->paramCount == 0) { w->as.ansiTerm->paramCount = 1; } int32_t idx = w->as.ansiTerm->paramCount - 1; if (idx < ANSI_MAX_PARAMS) { w->as.ansiTerm->params[idx] = w->as.ansiTerm->params[idx] * 10 + (ch - '0'); } } else if (ch == ';') { if (w->as.ansiTerm->paramCount < ANSI_MAX_PARAMS) { w->as.ansiTerm->paramCount++; } } else if (ch >= 0x40 && ch <= 0x7E) { // Final byte -- dispatch the CSI sequence ansiTermDispatchCsi(w, ch); w->as.ansiTerm->parseState = PARSE_NORMAL; } else { // Unexpected byte -- abort sequence w->as.ansiTerm->parseState = PARSE_NORMAL; } break; } } // ============================================================ // ansiTermProcessSgr // ============================================================ // // Handle SGR (Select Graphic Rendition) escape sequence. // Maps ANSI color codes to the CGA attribute byte. The attribute byte // is packed as (bg<<4)|fg, matching CGA text mode. Bold (code 1) sets // the high bit of the foreground (giving bright colors), while blink // (code 5) sets bit 3 of the background nibble (the CGA blink/bright-bg bit). // // Multiple SGR codes in a single sequence are processed left-to-right, // each modifying the running attribute. This handles sequences like // ESC[1;33;44m (bold yellow on blue) correctly. // // Codes 90-97/100-107 (bright colors) are non-standard but widely used // by BBS software as an alternative to bold+color for explicit bright colors. static void ansiTermProcessSgr(WidgetT *w) { if (w->as.ansiTerm->paramCount == 0) { // ESC[m with no params = reset w->as.ansiTerm->curAttr = ANSI_DEFAULT_ATTR; w->as.ansiTerm->bold = false; return; } for (int32_t i = 0; i < w->as.ansiTerm->paramCount; i++) { int32_t code = w->as.ansiTerm->params[i]; uint8_t fg = w->as.ansiTerm->curAttr & 0x0F; uint8_t bg = (w->as.ansiTerm->curAttr >> 4) & 0x0F; if (code == 0) { fg = 7; bg = 0; w->as.ansiTerm->bold = false; } else if (code == 1) { w->as.ansiTerm->bold = true; fg |= 8; } else if (code == 5) { // Blink -- sets bit 7 of attr byte via bg bit 3 bg |= 8; } else if (code == 25) { // Blink off bg &= 7; } else if (code == 7) { // Reverse video uint8_t tmp = fg; fg = bg; bg = tmp; } else if (code == 8) { // Invisible -- foreground same as background fg = bg & 0x07; } else if (code == 22) { // Normal intensity w->as.ansiTerm->bold = false; fg &= 7; } else if (code >= 30 && code <= 37) { fg = (uint8_t)sAnsiToCga[code - 30]; if (w->as.ansiTerm->bold) { fg |= 8; } } else if (code >= 40 && code <= 47) { bg = (uint8_t)(sAnsiToCga[code - 40] | (bg & 8)); } else if (code >= 90 && code <= 97) { // Bright foreground fg = (uint8_t)(sAnsiToCga[code - 90] | 8); } else if (code >= 100 && code <= 107) { // Bright background bg = (uint8_t)(sAnsiToCga[code - 100] | 8); } w->as.ansiTerm->curAttr = (uint8_t)((bg << 4) | fg); } } // ============================================================ // ansiTermPasteToComm // ============================================================ static void ansiTermPasteToComm(WidgetT *w) { if (!w->as.ansiTerm->commWrite) { return; } int32_t clipLen; const char *clip = clipboardGet(&clipLen); if (clipLen <= 0) { return; } // Transmit clipboard contents, converting \n to \r for the terminal. // Terminals expect CR as the line-ending character; the remote system // will echo back CR+LF if needed. for (int32_t i = 0; i < clipLen; i++) { uint8_t ch = (uint8_t)clip[i]; if (ch == '\n') { ch = '\r'; } w->as.ansiTerm->commWrite(w->as.ansiTerm->commCtx, &ch, 1); } } // ============================================================ // ansiTermPutChar // ============================================================ // // Place a character at the cursor and advance. static void ansiTermPutChar(WidgetT *w, uint8_t ch) { int32_t cols = w->as.ansiTerm->cols; int32_t rows = w->as.ansiTerm->rows; int32_t row = w->as.ansiTerm->cursorRow; int32_t col = w->as.ansiTerm->cursorCol; if (row >= 0 && row < rows && col >= 0 && col < cols) { int32_t idx = (row * cols + col) * 2; w->as.ansiTerm->cells[idx] = ch; w->as.ansiTerm->cells[idx + 1] = w->as.ansiTerm->curAttr; ansiTermDirtyRow(w, row); } w->as.ansiTerm->cursorCol++; if (w->as.ansiTerm->cursorCol >= cols) { if (w->as.ansiTerm->wrapMode) { w->as.ansiTerm->cursorCol = 0; ansiTermNewline(w); } else { w->as.ansiTerm->cursorCol = cols - 1; } } } // ============================================================ // ansiTermScrollDown // ============================================================ static void ansiTermScrollDown(WidgetT *w) { int32_t cols = w->as.ansiTerm->cols; int32_t top = w->as.ansiTerm->scrollTop; int32_t bot = w->as.ansiTerm->scrollBot; uint8_t *cells = w->as.ansiTerm->cells; int32_t bytesPerRow = cols * 2; // Shift lines within region down by one if (bot > top) { memmove(cells + (top + 1) * bytesPerRow, cells + top * bytesPerRow, (bot - top) * bytesPerRow); } // Clear the top line of the region ansiTermFillCells(w, top * cols, cols); // Dirty affected rows for (int32_t r = top; r <= bot && r < 32; r++) { w->as.ansiTerm->dirtyRows |= (1U << r); } } // ============================================================ // ansiTermScrollUp // ============================================================ // // Scroll the screen up by one line. The top line is pushed into // the scrollback buffer before being discarded. // // Scrollback capture only occurs for full-screen scrolls (top=0, // bot=last row). When a sub-region is scrolling (e.g., a BBS // split-screen chat window), those lines are NOT added to scrollback // because they represent transient UI content, not conversation history. static void ansiTermScrollUp(WidgetT *w) { int32_t cols = w->as.ansiTerm->cols; int32_t top = w->as.ansiTerm->scrollTop; int32_t bot = w->as.ansiTerm->scrollBot; uint8_t *cells = w->as.ansiTerm->cells; int32_t bytesPerRow = cols * 2; // Only push to scrollback when scrolling the full screen if (top == 0 && bot == w->as.ansiTerm->rows - 1) { bool wasAtBottom = (w->as.ansiTerm->scrollPos == w->as.ansiTerm->scrollbackCount); ansiTermAddToScrollback(w, 0); if (wasAtBottom) { w->as.ansiTerm->scrollPos = w->as.ansiTerm->scrollbackCount; } } // Shift lines within region up by one if (bot > top) { memmove(cells + top * bytesPerRow, cells + (top + 1) * bytesPerRow, (bot - top) * bytesPerRow); } // Clear the bottom line of the region ansiTermFillCells(w, bot * cols, cols); // Dirty affected rows for (int32_t r = top; r <= bot && r < 32; r++) { w->as.ansiTerm->dirtyRows |= (1U << r); } } // ============================================================ // ansiTermSelectionRange // ============================================================ // // Return selection start/end in normalized order (start <= end). static void ansiTermSelectionRange(const WidgetT *w, int32_t *startLine, int32_t *startCol, int32_t *endLine, int32_t *endCol) { int32_t sl = w->as.ansiTerm->selStartLine; int32_t sc = w->as.ansiTerm->selStartCol; int32_t el = w->as.ansiTerm->selEndLine; int32_t ec = w->as.ansiTerm->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; } } // ============================================================ // wgtAnsiTerm // ============================================================ // Create a new ANSI terminal widget. The cell buffer is allocated as a // flat array of (char, attr) pairs -- rows * cols * 2 bytes. The separate // heap allocation for AnsiTermDataT (via calloc) keeps the WidgetT union // small; terminal state is substantial (~100+ bytes of fields plus the // cell and scrollback buffers) so it's pointed to rather than inlined. WidgetT *wgtAnsiTerm(WidgetT *parent, int32_t cols, int32_t rows) { if (!parent) { return NULL; } if (cols <= 0) { cols = 80; } if (rows <= 0) { rows = 25; } WidgetT *w = widgetAlloc(parent, WidgetAnsiTermE); if (!w) { return NULL; } w->as.ansiTerm = (AnsiTermDataT *)calloc(1, sizeof(AnsiTermDataT)); if (!w->as.ansiTerm) { free(w); return NULL; } int32_t cellCount = cols * rows; w->as.ansiTerm->cells = (uint8_t *)malloc(cellCount * 2); if (!w->as.ansiTerm->cells) { free(w->as.ansiTerm); free(w); return NULL; } // Allocate scrollback buffer int32_t sbMax = ANSI_DEFAULT_SCROLLBACK; w->as.ansiTerm->scrollback = (uint8_t *)malloc(sbMax * cols * 2); if (!w->as.ansiTerm->scrollback) { free(w->as.ansiTerm->cells); free(w->as.ansiTerm); free(w); return NULL; } w->as.ansiTerm->cols = cols; w->as.ansiTerm->rows = rows; w->as.ansiTerm->cursorRow = 0; w->as.ansiTerm->cursorCol = 0; w->as.ansiTerm->cursorVisible = true; w->as.ansiTerm->wrapMode = true; w->as.ansiTerm->bold = false; w->as.ansiTerm->originMode = false; w->as.ansiTerm->csiPrivate = false; w->as.ansiTerm->curAttr = ANSI_DEFAULT_ATTR; w->as.ansiTerm->parseState = PARSE_NORMAL; w->as.ansiTerm->paramCount = 0; w->as.ansiTerm->savedRow = 0; w->as.ansiTerm->savedCol = 0; w->as.ansiTerm->scrollTop = 0; w->as.ansiTerm->scrollBot = rows - 1; w->as.ansiTerm->scrollbackMax = sbMax; w->as.ansiTerm->scrollbackCount = 0; w->as.ansiTerm->scrollbackHead = 0; w->as.ansiTerm->scrollPos = 0; w->as.ansiTerm->commCtx = NULL; w->as.ansiTerm->commRead = NULL; w->as.ansiTerm->commWrite = NULL; w->as.ansiTerm->selStartLine = -1; w->as.ansiTerm->selStartCol = -1; w->as.ansiTerm->selEndLine = -1; w->as.ansiTerm->selEndCol = -1; w->as.ansiTerm->selecting = false; w->as.ansiTerm->blinkVisible = true; w->as.ansiTerm->blinkTime = clock(); w->as.ansiTerm->cursorOn = true; w->as.ansiTerm->cursorTime = clock(); w->as.ansiTerm->dirtyRows = 0xFFFFFFFF; w->as.ansiTerm->lastCursorRow = -1; w->as.ansiTerm->lastCursorCol = -1; memset(w->as.ansiTerm->params, 0, sizeof(w->as.ansiTerm->params)); // Initialize all cells to space with default attribute for (int32_t i = 0; i < cellCount; i++) { w->as.ansiTerm->cells[i * 2] = ' '; w->as.ansiTerm->cells[i * 2 + 1] = ANSI_DEFAULT_ATTR; } return w; } // ============================================================ // wgtAnsiTermClear // ============================================================ void wgtAnsiTermClear(WidgetT *w) { VALIDATE_WIDGET_VOID(w, WidgetAnsiTermE); int32_t rows = w->as.ansiTerm->rows; int32_t cols = w->as.ansiTerm->cols; // Push all visible lines to scrollback bool wasAtBottom = (w->as.ansiTerm->scrollPos == w->as.ansiTerm->scrollbackCount); for (int32_t r = 0; r < rows; r++) { ansiTermAddToScrollback(w, r); } if (wasAtBottom) { w->as.ansiTerm->scrollPos = w->as.ansiTerm->scrollbackCount; } // Clear the screen int32_t cellCount = cols * rows; for (int32_t i = 0; i < cellCount; i++) { w->as.ansiTerm->cells[i * 2] = ' '; w->as.ansiTerm->cells[i * 2 + 1] = ANSI_DEFAULT_ATTR; } w->as.ansiTerm->cursorRow = 0; w->as.ansiTerm->cursorCol = 0; w->as.ansiTerm->curAttr = ANSI_DEFAULT_ATTR; w->as.ansiTerm->bold = false; w->as.ansiTerm->parseState = PARSE_NORMAL; } // ============================================================ // wgtAnsiTermSetScrollback // ============================================================ void wgtAnsiTermSetScrollback(WidgetT *w, int32_t maxLines) { if (!w || w->type != WidgetAnsiTermE || maxLines <= 0) { return; } int32_t cols = w->as.ansiTerm->cols; uint8_t *newBuf = (uint8_t *)malloc(maxLines * cols * 2); if (!newBuf) { return; } free(w->as.ansiTerm->scrollback); w->as.ansiTerm->scrollback = newBuf; w->as.ansiTerm->scrollbackMax = maxLines; w->as.ansiTerm->scrollbackCount = 0; w->as.ansiTerm->scrollbackHead = 0; w->as.ansiTerm->scrollPos = 0; } // ============================================================ // wgtAnsiTermPoll // ============================================================ // Poll the terminal for incoming data and update timers. This should be called // from the application's main loop at a reasonable frequency. It handles three // things: // 1. Text blink timer (500ms) -- toggles visibility of cells with the blink // attribute. Only rows containing blink cells are dirtied, avoiding // unnecessary repaints of static content. // 2. Cursor blink timer (250ms) -- faster than text blink to feel responsive. // Only the cursor's row is dirtied. // 3. Comm read -- pulls up to 256 bytes from the transport and feeds them // through the ANSI parser. The 256-byte chunk size balances between // responsiveness (smaller = more frequent repaints) and throughput // (larger = fewer function call overhead per byte). int32_t wgtAnsiTermPoll(WidgetT *w) { VALIDATE_WIDGET(w, WidgetAnsiTermE, 0); // Text blink timer -- toggle visibility and dirty rows with blinking cells 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 - w->as.ansiTerm->blinkTime) >= blinkInterval) { w->as.ansiTerm->blinkTime = now; w->as.ansiTerm->blinkVisible = !w->as.ansiTerm->blinkVisible; // Dirty any rows that contain cells with blink attribute int32_t cols = w->as.ansiTerm->cols; int32_t rows = w->as.ansiTerm->rows; for (int32_t row = 0; row < rows && row < 32; row++) { for (int32_t col = 0; col < cols; col++) { uint8_t attr = w->as.ansiTerm->cells[(row * cols + col) * 2 + 1]; if (attr & ATTR_BLINK_BIT) { w->as.ansiTerm->dirtyRows |= (1U << row); break; } } } } // Cursor blink timer if ((now - w->as.ansiTerm->cursorTime) >= curInterval) { w->as.ansiTerm->cursorTime = now; w->as.ansiTerm->cursorOn = !w->as.ansiTerm->cursorOn; int32_t cRow = w->as.ansiTerm->cursorRow; if (cRow >= 0 && cRow < 32) { w->as.ansiTerm->dirtyRows |= (1U << cRow); } } // Read from comm if (!w->as.ansiTerm->commRead) { return 0; } uint8_t buf[256]; int32_t n = w->as.ansiTerm->commRead(w->as.ansiTerm->commCtx, buf, (int32_t)sizeof(buf)); if (n > 0) { wgtAnsiTermWrite(w, buf, n); } return n > 0 ? n : 0; } // ============================================================ // wgtAnsiTermRepaint // ============================================================ // // Fast repaint: renders only dirty rows directly into the window's // content buffer, bypassing the full widget paint pipeline (no clear, // no relayout, no other widgets). This keeps ACK turnaround fast // for the serial link. // Fast repaint: renders dirty rows directly into the window's content buffer, // completely bypassing the normal widget paint pipeline (widgetOnPaint -> // widgetPaintOne -> full tree walk). Returns the number of rows repainted // and optionally reports the vertical extent via outY/outH so the caller // can issue a minimal compositor dirty rect. // // This exists because the normal paint path clears the entire content area // and repaints all widgets, which is far too expensive to do on every // incoming serial byte. With fast repaint, the path from data reception // to LFB flush is: poll -> write -> dirtyRows -> repaint -> compositor dirty, // keeping the round-trip under 1ms on a Pentium. int32_t wgtAnsiTermRepaint(WidgetT *w, int32_t *outY, int32_t *outH) { if (!w || w->type != WidgetAnsiTermE || !w->window) { return 0; } // Always repaint the row the cursor was on last time (to erase it) // and the row it's on now (to draw it) int32_t prevRow = w->as.ansiTerm->lastCursorRow; int32_t curRow = w->as.ansiTerm->cursorRow; int32_t prevCol = w->as.ansiTerm->lastCursorCol; int32_t curCol = w->as.ansiTerm->cursorCol; if (prevRow != curRow || prevCol != curCol) { if (prevRow >= 0 && prevRow < w->as.ansiTerm->rows) { w->as.ansiTerm->dirtyRows |= (1U << prevRow); } if (curRow >= 0 && curRow < w->as.ansiTerm->rows) { w->as.ansiTerm->dirtyRows |= (1U << curRow); } } uint32_t dirty = w->as.ansiTerm->dirtyRows; if (dirty == 0) { return 0; } WindowT *win = w->window; if (!win->contentBuf || !win->widgetRoot) { return 0; } AppContextT *ctx = wgtGetContext(win->widgetRoot); if (!ctx) { return 0; } // Set up a temporary DisplayT pointing at the window's content buffer // instead of the backbuffer. This lets us reuse all the drawing // primitives (drawTermRow, etc.) while rendering directly into the // window's private content bitmap. 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 = w->as.ansiTerm->cols; int32_t rows = w->as.ansiTerm->rows; int32_t cellH = font->charHeight; int32_t baseX = w->x + ANSI_BORDER; int32_t baseY = w->y + ANSI_BORDER; // Use cached palette ansiTermBuildPalette(w, &cd); const uint32_t *palette = w->as.ansiTerm->packedPalette; bool viewingLive = (w->as.ansiTerm->scrollPos == w->as.ansiTerm->scrollbackCount); int32_t repainted = 0; int32_t minRow = rows; int32_t maxRow = -1; for (int32_t row = 0; row < rows; row++) { if (!(dirty & (1U << row))) { continue; } int32_t lineIndex = w->as.ansiTerm->scrollPos + row; const uint8_t *lineData = ansiTermGetLine(w, lineIndex); // Cursor column for this row (-1 if cursor not on this row) int32_t curCol2 = -1; if (viewingLive && w->as.ansiTerm->cursorVisible && w->as.ansiTerm->cursorOn && row == w->as.ansiTerm->cursorRow) { curCol2 = w->as.ansiTerm->cursorCol; } drawTermRow(&cd, ops, font, baseX, baseY + row * cellH, cols, lineData, palette, w->as.ansiTerm->blinkVisible, curCol2); ansiTermPaintSelRow(w, &cd, ops, font, row, baseX, baseY); if (row < minRow) { minRow = row; } if (row > maxRow) { maxRow = row; } repainted++; } w->as.ansiTerm->dirtyRows = 0; w->as.ansiTerm->lastCursorRow = w->as.ansiTerm->cursorRow; w->as.ansiTerm->lastCursorCol = w->as.ansiTerm->cursorCol; if (outY) { *outY = baseY + minRow * cellH; } if (outH) { *outH = (maxRow - minRow + 1) * cellH; } return repainted; } // ============================================================ // wgtAnsiTermSetComm // ============================================================ // Attach communication callbacks to the terminal. The read/write function // pointers are transport-agnostic -- the terminal doesn't care whether // data comes from a raw UART, the secLink encrypted channel, or a // socket-based proxy. The ctx pointer is passed through opaquely. 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, WidgetAnsiTermE); w->as.ansiTerm->commCtx = ctx; w->as.ansiTerm->commRead = readFn; w->as.ansiTerm->commWrite = writeFn; } // ============================================================ // wgtAnsiTermWrite // ============================================================ // Write raw bytes into the terminal for parsing and display. Any active // selection is cleared first since the screen content is changing and the // selection coordinates would become stale. Each byte is fed through the // ANSI parser individually -- the parser maintains state between calls so // multi-byte sequences split across writes are handled correctly. void wgtAnsiTermWrite(WidgetT *w, const uint8_t *data, int32_t len) { if (!w || w->type != WidgetAnsiTermE || !data || len <= 0) { return; } ansiTermClearSelection(w); for (int32_t i = 0; i < len; i++) { ansiTermProcessByte(w, data[i]); } } // ============================================================ // widgetAnsiTermDestroy // ============================================================ void widgetAnsiTermDestroy(WidgetT *w) { free(w->as.ansiTerm->cells); free(w->as.ansiTerm->scrollback); free(w->as.ansiTerm); w->as.ansiTerm = NULL; } // ============================================================ // widgetAnsiTermCalcMinSize // ============================================================ // Min size = exact pixel dimensions needed for the grid plus border and // scrollbar. The terminal is not designed to be resizable -- the grid // dimensions (cols x rows) are fixed at creation time, matching the BBS // convention of 80x25 or similar fixed screen sizes. void widgetAnsiTermCalcMinSize(WidgetT *w, const BitmapFontT *font) { w->calcMinW = w->as.ansiTerm->cols * font->charWidth + ANSI_BORDER * 2 + ANSI_SB_W; w->calcMinH = w->as.ansiTerm->rows * font->charHeight + ANSI_BORDER * 2; } // ============================================================ // ansiTermPaintSelRow // ============================================================ // // Overlay selection highlighting for a single terminal row. // Selected cells are redrawn with fg/bg swapped (fg becomes bg and vice versa), // which is the traditional terminal selection highlight method. This avoids // needing a separate "selection color" and works regardless of the underlying // cell colors. The swap is done in palette-index space (not pixel space) so // it's just a few index lookups, keeping the per-cell cost minimal. 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; } int32_t sLine; int32_t sCol; int32_t eLine; int32_t eCol; ansiTermSelectionRange(w, &sLine, &sCol, &eLine, &eCol); int32_t cols = w->as.ansiTerm->cols; int32_t lineIndex = w->as.ansiTerm->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 = w->as.ansiTerm->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]; // swap: bg becomes fg uint32_t bg = palette[attr & 0x0F]; // swap: fg becomes bg drawChar(d, ops, font, baseX + col * cellW, baseY + screenRow * cellH, ch, fg, bg, true); } } // ============================================================ // widgetAnsiTermOnKey // ============================================================ // // Translate keyboard input to ANSI escape sequences and send // via the comm interface. Does nothing if commWrite is NULL. // Key handling for the terminal. Keyboard input is translated to ANSI // escape sequences and transmitted via the comm interface. The key codes // use the BIOS INT 16h convention: extended keys have bit 0x100 set, with // the scan code in the low byte. This matches the dvxApp keyboard layer. // // Ctrl+C has dual behavior: copy selection if one exists, otherwise send // the ^C control character to the remote (for breaking running programs). // Ctrl+V pastes clipboard contents to the terminal as keystrokes. void widgetAnsiTermOnKey(WidgetT *w, int32_t key, int32_t mod) { // Ctrl+C: copy if selection exists, otherwise send ^C if (key == 0x03 && (mod & KEY_MOD_CTRL)) { if (ansiTermHasSelection(w)) { ansiTermCopySelection(w); ansiTermClearSelection(w); wgtInvalidatePaint(w); return; } // No selection -- fall through to send ^C to terminal } // Ctrl+V: paste from clipboard to terminal if (key == 0x16 && (mod & KEY_MOD_CTRL)) { ansiTermPasteToComm(w); wgtInvalidatePaint(w); return; } if (!w->as.ansiTerm->commWrite) { return; } // Any keypress clears selection if (ansiTermHasSelection(w)) { ansiTermClearSelection(w); } uint8_t buf[8]; int32_t len = 0; if (key >= 32 && key < 127) { // Printable ASCII buf[0] = (uint8_t)key; len = 1; } else if (key == 0x1B) { // Escape buf[0] = 0x1B; len = 1; } else if (key == 0x09) { // Tab buf[0] = 0x09; len = 1; } else if (key == 13 || key == 10) { // Enter buf[0] = '\r'; len = 1; } else if (key == 8) { // Backspace buf[0] = 0x08; len = 1; } else if (key == (0x48 | 0x100)) { // Up arrow -> ESC[A buf[0] = 0x1B; buf[1] = '['; buf[2] = 'A'; len = 3; } else if (key == (0x50 | 0x100)) { // Down arrow -> ESC[B buf[0] = 0x1B; buf[1] = '['; buf[2] = 'B'; len = 3; } else if (key == (0x4D | 0x100)) { // Right arrow -> ESC[C buf[0] = 0x1B; buf[1] = '['; buf[2] = 'C'; len = 3; } else if (key == (0x4B | 0x100)) { // Left arrow -> ESC[D buf[0] = 0x1B; buf[1] = '['; buf[2] = 'D'; len = 3; } else if (key == (0x47 | 0x100)) { // Home -> ESC[H buf[0] = 0x1B; buf[1] = '['; buf[2] = 'H'; len = 3; } else if (key == (0x4F | 0x100)) { // End -> ESC[F buf[0] = 0x1B; buf[1] = '['; buf[2] = 'F'; len = 3; } else if (key == (0x49 | 0x100)) { // PgUp -> ESC[5~ buf[0] = 0x1B; buf[1] = '['; buf[2] = '5'; buf[3] = '~'; len = 4; } else if (key == (0x51 | 0x100)) { // PgDn -> ESC[6~ buf[0] = 0x1B; buf[1] = '['; buf[2] = '6'; buf[3] = '~'; len = 4; } else if (key == (0x53 | 0x100)) { // Delete -> ESC[3~ buf[0] = 0x1B; buf[1] = '['; buf[2] = '3'; buf[3] = '~'; len = 4; } else if (key >= 1 && key < 32) { // Control characters (^A=1, ^B=2, ^C=3, etc.) buf[0] = (uint8_t)key; len = 1; } if (len > 0) { w->as.ansiTerm->commWrite(w->as.ansiTerm->commCtx, buf, len); } wgtInvalidatePaint(w); } // ============================================================ // widgetAnsiTermOnMouse // ============================================================ // // Handle mouse clicks: scrollbar interaction and focus. // Mouse handling: the text area (left of the scrollbar) supports click-to-select // with single-click (anchor), double-click (word), and triple-click (line). // The scrollbar area uses direct hit testing on up/down arrow buttons and // page-up/page-down regions, with proportional thumb positioning. // Selection drag is handled externally by the widget event dispatcher via // sDragTextSelect -- on mouse-down we set the anchor and enable drag mode. void widgetAnsiTermOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) { AppContextT *actx = (AppContextT *)root->userData; const BitmapFontT *font = &actx->font; hit->focused = true; clearOtherSelections(hit); int32_t cols = hit->as.ansiTerm->cols; int32_t rows = hit->as.ansiTerm->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; // Click in text area -- start selection 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 = hit->as.ansiTerm->scrollPos + clickRow; int32_t clicks = multiClickDetect(vx, vy); if (clicks >= 3) { // Triple-click: select entire line hit->as.ansiTerm->selStartLine = lineIndex; hit->as.ansiTerm->selStartCol = 0; hit->as.ansiTerm->selEndLine = lineIndex; hit->as.ansiTerm->selEndCol = cols; hit->as.ansiTerm->selecting = false; sDragTextSelect = NULL; } else if (clicks == 2) { // Double-click: select word 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++; } hit->as.ansiTerm->selStartLine = lineIndex; hit->as.ansiTerm->selStartCol = ws; hit->as.ansiTerm->selEndLine = lineIndex; hit->as.ansiTerm->selEndCol = we; hit->as.ansiTerm->selecting = false; sDragTextSelect = NULL; } else { // Single click: start selection anchor hit->as.ansiTerm->selStartLine = lineIndex; hit->as.ansiTerm->selStartCol = clickCol; hit->as.ansiTerm->selEndLine = lineIndex; hit->as.ansiTerm->selEndCol = clickCol; hit->as.ansiTerm->selecting = true; sDragTextSelect = hit; } hit->as.ansiTerm->dirtyRows = 0xFFFFFFFF; return; } int32_t sbCount = hit->as.ansiTerm->scrollbackCount; if (sbCount == 0) { return; } int32_t maxScroll = sbCount; if (vy >= sbY && vy < sbY + arrowH) { // Up arrow hit->as.ansiTerm->scrollPos--; } else if (vy >= sbY + sbH - arrowH && vy < sbY + sbH) { // Down arrow hit->as.ansiTerm->scrollPos++; } else if (vy >= sbY + arrowH && vy < sbY + sbH - arrowH) { // Track area -- compute thumb position to determine page direction 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 + (hit->as.ansiTerm->scrollPos * thumbRange) / maxScroll; } if (vy < thumbY) { // Page up hit->as.ansiTerm->scrollPos -= rows; } else if (vy >= thumbY + thumbH) { // Page down hit->as.ansiTerm->scrollPos += rows; } } // Clamp if (hit->as.ansiTerm->scrollPos < 0) { hit->as.ansiTerm->scrollPos = 0; } if (hit->as.ansiTerm->scrollPos > maxScroll) { hit->as.ansiTerm->scrollPos = maxScroll; } } // ============================================================ // widgetAnsiTermPaint // ============================================================ // Full paint: renders the complete terminal widget including border, all text // rows, selection overlay, and scrollbar. This is called through the normal // widget paint pipeline (e.g., on window expose or full invalidation). // For incremental updates during data reception, wgtAnsiTermRepaint is used // instead -- it's much faster since it only repaints dirty rows and skips // the border/scrollbar. // // The terminal renders its own scrollbar rather than using the shared // widgetDrawScrollbarV because the scrollbar's total/visible/position // semantics are different -- the terminal scrollbar represents scrollback // lines (historical content above the screen), not a viewport over a // virtual content area. void widgetAnsiTermPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) { // Draw sunken bevel border BevelStyleT bevel; bevel.highlight = colors->windowShadow; bevel.shadow = colors->windowHighlight; bevel.face = 0; bevel.width = ANSI_BORDER; drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel); // Build/cache the 16-color packed palette ansiTermBuildPalette(w, d); const uint32_t *palette = w->as.ansiTerm->packedPalette; int32_t cols = w->as.ansiTerm->cols; int32_t rows = w->as.ansiTerm->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 = w->as.ansiTerm->scrollbackCount; // Determine if viewing live terminal or scrollback bool viewingLive = (w->as.ansiTerm->scrollPos == sbCount); // Render character cells row by row using bulk renderer. // Only repaint rows marked dirty; 0xFFFFFFFF means all rows. uint32_t dirty = w->as.ansiTerm->dirtyRows; if (dirty == 0) { dirty = 0xFFFFFFFF; } for (int32_t row = 0; row < rows; row++) { if (row < 32 && !(dirty & (1U << row))) { continue; } int32_t lineIndex = w->as.ansiTerm->scrollPos + row; const uint8_t *lineData = ansiTermGetLine(w, lineIndex); // Cursor column for this row (-1 if cursor not on this row) int32_t curCol = -1; if (viewingLive && w->as.ansiTerm->cursorVisible && w->as.ansiTerm->cursorOn && row == w->as.ansiTerm->cursorRow) { curCol = w->as.ansiTerm->cursorCol; } drawTermRow(d, ops, font, baseX, baseY + row * cellH, cols, lineData, palette, w->as.ansiTerm->blinkVisible, curCol); ansiTermPaintSelRow(w, d, ops, font, row, baseX, baseY); } w->as.ansiTerm->dirtyRows = 0; // Draw 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) { // No scrollback -- fill scrollbar area with trough color rectFill(d, ops, sbX, sbY, sbW, sbH, colors->scrollbarTrough); return; } // Track background int32_t trackY = sbY + arrowH; int32_t trackH = sbH - arrowH * 2; rectFill(d, ops, sbX, trackY, sbW, trackH, colors->scrollbarTrough); // Up arrow button 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); // Up arrow glyph (small triangle) 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); } // Down arrow button 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); } // Thumb 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 + (w->as.ansiTerm->scrollPos * thumbRange) / maxScroll; } drawBevel(d, ops, sbX, thumbY, sbW, thumbH, &btnBevel); } if (w->focused) { drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, colors->contentFg); } }