DVX_GUI/dvx/widgets/widgetListBox.c

632 lines
20 KiB
C

// 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 <stdlib.h>
#include <string.h>
#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;
}
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);
}
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);
}
if (hit->onDblClick && multiClickDetect(vx, vy) >= 2) {
hit->onDblClick(hit);
}
// 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);
}
}