// widgetCore.c -- Core widget infrastructure (alloc, tree ops, helpers) // // This file provides the foundation for the widget tree: allocation, // parent-child linking, focus management, hit testing, and shared // utility functions used across multiple widget types. // // Widgets form a tree using intrusive linked lists (firstChild/lastChild/ // nextSibling pointers inside each WidgetT). This is a singly-linked // child list with a tail pointer for O(1) append. The tree is owned // by its root, which is attached to a WindowT. Destroying the root // recursively destroys all descendants. // // Memory allocation is plain malloc/free rather than an arena or pool. // The widget count per window is typically small (tens to low hundreds), // so the allocation overhead is negligible on target hardware. An arena // approach was considered but rejected because widgets can be individually // created and destroyed at runtime (dialog dynamics, tree item insertion), // which doesn't map cleanly to an arena pattern. #include "widgetInternal.h" #include #include // ============================================================ // Global state for drag and popup tracking // ============================================================ // // These module-level pointers track ongoing UI interactions that span // multiple mouse events (drags, popups, button presses). They are global // rather than per-window because the DOS GUI is single-threaded and only // one interaction can be active at a time. // // Each pointer is set when an interaction begins (e.g. mouse-down on a // slider) and cleared when it ends (mouse-up). The event dispatcher in // widgetEvent.c checks these before normal hit testing -- active drags // take priority over everything else. // // All of these must be NULLed when the pointed-to widget is destroyed, // otherwise dangling pointers would cause crashes. widgetDestroyChildren() // and wgtDestroy() handle this cleanup. clock_t sDblClickTicks = 0; // set from ctx->dblClickTicks during first paint bool sDebugLayout = false; WidgetT *sFocusedWidget = NULL; // currently focused widget (O(1) access, avoids tree walk) WidgetT *sOpenPopup = NULL; // dropdown/combobox with open popup list WidgetT *sPressedButton = NULL; // button being held down (tracks mouse in/out) WidgetT *sDragSlider = NULL; // slider being dragged WidgetT *sDrawingCanvas = NULL; // canvas receiving paint strokes WidgetT *sDragTextSelect = NULL; // text widget in drag-select mode int32_t sDragOffset = 0; // pixel offset from drag start to thumb center WidgetT *sResizeListView = NULL; // ListView undergoing column resize int32_t sResizeCol = -1; // which column is being resized int32_t sResizeStartX = 0; // mouse X at resize start int32_t sResizeOrigW = 0; // column width at resize start bool sResizeDragging = false; // true once mouse moves during column resize WidgetT *sDragSplitter = NULL; // splitter being dragged int32_t sDragSplitStart = 0; // mouse offset from splitter edge at drag start WidgetT *sDragReorder = NULL; // list/tree widget in drag-reorder mode WidgetT *sDragScrollbar = NULL; // widget whose scrollbar thumb is being dragged int32_t sDragScrollbarOff = 0; // mouse offset within thumb at drag start int32_t sDragScrollbarOrient = 0; // 0=vertical, 1=horizontal bool (*sListViewColBorderHitFn)(const WidgetT *w, int32_t vx, int32_t vy) = NULL; void (*sSplitterClampPosFn)(WidgetT *w, int32_t *pos) = NULL; WidgetT *(*sTreeViewNextVisibleFn)(WidgetT *item, WidgetT *treeView) = NULL; // ============================================================ // widgetAddChild // ============================================================ // // Appends a child to the end of the parent's child list. O(1) // thanks to the lastChild tail pointer. The child list is singly- // linked (nextSibling), which saves 4 bytes per widget vs doubly- // linked and is sufficient because child removal is infrequent. void widgetAddChild(WidgetT *parent, WidgetT *child) { child->parent = parent; child->nextSibling = NULL; if (parent->lastChild) { parent->lastChild->nextSibling = child; parent->lastChild = child; } else { parent->firstChild = child; parent->lastChild = child; } } // ============================================================ // widgetAlloc // ============================================================ // // Allocates and zero-initializes a new widget, links it to its // class vtable via widgetClassTable[], and optionally adds it as // a child of the given parent. // // The memset to 0 is intentional -- it establishes sane defaults // for all fields: NULL pointers, zero coordinates, no focus, // no accel key, etc. Only visible and enabled default to true. // // The window pointer is inherited from the parent so that any // widget in the tree can find its owning window without walking // to the root. WidgetT *widgetAlloc(WidgetT *parent, WidgetTypeE type) { if (type < 0 || type >= WGT_MAX_TYPES || !widgetClassTable[type]) { return NULL; } WidgetT *w = (WidgetT *)malloc(sizeof(WidgetT)); if (!w) { return NULL; } memset(w, 0, sizeof(*w)); w->type = type; w->wclass = widgetClassTable[type]; w->visible = true; w->enabled = true; if (parent) { w->window = parent->window; widgetAddChild(parent, w); } return w; } // ============================================================ // widgetClearFocus // ============================================================ void widgetClearFocus(WidgetT *root) { if (!root) { return; } root->focused = false; for (WidgetT *c = root->firstChild; c; c = c->nextSibling) { widgetClearFocus(c); } } // ============================================================ // widgetCountVisibleChildren // ============================================================ int32_t widgetCountVisibleChildren(const WidgetT *w) { int32_t count = 0; for (const WidgetT *c = w->firstChild; c; c = c->nextSibling) { if (c->visible) { count++; } } return count; } // ============================================================ // widgetDestroyChildren // ============================================================ // // Recursively destroys all descendants of a widget. Processes // children depth-first (destroy grandchildren before the child // itself) so that per-widget destroy callbacks see a consistent // tree state. // // Critically, this function clears all global state pointers that // reference destroyed widgets. Without this, any pending drag or // focus state would become a dangling pointer. Each global is // checked individually rather than cleared unconditionally to // avoid disrupting unrelated ongoing interactions. void widgetDestroyChildren(WidgetT *w) { WidgetT *child = w->firstChild; while (child) { WidgetT *next = child->nextSibling; widgetDestroyChildren(child); if (child->wclass && child->wclass->destroy) { child->wclass->destroy(child); } // Clear static references if they point to destroyed widgets if (sFocusedWidget == child) { sFocusedWidget = NULL; } if (sOpenPopup == child) { sOpenPopup = NULL; } if (sPressedButton == child) { sPressedButton = NULL; } if (sDragSlider == child) { sDragSlider = NULL; } if (sDrawingCanvas == child) { sDrawingCanvas = NULL; } if (sResizeListView == child) { sResizeListView = NULL; sResizeCol = -1; sResizeDragging = false; } if (sDragScrollbar == child) { sDragScrollbar = NULL; } free(child); child = next; } w->firstChild = NULL; w->lastChild = NULL; } // ============================================================ // widgetDropdownPopupRect // ============================================================ // // Calculates the screen rectangle for a dropdown/combobox popup list. // Shared between Dropdown and ComboBox since they have identical // popup positioning logic. // // The popup tries to open below the widget first. If there isn't // enough room (popup would extend past the content area bottom), // it flips to open above instead. This ensures the popup is always // visible, even for dropdowns near the bottom of a window. // // Popup height is capped at DROPDOWN_MAX_VISIBLE items to prevent // huge popups from dominating the screen. void widgetDropdownPopupRect(WidgetT *w, const BitmapFontT *font, int32_t contentH, int32_t *popX, int32_t *popY, int32_t *popW, int32_t *popH) { int32_t itemCount = 0; if (w->type == WidgetDropdownE) { itemCount = w->as.dropdown.itemCount; } else if (w->type == WidgetComboBoxE) { itemCount = w->as.comboBox.itemCount; } int32_t visibleItems = itemCount; if (visibleItems > DROPDOWN_MAX_VISIBLE) { visibleItems = DROPDOWN_MAX_VISIBLE; } if (visibleItems < 1) { visibleItems = 1; } *popX = w->x; *popW = w->w; *popH = visibleItems * font->charHeight + 4; // 2px border each side // Try below first, then above if no room if (w->y + w->h + *popH <= contentH) { *popY = w->y + w->h; } else { *popY = w->y - *popH; if (*popY < 0) { *popY = 0; } } } // ============================================================ // widgetDrawDropdownArrow // ============================================================ // // Draws a small downward-pointing filled triangle (7, 5, 3, 1 pixels // wide across 4 rows) centered at the given position. Used by both // Dropdown and ComboBox for the drop button arrow glyph. void widgetDrawDropdownArrow(DisplayT *d, const BlitOpsT *ops, int32_t centerX, int32_t centerY, uint32_t color) { for (int32_t i = 0; i < 4; i++) { drawHLine(d, ops, centerX - 3 + i, centerY + i, 7 - i * 2, color); } } // ============================================================ // widgetFindByAccel // ============================================================ // // Finds a widget with the given Alt+key accelerator. Recurses the // tree depth-first, respecting visibility and enabled state. // // Special case for TabPage widgets: even if the tab page itself is // not visible (inactive tab), its accelKey is still checked. This // allows Alt+key to switch to a different tab. However, children // of invisible tab pages are NOT searched -- their accelerators // should not be active when the tab is hidden. WidgetT *widgetFindByAccel(WidgetT *root, char key) { if (!root || !root->enabled) { return NULL; } // Invisible tab pages: match the page itself (for tab switching) // but don't recurse into children (their accels shouldn't be active) if (!root->visible) { if (root->type == WidgetTabPageE && root->accelKey == key) { return root; } return NULL; } if (root->accelKey == key) { return root; } for (WidgetT *c = root->firstChild; c; c = c->nextSibling) { WidgetT *found = widgetFindByAccel(c, key); if (found) { return found; } } return NULL; } // ============================================================ // widgetFindNextFocusable // ============================================================ // // Implements Tab-order navigation: finds the next focusable widget // after 'after' in depth-first tree order. The two-pass approach // (search from 'after' to end, then wrap to start) ensures circular // tabbing -- Tab on the last focusable widget wraps to the first. // // The pastAfter flag tracks whether we've passed the 'after' widget // during traversal. Once past it, the next focusable widget is the // answer. This avoids collecting all focusable widgets into an array // just to find the next one -- the common case returns quickly. static WidgetT *findNextFocusableImpl(WidgetT *w, WidgetT *after, bool *pastAfter) { if (!w->visible || !w->enabled) { return NULL; } if (after == NULL) { *pastAfter = true; } if (w == after) { *pastAfter = true; } else if (*pastAfter && widgetIsFocusable(w->type)) { return w; } for (WidgetT *c = w->firstChild; c; c = c->nextSibling) { WidgetT *found = findNextFocusableImpl(c, after, pastAfter); if (found) { return found; } } return NULL; } WidgetT *widgetFindNextFocusable(WidgetT *root, WidgetT *after) { bool pastAfter = false; WidgetT *found = findNextFocusableImpl(root, after, &pastAfter); if (found) { return found; } // Wrap around -- search from the beginning pastAfter = true; return findNextFocusableImpl(root, NULL, &pastAfter); } // ============================================================ // widgetFindPrevFocusable // ============================================================ // // Shift+Tab navigation: finds the previous focusable widget. // Unlike findNextFocusable which can short-circuit during traversal, // finding the PREVIOUS widget requires knowing the full order. // So this collects all focusable widgets into an array, finds the // target's index, and returns index-1 (with wraparound). // // The explicit stack-based DFS (rather than recursion) is used here // because we need to push children in reverse order to get the same // left-to-right depth-first ordering as the recursive version. // Fixed-size arrays (128 widgets, 64 stack depth) are adequate for // any reasonable dialog layout and avoid dynamic allocation. WidgetT *widgetFindPrevFocusable(WidgetT *root, WidgetT *before) { WidgetT *list[128]; int32_t count = 0; // Collect all focusable widgets via depth-first traversal WidgetT *stack[64]; int32_t top = 0; stack[top++] = root; while (top > 0) { WidgetT *w = stack[--top]; if (!w->visible || !w->enabled) { continue; } if (widgetIsFocusable(w->type) && count < 128) { list[count++] = w; } // Push children in reverse order so first child is processed first WidgetT *children[64]; int32_t childCount = 0; for (WidgetT *c = w->firstChild; c; c = c->nextSibling) { if (childCount < 64) { children[childCount++] = c; } } for (int32_t i = childCount - 1; i >= 0; i--) { if (top < 64) { stack[top++] = children[i]; } } } if (count == 0) { return NULL; } // Find 'before' in the list int32_t idx = -1; for (int32_t i = 0; i < count; i++) { if (list[i] == before) { idx = i; break; } } if (idx <= 0) { return list[count - 1]; // Wrap to last } return list[idx - 1]; } // ============================================================ // widgetFrameBorderWidth // ============================================================ int32_t widgetFrameBorderWidth(const WidgetT *w) { if (w->type != WidgetFrameE) { return 0; } if (w->as.frame.style == FrameFlatE) { return FRAME_FLAT_BORDER; } return FRAME_BEVEL_BORDER; } // ============================================================ // widgetHitTest // ============================================================ // // Recursive hit testing: finds the deepest (most specific) widget // under the given coordinates. Returns the widget itself if no // child is hit, or NULL if the point is outside this widget. // // Children are iterated front-to-back (first to last in the linked // list), but the LAST match wins. This gives later siblings higher // Z-order, which matches the painting order (later children paint // on top of earlier ones). This is important for overlapping widgets, // though in practice the layout engine rarely produces overlap. // // Widgets with WCLASS_NO_HIT_RECURSE stop the recursion -- the parent // widget handles all mouse events for its children. This is used by // TreeView, ScrollPane, ListView, and Splitter, which need to manage // their own internal regions (scrollbars, column headers, tree // expand buttons) that don't correspond to child widgets. WidgetT *widgetHitTest(WidgetT *w, int32_t x, int32_t y) { if (!w->visible) { return NULL; } if (x < w->x || x >= w->x + w->w || y < w->y || y >= w->y + w->h) { return NULL; } // Widgets with WCLASS_NO_HIT_RECURSE manage their own children if (w->wclass && (w->wclass->flags & WCLASS_NO_HIT_RECURSE)) { return w; } // Check children -- take the last match (topmost in Z-order) WidgetT *hit = NULL; for (WidgetT *c = w->firstChild; c; c = c->nextSibling) { WidgetT *childHit = widgetHitTest(c, x, y); if (childHit) { hit = childHit; } } return hit ? hit : w; } // ============================================================ // widgetIsFocusable // ============================================================ bool widgetIsFocusable(WidgetTypeE type) { if (type < 0 || type >= WGT_MAX_TYPES || !widgetClassTable[type]) { return false; } return (widgetClassTable[type]->flags & WCLASS_FOCUSABLE) != 0; } // ============================================================ // widgetIsBoxContainer // ============================================================ // // Returns true for widget types that use the generic box layout. bool widgetIsBoxContainer(WidgetTypeE type) { if (type < 0 || type >= WGT_MAX_TYPES || !widgetClassTable[type]) { return false; } return (widgetClassTable[type]->flags & WCLASS_BOX_CONTAINER) != 0; } // ============================================================ // widgetIsHorizContainer // ============================================================ // // Returns true for container types that lay out children horizontally. bool widgetIsHorizContainer(WidgetTypeE type) { if (type < 0 || type >= WGT_MAX_TYPES || !widgetClassTable[type]) { return false; } return (widgetClassTable[type]->flags & WCLASS_HORIZ_CONTAINER) != 0; } // ============================================================ // widgetMaxItemLen // ============================================================ // // Scans an array of string items and returns the maximum strlen. // Shared by ListBox, Dropdown, and ComboBox to cache the widest // item length for calcMinSize without duplicating the loop. int32_t widgetMaxItemLen(const char **items, int32_t count) { int32_t maxLen = 0; for (int32_t i = 0; i < count; i++) { int32_t slen = (int32_t)strlen(items[i]); if (slen > maxLen) { maxLen = slen; } } return maxLen; } // ============================================================ // widgetNavigateIndex // ============================================================ // // Shared keyboard navigation for list-like widgets (ListBox, Dropdown, // ListView, etc.). Encapsulates the Up/Down/Home/End/PgUp/PgDn logic // so each widget doesn't have to reimplement index clamping. // // Key values use the 0x100 flag to mark extended scan codes (arrow // keys, Home, End, etc.) -- this is the DVX convention for passing // scan codes through the same int32_t channel as ASCII values. // // Returns -1 for unrecognized keys so callers can check whether the // key was consumed. int32_t widgetNavigateIndex(int32_t key, int32_t current, int32_t count, int32_t pageSize) { if (key == (0x50 | 0x100)) { // Down arrow if (current < count - 1) { return current + 1; } return current < 0 ? 0 : current; } if (key == (0x48 | 0x100)) { // Up arrow if (current > 0) { return current - 1; } return current < 0 ? 0 : current; } if (key == (0x47 | 0x100)) { // Home return 0; } if (key == (0x4F | 0x100)) { // End return count - 1; } if (key == (0x51 | 0x100)) { // Page Down int32_t n = current + pageSize; return n >= count ? count - 1 : n; } if (key == (0x49 | 0x100)) { // Page Up int32_t n = current - pageSize; return n < 0 ? 0 : n; } return -1; } // ============================================================ // widgetPaintPopupList // ============================================================ // // Shared popup list painting for Dropdown and ComboBox. void widgetPaintPopupList(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t popX, int32_t popY, int32_t popW, int32_t popH, const char **items, int32_t itemCount, int32_t hoverIdx, int32_t scrollPos) { // Draw popup border BevelStyleT bevel; bevel.highlight = colors->windowHighlight; bevel.shadow = colors->windowShadow; bevel.face = colors->contentBg; bevel.width = 2; drawBevel(d, ops, popX, popY, popW, popH, &bevel); // Draw items int32_t visibleItems = popH / font->charHeight; int32_t textX = popX + TEXT_INPUT_PAD; int32_t textY = popY + 2; int32_t textW = popW - TEXT_INPUT_PAD * 2 - 4; for (int32_t i = 0; i < visibleItems && (scrollPos + i) < itemCount; i++) { int32_t idx = scrollPos + i; int32_t iy = textY + i * font->charHeight; uint32_t ifg = colors->contentFg; uint32_t ibg = colors->contentBg; if (idx == hoverIdx) { ifg = colors->menuHighlightFg; ibg = colors->menuHighlightBg; rectFill(d, ops, popX + 2, iy, textW + TEXT_INPUT_PAD * 2, font->charHeight, ibg); } drawText(d, ops, font, textX, iy, items[idx], ifg, ibg, false); } } // ============================================================ // widgetScrollbarThumb // ============================================================ // // Calculates thumb position and size for a scrollbar track. // Used by both the WM-level scrollbars and widget-internal scrollbars // (ListBox, TreeView, etc.) to maintain consistent scrollbar behavior. // // The thumb size is proportional to visibleSize/totalSize -- a larger // visible area means a larger thumb, giving visual feedback about how // much content is scrollable. SB_MIN_THUMB prevents the thumb from // becoming too small to grab with a mouse. void widgetScrollbarThumb(int32_t trackLen, int32_t totalSize, int32_t visibleSize, int32_t scrollPos, int32_t *thumbPos, int32_t *thumbSize) { *thumbSize = (trackLen * visibleSize) / totalSize; if (*thumbSize < SB_MIN_THUMB) { *thumbSize = SB_MIN_THUMB; } if (*thumbSize > trackLen) { *thumbSize = trackLen; } int32_t maxScroll = totalSize - visibleSize; if (maxScroll > 0) { *thumbPos = ((trackLen - *thumbSize) * scrollPos) / maxScroll; } else { *thumbPos = 0; } } // ============================================================ // widgetRemoveChild // ============================================================ // // Unlinks a child from its parent's child list. O(n) in the number // of children because the singly-linked list requires walking to // find the predecessor. This is acceptable because child removal // is infrequent (widget destruction, tree item reordering). void widgetRemoveChild(WidgetT *parent, WidgetT *child) { WidgetT *prev = NULL; for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) { if (c == child) { if (prev) { prev->nextSibling = c->nextSibling; } else { parent->firstChild = c->nextSibling; } if (parent->lastChild == child) { parent->lastChild = prev; } child->nextSibling = NULL; child->parent = NULL; return; } prev = c; } } // ============================================================ // Shared text editing infrastructure // ============================================================ // // The following functions provide clipboard, multi-click detection, // word boundary logic, cross-widget selection clearing, and the // single-line text editing engine. They are shared across multiple // widget DXEs (TextInput, TextArea, ComboBox, Spinner, AnsiTerm) // and live here in the core library so all DXEs can link to them. #define CLIPBOARD_MAX 4096 // Shared clipboard -- process-wide, not per-widget. static char sClipboard[CLIPBOARD_MAX]; static int32_t sClipboardLen = 0; // Multi-click state static clock_t sLastClickTime = 0; static int32_t sLastClickX = -1; static int32_t sLastClickY = -1; static int32_t sClickCount = 0; // Track the widget that last had an active selection so we can // clear it in O(1) instead of walking every widget in every window. static WidgetT *sLastSelectedWidget = NULL; // TextArea line helpers (static copies for widgetTextDragUpdate) static int32_t textAreaCountLines(const char *buf, int32_t len); static int32_t textAreaCursorToOff(const char *buf, int32_t len, int32_t row, int32_t col); static int32_t textAreaGetLineCount(WidgetT *w); static int32_t textAreaGetMaxLineLen(WidgetT *w); static int32_t textAreaLineLen(const char *buf, int32_t len, int32_t row); static int32_t textAreaLineStart(const char *buf, int32_t len, int32_t row); static int32_t textAreaMaxLineLen(const char *buf, int32_t len); // Shared undo/selection helpers static bool clearSelectionOnWidget(WidgetT *w); static void textEditDeleteSelection(char *buf, int32_t *pLen, int32_t *pCursor, int32_t *pSelStart, int32_t *pSelEnd); static void textEditSaveUndo(char *buf, int32_t len, int32_t cursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t bufSize); static int32_t wordBoundaryLeft(const char *buf, int32_t pos); static int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos); static bool clearSelectionOnWidget(WidgetT *w) { if (w->type == WidgetTextInputE) { if (w->as.textInput.selStart != w->as.textInput.selEnd) { w->as.textInput.selStart = -1; w->as.textInput.selEnd = -1; return true; } w->as.textInput.selStart = -1; w->as.textInput.selEnd = -1; } else if (w->type == WidgetTextAreaE) { if (w->as.textArea.selAnchor != w->as.textArea.selCursor) { w->as.textArea.selAnchor = -1; w->as.textArea.selCursor = -1; return true; } w->as.textArea.selAnchor = -1; w->as.textArea.selCursor = -1; } else if (w->type == WidgetComboBoxE) { if (w->as.comboBox.selStart != w->as.comboBox.selEnd) { w->as.comboBox.selStart = -1; w->as.comboBox.selEnd = -1; return true; } w->as.comboBox.selStart = -1; w->as.comboBox.selEnd = -1; } else if (w->type == WidgetAnsiTermE) { if (w->as.ansiTerm->selStartLine >= 0 && (w->as.ansiTerm->selStartLine != w->as.ansiTerm->selEndLine || w->as.ansiTerm->selStartCol != w->as.ansiTerm->selEndCol)) { 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; return true; } 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; } return false; } // Clears selection on the previously-selected widget (if different // from the newly-focused one). Validates that the previous widget's // window is still in the window stack before touching it -- the // window may have been closed since sLastSelectedWidget was set. // If the previous widget was in a different window, that window // gets a full repaint to clear the stale selection highlight. void clearOtherSelections(WidgetT *except) { if (!except || !except->window || !except->window->widgetRoot) { return; } WidgetT *prev = sLastSelectedWidget; sLastSelectedWidget = except; if (!prev || prev == except) { return; } // Verify the widget is still alive (its window still in the stack) WindowT *prevWin = prev->window; if (!prevWin) { return; } AppContextT *ctx = wgtGetContext(except); if (!ctx) { return; } bool found = false; for (int32_t i = 0; i < ctx->stack.count; i++) { if (ctx->stack.windows[i] == prevWin) { found = true; break; } } if (!found) { return; } if (clearSelectionOnWidget(prev) && prevWin != except->window) { dvxInvalidateWindow(ctx, prevWin); } } void clipboardCopy(const char *text, int32_t len) { if (!text || len <= 0) { return; } if (len > CLIPBOARD_MAX - 1) { len = CLIPBOARD_MAX - 1; } memcpy(sClipboard, text, len); sClipboard[len] = '\0'; sClipboardLen = len; } const char *clipboardGet(int32_t *outLen) { if (outLen) { *outLen = sClipboardLen; } return sClipboard; } bool isWordChar(char c) { return isalnum((unsigned char)c) || c == '_'; } int32_t multiClickDetect(int32_t vx, int32_t vy) { clock_t now = clock(); if ((now - sLastClickTime) < sDblClickTicks && abs(vx - sLastClickX) < 4 && abs(vy - sLastClickY) < 4) { sClickCount++; } else { sClickCount = 1; } sLastClickTime = now; sLastClickX = vx; sLastClickY = vy; return sClickCount; } // ============================================================ // TextArea line helpers (static copies for widgetTextDragUpdate) // ============================================================ static int32_t textAreaCountLines(const char *buf, int32_t len) { int32_t lines = 1; for (int32_t i = 0; i < len; i++) { if (buf[i] == '\n') { lines++; } } return lines; } static int32_t textAreaGetLineCount(WidgetT *w) { if (w->as.textArea.cachedLines < 0) { w->as.textArea.cachedLines = textAreaCountLines(w->as.textArea.buf, w->as.textArea.len); } return w->as.textArea.cachedLines; } static int32_t textAreaLineStart(const char *buf, int32_t len, int32_t row) { (void)len; int32_t off = 0; for (int32_t r = 0; r < row; r++) { while (off < len && buf[off] != '\n') { off++; } if (off < len) { off++; } } return off; } static int32_t textAreaLineLen(const char *buf, int32_t len, int32_t row) { int32_t start = textAreaLineStart(buf, len, row); int32_t end = start; while (end < len && buf[end] != '\n') { end++; } return end - start; } static int32_t textAreaMaxLineLen(const char *buf, int32_t len) { int32_t maxLen = 0; int32_t curLen = 0; for (int32_t i = 0; i < len; i++) { if (buf[i] == '\n') { if (curLen > maxLen) { maxLen = curLen; } curLen = 0; } else { curLen++; } } if (curLen > maxLen) { maxLen = curLen; } return maxLen; } static int32_t textAreaGetMaxLineLen(WidgetT *w) { if (w->as.textArea.cachedMaxLL < 0) { w->as.textArea.cachedMaxLL = textAreaMaxLineLen(w->as.textArea.buf, w->as.textArea.len); } return w->as.textArea.cachedMaxLL; } static int32_t textAreaCursorToOff(const char *buf, int32_t len, int32_t row, int32_t col) { int32_t start = textAreaLineStart(buf, len, row); int32_t lineL = textAreaLineLen(buf, len, row); int32_t clampC = col < lineL ? col : lineL; return start + clampC; } // ============================================================ // Shared undo/selection helpers // ============================================================ static void textEditSaveUndo(char *buf, int32_t len, int32_t cursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t bufSize) { if (!undoBuf) { return; } int32_t copyLen = len < bufSize ? len : bufSize - 1; memcpy(undoBuf, buf, copyLen); undoBuf[copyLen] = '\0'; *pUndoLen = copyLen; *pUndoCursor = cursor; } static void textEditDeleteSelection(char *buf, int32_t *pLen, int32_t *pCursor, int32_t *pSelStart, int32_t *pSelEnd) { int32_t lo = *pSelStart < *pSelEnd ? *pSelStart : *pSelEnd; int32_t hi = *pSelStart < *pSelEnd ? *pSelEnd : *pSelStart; if (lo < 0) { lo = 0; } if (hi > *pLen) { hi = *pLen; } if (lo >= hi) { *pSelStart = -1; *pSelEnd = -1; return; } memmove(buf + lo, buf + hi, *pLen - hi + 1); *pLen -= (hi - lo); *pCursor = lo; *pSelStart = -1; *pSelEnd = -1; } // ============================================================ // Word boundary helpers // ============================================================ static int32_t wordBoundaryLeft(const char *buf, int32_t pos) { if (pos <= 0) { return 0; } // Skip non-word characters while (pos > 0 && !isalnum((unsigned char)buf[pos - 1]) && buf[pos - 1] != '_') { pos--; } // Skip word characters while (pos > 0 && (isalnum((unsigned char)buf[pos - 1]) || buf[pos - 1] == '_')) { pos--; } return pos; } static int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos) { if (pos >= len) { return len; } // Skip word characters while (pos < len && (isalnum((unsigned char)buf[pos]) || buf[pos] == '_')) { pos++; } // Skip non-word characters while (pos < len && !isalnum((unsigned char)buf[pos]) && buf[pos] != '_') { pos++; } return pos; } // ============================================================ // widgetTextDragUpdate -- update selection during mouse drag // ============================================================ // // Called by the event loop on mouse-move while sDragTextSelect is set. // Extends the selection from the anchor to the current mouse position. // Handles auto-scroll: when the mouse is past the widget edges, the // scroll offset is nudged by one unit per event, creating a smooth // scroll-while-dragging effect. void widgetTextDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) { AppContextT *ctx = (AppContextT *)root->userData; const BitmapFontT *font = &ctx->font; if (w->type == WidgetTextInputE) { int32_t leftEdge = w->x + TEXT_INPUT_PAD; int32_t maxChars = (w->w - TEXT_INPUT_PAD * 2) / font->charWidth; widgetTextEditDragUpdateLine(vx, leftEdge, maxChars, font, w->as.textInput.len, &w->as.textInput.cursorPos, &w->as.textInput.scrollOff, &w->as.textInput.selEnd); } else if (w->type == WidgetTextAreaE) { int32_t innerX = w->x + 2 + 2; // TEXTAREA_BORDER + TEXTAREA_PAD int32_t innerY = w->y + 2; // TEXTAREA_BORDER int32_t innerW = w->w - 2 * 2 - 2 * 2 - 14; // borders, pads, scrollbar int32_t visCols = innerW / font->charWidth; int32_t maxLL = textAreaGetMaxLineLen(w); bool needHSb = (maxLL > visCols); int32_t innerH = w->h - 2 * 2 - (needHSb ? 14 : 0); int32_t visRows = innerH / font->charHeight; int32_t totalLines = textAreaGetLineCount(w); if (visRows < 1) { visRows = 1; } if (visCols < 1) { visCols = 1; } // Auto-scroll vertically if (vy < innerY && w->as.textArea.scrollRow > 0) { w->as.textArea.scrollRow--; } else if (vy >= innerY + visRows * font->charHeight && w->as.textArea.scrollRow + visRows < totalLines) { w->as.textArea.scrollRow++; } // Auto-scroll horizontally int32_t rightEdge = innerX + visCols * font->charWidth; if (vx < innerX && w->as.textArea.scrollCol > 0) { w->as.textArea.scrollCol--; } else if (vx >= rightEdge && w->as.textArea.scrollCol < maxLL - visCols) { w->as.textArea.scrollCol++; } int32_t relX = vx - innerX; int32_t relY = vy - innerY; int32_t clickRow = w->as.textArea.scrollRow + relY / font->charHeight; int32_t clickCol = w->as.textArea.scrollCol + relX / font->charWidth; if (clickRow < 0) { clickRow = 0; } if (clickRow >= totalLines) { clickRow = totalLines - 1; } if (clickCol < 0) { clickCol = 0; } int32_t lineL = textAreaLineLen(w->as.textArea.buf, w->as.textArea.len, clickRow); if (clickCol > lineL) { clickCol = lineL; } w->as.textArea.cursorRow = clickRow; w->as.textArea.cursorCol = clickCol; w->as.textArea.desiredCol = clickCol; w->as.textArea.selCursor = textAreaCursorToOff(w->as.textArea.buf, w->as.textArea.len, clickRow, clickCol); } else if (w->type == WidgetComboBoxE) { int32_t leftEdge = w->x + TEXT_INPUT_PAD; int32_t maxChars = (w->w - TEXT_INPUT_PAD * 2 - 16) / font->charWidth; widgetTextEditDragUpdateLine(vx, leftEdge, maxChars, font, w->as.comboBox.len, &w->as.comboBox.cursorPos, &w->as.comboBox.scrollOff, &w->as.comboBox.selEnd); } else if (w->type == WidgetAnsiTermE) { int32_t baseX = w->x + 2; // ANSI_BORDER int32_t baseY = w->y + 2; int32_t cols = w->as.ansiTerm->cols; int32_t rows = w->as.ansiTerm->rows; 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; } w->as.ansiTerm->selEndLine = w->as.ansiTerm->scrollPos + clickRow; w->as.ansiTerm->selEndCol = clickCol; w->as.ansiTerm->dirtyRows = 0xFFFFFFFF; } } // ============================================================ // widgetTextEditDragUpdateLine // ============================================================ // // Called during mouse drag to extend the selection for single-line // text widgets. Auto-scrolls when the mouse moves past the visible // text edges. void widgetTextEditDragUpdateLine(int32_t vx, int32_t leftEdge, int32_t maxChars, const BitmapFontT *font, int32_t len, int32_t *pCursorPos, int32_t *pScrollOff, int32_t *pSelEnd) { int32_t rightEdge = leftEdge + maxChars * font->charWidth; if (vx < leftEdge && *pScrollOff > 0) { (*pScrollOff)--; } else if (vx >= rightEdge && *pScrollOff + maxChars < len) { (*pScrollOff)++; } int32_t relX = vx - leftEdge; int32_t charPos = relX / font->charWidth + *pScrollOff; if (charPos < 0) { charPos = 0; } if (charPos > len) { charPos = len; } *pCursorPos = charPos; *pSelEnd = charPos; } // ============================================================ // widgetTextEditMouseClick // ============================================================ // // Computes cursor position from pixel coordinates, handles multi-click // (double = word select, triple = select all), and optionally starts // drag-select. void widgetTextEditMouseClick(WidgetT *w, int32_t vx, int32_t vy, int32_t textLeftX, const BitmapFontT *font, const char *buf, int32_t len, int32_t scrollOff, int32_t *pCursorPos, int32_t *pSelStart, int32_t *pSelEnd, bool wordSelect, bool dragSelect) { int32_t relX = vx - textLeftX; int32_t charPos = relX / font->charWidth + scrollOff; if (charPos < 0) { charPos = 0; } if (charPos > len) { charPos = len; } int32_t clicks = multiClickDetect(vx, vy); if (clicks >= 3) { *pSelStart = 0; *pSelEnd = len; *pCursorPos = len; sDragTextSelect = NULL; return; } if (clicks == 2) { if (wordSelect && buf) { int32_t ws = wordStart(buf, charPos); int32_t we = wordEnd(buf, len, charPos); *pSelStart = ws; *pSelEnd = we; *pCursorPos = we; } else { *pSelStart = 0; *pSelEnd = len; *pCursorPos = len; } sDragTextSelect = NULL; return; } // Single click: place cursor *pCursorPos = charPos; *pSelStart = charPos; *pSelEnd = charPos; sDragTextSelect = dragSelect ? w : NULL; } // ============================================================ // widgetTextEditOnKey -- shared single-line text editing logic // ============================================================ // // This is the core single-line text editing engine, parameterized by // pointer to allow reuse across TextInput, Spinner, and ComboBox. void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursor, int32_t *pScrollOff, int32_t *pSelStart, int32_t *pSelEnd, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor) { bool shift = (mod & KEY_MOD_SHIFT) != 0; bool hasSel = (pSelStart && pSelEnd && *pSelStart >= 0 && *pSelEnd >= 0 && *pSelStart != *pSelEnd); int32_t selLo = hasSel ? (*pSelStart < *pSelEnd ? *pSelStart : *pSelEnd) : -1; int32_t selHi = hasSel ? (*pSelStart < *pSelEnd ? *pSelEnd : *pSelStart) : -1; // Clamp selection to buffer bounds if (hasSel) { if (selLo < 0) { selLo = 0; } if (selHi > *pLen) { selHi = *pLen; } if (selLo >= selHi) { hasSel = false; selLo = -1; selHi = -1; } } // Ctrl+A -- select all if (key == 1 && pSelStart && pSelEnd) { *pSelStart = 0; *pSelEnd = *pLen; *pCursor = *pLen; goto adjustScroll; } // Ctrl+C -- copy if (key == 3) { if (hasSel) { clipboardCopy(buf + selLo, selHi - selLo); } return; } // Ctrl+V -- paste if (key == 22) { if (sClipboardLen > 0) { if (undoBuf) { textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize); } if (hasSel) { textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd); } int32_t canFit = bufSize - 1 - *pLen; // For single-line, skip newlines in clipboard int32_t paste = 0; for (int32_t i = 0; i < sClipboardLen && paste < canFit; i++) { if (sClipboard[i] != '\n' && sClipboard[i] != '\r') { paste++; } } if (paste > 0) { int32_t pos = *pCursor; memmove(buf + pos + paste, buf + pos, *pLen - pos + 1); int32_t j = 0; for (int32_t i = 0; i < sClipboardLen && j < paste; i++) { if (sClipboard[i] != '\n' && sClipboard[i] != '\r') { buf[pos + j] = sClipboard[i]; j++; } } *pLen += paste; *pCursor += paste; } if (w->onChange) { w->onChange(w); } } goto adjustScroll; } // Ctrl+X -- cut if (key == 24) { if (hasSel) { clipboardCopy(buf + selLo, selHi - selLo); if (undoBuf) { textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize); } textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd); if (w->onChange) { w->onChange(w); } } goto adjustScroll; } // Ctrl+Z -- undo if (key == 26 && undoBuf && pUndoLen && pUndoCursor) { // Swap current and undo char tmpBuf[CLIPBOARD_MAX]; int32_t tmpLen = *pLen; int32_t tmpCursor = *pCursor; int32_t copyLen = tmpLen < (int32_t)sizeof(tmpBuf) - 1 ? tmpLen : (int32_t)sizeof(tmpBuf) - 1; memcpy(tmpBuf, buf, copyLen); tmpBuf[copyLen] = '\0'; int32_t restLen = *pUndoLen < bufSize - 1 ? *pUndoLen : bufSize - 1; memcpy(buf, undoBuf, restLen); buf[restLen] = '\0'; *pLen = restLen; int32_t restoreCursor = *pUndoCursor < *pLen ? *pUndoCursor : *pLen; // Save old as new undo int32_t saveLen = copyLen < bufSize - 1 ? copyLen : bufSize - 1; memcpy(undoBuf, tmpBuf, saveLen); undoBuf[saveLen] = '\0'; *pUndoLen = saveLen; *pUndoCursor = tmpCursor; *pCursor = restoreCursor; if (pSelStart) { *pSelStart = -1; } if (pSelEnd) { *pSelEnd = -1; } if (w->onChange) { w->onChange(w); } goto adjustScroll; } if (key >= 32 && key < 127) { // Printable character if (undoBuf) { textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize); } if (hasSel) { textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd); } if (*pLen < bufSize - 1) { int32_t pos = *pCursor; memmove(buf + pos + 1, buf + pos, *pLen - pos + 1); buf[pos] = (char)key; (*pLen)++; (*pCursor)++; if (w->onChange) { w->onChange(w); } } } else if (key == 8) { // Backspace if (hasSel) { if (undoBuf) { textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize); } textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd); if (w->onChange) { w->onChange(w); } } else if (*pCursor > 0) { if (undoBuf) { textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize); } int32_t pos = *pCursor; memmove(buf + pos - 1, buf + pos, *pLen - pos + 1); (*pLen)--; (*pCursor)--; if (w->onChange) { w->onChange(w); } } } else if (key == (0x4B | 0x100)) { // Left arrow if (shift && pSelStart && pSelEnd) { if (*pSelStart < 0) { *pSelStart = *pCursor; *pSelEnd = *pCursor; } if (*pCursor > 0) { (*pCursor)--; } *pSelEnd = *pCursor; } else { if (pSelStart) { *pSelStart = -1; } if (pSelEnd) { *pSelEnd = -1; } if (*pCursor > 0) { (*pCursor)--; } } } else if (key == (0x4D | 0x100)) { // Right arrow if (shift && pSelStart && pSelEnd) { if (*pSelStart < 0) { *pSelStart = *pCursor; *pSelEnd = *pCursor; } if (*pCursor < *pLen) { (*pCursor)++; } *pSelEnd = *pCursor; } else { if (pSelStart) { *pSelStart = -1; } if (pSelEnd) { *pSelEnd = -1; } if (*pCursor < *pLen) { (*pCursor)++; } } } else if (key == (0x73 | 0x100)) { // Ctrl+Left -- word left int32_t newPos = wordBoundaryLeft(buf, *pCursor); if (shift && pSelStart && pSelEnd) { if (*pSelStart < 0) { *pSelStart = *pCursor; *pSelEnd = *pCursor; } *pCursor = newPos; *pSelEnd = newPos; } else { if (pSelStart) { *pSelStart = -1; } if (pSelEnd) { *pSelEnd = -1; } *pCursor = newPos; } } else if (key == (0x74 | 0x100)) { // Ctrl+Right -- word right int32_t newPos = wordBoundaryRight(buf, *pLen, *pCursor); if (shift && pSelStart && pSelEnd) { if (*pSelStart < 0) { *pSelStart = *pCursor; *pSelEnd = *pCursor; } *pCursor = newPos; *pSelEnd = newPos; } else { if (pSelStart) { *pSelStart = -1; } if (pSelEnd) { *pSelEnd = -1; } *pCursor = newPos; } } else if (key == (0x47 | 0x100)) { // Home if (shift && pSelStart && pSelEnd) { if (*pSelStart < 0) { *pSelStart = *pCursor; *pSelEnd = *pCursor; } *pCursor = 0; *pSelEnd = 0; } else { if (pSelStart) { *pSelStart = -1; } if (pSelEnd) { *pSelEnd = -1; } *pCursor = 0; } } else if (key == (0x4F | 0x100)) { // End if (shift && pSelStart && pSelEnd) { if (*pSelStart < 0) { *pSelStart = *pCursor; *pSelEnd = *pCursor; } *pCursor = *pLen; *pSelEnd = *pLen; } else { if (pSelStart) { *pSelStart = -1; } if (pSelEnd) { *pSelEnd = -1; } *pCursor = *pLen; } } else if (key == (0x53 | 0x100)) { // Delete if (hasSel) { if (undoBuf) { textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize); } textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd); if (w->onChange) { w->onChange(w); } } else if (*pCursor < *pLen) { if (undoBuf) { textEditSaveUndo(buf, *pLen, *pCursor, undoBuf, pUndoLen, pUndoCursor, bufSize); } int32_t pos = *pCursor; memmove(buf + pos, buf + pos + 1, *pLen - pos); (*pLen)--; if (w->onChange) { w->onChange(w); } } } else { return; } adjustScroll: // Adjust scroll offset to keep cursor visible { AppContextT *ctx = wgtGetContext(w); const BitmapFontT *font = &ctx->font; int32_t fieldW = w->w; if (w->type == WidgetComboBoxE) { fieldW -= DROPDOWN_BTN_WIDTH; } int32_t visibleChars = (fieldW - TEXT_INPUT_PAD * 2) / font->charWidth; if (*pCursor < *pScrollOff) { *pScrollOff = *pCursor; } if (*pCursor >= *pScrollOff + visibleChars) { *pScrollOff = *pCursor - visibleChars + 1; } } wgtInvalidatePaint(w); } // ============================================================ // widgetTextEditPaintLine // ============================================================ // // Renders a single line of text with optional selection highlighting // and a blinking cursor. void widgetTextEditPaintLine(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t textX, int32_t textY, const char *buf, int32_t visLen, int32_t scrollOff, int32_t cursorPos, int32_t selStart, int32_t selEnd, uint32_t fg, uint32_t bg, bool showCursor, int32_t cursorMinX, int32_t cursorMaxX) { // Normalize selection to low/high int32_t selLo = -1; int32_t selHi = -1; if (selStart >= 0 && selEnd >= 0 && selStart != selEnd) { selLo = selStart < selEnd ? selStart : selEnd; selHi = selStart < selEnd ? selEnd : selStart; } // Map selection to visible range int32_t visSelLo = selLo - scrollOff; int32_t visSelHi = selHi - scrollOff; if (visSelLo < 0) { visSelLo = 0; } if (visSelHi > visLen) { visSelHi = visLen; } if (selLo >= 0 && visSelLo < visSelHi) { if (visSelLo > 0) { drawTextN(d, ops, font, textX, textY, buf, visSelLo, fg, bg, true); } drawTextN(d, ops, font, textX + visSelLo * font->charWidth, textY, buf + visSelLo, visSelHi - visSelLo, colors->menuHighlightFg, colors->menuHighlightBg, true); if (visSelHi < visLen) { drawTextN(d, ops, font, textX + visSelHi * font->charWidth, textY, buf + visSelHi, visLen - visSelHi, fg, bg, true); } } else if (visLen > 0) { drawTextN(d, ops, font, textX, textY, buf, visLen, fg, bg, true); } // Blinking cursor if (showCursor && sCursorBlinkOn) { int32_t cursorX = textX + (cursorPos - scrollOff) * font->charWidth; if (cursorX >= cursorMinX && cursorX < cursorMaxX) { drawVLine(d, ops, cursorX, textY, font->charHeight, fg); } } } int32_t wordEnd(const char *buf, int32_t len, int32_t pos) { while (pos < len && isWordChar(buf[pos])) { pos++; } return pos; } int32_t wordStart(const char *buf, int32_t pos) { while (pos > 0 && isWordChar(buf[pos - 1])) { pos--; } return pos; }