DVX_GUI/dvx/widgets/widgetListView.c

1319 lines
46 KiB
C

// widgetListView.c — ListView (multi-column list) widget
//
// A multi-column list with clickable/sortable column headers, horizontal
// and vertical scrolling, column resize by dragging header borders, and
// multi-select support. This is the most complex widget in the toolkit.
//
// Data model: cell data is stored as a flat array of const char* pointers
// in row-major order (cellData[row * colCount + col]). This external data
// model (pointers, not copies) keeps memory usage minimal and avoids
// copying strings that the application already owns.
//
// ListView state is heap-allocated separately (ListViewDataT*) rather than
// inlined in the widget union because the state is too large for a union
// member — it includes per-column resolved widths, sort index, selection
// bits, and numerous scroll/drag state fields.
//
// Sort: clicking a column header toggles sort direction. Sorting uses an
// indirection array (sortIndex) rather than rearranging the cellData. This
// is critical because:
// 1. The application owns cellData and may not expect it to be mutated
// 2. Multi-select selBits are indexed by data row, not display row —
// rearranging data would invalidate all selection state
// 3. The indirection allows O(1) display↔data row mapping
//
// Insertion sort is used because it's stable (preserves original order for
// equal keys) and has good performance for the typical case of partially-sorted
// data. For the row counts typical in a DOS GUI (hundreds, not millions),
// insertion sort's O(n^2) worst case is acceptable and the constant factors
// are lower than quicksort.
//
// Column widths support four sizing modes via a tagged integer:
// - 0: auto-size (scan all data for widest string)
// - positive small: character count (resolved via font->charWidth)
// - positive large: pixel width
// - negative: percentage of parent width
// The resolution is done lazily in resolveColumnWidths, which is called on
// first paint or mouse event after data changes.
//
// Column resize: dragging a column header border (3px hot zone) resizes the
// column. The drag is tracked via sResizeListView/sResizeCol/sResizeStartX/
// sResizeOrigW globals. A minimum column width (LISTVIEW_MIN_COL_W) prevents
// columns from collapsing to zero.
//
// Horizontal scrolling is needed when total column width exceeds the widget's
// inner width. Both headers and data rows are offset by scrollPosH. The
// horizontal scrollbar appears automatically when needed, and its presence
// may trigger the vertical scrollbar to appear (and vice versa) — this
// two-pass logic handles the interdependency correctly.
#include "widgetInternal.h"
#include <stdlib.h>
#include <string.h>
#define LISTVIEW_PAD 3
#define LISTVIEW_MIN_ROWS 4
#define LISTVIEW_COL_PAD 6
#define LISTVIEW_SORT_W 10
// ============================================================
// Prototypes
// ============================================================
static void allocListViewSelBits(WidgetT *w);
static void listViewBuildSortIndex(WidgetT *w);
static void resolveColumnWidths(WidgetT *w, const BitmapFontT *font);
// ============================================================
// allocListViewSelBits
// ============================================================
// Same allocation strategy as ListBox's selBits — one byte per row,
// only allocated when multiSelect is enabled.
static void allocListViewSelBits(WidgetT *w) {
if (w->as.listView->selBits) {
free(w->as.listView->selBits);
w->as.listView->selBits = NULL;
}
int32_t count = w->as.listView->rowCount;
if (count > 0 && w->as.listView->multiSelect) {
w->as.listView->selBits = (uint8_t *)calloc(count, 1);
}
}
// ============================================================
// listViewBuildSortIndex
// ============================================================
//
// Build or rebuild the sortIndex array. Uses insertion sort
// (stable) on the sort column. If no sort is active, frees
// the index so paint uses natural order.
// Build the sort indirection array. Rather than rearranging cellData (which
// the application owns and indexes into), we maintain a separate array that
// maps display-row → data-row. Paint and keyboard navigation work in display-row
// space, then map through sortIndex to get the data row for cell lookups and
// selection operations. This decoupling is essential because selBits are
// indexed by data row — sorting must not invalidate selection state.
static void listViewBuildSortIndex(WidgetT *w) {
int32_t rowCount = w->as.listView->rowCount;
int32_t sortCol = w->as.listView->sortCol;
int32_t colCount = w->as.listView->colCount;
// No sort active — clear index
if (sortCol < 0 || w->as.listView->sortDir == ListViewSortNoneE || rowCount <= 0) {
if (w->as.listView->sortIndex) {
free(w->as.listView->sortIndex);
w->as.listView->sortIndex = NULL;
}
return;
}
// Allocate or reuse
int32_t *idx = w->as.listView->sortIndex;
if (!idx) {
idx = (int32_t *)malloc(rowCount * sizeof(int32_t));
if (!idx) {
return;
}
w->as.listView->sortIndex = idx;
}
// Initialize identity
for (int32_t i = 0; i < rowCount; i++) {
idx[i] = i;
}
// Insertion sort — stable (equal elements keep their original order),
// O(n^2) worst case but with very low constant factors. For typical
// ListView row counts (10s to low 100s), this beats quicksort because
// there's no recursion overhead, no stack usage, and the inner loop
// is a simple pointer chase. On a 486, the predictable memory access
// pattern also helps since insertion sort is cache-friendly (sequential
// access to adjacent elements).
bool ascending = (w->as.listView->sortDir == ListViewSortAscE);
for (int32_t i = 1; i < rowCount; i++) {
int32_t key = idx[i];
const char *keyStr = w->as.listView->cellData[key * colCount + sortCol];
if (!keyStr) {
keyStr = "";
}
int32_t j = i - 1;
while (j >= 0) {
const char *jStr = w->as.listView->cellData[idx[j] * colCount + sortCol];
if (!jStr) {
jStr = "";
}
int32_t cmp = strcmp(jStr, keyStr);
if (!ascending) {
cmp = -cmp;
}
if (cmp <= 0) {
break;
}
idx[j + 1] = idx[j];
j--;
}
idx[j + 1] = key;
}
}
// ============================================================
// resolveColumnWidths
// ============================================================
//
// Resolve tagged column sizes (pixels, chars, percent, or auto)
// to actual pixel widths. Auto-sized columns (width==0) scan
// cellData for the widest string in that column.
// Resolve tagged column widths to actual pixel values. Column widths in the
// ListViewColT definition can be specified as:
// 0 = auto (scan data for widest string, include header)
// positive = passed to wgtResolveSize (handles px, chars, or percent)
//
// Auto-sizing scans all rows for the widest string in that column, which
// is O(rows * cols) but only runs once per data change (totalColW is reset
// to 0 when data changes, triggering recalculation on next paint/mouse).
static void resolveColumnWidths(WidgetT *w, const BitmapFontT *font) {
int32_t colCount = w->as.listView->colCount;
int32_t parentW = w->w - LISTVIEW_BORDER * 2;
if (parentW < 0) {
parentW = 0;
}
int32_t totalW = 0;
for (int32_t c = 0; c < colCount; c++) {
int32_t taggedW = w->as.listView->cols[c].width;
if (taggedW == 0) {
// Auto-size: scan data for widest string in this column
int32_t maxLen = (int32_t)strlen(w->as.listView->cols[c].title);
for (int32_t r = 0; r < w->as.listView->rowCount; r++) {
const char *cell = w->as.listView->cellData[r * colCount + c];
if (cell) {
int32_t slen = (int32_t)strlen(cell);
if (slen > maxLen) {
maxLen = slen;
}
}
}
w->as.listView->resolvedColW[c] = maxLen * font->charWidth + LISTVIEW_COL_PAD;
} else {
w->as.listView->resolvedColW[c] = wgtResolveSize(taggedW, parentW, font->charWidth);
}
totalW += w->as.listView->resolvedColW[c];
}
w->as.listView->totalColW = totalW;
}
// ============================================================
// widgetListViewColBorderHit
// ============================================================
//
// Returns true if (vx, vy) is on a column header border (resize zone).
// Coordinates are in widget/virtual space (scroll-adjusted).
// Test if a point is on a column header border (within 3px). Used by the
// cursor-shape logic to show a resize cursor when hovering over column borders.
// The 3px zone on each side of the border line makes it easy to grab even
// on low-resolution displays where pixel-precise clicking is difficult.
bool widgetListViewColBorderHit(const WidgetT *w, int32_t vx, int32_t vy) {
if (!w || w->type != WidgetListViewE || w->as.listView->colCount == 0) {
return false;
}
AppContextT *ctx = wgtGetContext(w);
const BitmapFontT *font = &ctx->font;
int32_t headerH = font->charHeight + 4;
int32_t headerTop = w->y + LISTVIEW_BORDER;
if (vy < headerTop || vy >= headerTop + headerH) {
return false;
}
int32_t colX = w->x + LISTVIEW_BORDER - w->as.listView->scrollPosH;
for (int32_t c = 0; c < w->as.listView->colCount; c++) {
int32_t border = colX + w->as.listView->resolvedColW[c];
if (vx >= border - 3 && vx <= border + 3) {
return true;
}
colX += w->as.listView->resolvedColW[c];
}
return false;
}
// ============================================================
// widgetListViewDestroy
// ============================================================
// Free all heap-allocated ListView state. The sortIndex and selBits are
// optional (may be NULL if no sort or single-select), so we check before
// freeing. The ListViewDataT struct itself is always freed.
void widgetListViewDestroy(WidgetT *w) {
if (w->as.listView->sortIndex) {
free(w->as.listView->sortIndex);
}
if (w->as.listView->selBits) {
free(w->as.listView->selBits);
}
free(w->as.listView);
w->as.listView = NULL;
}
// ============================================================
// wgtListView
// ============================================================
// Create a ListView widget. The ListViewDataT is heap-allocated separately
// because it's too large for the widget union (it contains fixed-size arrays
// for resolved column widths, plus numerous state fields for scrolling, sorting,
// selection, and drag-reorder). Default weight=100 makes the ListView expand
// to fill available space, which is the typical desired behavior.
WidgetT *wgtListView(WidgetT *parent) {
WidgetT *w = widgetAlloc(parent, WidgetListViewE);
if (!w) {
return NULL;
}
w->as.listView = (ListViewDataT *)calloc(1, sizeof(ListViewDataT));
if (!w->as.listView) {
free(w);
return NULL;
}
w->as.listView->selectedIdx = -1;
w->as.listView->anchorIdx = -1;
w->as.listView->sortCol = -1;
w->as.listView->sortDir = ListViewSortNoneE;
w->as.listView->dragIdx = -1;
w->as.listView->dropIdx = -1;
w->weight = 100;
return w;
}
// ============================================================
// wgtListViewGetSelected
// ============================================================
int32_t wgtListViewGetSelected(const WidgetT *w) {
VALIDATE_WIDGET(w, WidgetListViewE, -1);
return w->as.listView->selectedIdx;
}
// ============================================================
// wgtListViewClearSelection
// ============================================================
void wgtListViewClearSelection(WidgetT *w) {
if (!w || w->type != WidgetListViewE || !w->as.listView->selBits) {
return;
}
memset(w->as.listView->selBits, 0, w->as.listView->rowCount);
wgtInvalidatePaint(w);
}
// ============================================================
// wgtListViewIsItemSelected
// ============================================================
bool wgtListViewIsItemSelected(const WidgetT *w, int32_t idx) {
VALIDATE_WIDGET(w, WidgetListViewE, false);
if (!w->as.listView->multiSelect) {
return idx == w->as.listView->selectedIdx;
}
if (!w->as.listView->selBits || idx < 0 || idx >= w->as.listView->rowCount) {
return false;
}
return w->as.listView->selBits[idx] != 0;
}
// ============================================================
// wgtListViewSelectAll
// ============================================================
void wgtListViewSelectAll(WidgetT *w) {
if (!w || w->type != WidgetListViewE || !w->as.listView->selBits) {
return;
}
memset(w->as.listView->selBits, 1, w->as.listView->rowCount);
wgtInvalidatePaint(w);
}
// ============================================================
// wgtListViewSetColumns
// ============================================================
void wgtListViewSetColumns(WidgetT *w, const ListViewColT *cols, int32_t count) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
if (count > LISTVIEW_MAX_COLS) {
count = LISTVIEW_MAX_COLS;
}
w->as.listView->cols = cols;
w->as.listView->colCount = count;
w->as.listView->totalColW = 0;
wgtInvalidate(w);
}
// ============================================================
// wgtListViewSetData
// ============================================================
void wgtListViewSetData(WidgetT *w, const char **cellData, int32_t rowCount) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
// Free old sort index since row count may have changed
if (w->as.listView->sortIndex) {
free(w->as.listView->sortIndex);
w->as.listView->sortIndex = NULL;
}
w->as.listView->cellData = cellData;
w->as.listView->rowCount = rowCount;
w->as.listView->totalColW = 0;
if (w->as.listView->selectedIdx >= rowCount) {
w->as.listView->selectedIdx = rowCount > 0 ? 0 : -1;
}
if (w->as.listView->selectedIdx < 0 && rowCount > 0) {
w->as.listView->selectedIdx = 0;
}
w->as.listView->anchorIdx = w->as.listView->selectedIdx;
// Rebuild sort index if sort is active
listViewBuildSortIndex(w);
// Reallocate selection bits
allocListViewSelBits(w);
if (w->as.listView->selBits && w->as.listView->selectedIdx >= 0) {
w->as.listView->selBits[w->as.listView->selectedIdx] = 1;
}
wgtInvalidate(w);
}
// ============================================================
// wgtListViewSetHeaderClickCallback
// ============================================================
void wgtListViewSetHeaderClickCallback(WidgetT *w, void (*cb)(WidgetT *w, int32_t col, ListViewSortE dir)) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
w->as.listView->onHeaderClick = cb;
}
// ============================================================
// wgtListViewSetItemSelected
// ============================================================
void wgtListViewSetItemSelected(WidgetT *w, int32_t idx, bool selected) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
if (!w->as.listView->selBits || idx < 0 || idx >= w->as.listView->rowCount) {
return;
}
w->as.listView->selBits[idx] = selected ? 1 : 0;
wgtInvalidatePaint(w);
}
// ============================================================
// wgtListViewSetReorderable
// ============================================================
// Enable/disable drag-reorder. When reorderable is enabled, sorting is
// disabled because the two features are fundamentally incompatible: if the
// user manually arranges items, then sorts by a column, the manual order
// is lost. By clearing sortCol and freeing sortIndex, we ensure the display
// shows the natural data order which the user's drag operations will modify.
void wgtListViewSetReorderable(WidgetT *w, bool reorderable) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
w->as.listView->reorderable = reorderable;
// Disable sorting when reorderable — sort order conflicts with manual order
if (reorderable) {
w->as.listView->sortCol = -1;
w->as.listView->sortDir = ListViewSortNoneE;
if (w->as.listView->sortIndex) {
free(w->as.listView->sortIndex);
w->as.listView->sortIndex = NULL;
}
}
}
// ============================================================
// wgtListViewSetMultiSelect
// ============================================================
void wgtListViewSetMultiSelect(WidgetT *w, bool multi) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
w->as.listView->multiSelect = multi;
allocListViewSelBits(w);
if (w->as.listView->selBits && w->as.listView->selectedIdx >= 0) {
w->as.listView->selBits[w->as.listView->selectedIdx] = 1;
}
}
// ============================================================
// wgtListViewSetSelected
// ============================================================
void wgtListViewSetSelected(WidgetT *w, int32_t idx) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
w->as.listView->selectedIdx = idx;
w->as.listView->anchorIdx = idx;
if (w->as.listView->selBits) {
memset(w->as.listView->selBits, 0, w->as.listView->rowCount);
if (idx >= 0 && idx < w->as.listView->rowCount) {
w->as.listView->selBits[idx] = 1;
}
}
wgtInvalidatePaint(w);
}
// ============================================================
// wgtListViewSetSort
// ============================================================
void wgtListViewSetSort(WidgetT *w, int32_t col, ListViewSortE dir) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
w->as.listView->sortCol = col;
w->as.listView->sortDir = dir;
listViewBuildSortIndex(w);
wgtInvalidatePaint(w);
}
// ============================================================
// widgetListViewCalcMinSize
// ============================================================
void widgetListViewCalcMinSize(WidgetT *w, const BitmapFontT *font) {
int32_t headerH = font->charHeight + 4;
int32_t minW = font->charWidth * 12 + LISTVIEW_BORDER * 2 + WGT_SB_W;
w->calcMinW = minW;
w->calcMinH = headerH + LISTVIEW_MIN_ROWS * font->charHeight + LISTVIEW_BORDER * 2;
}
// ============================================================
// widgetListViewOnKey
// ============================================================
// Key handling must account for the sort indirection layer. Navigation happens
// in display-row space (what the user sees on screen), but selection and data
// access happen in data-row space (the original cellData indices). This means:
// 1. Convert selectedIdx (data row) to display row via linear search in sortIndex
// 2. Navigate in display space (widgetNavigateIndex)
// 3. Convert back to data row via sortIndex[displayRow]
// 4. Update selectedIdx and selBits using data-row indices
//
// Shift-select for sorted views is particularly tricky: the range must be
// computed in display-row space (what the user sees as contiguous) but applied
// to the data-row selBits. This means the selected data rows may not be
// contiguous in the original data order.
void widgetListViewOnKey(WidgetT *w, int32_t key, int32_t mod) {
if (!w || w->type != WidgetListViewE || w->as.listView->rowCount == 0) {
return;
}
bool multi = w->as.listView->multiSelect;
bool shift = (mod & KEY_MOD_SHIFT) != 0;
bool ctrl = (mod & KEY_MOD_CTRL) != 0;
int32_t rowCount = w->as.listView->rowCount;
int32_t *sortIdx = w->as.listView->sortIndex;
// Ctrl+A — select all (multi-select only)
if (multi && ctrl && (key == 'a' || key == 'A' || key == 1)) {
wgtListViewSelectAll(w);
return;
}
// Space — toggle current item (multi-select only)
if (multi && key == ' ') {
int32_t sel = w->as.listView->selectedIdx;
if (sel >= 0 && w->as.listView->selBits) {
w->as.listView->selBits[sel] ^= 1;
w->as.listView->anchorIdx = sel;
}
if (w->onChange) {
w->onChange(w);
}
wgtInvalidatePaint(w);
return;
}
// Find current display row from selectedIdx (data row)
int32_t displaySel = -1;
if (w->as.listView->selectedIdx >= 0) {
if (sortIdx) {
for (int32_t i = 0; i < rowCount; i++) {
if (sortIdx[i] == w->as.listView->selectedIdx) {
displaySel = i;
break;
}
}
} else {
displaySel = w->as.listView->selectedIdx;
}
}
// Compute visible rows for page up/down
AppContextT *ctx = wgtGetContext(w);
const BitmapFontT *font = &ctx->font;
int32_t headerH = font->charHeight + 4;
int32_t innerH = w->h - LISTVIEW_BORDER * 2 - headerH;
int32_t visibleRows = innerH / font->charHeight;
if (visibleRows < 1) {
visibleRows = 1;
}
int32_t newDisplaySel = widgetNavigateIndex(key, displaySel, rowCount, visibleRows);
if (newDisplaySel < 0) {
return;
}
displaySel = newDisplaySel;
// Convert display row back to data row
int32_t newDataRow = sortIdx ? sortIdx[displaySel] : displaySel;
if (newDataRow == w->as.listView->selectedIdx) {
return;
}
w->as.listView->selectedIdx = newDataRow;
// Update multi-select
if (multi && w->as.listView->selBits) {
if (shift) {
// Shift+arrow: range from anchor to new cursor (in data-row space)
memset(w->as.listView->selBits, 0, rowCount);
// Convert anchor to display row, then select display range mapped to data rows
int32_t anchorDisplay = -1;
int32_t anchor = w->as.listView->anchorIdx;
if (sortIdx && anchor >= 0) {
for (int32_t i = 0; i < rowCount; i++) {
if (sortIdx[i] == anchor) {
anchorDisplay = i;
break;
}
}
} else {
anchorDisplay = anchor;
}
// Select all display rows in range, mapping to data rows
int32_t lo = anchorDisplay < displaySel ? anchorDisplay : displaySel;
int32_t hi = anchorDisplay > displaySel ? anchorDisplay : displaySel;
if (lo < 0) {
lo = 0;
}
if (hi >= rowCount) {
hi = rowCount - 1;
}
for (int32_t i = lo; i <= hi; i++) {
int32_t dr = sortIdx ? sortIdx[i] : i;
w->as.listView->selBits[dr] = 1;
}
}
// Plain arrow: just move cursor, leave selections untouched
}
// Scroll to keep selection visible (in display-row space)
if (displaySel >= 0) {
if (displaySel < w->as.listView->scrollPos) {
w->as.listView->scrollPos = displaySel;
} else if (displaySel >= w->as.listView->scrollPos + visibleRows) {
w->as.listView->scrollPos = displaySel - visibleRows + 1;
}
}
if (w->onChange) {
w->onChange(w);
}
wgtInvalidatePaint(w);
}
// ============================================================
// widgetListViewOnMouse
// ============================================================
// Mouse handling is the most complex part of ListView. It handles four
// distinct hit regions (checked in priority order):
// 1. Vertical scrollbar (right edge, if visible)
// 2. Horizontal scrollbar (bottom edge, if visible)
// 3. Dead corner (when both scrollbars present — no action)
// 4. Column headers: resize drag (border +-3px) or sort toggle (click)
// 5. Data rows: item selection with Ctrl/Shift modifiers, double-click,
// and drag-reorder initiation
//
// The scrollbar visibility calculation is repeated here (matching paint)
// because scrollbar presence depends on content/widget dimensions which
// may have changed since the last paint.
void widgetListViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
hit->focused = true;
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
// Resolve column widths if needed
if (hit->as.listView->totalColW == 0 && hit->as.listView->colCount > 0) {
resolveColumnWidths(hit, font);
}
int32_t headerH = font->charHeight + 4;
int32_t innerH = hit->h - LISTVIEW_BORDER * 2 - headerH;
int32_t innerW = hit->w - LISTVIEW_BORDER * 2;
int32_t visibleRows = innerH / font->charHeight;
int32_t totalColW = hit->as.listView->totalColW;
bool needVSb = (hit->as.listView->rowCount > visibleRows);
bool needHSb = false;
// Scrollbar interdependency: adding a vertical scrollbar reduces innerW,
// which may cause columns to overflow and require a horizontal scrollbar.
// Adding a horizontal scrollbar reduces innerH, which may require a
// vertical scrollbar. This two-pass check handles the mutual dependency.
if (needVSb) {
innerW -= WGT_SB_W;
}
if (totalColW > innerW) {
needHSb = true;
innerH -= WGT_SB_W;
visibleRows = innerH / font->charHeight;
if (!needVSb && hit->as.listView->rowCount > visibleRows) {
needVSb = true;
innerW -= WGT_SB_W;
}
}
if (visibleRows < 1) {
visibleRows = 1;
}
// Clamp scroll positions
int32_t maxScrollV = hit->as.listView->rowCount - visibleRows;
int32_t maxScrollH = totalColW - innerW;
if (maxScrollV < 0) {
maxScrollV = 0;
}
if (maxScrollH < 0) {
maxScrollH = 0;
}
hit->as.listView->scrollPos = clampInt(hit->as.listView->scrollPos, 0, maxScrollV);
hit->as.listView->scrollPosH = clampInt(hit->as.listView->scrollPosH, 0, maxScrollH);
// Check vertical scrollbar
if (needVSb) {
int32_t sbX = hit->x + hit->w - LISTVIEW_BORDER - WGT_SB_W;
int32_t sbY = hit->y + LISTVIEW_BORDER + headerH;
if (vx >= sbX && vy >= sbY && vy < sbY + innerH) {
int32_t relY = vy - sbY;
ScrollHitE sh = widgetScrollbarHitTest(innerH, relY, hit->as.listView->rowCount, visibleRows, hit->as.listView->scrollPos);
if (sh == ScrollHitArrowDecE) {
if (hit->as.listView->scrollPos > 0) {
hit->as.listView->scrollPos--;
}
} else if (sh == ScrollHitArrowIncE) {
if (hit->as.listView->scrollPos < maxScrollV) {
hit->as.listView->scrollPos++;
}
} else if (sh == ScrollHitPageDecE) {
hit->as.listView->scrollPos -= visibleRows;
hit->as.listView->scrollPos = clampInt(hit->as.listView->scrollPos, 0, maxScrollV);
} else if (sh == ScrollHitPageIncE) {
hit->as.listView->scrollPos += visibleRows;
hit->as.listView->scrollPos = clampInt(hit->as.listView->scrollPos, 0, maxScrollV);
} else if (sh == ScrollHitThumbE) {
int32_t trackLen = innerH - WGT_SB_W * 2;
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, hit->as.listView->rowCount, visibleRows, hit->as.listView->scrollPos, &thumbPos, &thumbSize);
sDragScrollbar = hit;
sDragScrollbarOrient = 0;
sDragScrollbarOff = relY - WGT_SB_W - thumbPos;
}
return;
}
}
// Check horizontal scrollbar
if (needHSb) {
int32_t sbX = hit->x + LISTVIEW_BORDER;
int32_t sbY = hit->y + hit->h - LISTVIEW_BORDER - WGT_SB_W;
if (vy >= sbY && vx >= sbX && vx < sbX + innerW) {
int32_t relX = vx - sbX;
int32_t pageSize = innerW - font->charWidth;
if (pageSize < font->charWidth) {
pageSize = font->charWidth;
}
ScrollHitE sh = widgetScrollbarHitTest(innerW, relX, totalColW, innerW, hit->as.listView->scrollPosH);
if (sh == ScrollHitArrowDecE) {
hit->as.listView->scrollPosH -= font->charWidth;
} else if (sh == ScrollHitArrowIncE) {
hit->as.listView->scrollPosH += font->charWidth;
} else if (sh == ScrollHitPageDecE) {
hit->as.listView->scrollPosH -= pageSize;
} else if (sh == ScrollHitPageIncE) {
hit->as.listView->scrollPosH += pageSize;
} else if (sh == ScrollHitThumbE) {
int32_t trackLen = innerW - WGT_SB_W * 2;
int32_t thumbPos;
int32_t thumbSize;
widgetScrollbarThumb(trackLen, totalColW, innerW, hit->as.listView->scrollPosH, &thumbPos, &thumbSize);
sDragScrollbar = hit;
sDragScrollbarOrient = 1;
sDragScrollbarOff = relX - WGT_SB_W - thumbPos;
}
hit->as.listView->scrollPosH = clampInt(hit->as.listView->scrollPosH, 0, maxScrollH);
return;
}
}
// Check dead corner (both scrollbars present)
if (needVSb && needHSb) {
int32_t cornerX = hit->x + hit->w - LISTVIEW_BORDER - WGT_SB_W;
int32_t cornerY = hit->y + hit->h - LISTVIEW_BORDER - WGT_SB_W;
if (vx >= cornerX && vy >= cornerY) {
return;
}
}
// Check column header area
int32_t headerTop = hit->y + LISTVIEW_BORDER;
if (vy >= headerTop && vy < headerTop + headerH) {
// Check for column border resize (3px zone on each side of border)
int32_t colX = hit->x + LISTVIEW_BORDER - hit->as.listView->scrollPosH;
for (int32_t c = 0; c < hit->as.listView->colCount; c++) {
int32_t cw = hit->as.listView->resolvedColW[c];
int32_t border = colX + cw;
if (vx >= border - 3 && vx <= border + 3 && c < hit->as.listView->colCount) {
// Start column resize drag
sResizeListView = hit;
sResizeCol = c;
sResizeStartX = vx;
sResizeOrigW = cw;
return;
}
colX += cw;
}
// Not on a border — check for sort click (disabled when reorderable
// since sorting conflicts with manual ordering). Clicking a column
// toggles between ascending/descending; clicking a different column
// starts ascending.
if (!hit->as.listView->reorderable) {
colX = hit->x + LISTVIEW_BORDER - hit->as.listView->scrollPosH;
for (int32_t c = 0; c < hit->as.listView->colCount; c++) {
int32_t cw = hit->as.listView->resolvedColW[c];
if (vx >= colX && vx < colX + cw) {
// Toggle sort direction for this column
if (hit->as.listView->sortCol == c) {
if (hit->as.listView->sortDir == ListViewSortAscE) {
hit->as.listView->sortDir = ListViewSortDescE;
} else {
hit->as.listView->sortDir = ListViewSortAscE;
}
} else {
hit->as.listView->sortCol = c;
hit->as.listView->sortDir = ListViewSortAscE;
}
listViewBuildSortIndex(hit);
if (hit->as.listView->onHeaderClick) {
hit->as.listView->onHeaderClick(hit, c, hit->as.listView->sortDir);
}
wgtInvalidatePaint(hit);
return;
}
colX += cw;
}
}
return;
}
// Click on data area
int32_t dataTop = headerTop + headerH;
int32_t relY = vy - dataTop;
if (relY < 0) {
return;
}
int32_t clickedRow = relY / font->charHeight;
int32_t displayRow = hit->as.listView->scrollPos + clickedRow;
if (displayRow >= 0 && displayRow < hit->as.listView->rowCount) {
int32_t dataRow = hit->as.listView->sortIndex ? hit->as.listView->sortIndex[displayRow] : displayRow;
hit->as.listView->selectedIdx = dataRow;
bool multi = hit->as.listView->multiSelect;
bool shift = (ctx->keyModifiers & KEY_MOD_SHIFT) != 0;
bool ctrl = (ctx->keyModifiers & KEY_MOD_CTRL) != 0;
if (multi && hit->as.listView->selBits) {
if (ctrl) {
// Ctrl+click: toggle item, update anchor
hit->as.listView->selBits[dataRow] ^= 1;
hit->as.listView->anchorIdx = dataRow;
} else if (shift) {
// Shift+click: range from anchor to clicked (in display-row space)
memset(hit->as.listView->selBits, 0, hit->as.listView->rowCount);
int32_t anchorDisplay = -1;
int32_t anchor = hit->as.listView->anchorIdx;
if (hit->as.listView->sortIndex && anchor >= 0) {
for (int32_t i = 0; i < hit->as.listView->rowCount; i++) {
if (hit->as.listView->sortIndex[i] == anchor) {
anchorDisplay = i;
break;
}
}
} else {
anchorDisplay = anchor;
}
int32_t lo = anchorDisplay < displayRow ? anchorDisplay : displayRow;
int32_t hi = anchorDisplay > displayRow ? anchorDisplay : displayRow;
if (lo < 0) {
lo = 0;
}
if (hi >= hit->as.listView->rowCount) {
hi = hit->as.listView->rowCount - 1;
}
for (int32_t i = lo; i <= hi; i++) {
int32_t dr = hit->as.listView->sortIndex ? hit->as.listView->sortIndex[i] : i;
hit->as.listView->selBits[dr] = 1;
}
} else {
// Plain click: select only this item, update anchor
memset(hit->as.listView->selBits, 0, hit->as.listView->rowCount);
hit->as.listView->selBits[dataRow] = 1;
hit->as.listView->anchorIdx = dataRow;
}
}
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.listView->reorderable && !shift && !ctrl) {
hit->as.listView->dragIdx = dataRow;
hit->as.listView->dropIdx = dataRow;
sDragReorder = hit;
}
}
wgtInvalidatePaint(hit);
}
// ============================================================
// widgetListViewPaint
// ============================================================
// Paint: the most involved paint function in the widget toolkit. Renders
// in layers:
// 1. Outer sunken bevel border
// 2. Column headers (clipped to content width, scrolled by scrollPosH):
// each header is a raised bevel button with centered text and optional
// sort indicator (up/down triangle)
// 3. Data rows (clipped to data area, scrolled both H and V):
// background fill, then per-cell text rendering with alignment, then
// selection highlight, then cursor focus rect in multi-select mode
// 4. Drag-reorder insertion line (if active)
// 5. Scrollbars (V and/or H, with dead corner fill)
// 6. Outer focus rect
//
// Clip rectangles are saved/restored around the header and data sections to
// prevent rendering from bleeding into the scrollbar or border areas. This
// is cheaper than per-cell clipping and handles horizontal scroll correctly
// (partially visible columns at the edges are clipped by the display's clip
// rect rather than by the draw calls).
void widgetListViewPaint(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;
// Resolve column widths if needed
if (w->as.listView->totalColW == 0 && w->as.listView->colCount > 0) {
resolveColumnWidths(w, font);
}
int32_t headerH = font->charHeight + 4;
int32_t innerH = w->h - LISTVIEW_BORDER * 2 - headerH;
int32_t innerW = w->w - LISTVIEW_BORDER * 2;
int32_t visibleRows = innerH / font->charHeight;
int32_t totalColW = w->as.listView->totalColW;
bool needVSb = (w->as.listView->rowCount > visibleRows);
bool needHSb = false;
if (needVSb) {
innerW -= WGT_SB_W;
}
if (totalColW > innerW) {
needHSb = true;
innerH -= WGT_SB_W;
visibleRows = innerH / font->charHeight;
if (!needVSb && w->as.listView->rowCount > visibleRows) {
needVSb = true;
innerW -= WGT_SB_W;
}
}
if (visibleRows < 1) {
visibleRows = 1;
}
// Sunken border
BevelStyleT bevel = BEVEL_SUNKEN(colors, bg, 2);
drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
// Clamp scroll positions
int32_t maxScrollV = w->as.listView->rowCount - visibleRows;
int32_t maxScrollH = totalColW - innerW;
if (maxScrollV < 0) {
maxScrollV = 0;
}
if (maxScrollH < 0) {
maxScrollH = 0;
}
w->as.listView->scrollPos = clampInt(w->as.listView->scrollPos, 0, maxScrollV);
w->as.listView->scrollPosH = clampInt(w->as.listView->scrollPosH, 0, maxScrollH);
int32_t baseX = w->x + LISTVIEW_BORDER;
int32_t baseY = w->y + LISTVIEW_BORDER;
int32_t colCount = w->as.listView->colCount;
// ---- Draw column headers ----
{
// Clip headers to the content area width
int32_t oldClipX = d->clipX;
int32_t oldClipY = d->clipY;
int32_t oldClipW = d->clipW;
int32_t oldClipH = d->clipH;
setClipRect(d, baseX, baseY, innerW, headerH);
int32_t hdrX = baseX - w->as.listView->scrollPosH;
BevelStyleT hdrBevel = BEVEL_RAISED(colors, 1);
for (int32_t c = 0; c < colCount; c++) {
int32_t cw = w->as.listView->resolvedColW[c];
// Draw raised button for header
drawBevel(d, ops, hdrX, baseY, cw, headerH, &hdrBevel);
// Header text
int32_t textX = hdrX + LISTVIEW_PAD;
int32_t textY = baseY + 2;
int32_t availTextW = cw - LISTVIEW_PAD * 2;
// Reserve space for sort indicator if this is the sort column
if (c == w->as.listView->sortCol && w->as.listView->sortDir != ListViewSortNoneE) {
availTextW -= LISTVIEW_SORT_W;
}
if (w->as.listView->cols[c].title) {
int32_t titleLen = (int32_t)strlen(w->as.listView->cols[c].title);
int32_t titleW = titleLen * font->charWidth;
if (titleW > availTextW) {
titleLen = availTextW / font->charWidth;
}
if (titleLen > 0) {
drawTextN(d, ops, font, textX, textY, w->as.listView->cols[c].title, titleLen, colors->contentFg, colors->windowFace, true);
}
}
// Sort indicator: a small filled triangle (4px tall) in the header.
// Up triangle for ascending, down triangle for descending. Drawn as
// horizontal lines of increasing/decreasing width, same technique as
// the dropdown arrow glyph.
if (c == w->as.listView->sortCol && w->as.listView->sortDir != ListViewSortNoneE) {
int32_t cx = hdrX + cw - LISTVIEW_SORT_W / 2 - LISTVIEW_PAD;
int32_t cy = baseY + headerH / 2;
if (w->as.listView->sortDir == ListViewSortAscE) {
// Up triangle (ascending)
for (int32_t i = 0; i < 4; i++) {
drawHLine(d, ops, cx - i, cy - 2 + i, 1 + i * 2, colors->contentFg);
}
} else {
// Down triangle (descending)
for (int32_t i = 0; i < 4; i++) {
drawHLine(d, ops, cx - 3 + i, cy - 1 + i, 7 - i * 2, colors->contentFg);
}
}
}
hdrX += cw;
}
// Fill any remaining header space to the right of columns
if (hdrX < baseX + innerW) {
drawBevel(d, ops, hdrX, baseY, baseX + innerW - hdrX, headerH, &hdrBevel);
}
setClipRect(d, oldClipX, oldClipY, oldClipW, oldClipH);
}
// ---- Draw data rows ----
{
int32_t dataY = baseY + headerH;
// Set clip rect to data area
int32_t oldClipX = d->clipX;
int32_t oldClipY = d->clipY;
int32_t oldClipW = d->clipW;
int32_t oldClipH = d->clipH;
setClipRect(d, baseX, dataY, innerW, innerH);
int32_t scrollPos = w->as.listView->scrollPos;
int32_t *sortIdx = w->as.listView->sortIndex;
// Fill entire data area background first. This is done as a single
// rectFill rather than per-row because it's cheaper to fill once and
// then overdraw selected rows than to compute and fill each unselected
// row's background separately.
rectFill(d, ops, baseX, dataY, innerW, innerH, bg);
bool multi = w->as.listView->multiSelect;
uint8_t *selBits = w->as.listView->selBits;
for (int32_t i = 0; i < visibleRows && (scrollPos + i) < w->as.listView->rowCount; i++) {
int32_t displayRow = scrollPos + i;
int32_t dataRow = sortIdx ? sortIdx[displayRow] : displayRow;
int32_t iy = dataY + i * font->charHeight;
uint32_t ifg = fg;
uint32_t ibg = bg;
bool selected = false;
if (multi && selBits) {
selected = selBits[dataRow] != 0;
} else {
selected = (dataRow == w->as.listView->selectedIdx);
}
if (selected) {
ifg = colors->menuHighlightFg;
ibg = colors->menuHighlightBg;
rectFill(d, ops, baseX, iy, innerW, font->charHeight, ibg);
}
// Draw each cell
int32_t cellX = baseX - w->as.listView->scrollPosH;
for (int32_t c = 0; c < colCount; c++) {
int32_t cw = w->as.listView->resolvedColW[c];
const char *cell = w->as.listView->cellData[dataRow * colCount + c];
if (cell) {
int32_t cellLen = (int32_t)strlen(cell);
int32_t maxChars = (cw - LISTVIEW_PAD * 2) / font->charWidth;
if (maxChars < 0) {
maxChars = 0;
}
if (cellLen > maxChars) {
cellLen = maxChars;
}
int32_t tx = cellX + LISTVIEW_PAD;
// Column alignment: left (default), right, or center.
// Right/center alignment compute the available width and
// offset the text start position accordingly. This is done
// per-cell rather than using a general-purpose aligned draw
// function to keep the inner loop simple and avoid extra
// function call overhead per cell.
if (w->as.listView->cols[c].align == ListViewAlignRightE) {
int32_t renderedW = cellLen * font->charWidth;
int32_t availW = cw - LISTVIEW_PAD * 2;
tx = cellX + LISTVIEW_PAD + (availW - renderedW);
} else if (w->as.listView->cols[c].align == ListViewAlignCenterE) {
int32_t renderedW = cellLen * font->charWidth;
int32_t availW = cw - LISTVIEW_PAD * 2;
tx = cellX + LISTVIEW_PAD + (availW - renderedW) / 2;
}
if (cellLen > 0) {
drawTextN(d, ops, font, tx, iy, cell, cellLen, ifg, ibg, true);
}
}
cellX += cw;
}
// Draw cursor focus rect in multi-select mode (on top of text)
if (multi && dataRow == w->as.listView->selectedIdx && w->focused) {
drawFocusRect(d, ops, baseX, iy, innerW, font->charHeight, fg);
}
}
// Draw drag-reorder insertion indicator
if (w->as.listView->reorderable && w->as.listView->dragIdx >= 0 && w->as.listView->dropIdx >= 0) {
int32_t drop = w->as.listView->dropIdx;
int32_t lineY = dataY + (drop - w->as.listView->scrollPos) * font->charHeight;
if (lineY >= dataY && lineY <= dataY + innerH) {
drawHLine(d, ops, baseX, lineY, innerW, fg);
drawHLine(d, ops, baseX, lineY + 1, innerW, fg);
}
}
setClipRect(d, oldClipX, oldClipY, oldClipW, oldClipH);
}
// ---- Draw scrollbars ----
if (needVSb) {
int32_t sbX = w->x + w->w - LISTVIEW_BORDER - WGT_SB_W;
int32_t sbY = w->y + LISTVIEW_BORDER + headerH;
widgetDrawScrollbarV(d, ops, colors, sbX, sbY, innerH, w->as.listView->rowCount, visibleRows, w->as.listView->scrollPos);
}
if (needHSb) {
int32_t sbX = w->x + LISTVIEW_BORDER;
int32_t sbY = w->y + w->h - LISTVIEW_BORDER - WGT_SB_W;
widgetDrawScrollbarH(d, ops, colors, sbX, sbY, innerW, totalColW, innerW, w->as.listView->scrollPosH);
// Fill the dead corner when both scrollbars are present. This is the
// small square at the intersection of the two scrollbars (bottom-right)
// that doesn't belong to either. Without filling it, stale content
// from previous paints would show through.
if (needVSb) {
rectFill(d, ops, sbX + innerW, sbY, WGT_SB_W, WGT_SB_W, colors->windowFace);
}
}
if (w->focused) {
drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg);
}
}