// widgetListBox.c -- ListBox widget (single and multi-select) // // Scrollable list of text items with single-select or multi-select modes. // Items are stored as external string pointers (not copied), with a vertical // scrollbar appearing when the item count exceeds visible rows. // // Multi-select uses a parallel selBits array (one byte per item, 0 or 1) // rather than a bitfield. Using a full byte per item wastes some memory but // makes individual item toggle/test trivial without shift/mask operations, // which matters when the selection code runs on every click and keyboard event. // // The selection model follows Windows explorer conventions: // - Plain click: select one item, clear others, set anchor // - Ctrl+click: toggle one item, update anchor // - Shift+click: range select from anchor to clicked item // - Shift+arrow: range select from anchor to cursor // - Space: toggle current item (multi-select) // - Ctrl+A: select all (multi-select) // // The "anchor" concept is key: it's the starting point for shift-select // ranges. It's updated on non-shift clicks but stays fixed during shift // operations, allowing the user to extend/shrink the selection by // shift-clicking different endpoints. // // Drag-reorder support allows items to be rearranged by dragging. When // enabled, a mouse-down initiates a drag (tracked via sDragReorder global), // and a 2px horizontal line indicator shows the insertion point. The actual // reordering is handled by the application's onReorder callback. #include "widgetInternal.h" #include #include #define LISTBOX_PAD 2 #define LISTBOX_MIN_ROWS 4 // ============================================================ // Prototypes // ============================================================ static void allocSelBits(WidgetT *w); static void ensureScrollVisible(WidgetT *w, int32_t idx); static void selectRange(WidgetT *w, int32_t from, int32_t to); // ============================================================ // allocSelBits // ============================================================ // Allocate (or re-allocate) the selection bits array. Only allocated in // multi-select mode -- in single-select, selectedIdx alone tracks the // selection. calloc initializes all bits to 0 (nothing selected). static void allocSelBits(WidgetT *w) { if (w->as.listBox.selBits) { free(w->as.listBox.selBits); w->as.listBox.selBits = NULL; } int32_t count = w->as.listBox.itemCount; if (count > 0 && w->as.listBox.multiSelect) { w->as.listBox.selBits = (uint8_t *)calloc(count, 1); } } // ============================================================ // ensureScrollVisible // ============================================================ // Adjust scroll position so the item at idx is within the visible viewport. // If the item is above the viewport, scroll up to it. If below, scroll down // to show it at the bottom. If already visible, do nothing. static void ensureScrollVisible(WidgetT *w, int32_t idx) { if (idx < 0) { return; } AppContextT *ctx = wgtGetContext(w); const BitmapFontT *font = &ctx->font; int32_t innerH = w->h - LISTBOX_BORDER * 2; int32_t visibleRows = innerH / font->charHeight; if (visibleRows < 1) { visibleRows = 1; } if (idx < w->as.listBox.scrollPos) { w->as.listBox.scrollPos = idx; } else if (idx >= w->as.listBox.scrollPos + visibleRows) { w->as.listBox.scrollPos = idx - visibleRows + 1; } } // ============================================================ // selectRange // ============================================================ // Set selection bits for all items in the range [from, to] (inclusive, // order-independent). Used for shift-click and shift-arrow range selection. static void selectRange(WidgetT *w, int32_t from, int32_t to) { if (!w->as.listBox.selBits) { return; } int32_t lo = from < to ? from : to; int32_t hi = from > to ? from : to; if (lo < 0) { lo = 0; } if (hi >= w->as.listBox.itemCount) { hi = w->as.listBox.itemCount - 1; } for (int32_t i = lo; i <= hi; i++) { w->as.listBox.selBits[i] = 1; } } // ============================================================ // widgetListBoxDestroy // ============================================================ void widgetListBoxDestroy(WidgetT *w) { if (w->as.listBox.selBits) { free(w->as.listBox.selBits); w->as.listBox.selBits = NULL; } } // ============================================================ // wgtListBox // ============================================================ WidgetT *wgtListBox(WidgetT *parent) { WidgetT *w = widgetAlloc(parent, WidgetListBoxE); if (w) { w->as.listBox.selectedIdx = -1; w->as.listBox.anchorIdx = -1; w->as.listBox.dragIdx = -1; w->as.listBox.dropIdx = -1; } return w; } // ============================================================ // wgtListBoxClearSelection // ============================================================ void wgtListBoxClearSelection(WidgetT *w) { if (!w || w->type != WidgetListBoxE || !w->as.listBox.selBits) { return; } memset(w->as.listBox.selBits, 0, w->as.listBox.itemCount); wgtInvalidatePaint(w); } // ============================================================ // wgtListBoxGetSelected // ============================================================ int32_t wgtListBoxGetSelected(const WidgetT *w) { VALIDATE_WIDGET(w, WidgetListBoxE, -1); return w->as.listBox.selectedIdx; } // ============================================================ // wgtListBoxIsItemSelected // ============================================================ bool wgtListBoxIsItemSelected(const WidgetT *w, int32_t idx) { VALIDATE_WIDGET(w, WidgetListBoxE, false); if (!w->as.listBox.multiSelect) { return idx == w->as.listBox.selectedIdx; } if (!w->as.listBox.selBits || idx < 0 || idx >= w->as.listBox.itemCount) { return false; } return w->as.listBox.selBits[idx] != 0; } // ============================================================ // wgtListBoxSelectAll // ============================================================ void wgtListBoxSelectAll(WidgetT *w) { if (!w || w->type != WidgetListBoxE || !w->as.listBox.selBits) { return; } memset(w->as.listBox.selBits, 1, w->as.listBox.itemCount); wgtInvalidatePaint(w); } // ============================================================ // wgtListBoxSetItemSelected // ============================================================ void wgtListBoxSetItemSelected(WidgetT *w, int32_t idx, bool selected) { VALIDATE_WIDGET_VOID(w, WidgetListBoxE); if (!w->as.listBox.selBits || idx < 0 || idx >= w->as.listBox.itemCount) { return; } w->as.listBox.selBits[idx] = selected ? 1 : 0; wgtInvalidatePaint(w); } // ============================================================ // wgtListBoxSetItems // ============================================================ void wgtListBoxSetItems(WidgetT *w, const char **items, int32_t count) { VALIDATE_WIDGET_VOID(w, WidgetListBoxE); w->as.listBox.items = items; w->as.listBox.itemCount = count; // Cache max item strlen to avoid recomputing in calcMinSize 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; } } w->as.listBox.maxItemLen = maxLen; if (w->as.listBox.selectedIdx >= count) { w->as.listBox.selectedIdx = count > 0 ? 0 : -1; } if (w->as.listBox.selectedIdx < 0 && count > 0) { w->as.listBox.selectedIdx = 0; } w->as.listBox.anchorIdx = w->as.listBox.selectedIdx; // Reallocate selection bits allocSelBits(w); // Pre-select the cursor item if (w->as.listBox.selBits && w->as.listBox.selectedIdx >= 0) { w->as.listBox.selBits[w->as.listBox.selectedIdx] = 1; } wgtInvalidate(w); } // ============================================================ // wgtListBoxSetReorderable // ============================================================ void wgtListBoxSetReorderable(WidgetT *w, bool reorderable) { VALIDATE_WIDGET_VOID(w, WidgetListBoxE); w->as.listBox.reorderable = reorderable; } // ============================================================ // wgtListBoxSetMultiSelect // ============================================================ void wgtListBoxSetMultiSelect(WidgetT *w, bool multi) { VALIDATE_WIDGET_VOID(w, WidgetListBoxE); w->as.listBox.multiSelect = multi; allocSelBits(w); // Sync: mark current selection if (w->as.listBox.selBits && w->as.listBox.selectedIdx >= 0) { w->as.listBox.selBits[w->as.listBox.selectedIdx] = 1; } } // ============================================================ // wgtListBoxSetSelected // ============================================================ void wgtListBoxSetSelected(WidgetT *w, int32_t idx) { VALIDATE_WIDGET_VOID(w, WidgetListBoxE); w->as.listBox.selectedIdx = idx; w->as.listBox.anchorIdx = idx; // In multi-select, clear all then select just this one if (w->as.listBox.selBits) { memset(w->as.listBox.selBits, 0, w->as.listBox.itemCount); if (idx >= 0 && idx < w->as.listBox.itemCount) { w->as.listBox.selBits[idx] = 1; } } wgtInvalidatePaint(w); } // ============================================================ // widgetListBoxCalcMinSize // ============================================================ // Min size accounts for the widest item, a scrollbar, padding, and border. // Height is based on LISTBOX_MIN_ROWS (4 rows) so the listbox has a usable // minimum height even when empty. The 8-character minimum width prevents // the listbox from collapsing too narrow when items are short or absent. void widgetListBoxCalcMinSize(WidgetT *w, const BitmapFontT *font) { int32_t maxItemW = w->as.listBox.maxItemLen * font->charWidth; int32_t minW = font->charWidth * 8; if (maxItemW < minW) { maxItemW = minW; } w->calcMinW = maxItemW + LISTBOX_PAD * 2 + LISTBOX_BORDER * 2 + WGT_SB_W; w->calcMinH = LISTBOX_MIN_ROWS * font->charHeight + LISTBOX_BORDER * 2; } // ============================================================ // widgetListBoxOnKey // ============================================================ // Key handling: delegates to widgetNavigateIndex for cursor movement // (Up, Down, Home, End, PgUp, PgDn) which returns the new index after // navigation. This shared helper ensures consistent keyboard navigation // across ListBox, ListView, and other scrollable widgets. void widgetListBoxOnKey(WidgetT *w, int32_t key, int32_t mod) { if (!w || w->type != WidgetListBoxE || w->as.listBox.itemCount == 0) { return; } bool multi = w->as.listBox.multiSelect; bool shift = (mod & KEY_MOD_SHIFT) != 0; bool ctrl = (mod & KEY_MOD_CTRL) != 0; int32_t sel = w->as.listBox.selectedIdx; // Ctrl+A -- select all (multi-select only) if (multi && ctrl && (key == 'a' || key == 'A' || key == 1)) { wgtListBoxSelectAll(w); return; } // Space -- toggle current item (multi-select only) if (multi && key == ' ') { if (sel >= 0 && w->as.listBox.selBits) { w->as.listBox.selBits[sel] ^= 1; w->as.listBox.anchorIdx = sel; } if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); return; } // Enter -- activate selected item (same as double-click) if (key == '\r' || key == '\n') { if (w->onDblClick && sel >= 0) { w->onDblClick(w); } return; } AppContextT *ctx = wgtGetContext(w); const BitmapFontT *font = &ctx->font; int32_t visibleRows = (w->h - LISTBOX_BORDER * 2) / font->charHeight; if (visibleRows < 1) { visibleRows = 1; } int32_t newSel = widgetNavigateIndex(key, sel, w->as.listBox.itemCount, visibleRows); if (newSel < 0) { return; } if (newSel == sel) { return; } w->as.listBox.selectedIdx = newSel; // Update selection if (multi && w->as.listBox.selBits) { if (shift) { // Shift+arrow: range from anchor to new cursor memset(w->as.listBox.selBits, 0, w->as.listBox.itemCount); selectRange(w, w->as.listBox.anchorIdx, newSel); } // Plain arrow: just move cursor, leave selections untouched } ensureScrollVisible(w, newSel); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); } // ============================================================ // widgetListBoxOnMouse // ============================================================ // Mouse handling: first checks if the click is on the scrollbar (right edge), // then falls through to item click handling. The scrollbar hit-test uses // widgetScrollbarHitTest which divides the scrollbar into arrow buttons, page // regions, and thumb based on the click position. Item clicks are translated // from pixel coordinates to item index using integer division by charHeight. void widgetListBoxOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) { AppContextT *ctx = (AppContextT *)root->userData; const BitmapFontT *font = &ctx->font; int32_t innerH = hit->h - LISTBOX_BORDER * 2; int32_t visibleRows = innerH / font->charHeight; bool needSb = (hit->as.listBox.itemCount > visibleRows); // Clamp scroll int32_t maxScroll = hit->as.listBox.itemCount - visibleRows; if (maxScroll < 0) { maxScroll = 0; } hit->as.listBox.scrollPos = clampInt(hit->as.listBox.scrollPos, 0, maxScroll); // Check if click is on the scrollbar if (needSb) { int32_t sbX = hit->x + hit->w - LISTBOX_BORDER - WGT_SB_W; if (vx >= sbX) { int32_t relY = vy - (hit->y + LISTBOX_BORDER); ScrollHitE sh = widgetScrollbarHitTest(innerH, relY, hit->as.listBox.itemCount, visibleRows, hit->as.listBox.scrollPos); if (sh == ScrollHitArrowDecE) { if (hit->as.listBox.scrollPos > 0) { hit->as.listBox.scrollPos--; } } else if (sh == ScrollHitArrowIncE) { if (hit->as.listBox.scrollPos < maxScroll) { hit->as.listBox.scrollPos++; } } else if (sh == ScrollHitPageDecE) { hit->as.listBox.scrollPos -= visibleRows; hit->as.listBox.scrollPos = clampInt(hit->as.listBox.scrollPos, 0, maxScroll); } else if (sh == ScrollHitPageIncE) { hit->as.listBox.scrollPos += visibleRows; hit->as.listBox.scrollPos = clampInt(hit->as.listBox.scrollPos, 0, maxScroll); } else if (sh == ScrollHitThumbE) { int32_t trackLen = innerH - WGT_SB_W * 2; int32_t thumbPos; int32_t thumbSize; widgetScrollbarThumb(trackLen, hit->as.listBox.itemCount, visibleRows, hit->as.listBox.scrollPos, &thumbPos, &thumbSize); sDragScrollbar = hit; sDragScrollbarOrient = 0; sDragScrollbarOff = relY - WGT_SB_W - thumbPos; } hit->focused = true; return; } } // Click on item area int32_t innerY = hit->y + LISTBOX_BORDER; int32_t relY = vy - innerY; if (relY < 0) { return; } int32_t clickedRow = relY / font->charHeight; int32_t idx = hit->as.listBox.scrollPos + clickedRow; if (idx < 0 || idx >= hit->as.listBox.itemCount) { return; } bool multi = hit->as.listBox.multiSelect; bool shift = (ctx->keyModifiers & KEY_MOD_SHIFT) != 0; bool ctrl = (ctx->keyModifiers & KEY_MOD_CTRL) != 0; hit->as.listBox.selectedIdx = idx; hit->focused = true; if (multi && hit->as.listBox.selBits) { if (ctrl) { // Ctrl+click: toggle item, update anchor hit->as.listBox.selBits[idx] ^= 1; hit->as.listBox.anchorIdx = idx; } else if (shift) { // Shift+click: range from anchor to clicked memset(hit->as.listBox.selBits, 0, hit->as.listBox.itemCount); selectRange(hit, hit->as.listBox.anchorIdx, idx); } else { // Plain click: select only this item, update anchor memset(hit->as.listBox.selBits, 0, hit->as.listBox.itemCount); hit->as.listBox.selBits[idx] = 1; hit->as.listBox.anchorIdx = idx; } } if (hit->onChange) { hit->onChange(hit); } // onDblClick is handled by the central dispatcher in widgetEvent.c // Initiate drag-reorder if enabled (not from modifier clicks) if (hit->as.listBox.reorderable && !shift && !ctrl) { hit->as.listBox.dragIdx = idx; hit->as.listBox.dropIdx = idx; sDragReorder = hit; } } // ============================================================ // widgetListBoxPaint // ============================================================ // Paint: sunken bevel border, then iterate visible rows drawing text. // Selected items get highlight background (full row width) with contrasting // text color. In multi-select mode, the cursor item (selectedIdx) gets a // dotted focus rect overlay -- this is separate from the selection highlight // so the user can see which item the keyboard cursor is on even when multiple // items are selected. The scrollbar is drawn only when needed (item count // exceeds visible rows), and the content width is reduced accordingly. void widgetListBoxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) { uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg; uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg; int32_t innerH = w->h - LISTBOX_BORDER * 2; int32_t visibleRows = innerH / font->charHeight; bool needSb = (w->as.listBox.itemCount > visibleRows); int32_t contentW = w->w - LISTBOX_BORDER * 2; if (needSb) { contentW -= WGT_SB_W; } // Sunken border BevelStyleT bevel = BEVEL_SUNKEN(colors, bg, 2); drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel); // Clamp scroll position int32_t maxScroll = w->as.listBox.itemCount - visibleRows; if (maxScroll < 0) { maxScroll = 0; } w->as.listBox.scrollPos = clampInt(w->as.listBox.scrollPos, 0, maxScroll); // Draw items int32_t innerX = w->x + LISTBOX_BORDER + LISTBOX_PAD; int32_t innerY = w->y + LISTBOX_BORDER; int32_t scrollPos = w->as.listBox.scrollPos; bool multi = w->as.listBox.multiSelect; uint8_t *selBits = w->as.listBox.selBits; for (int32_t i = 0; i < visibleRows && (scrollPos + i) < w->as.listBox.itemCount; i++) { int32_t idx = scrollPos + i; int32_t iy = innerY + i * font->charHeight; uint32_t ifg = fg; uint32_t ibg = bg; bool isSelected; if (multi && selBits) { isSelected = selBits[idx] != 0; } else { isSelected = (idx == w->as.listBox.selectedIdx); } if (isSelected) { ifg = colors->menuHighlightFg; ibg = colors->menuHighlightBg; rectFill(d, ops, w->x + LISTBOX_BORDER, iy, contentW, font->charHeight, ibg); } drawText(d, ops, font, innerX, iy, w->as.listBox.items[idx], ifg, ibg, false); // Draw cursor indicator in multi-select (dotted focus rect on cursor item) if (multi && idx == w->as.listBox.selectedIdx && w->focused) { drawFocusRect(d, ops, w->x + LISTBOX_BORDER, iy, contentW, font->charHeight, fg); } } // Draw drag-reorder insertion indicator: a 2px horizontal line at the // drop position. The line is drawn between items (not on top of them) // so it's clear where the dragged item will be inserted. if (w->as.listBox.reorderable && w->as.listBox.dragIdx >= 0 && w->as.listBox.dropIdx >= 0) { int32_t drop = w->as.listBox.dropIdx; int32_t lineY = innerY + (drop - scrollPos) * font->charHeight; if (lineY >= innerY && lineY <= innerY + innerH) { drawHLine(d, ops, w->x + LISTBOX_BORDER, lineY, contentW, colors->contentFg); drawHLine(d, ops, w->x + LISTBOX_BORDER, lineY + 1, contentW, colors->contentFg); } } // Draw scrollbar if (needSb) { int32_t sbX = w->x + w->w - LISTBOX_BORDER - WGT_SB_W; int32_t sbY = w->y + LISTBOX_BORDER; widgetDrawScrollbarV(d, ops, colors, sbX, sbY, innerH, w->as.listBox.itemCount, visibleRows, w->as.listBox.scrollPos); } if (w->focused) { drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg); } }