DVX_GUI/dvx/widgets/widgetTreeView.c

1165 lines
36 KiB
C

// widgetTreeView.c — TreeView and TreeItem widgets
#include "widgetInternal.h"
// ============================================================
// Prototypes
// ============================================================
static int32_t calcTreeItemsHeight(WidgetT *parent, const BitmapFontT *font);
static int32_t calcTreeItemsMaxWidth(WidgetT *parent, const BitmapFontT *font, int32_t depth);
static void clearAllSelections(WidgetT *parent);
static WidgetT *firstVisibleItem(WidgetT *treeView);
static void layoutTreeItems(WidgetT *parent, const BitmapFontT *font, int32_t x, int32_t *y, int32_t width, int32_t depth);
static WidgetT *nextVisibleItem(WidgetT *item, WidgetT *treeView);
static void paintReorderIndicator(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t innerW);
static void paintTreeItems(WidgetT *parent, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t baseX, int32_t *itemY, int32_t depth, int32_t clipTop, int32_t clipBottom, WidgetT *treeView);
static WidgetT *prevVisibleItem(WidgetT *item, WidgetT *treeView);
static void selectRange(WidgetT *treeView, WidgetT *from, WidgetT *to);
static void treeCalcScrollbarNeeds(WidgetT *w, const BitmapFontT *font, int32_t *outTotalH, int32_t *outTotalW, int32_t *outInnerH, int32_t *outInnerW, bool *outNeedVSb, bool *outNeedHSb);
static WidgetT *treeItemAtY(WidgetT *parent, int32_t targetY, int32_t *curY, const BitmapFontT *font);
static int32_t treeItemYPos(WidgetT *treeView, WidgetT *target, const BitmapFontT *font);
static int32_t treeItemYPosHelper(WidgetT *parent, WidgetT *target, int32_t *curY, const BitmapFontT *font);
// ============================================================
// calcTreeItemsHeight
// ============================================================
static int32_t calcTreeItemsHeight(WidgetT *parent, const BitmapFontT *font) {
int32_t totalH = 0;
for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) {
if (c->type != WidgetTreeItemE || !c->visible) {
continue;
}
totalH += font->charHeight;
if (c->as.treeItem.expanded && c->firstChild) {
totalH += calcTreeItemsHeight(c, font);
}
}
return totalH;
}
// ============================================================
// calcTreeItemsMaxWidth
// ============================================================
static int32_t calcTreeItemsMaxWidth(WidgetT *parent, const BitmapFontT *font, int32_t depth) {
int32_t maxW = 0;
for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) {
if (c->type != WidgetTreeItemE || !c->visible) {
continue;
}
int32_t indent = depth * TREE_INDENT + TREE_EXPAND_SIZE + TREE_ICON_GAP;
int32_t textW = (int32_t)strlen(c->as.treeItem.text) * font->charWidth;
int32_t itemW = indent + textW;
if (itemW > maxW) {
maxW = itemW;
}
if (c->as.treeItem.expanded && c->firstChild) {
int32_t childW = calcTreeItemsMaxWidth(c, font, depth + 1);
if (childW > maxW) {
maxW = childW;
}
}
}
return maxW;
}
// ============================================================
// clearAllSelections
// ============================================================
static void clearAllSelections(WidgetT *parent) {
for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) {
if (c->type != WidgetTreeItemE) {
continue;
}
c->as.treeItem.selected = false;
if (c->firstChild) {
clearAllSelections(c);
}
}
}
// ============================================================
// firstVisibleItem
// ============================================================
//
// Return the first visible tree item (depth-first).
static WidgetT *firstVisibleItem(WidgetT *treeView) {
for (WidgetT *c = treeView->firstChild; c; c = c->nextSibling) {
if (c->type == WidgetTreeItemE && c->visible) {
return c;
}
}
return NULL;
}
// ============================================================
// layoutTreeItems
// ============================================================
static void layoutTreeItems(WidgetT *parent, const BitmapFontT *font, int32_t x, int32_t *y, int32_t width, int32_t depth) {
for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) {
if (c->type != WidgetTreeItemE || !c->visible) {
continue;
}
c->x = x;
c->y = *y;
c->w = width;
c->h = font->charHeight;
*y += font->charHeight;
if (c->as.treeItem.expanded && c->firstChild) {
layoutTreeItems(c, font, x, y, width, depth + 1);
}
}
}
// ============================================================
// nextVisibleItem
// ============================================================
//
// Return the next visible tree item after the given item
// (depth-first order: children first, then siblings, then uncle).
static WidgetT *nextVisibleItem(WidgetT *item, WidgetT *treeView) {
// If expanded with children, descend
if (item->as.treeItem.expanded) {
for (WidgetT *c = item->firstChild; c; c = c->nextSibling) {
if (c->type == WidgetTreeItemE && c->visible) {
return c;
}
}
}
// Try next sibling
for (WidgetT *s = item->nextSibling; s; s = s->nextSibling) {
if (s->type == WidgetTreeItemE && s->visible) {
return s;
}
}
// Walk up parents, looking for their next sibling
for (WidgetT *p = item->parent; p && p != treeView; p = p->parent) {
for (WidgetT *s = p->nextSibling; s; s = s->nextSibling) {
if (s->type == WidgetTreeItemE && s->visible) {
return s;
}
}
}
return NULL;
}
// ============================================================
// paintReorderIndicator
// ============================================================
//
// Draw a 2px insertion line at the drop target position.
static void paintReorderIndicator(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t innerW) {
if (!w->as.treeView.reorderable || !w->as.treeView.dropTarget || !w->as.treeView.dragItem) {
return;
}
int32_t dropY = treeItemYPos(w, w->as.treeView.dropTarget, font);
if (dropY < 0) {
return;
}
int32_t lineY = w->y + TREE_BORDER - w->as.treeView.scrollPos + dropY;
if (w->as.treeView.dropAfter) {
lineY += font->charHeight;
}
int32_t lineX = w->x + TREE_BORDER;
drawHLine(d, ops, lineX, lineY, innerW, colors->contentFg);
drawHLine(d, ops, lineX, lineY + 1, innerW, colors->contentFg);
}
// ============================================================
// paintTreeItems
// ============================================================
static void paintTreeItems(WidgetT *parent, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t baseX, int32_t *itemY, int32_t depth, int32_t clipTop, int32_t clipBottom, WidgetT *treeView) {
bool multi = treeView->as.treeView.multiSelect;
for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) {
if (c->type != WidgetTreeItemE || !c->visible) {
continue;
}
int32_t ix = baseX + depth * TREE_INDENT;
int32_t iy = *itemY;
*itemY += font->charHeight;
// Skip items outside visible area
if (iy + font->charHeight <= clipTop || iy >= clipBottom) {
if (c->as.treeItem.expanded && c->firstChild) {
paintTreeItems(c, d, ops, font, colors, baseX, itemY, depth + 1, clipTop, clipBottom, treeView);
}
continue;
}
// Highlight selected item(s)
bool isSelected;
if (multi) {
isSelected = c->as.treeItem.selected;
} else {
isSelected = (c == treeView->as.treeView.selectedItem);
}
uint32_t fg;
uint32_t bg;
if (isSelected) {
fg = colors->menuHighlightFg;
bg = colors->menuHighlightBg;
rectFill(d, ops, baseX, iy, d->clipW, font->charHeight, bg);
} else {
fg = c->fgColor ? c->fgColor : colors->contentFg;
bg = c->bgColor ? c->bgColor : colors->contentBg;
}
// Draw expand/collapse icon if item has children
bool hasChildren = false;
for (WidgetT *gc = c->firstChild; gc; gc = gc->nextSibling) {
if (gc->type == WidgetTreeItemE) {
hasChildren = true;
break;
}
}
int32_t textX = ix;
if (hasChildren) {
int32_t iconX = ix;
int32_t iconY = iy + (font->charHeight - TREE_EXPAND_SIZE) / 2;
// Draw box
rectFill(d, ops, iconX + 1, iconY + 1, TREE_EXPAND_SIZE - 2, TREE_EXPAND_SIZE - 2, colors->contentBg);
drawHLine(d, ops, iconX, iconY, TREE_EXPAND_SIZE, colors->windowShadow);
drawHLine(d, ops, iconX, iconY + TREE_EXPAND_SIZE - 1, TREE_EXPAND_SIZE, colors->windowShadow);
drawVLine(d, ops, iconX, iconY, TREE_EXPAND_SIZE, colors->windowShadow);
drawVLine(d, ops, iconX + TREE_EXPAND_SIZE - 1, iconY, TREE_EXPAND_SIZE, colors->windowShadow);
// Draw + or -
int32_t mid = TREE_EXPAND_SIZE / 2;
drawHLine(d, ops, iconX + 2, iconY + mid, TREE_EXPAND_SIZE - 4, colors->contentFg);
if (!c->as.treeItem.expanded) {
drawVLine(d, ops, iconX + mid, iconY + 2, TREE_EXPAND_SIZE - 4, colors->contentFg);
}
textX += TREE_EXPAND_SIZE + TREE_ICON_GAP;
} else {
textX += TREE_EXPAND_SIZE + TREE_ICON_GAP;
}
// Draw text
drawText(d, ops, font, textX, iy, c->as.treeItem.text, fg, bg, isSelected);
// Draw focus rectangle around cursor item in multi-select mode
if (multi && c == treeView->as.treeView.selectedItem && treeView->focused) {
uint32_t focusFg = isSelected ? colors->menuHighlightFg : colors->contentFg;
drawFocusRect(d, ops, baseX, iy, d->clipW, font->charHeight, focusFg);
}
// Recurse into expanded children
if (c->as.treeItem.expanded && c->firstChild) {
paintTreeItems(c, d, ops, font, colors, baseX, itemY, depth + 1, clipTop, clipBottom, treeView);
}
}
}
// ============================================================
// prevVisibleItem
// ============================================================
//
// Return the previous visible tree item before the given item
// (depth-first order: last visible descendant of previous sibling,
// or parent).
static WidgetT *prevVisibleItem(WidgetT *item, WidgetT *treeView) {
// Find previous sibling
WidgetT *prevSib = NULL;
for (WidgetT *s = item->parent->firstChild; s && s != item; s = s->nextSibling) {
if (s->type == WidgetTreeItemE && s->visible) {
prevSib = s;
}
}
if (prevSib) {
// Descend to last visible descendant of previous sibling
WidgetT *node = prevSib;
while (node->as.treeItem.expanded) {
WidgetT *last = NULL;
for (WidgetT *c = node->firstChild; c; c = c->nextSibling) {
if (c->type == WidgetTreeItemE && c->visible) {
last = c;
}
}
if (!last) {
break;
}
node = last;
}
return node;
}
// No previous sibling — go to parent (if it's a tree item)
if (item->parent && item->parent != treeView && item->parent->type == WidgetTreeItemE) {
return item->parent;
}
return NULL;
}
// ============================================================
// selectRange
// ============================================================
//
// Select all visible items between 'from' and 'to' (inclusive).
// Direction is auto-detected.
static void selectRange(WidgetT *treeView, WidgetT *from, WidgetT *to) {
if (!from || !to) {
return;
}
if (from == to) {
from->as.treeItem.selected = true;
return;
}
// Walk forward from 'from'. If we hit 'to', that's the direction.
// Otherwise, walk forward from 'to' to find 'from'.
WidgetT *start = from;
WidgetT *end = to;
// Check if 'to' comes after 'from'
bool forward = false;
for (WidgetT *v = nextVisibleItem(from, treeView); v; v = nextVisibleItem(v, treeView)) {
if (v == to) {
forward = true;
break;
}
}
if (!forward) {
start = to;
end = from;
}
for (WidgetT *v = start; v; v = nextVisibleItem(v, treeView)) {
v->as.treeItem.selected = true;
if (v == end) {
break;
}
}
}
// ============================================================
// treeCalcScrollbarNeeds
// ============================================================
//
// Compute content dimensions and determine which scrollbars are
// needed, accounting for the mutual space dependency between them.
static void treeCalcScrollbarNeeds(WidgetT *w, const BitmapFontT *font, int32_t *outTotalH, int32_t *outTotalW, int32_t *outInnerH, int32_t *outInnerW, bool *outNeedVSb, bool *outNeedHSb) {
int32_t totalH = calcTreeItemsHeight(w, font);
int32_t totalW = calcTreeItemsMaxWidth(w, font, 0);
int32_t innerH = w->h - TREE_BORDER * 2;
int32_t innerW = w->w - TREE_BORDER * 2;
bool needVSb = (totalH > innerH);
bool needHSb = (totalW > innerW);
// V scrollbar reduces available width — may trigger H scrollbar
if (needVSb) {
innerW -= WGT_SB_W;
if (!needHSb && totalW > innerW) {
needHSb = true;
}
}
// H scrollbar reduces available height — may trigger V scrollbar
if (needHSb) {
innerH -= WGT_SB_W;
if (!needVSb && totalH > innerH) {
needVSb = true;
innerW -= WGT_SB_W;
}
}
*outTotalH = totalH;
*outTotalW = totalW;
*outInnerH = innerH;
*outInnerW = innerW;
*outNeedVSb = needVSb;
*outNeedHSb = needHSb;
}
// ============================================================
// treeItemAtY
// ============================================================
//
// Find the tree item at a given Y coordinate.
static WidgetT *treeItemAtY(WidgetT *parent, int32_t targetY, int32_t *curY, const BitmapFontT *font) {
for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) {
if (c->type != WidgetTreeItemE || !c->visible) {
continue;
}
if (targetY >= *curY && targetY < *curY + font->charHeight) {
return c;
}
*curY += font->charHeight;
if (c->as.treeItem.expanded && c->firstChild) {
WidgetT *found = treeItemAtY(c, targetY, curY, font);
if (found) {
return found;
}
}
}
return NULL;
}
// ============================================================
// treeItemYPos
// ============================================================
//
// Return the Y offset (from tree top, 0-based) of a given item,
// or -1 if not found/visible.
static int32_t treeItemYPos(WidgetT *treeView, WidgetT *target, const BitmapFontT *font) {
int32_t curY = 0;
if (treeItemYPosHelper(treeView, target, &curY, font)) {
return curY;
}
return -1;
}
// ============================================================
// treeItemYPosHelper
// ============================================================
static int32_t treeItemYPosHelper(WidgetT *parent, WidgetT *target, int32_t *curY, const BitmapFontT *font) {
for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) {
if (c->type != WidgetTreeItemE || !c->visible) {
continue;
}
if (c == target) {
return 1;
}
*curY += font->charHeight;
if (c->as.treeItem.expanded && c->firstChild) {
if (treeItemYPosHelper(c, target, curY, font)) {
return 1;
}
}
}
return 0;
}
// ============================================================
// wgtTreeItem
// ============================================================
WidgetT *wgtTreeItem(WidgetT *parent, const char *text) {
WidgetT *w = widgetAlloc(parent, WidgetTreeItemE);
if (w) {
w->as.treeItem.text = text;
w->as.treeItem.expanded = false;
}
return w;
}
// ============================================================
// widgetTreeItemGetText
// ============================================================
const char *widgetTreeItemGetText(const WidgetT *w) {
return w->as.treeItem.text ? w->as.treeItem.text : "";
}
// ============================================================
// widgetTreeItemSetText
// ============================================================
void widgetTreeItemSetText(WidgetT *w, const char *text) {
w->as.treeItem.text = text;
}
// ============================================================
// wgtTreeItemIsExpanded
// ============================================================
bool wgtTreeItemIsExpanded(const WidgetT *w) {
VALIDATE_WIDGET(w, WidgetTreeItemE, false);
return w->as.treeItem.expanded;
}
// ============================================================
// wgtTreeItemSetExpanded
// ============================================================
void wgtTreeItemSetExpanded(WidgetT *w, bool expanded) {
VALIDATE_WIDGET_VOID(w, WidgetTreeItemE);
w->as.treeItem.expanded = expanded;
}
// ============================================================
// wgtTreeView
// ============================================================
WidgetT *wgtTreeView(WidgetT *parent) {
WidgetT *w = widgetAlloc(parent, WidgetTreeViewE);
if (w) {
w->weight = 100;
}
return w;
}
// ============================================================
// wgtTreeViewGetSelected
// ============================================================
WidgetT *wgtTreeViewGetSelected(const WidgetT *w) {
VALIDATE_WIDGET(w, WidgetTreeViewE, NULL);
return w->as.treeView.selectedItem;
}
// ============================================================
// wgtTreeViewSetSelected
// ============================================================
void wgtTreeViewSetSelected(WidgetT *w, WidgetT *item) {
VALIDATE_WIDGET_VOID(w, WidgetTreeViewE);
w->as.treeView.selectedItem = item;
if (w->as.treeView.multiSelect && item) {
clearAllSelections(w);
item->as.treeItem.selected = true;
w->as.treeView.anchorItem = item;
}
}
// ============================================================
// wgtTreeViewSetMultiSelect
// ============================================================
void wgtTreeViewSetMultiSelect(WidgetT *w, bool multi) {
VALIDATE_WIDGET_VOID(w, WidgetTreeViewE);
w->as.treeView.multiSelect = multi;
}
// ============================================================
// wgtTreeViewSetReorderable
// ============================================================
void wgtTreeViewSetReorderable(WidgetT *w, bool reorderable) {
VALIDATE_WIDGET_VOID(w, WidgetTreeViewE);
w->as.treeView.reorderable = reorderable;
}
// ============================================================
// widgetTreeViewNextVisible
// ============================================================
//
// Non-static wrapper around nextVisibleItem for use by
// widgetReorderUpdate() in widgetEvent.c.
WidgetT *widgetTreeViewNextVisible(WidgetT *item, WidgetT *treeView) {
return nextVisibleItem(item, treeView);
}
// ============================================================
// wgtTreeItemIsSelected
// ============================================================
bool wgtTreeItemIsSelected(const WidgetT *w) {
VALIDATE_WIDGET(w, WidgetTreeItemE, false);
return w->as.treeItem.selected;
}
// ============================================================
// wgtTreeItemSetSelected
// ============================================================
void wgtTreeItemSetSelected(WidgetT *w, bool selected) {
VALIDATE_WIDGET_VOID(w, WidgetTreeItemE);
w->as.treeItem.selected = selected;
}
// ============================================================
// widgetTreeViewCalcMinSize
// ============================================================
void widgetTreeViewCalcMinSize(WidgetT *w, const BitmapFontT *font) {
int32_t minContentW = TREE_INDENT + TREE_EXPAND_SIZE + TREE_ICON_GAP + 6 * font->charWidth;
w->calcMinW = minContentW + TREE_BORDER * 2 + WGT_SB_W;
w->calcMinH = TREE_MIN_ROWS * font->charHeight + TREE_BORDER * 2;
}
// ============================================================
// widgetTreeViewOnKey
// ============================================================
void widgetTreeViewOnKey(WidgetT *w, int32_t key, int32_t mod) {
VALIDATE_WIDGET_VOID(w, WidgetTreeViewE);
bool multi = w->as.treeView.multiSelect;
bool shift = (mod & KEY_MOD_SHIFT) != 0;
WidgetT *sel = w->as.treeView.selectedItem;
if (key == (0x50 | 0x100)) {
// Down arrow — next visible item
if (!sel) {
w->as.treeView.selectedItem = firstVisibleItem(w);
} else {
WidgetT *next = nextVisibleItem(sel, w);
if (next) {
w->as.treeView.selectedItem = next;
}
}
} else if (key == (0x48 | 0x100)) {
// Up arrow — previous visible item
if (!sel) {
w->as.treeView.selectedItem = firstVisibleItem(w);
} else {
WidgetT *prev = prevVisibleItem(sel, w);
if (prev) {
w->as.treeView.selectedItem = prev;
}
}
} else if (key == (0x4D | 0x100)) {
// Right arrow — expand if collapsed, else move to first child
if (sel) {
bool hasChildren = false;
for (WidgetT *c = sel->firstChild; c; c = c->nextSibling) {
if (c->type == WidgetTreeItemE) {
hasChildren = true;
break;
}
}
if (hasChildren) {
if (!sel->as.treeItem.expanded) {
sel->as.treeItem.expanded = true;
if (sel->onChange) {
sel->onChange(sel);
}
} else {
WidgetT *next = nextVisibleItem(sel, w);
if (next) {
w->as.treeView.selectedItem = next;
}
}
}
}
} else if (key == (0x4B | 0x100)) {
// Left arrow — collapse if expanded, else move to parent
if (sel) {
if (sel->as.treeItem.expanded) {
sel->as.treeItem.expanded = false;
if (sel->onChange) {
sel->onChange(sel);
}
} else if (sel->parent && sel->parent != w && sel->parent->type == WidgetTreeItemE) {
w->as.treeView.selectedItem = sel->parent;
}
}
} else if (key == 0x0D) {
// Enter — toggle expand/collapse for parents, onClick for leaves
if (sel) {
bool hasChildren = false;
for (WidgetT *c = sel->firstChild; c; c = c->nextSibling) {
if (c->type == WidgetTreeItemE) {
hasChildren = true;
break;
}
}
if (hasChildren) {
sel->as.treeItem.expanded = !sel->as.treeItem.expanded;
if (sel->onChange) {
sel->onChange(sel);
}
} else {
if (sel->onClick) {
sel->onClick(sel);
}
}
}
} else if (key == ' ' && multi) {
// Space — toggle selection of current item in multi-select
if (sel) {
sel->as.treeItem.selected = !sel->as.treeItem.selected;
w->as.treeView.anchorItem = sel;
}
} else {
return;
}
// Update multi-select state after Up/Down navigation
WidgetT *newSel = w->as.treeView.selectedItem;
if (multi && newSel != sel && newSel) {
if (shift && w->as.treeView.anchorItem) {
// Shift+arrow: range from anchor to new cursor
clearAllSelections(w);
selectRange(w, w->as.treeView.anchorItem, newSel);
}
// Plain arrow: just move cursor, leave selections untouched
}
// Set anchor on first selection if not set
if (multi && !w->as.treeView.anchorItem && newSel) {
w->as.treeView.anchorItem = newSel;
}
// Scroll to keep selected item visible
sel = w->as.treeView.selectedItem;
if (sel) {
AppContextT *ctx = (AppContextT *)w->window->widgetRoot->userData;
const BitmapFontT *font = &ctx->font;
int32_t itemY = treeItemYPos(w, sel, font);
if (itemY >= 0) {
int32_t totalH;
int32_t totalW;
int32_t innerH;
int32_t innerW;
bool needVSb;
bool needHSb;
treeCalcScrollbarNeeds(w, font, &totalH, &totalW, &innerH, &innerW, &needVSb, &needHSb);
if (itemY < w->as.treeView.scrollPos) {
w->as.treeView.scrollPos = itemY;
} else if (itemY + font->charHeight > w->as.treeView.scrollPos + innerH) {
w->as.treeView.scrollPos = itemY + font->charHeight - innerH;
}
}
}
wgtInvalidate(w);
}
// ============================================================
// widgetTreeViewLayout
// ============================================================
void widgetTreeViewLayout(WidgetT *w, const BitmapFontT *font) {
// Auto-select first item if nothing is selected
if (!w->as.treeView.selectedItem) {
WidgetT *first = firstVisibleItem(w);
w->as.treeView.selectedItem = first;
if (w->as.treeView.multiSelect && first) {
first->as.treeItem.selected = true;
w->as.treeView.anchorItem = first;
}
}
int32_t totalH;
int32_t totalW;
int32_t innerH;
int32_t innerW;
bool needVSb;
bool needHSb;
treeCalcScrollbarNeeds(w, font, &totalH, &totalW, &innerH, &innerW, &needVSb, &needHSb);
int32_t itemW = innerW > totalW ? innerW : totalW;
int32_t innerX = w->x + TREE_BORDER;
int32_t innerY = w->y + TREE_BORDER;
layoutTreeItems(w, font, innerX, &innerY, itemW, 0);
}
// ============================================================
// widgetTreeViewOnMouse
// ============================================================
void widgetTreeViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
hit->focused = true;
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
int32_t totalH;
int32_t totalW;
int32_t innerH;
int32_t innerW;
bool needVSb;
bool needHSb;
treeCalcScrollbarNeeds(hit, font, &totalH, &totalW, &innerH, &innerW, &needVSb, &needHSb);
// Clamp scroll positions
int32_t maxScrollV = totalH - innerH;
int32_t maxScrollH = totalW - innerW;
if (maxScrollV < 0) {
maxScrollV = 0;
}
if (maxScrollH < 0) {
maxScrollH = 0;
}
hit->as.treeView.scrollPos = clampInt(hit->as.treeView.scrollPos, 0, maxScrollV);
hit->as.treeView.scrollPosH = clampInt(hit->as.treeView.scrollPosH, 0, maxScrollH);
// Check if click is on the vertical scrollbar
if (needVSb) {
int32_t sbX = hit->x + hit->w - TREE_BORDER - WGT_SB_W;
if (vx >= sbX && vy < hit->y + TREE_BORDER + innerH) {
int32_t relY = vy - (hit->y + TREE_BORDER);
int32_t pageSize = innerH - font->charHeight;
if (pageSize < font->charHeight) {
pageSize = font->charHeight;
}
ScrollHitE sh = widgetScrollbarHitTest(innerH, relY, totalH, innerH, hit->as.treeView.scrollPos);
if (sh == ScrollHitArrowDecE) {
hit->as.treeView.scrollPos -= font->charHeight;
} else if (sh == ScrollHitArrowIncE) {
hit->as.treeView.scrollPos += font->charHeight;
} else if (sh == ScrollHitPageDecE) {
hit->as.treeView.scrollPos -= pageSize;
} else if (sh == ScrollHitPageIncE) {
hit->as.treeView.scrollPos += pageSize;
}
hit->as.treeView.scrollPos = clampInt(hit->as.treeView.scrollPos, 0, maxScrollV);
return;
}
}
// Check if click is on the horizontal scrollbar
if (needHSb) {
int32_t sbY = hit->y + hit->h - TREE_BORDER - WGT_SB_W;
if (vy >= sbY && vx < hit->x + TREE_BORDER + innerW) {
int32_t relX = vx - (hit->x + TREE_BORDER);
int32_t pageSize = innerW - font->charWidth;
if (pageSize < font->charWidth) {
pageSize = font->charWidth;
}
ScrollHitE sh = widgetScrollbarHitTest(innerW, relX, totalW, innerW, hit->as.treeView.scrollPosH);
if (sh == ScrollHitArrowDecE) {
hit->as.treeView.scrollPosH -= font->charWidth;
} else if (sh == ScrollHitArrowIncE) {
hit->as.treeView.scrollPosH += font->charWidth;
} else if (sh == ScrollHitPageDecE) {
hit->as.treeView.scrollPosH -= pageSize;
} else if (sh == ScrollHitPageIncE) {
hit->as.treeView.scrollPosH += pageSize;
}
hit->as.treeView.scrollPosH = clampInt(hit->as.treeView.scrollPosH, 0, maxScrollH);
return;
}
}
// Click in dead corner (both scrollbars present) — ignore
if (needVSb && needHSb) {
int32_t cornerX = hit->x + hit->w - TREE_BORDER - WGT_SB_W;
int32_t cornerY = hit->y + hit->h - TREE_BORDER - WGT_SB_W;
if (vx >= cornerX && vy >= cornerY) {
return;
}
}
// Tree item click — adjust for scroll offsets
int32_t curY = hit->y + TREE_BORDER - hit->as.treeView.scrollPos;
WidgetT *item = treeItemAtY(hit, vy, &curY, font);
if (!item) {
return;
}
// Update selection
bool multi = hit->as.treeView.multiSelect;
bool shift = (ctx->keyModifiers & KEY_MOD_SHIFT) != 0;
bool ctrl = (ctx->keyModifiers & KEY_MOD_CTRL) != 0;
hit->as.treeView.selectedItem = item;
if (multi) {
if (ctrl) {
// Ctrl+click: toggle item, update anchor
item->as.treeItem.selected = !item->as.treeItem.selected;
hit->as.treeView.anchorItem = item;
} else if (shift && hit->as.treeView.anchorItem) {
// Shift+click: range from anchor to clicked
clearAllSelections(hit);
selectRange(hit, hit->as.treeView.anchorItem, item);
} else {
// Plain click: select only this item, update anchor
clearAllSelections(hit);
item->as.treeItem.selected = true;
hit->as.treeView.anchorItem = item;
}
}
// Check if click is on expand/collapse icon
bool hasChildren = false;
bool clickedExpandIcon = false;
for (WidgetT *gc = item->firstChild; gc; gc = gc->nextSibling) {
if (gc->type == WidgetTreeItemE) {
hasChildren = true;
break;
}
}
if (hasChildren) {
// Calculate indent depth
int32_t depth = 0;
WidgetT *p = item->parent;
while (p && p->type == WidgetTreeItemE) {
depth++;
p = p->parent;
}
int32_t iconX = hit->x + TREE_BORDER + depth * TREE_INDENT - hit->as.treeView.scrollPosH;
if (vx >= iconX && vx < iconX + TREE_EXPAND_SIZE) {
clickedExpandIcon = true;
item->as.treeItem.expanded = !item->as.treeItem.expanded;
// Clamp scroll positions if collapsing reduced content size
if (!item->as.treeItem.expanded) {
int32_t newTotalH;
int32_t newTotalW;
int32_t newInnerH;
int32_t newInnerW;
bool newNeedVSb;
bool newNeedHSb;
treeCalcScrollbarNeeds(hit, font, &newTotalH, &newTotalW, &newInnerH, &newInnerW, &newNeedVSb, &newNeedHSb);
int32_t newMaxScrlV = newTotalH - newInnerH;
int32_t newMaxScrlH = newTotalW - newInnerW;
if (newMaxScrlV < 0) {
newMaxScrlV = 0;
}
if (newMaxScrlH < 0) {
newMaxScrlH = 0;
}
hit->as.treeView.scrollPos = clampInt(hit->as.treeView.scrollPos, 0, newMaxScrlV);
hit->as.treeView.scrollPosH = clampInt(hit->as.treeView.scrollPosH, 0, newMaxScrlH);
}
if (item->onChange) {
item->onChange(item);
}
} else {
if (item->onClick) {
item->onClick(item);
}
}
} else {
if (item->onClick) {
item->onClick(item);
}
}
// Initiate drag-reorder if enabled (not from expand icon or modifier clicks)
if (hit->as.treeView.reorderable && !clickedExpandIcon && !shift && !ctrl) {
hit->as.treeView.dragItem = item;
hit->as.treeView.dropTarget = NULL;
sDragReorder = hit;
}
}
// ============================================================
// widgetTreeViewPaint
// ============================================================
void widgetTreeViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
int32_t totalH;
int32_t totalW;
int32_t innerH;
int32_t innerW;
bool needVSb;
bool needHSb;
treeCalcScrollbarNeeds(w, font, &totalH, &totalW, &innerH, &innerW, &needVSb, &needHSb);
// Clamp scroll positions
int32_t maxScrollV = totalH - innerH;
int32_t maxScrollH = totalW - innerW;
if (maxScrollV < 0) {
maxScrollV = 0;
}
if (maxScrollH < 0) {
maxScrollH = 0;
}
w->as.treeView.scrollPos = clampInt(w->as.treeView.scrollPos, 0, maxScrollV);
w->as.treeView.scrollPosH = clampInt(w->as.treeView.scrollPosH, 0, maxScrollH);
// Sunken border
BevelStyleT bevel = BEVEL_SUNKEN(colors, bg, 2);
drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
// Set clip rect to inner content area (excludes scrollbars)
int32_t oldClipX = d->clipX;
int32_t oldClipY = d->clipY;
int32_t oldClipW = d->clipW;
int32_t oldClipH = d->clipH;
setClipRect(d, w->x + TREE_BORDER, w->y + TREE_BORDER, innerW, innerH);
// Paint tree items offset by both scroll positions
int32_t itemY = w->y + TREE_BORDER - w->as.treeView.scrollPos;
int32_t baseX = w->x + TREE_BORDER - w->as.treeView.scrollPosH;
paintTreeItems(w, d, ops, font, colors,
baseX, &itemY, 0,
w->y + TREE_BORDER, w->y + TREE_BORDER + innerH,
w);
// Draw drag-reorder insertion indicator (while still clipped)
paintReorderIndicator(w, d, ops, font, colors, innerW);
// Restore clip rect
setClipRect(d, oldClipX, oldClipY, oldClipW, oldClipH);
// Draw scrollbars
if (needVSb) {
int32_t sbX = w->x + w->w - TREE_BORDER - WGT_SB_W;
int32_t sbY = w->y + TREE_BORDER;
widgetDrawScrollbarV(d, ops, colors, sbX, sbY, innerH, totalH, innerH, w->as.treeView.scrollPos);
}
if (needHSb) {
int32_t sbX = w->x + TREE_BORDER;
int32_t sbY = w->y + w->h - TREE_BORDER - WGT_SB_W;
widgetDrawScrollbarH(d, ops, colors, sbX, sbY, innerW, totalW, innerW, w->as.treeView.scrollPosH);
// Fill dead corner when both scrollbars present
if (needVSb) {
rectFill(d, ops, sbX + innerW, sbY, WGT_SB_W, WGT_SB_W, colors->windowFace);
}
}
if (w->focused) {
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg);
}
}