// The MIT License (MIT) // // Copyright (C) 2026 Scott Duensing // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to // deal in the Software without restriction, including without limitation the // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS // IN THE SOFTWARE. #define DVX_WIDGET_IMPL // widgetTreeView.c -- TreeView and TreeItem widgets // // A hierarchical list with expand/collapse nodes, scrolling, multi-select, // and drag-reorder support. // // Architecture: TreeViewE is the container/viewport widget. TreeItemE // children are the actual data nodes, forming a tree via the standard // widget parent/child/sibling links. This reuses the existing widget // tree structure as the data model -- no separate tree data structure // needed. Each TreeItemE can have child TreeItemEs for nesting. // // Traversal: the tree is navigated in depth-first order using // nextVisibleItem/prevVisibleItem. "Visible" means the item's parent // chain is fully expanded -- collapsed subtrees are skipped. These // traversal functions are O(depth) worst case, which is fine for the // moderate tree depths typical in a DOS GUI. // // Selection model: single-select uses selectedItem pointer on the // TreeViewE. Multi-select adds per-item 'selected' booleans and an // anchorItem for Shift+click range selection. The cursor (selectedItem) // and selection are separate concepts in multi-select mode -- the // cursor is the "current" item for keyboard navigation, while the // selected set is the highlighted items. Space toggles individual // items, Shift+arrow extends range from anchor. // // Scrolling: dual-axis with automatic scrollbar appearance using the // same two-pass mutual-dependency resolution as ScrollPane. Vertical // scroll is in pixels (not items), allowing smooth scrolling. The // treeCalcScrollbarNeeds function centralizes the dimension/scrollbar // computation shared by layout, paint, and mouse handlers. // // Rendering: items are painted recursively with depth-based indent. // The expand/collapse icon is a 9x9 box with +/- inside (Windows // Explorer style). Only items within the visible clip region are // actually drawn -- items outside clipTop/clipBottom are skipped // (though their Y positions are still accumulated for correct // positioning of subsequent items). // // Drag-reorder: when enabled, mouse-down on an item sets dragItem. // Mouse-move (handled in widgetEvent.c) updates dropTarget/dropAfter // to show an insertion line. Mouse-up (widgetReorderDrop) performs // the actual node reparenting via widgetRemoveChild/widgetAddChild. // // Performance note: calcTreeItemsHeight and calcTreeItemsMaxWidth // walk the full visible tree, but results are cached in the TreeView's // cachedTotalHeight/cachedMaxWidth fields. The cache is invalidated // (dimsValid = false) whenever the tree structure changes: item add/ // remove, expand/collapse, text change, or drag-reorder. #include "dvxWgtP.h" #define TREE_INDENT 16 #define TREE_EXPAND_SIZE 9 #define TREE_ICON_GAP 4 #define TREE_BORDER 2 #define TREE_MIN_ROWS 4 static int32_t sTreeViewTypeId = -1; static int32_t sTreeItemTypeId = -1; typedef struct { int32_t scrollPos; int32_t scrollPosH; WidgetT *selectedItem; WidgetT *anchorItem; bool multiSelect; bool reorderable; WidgetT *dragItem; WidgetT *dropTarget; bool dropAfter; bool dropInto; int32_t cachedTotalHeight; int32_t cachedMaxWidth; bool dimsValid; int32_t sbDragOrient; int32_t sbDragOff; } TreeViewDataT; typedef struct { const char *text; bool expanded; bool selected; } TreeItemDataT; // ============================================================ // Prototypes // ============================================================ static void basAddChildItem(WidgetT *w, int32_t parentIdx, const char *text); static void basAddItem(WidgetT *w, const char *text); static void basClear(WidgetT *w); static const char *basGetItemText(const WidgetT *w, int32_t idx); static bool basIsExpanded(const WidgetT *w, int32_t idx); static bool basIsItemSelected(const WidgetT *w, int32_t idx); static int32_t basItemCount(const WidgetT *w); static void basSetExpanded(WidgetT *w, int32_t idx, bool expanded); static void basSetItemSelected(WidgetT *w, int32_t idx, bool selected); static int32_t basTreeCountItems(WidgetT *parent); static WidgetT *basTreeFindByIndex(WidgetT *parent, int32_t target, int32_t *cur); static WidgetT *basTreeItemAt(WidgetT *tree, int32_t index); 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 invalidateTreeDims(WidgetT *w); 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 setSelectedItem(WidgetT *treeView, WidgetT *item); 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); WidgetT *wgtTreeItem(WidgetT *parent, const char *text); bool wgtTreeItemIsExpanded(const WidgetT *w); bool wgtTreeItemIsSelected(const WidgetT *w); void wgtTreeItemSetExpanded(WidgetT *w, bool expanded); void wgtTreeItemSetSelected(WidgetT *w, bool selected); WidgetT *wgtTreeView(WidgetT *parent); WidgetT *wgtTreeViewGetSelected(const WidgetT *w); void wgtTreeViewSetMultiSelect(WidgetT *w, bool multi); void wgtTreeViewSetReorderable(WidgetT *w, bool reorderable); void wgtTreeViewSetSelected(WidgetT *w, WidgetT *item); static void widgetTreeItemDestroy(WidgetT *w); const char *widgetTreeItemGetText(const WidgetT *w); void widgetTreeItemSetText(WidgetT *w, const char *text); void widgetTreeViewCalcMinSize(WidgetT *w, const BitmapFontT *font); static void widgetTreeViewDestroy(WidgetT *w); void widgetTreeViewLayout(WidgetT *w, const BitmapFontT *font); WidgetT *widgetTreeViewNextVisible(WidgetT *item, WidgetT *treeView); void widgetTreeViewOnChildChanged(WidgetT *parent, WidgetT *child); static void widgetTreeViewOnDragEnd(WidgetT *w, WidgetT *root, int32_t x, int32_t y); static void widgetTreeViewOnDragUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y); void widgetTreeViewOnKey(WidgetT *w, int32_t key, int32_t mod); void widgetTreeViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy); void widgetTreeViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); static void widgetTreeViewReorderDrop(WidgetT *w); static void widgetTreeViewReorderUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y); static void widgetTreeViewScrollDragUpdate(WidgetT *w, int32_t orient, int32_t dragOff, int32_t mouseX, int32_t mouseY); // AddChildItem index%, text$ -- add child under item at depth-first index static void basAddChildItem(WidgetT *w, int32_t parentIdx, const char *text) { WidgetT *parent = basTreeItemAt(w, parentIdx); if (parent) { wgtTreeItem(parent, text); } } // AddItem text$ -- add root-level item static void basAddItem(WidgetT *w, const char *text) { wgtTreeItem(w, text); } // Clear -- remove all items static void basClear(WidgetT *w) { // Remove all tree item children WidgetT *c = w->firstChild; while (c) { WidgetT *next = c->nextSibling; if (c->type == sTreeItemTypeId) { wgtDestroy(c); } c = next; } } // GetItemText$(index%) -- get item text at index static const char *basGetItemText(const WidgetT *w, int32_t idx) { WidgetT *item = basTreeItemAt((WidgetT *)w, idx); if (item && item->data) { TreeItemDataT *ti = (TreeItemDataT *)item->data; return ti->text ? ti->text : ""; } return ""; } // IsExpanded(index%) -- check if item at index is expanded static bool basIsExpanded(const WidgetT *w, int32_t idx) { WidgetT *item = basTreeItemAt((WidgetT *)w, idx); return item ? wgtTreeItemIsExpanded(item) : false; } // IsItemSelected(index%) -- check if item at index is selected static bool basIsItemSelected(const WidgetT *w, int32_t idx) { WidgetT *item = basTreeItemAt((WidgetT *)w, idx); return item ? wgtTreeItemIsSelected(item) : false; } // ItemCount -- return total number of items (depth-first) static int32_t basItemCount(const WidgetT *w) { return basTreeCountItems((WidgetT *)w); } // SetExpanded index%, expanded -- expand/collapse item at index static void basSetExpanded(WidgetT *w, int32_t idx, bool expanded) { WidgetT *item = basTreeItemAt(w, idx); if (item) { wgtTreeItemSetExpanded(item, expanded); } } // SetItemSelected index%, selected -- select/deselect item at index static void basSetItemSelected(WidgetT *w, int32_t idx, bool selected) { WidgetT *item = basTreeItemAt(w, idx); if (item) { wgtTreeItemSetSelected(item, selected); } } static int32_t basTreeCountItems(WidgetT *parent) { int32_t count = 0; for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) { if (c->type != sTreeItemTypeId) { continue; } count++; count += basTreeCountItems(c); } return count; } static WidgetT *basTreeFindByIndex(WidgetT *parent, int32_t target, int32_t *cur) { for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) { if (c->type != sTreeItemTypeId) { continue; } if (*cur == target) { return c; } (*cur)++; WidgetT *found = basTreeFindByIndex(c, target, cur); if (found) { return found; } } return NULL; } static WidgetT *basTreeItemAt(WidgetT *tree, int32_t index) { int32_t cur = 0; return basTreeFindByIndex(tree, index, &cur); } // Recursively sums the pixel height of all visible (expanded) items. // Each item is one charHeight row. Only descends into expanded nodes, // so collapsed subtrees contribute zero height. 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 != sTreeItemTypeId || !c->visible) { continue; } TreeItemDataT *ti = (TreeItemDataT *)c->data; totalH += font->charHeight; if (ti->expanded && c->firstChild) { totalH += calcTreeItemsHeight(c, font); } } return totalH; } // Finds the widest visible item accounting for depth-based indent. // Used to determine if a horizontal scrollbar is needed. The text // width uses strlen * charWidth (monospace font assumption) rather // than a proportional measurement function. 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 != sTreeItemTypeId || !c->visible) { continue; } TreeItemDataT *ti = (TreeItemDataT *)c->data; int32_t indent = depth * TREE_INDENT + TREE_EXPAND_SIZE + TREE_ICON_GAP; int32_t textW = (int32_t)strlen(ti->text) * font->charWidth; int32_t itemW = indent + textW; if (itemW > maxW) { maxW = itemW; } if (ti->expanded && c->firstChild) { int32_t childW = calcTreeItemsMaxWidth(c, font, depth + 1); if (childW > maxW) { maxW = childW; } } } return maxW; } static void clearAllSelections(WidgetT *parent) { for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) { if (c->type != sTreeItemTypeId) { continue; } TreeItemDataT *ti = (TreeItemDataT *)c->data; ti->selected = false; if (c->firstChild) { clearAllSelections(c); } } } // 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 == sTreeItemTypeId && c->visible) { return c; } } return NULL; } // Walk up from any widget (typically a TreeItem) to find the ancestor // TreeView and mark its cached dimensions invalid. This ensures the // next treeCalcScrollbarNeeds call will recompute height and width. static void invalidateTreeDims(WidgetT *w) { for (WidgetT *p = w; p; p = p->parent) { if (p->type == sTreeViewTypeId) { TreeViewDataT *tv = (TreeViewDataT *)p->data; tv->dimsValid = false; return; } } } 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 != sTreeItemTypeId || !c->visible) { continue; } TreeItemDataT *ti = (TreeItemDataT *)c->data; c->x = x; c->y = *y; c->w = width; c->h = font->charHeight; *y += font->charHeight; if (ti->expanded && c->firstChild) { layoutTreeItems(c, font, x, y, width, depth + 1); } } } // Depth-first forward traversal: try children first (if expanded), // then next sibling, then walk up to find an uncle. This gives the // standard tree traversal order matching what the user sees on screen. // The treeView parameter is the traversal boundary -- we stop walking // up when we reach it. static WidgetT *nextVisibleItem(WidgetT *item, WidgetT *treeView) { TreeItemDataT *ti = (TreeItemDataT *)item->data; // If expanded with children, descend if (ti->expanded) { for (WidgetT *c = item->firstChild; c; c = c->nextSibling) { if (c->type == sTreeItemTypeId && c->visible) { return c; } } } // Try next sibling for (WidgetT *s = item->nextSibling; s; s = s->nextSibling) { if (s->type == sTreeItemTypeId && 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 == sTreeItemTypeId && s->visible) { return s; } } } return NULL; } // 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) { TreeViewDataT *tv = (TreeViewDataT *)w->data; if (!tv->reorderable || !tv->dropTarget || !tv->dragItem) { return; } int32_t dropY = treeItemYPos(w, tv->dropTarget, font); if (dropY < 0) { return; } int32_t baseY = w->y + TREE_BORDER - tv->scrollPos + dropY; int32_t lineX = w->x + TREE_BORDER; if (tv->dropInto) { // Draw a rectangle around the target to indicate reparenting drawHLine(d, ops, lineX, baseY, innerW, colors->contentFg); drawHLine(d, ops, lineX, baseY + font->charHeight - 1, innerW, colors->contentFg); drawVLine(d, ops, lineX, baseY, font->charHeight, colors->contentFg); drawVLine(d, ops, lineX + innerW - 1, baseY, font->charHeight, colors->contentFg); } else { // Draw a 2px insertion line int32_t lineY = baseY; if (tv->dropAfter) { lineY += font->charHeight; } drawHLine(d, ops, lineX, lineY, innerW, colors->contentFg); drawHLine(d, ops, lineX, lineY + 1, innerW, colors->contentFg); } } // Recursive item painting with visibility culling. Items whose Y // range falls entirely outside [clipTop, clipBottom) are skipped -- // their Y is still accumulated so subsequent items position correctly, // but no draw calls are made. This is the key performance optimization // for large trees: only visible items incur draw cost. // // Each visible item draws: optional selection highlight background, // expand/collapse icon (if has children), and text label. The expand // icon is a 9x9 bordered box with a horizontal line (minus) when // expanded, or horizontal + vertical lines (plus) when collapsed. // This matches the Windows 95/NT Explorer style. // // In multi-select mode, a focus rect is drawn around the cursor item // (selectedItem) to distinguish it from selected-but-not-cursor items. 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) { TreeViewDataT *tv = (TreeViewDataT *)treeView->data; bool multi = tv->multiSelect; for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) { if (c->type != sTreeItemTypeId || !c->visible) { continue; } TreeItemDataT *ti = (TreeItemDataT *)c->data; 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 (ti->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 = ti->selected; } else { isSelected = (c == tv->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 == sTreeItemTypeId) { 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 (!ti->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, ti->text, fg, bg, isSelected); // Draw focus rectangle around cursor item in multi-select mode if (multi && c == tv->selectedItem && treeView == sFocusedWidget) { uint32_t focusFg = isSelected ? colors->menuHighlightFg : colors->contentFg; drawFocusRect(d, ops, baseX, iy, d->clipW, font->charHeight, focusFg); } // Recurse into expanded children if (ti->expanded && c->firstChild) { paintTreeItems(c, d, ops, font, colors, baseX, itemY, depth + 1, clipTop, clipBottom, treeView); } } } // Depth-first backward traversal: find previous sibling, then descend // to its last visible descendant (the deepest last-child chain). If // no previous sibling, go to parent. This is the inverse of // nextVisibleItem and produces the correct "up arrow" traversal order. 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 == sTreeItemTypeId && s->visible) { prevSib = s; } } if (prevSib) { // Descend to last visible descendant of previous sibling WidgetT *node = prevSib; while (((TreeItemDataT *)node->data)->expanded) { WidgetT *last = NULL; for (WidgetT *c = node->firstChild; c; c = c->nextSibling) { if (c->type == sTreeItemTypeId && 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 == sTreeItemTypeId) { return item->parent; } return NULL; } // Selects all visible items between 'from' and 'to' inclusive. The // direction is auto-detected by walking forward from 'from' -- if // 'to' is found, that's the forward direction; otherwise we swap. // This handles both Shift+Down and Shift+Up range selection with // the same code. The forward walk is O(N) in the worst case but // range selection in trees is inherently O(N) anyway. static void selectRange(WidgetT *treeView, WidgetT *from, WidgetT *to) { if (!from || !to) { return; } if (from == to) { ((TreeItemDataT *)from->data)->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)) { ((TreeItemDataT *)v->data)->selected = true; if (v == end) { break; } } } // Set the selected item and fire the tree widget's onChange callback // so the host knows the selection changed. static void setSelectedItem(WidgetT *treeView, WidgetT *item) { TreeViewDataT *tv = (TreeViewDataT *)treeView->data; if (tv->selectedItem == item) { return; } tv->selectedItem = item; if (treeView->onChange) { treeView->onChange(treeView); } } // 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) { TreeViewDataT *tv = (TreeViewDataT *)w->data; if (!tv->dimsValid) { tv->cachedTotalHeight = calcTreeItemsHeight(w, font); tv->cachedMaxWidth = calcTreeItemsMaxWidth(w, font, 0); tv->dimsValid = true; } int32_t totalH = tv->cachedTotalHeight; int32_t totalW = tv->cachedMaxWidth; 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; } // 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 != sTreeItemTypeId || !c->visible) { continue; } if (targetY >= *curY && targetY < *curY + font->charHeight) { return c; } *curY += font->charHeight; TreeItemDataT *ti = (TreeItemDataT *)c->data; if (ti->expanded && c->firstChild) { WidgetT *found = treeItemAtY(c, targetY, curY, font); if (found) { return found; } } } return NULL; } // 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; } 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 != sTreeItemTypeId || !c->visible) { continue; } if (c == target) { return 1; } *curY += font->charHeight; TreeItemDataT *ti = (TreeItemDataT *)c->data; if (ti->expanded && c->firstChild) { if (treeItemYPosHelper(c, target, curY, font)) { return 1; } } } return 0; } WidgetT *wgtTreeItem(WidgetT *parent, const char *text) { WidgetT *w = widgetAlloc(parent, sTreeItemTypeId); if (w) { TreeItemDataT *ti = (TreeItemDataT *)calloc(1, sizeof(TreeItemDataT)); if (ti) { w->data = ti; ti->text = text ? strdup(text) : NULL; ti->expanded = false; } invalidateTreeDims(w); } return w; } bool wgtTreeItemIsExpanded(const WidgetT *w) { VALIDATE_WIDGET(w, sTreeItemTypeId, false); TreeItemDataT *ti = (TreeItemDataT *)w->data; return ti->expanded; } bool wgtTreeItemIsSelected(const WidgetT *w) { VALIDATE_WIDGET(w, sTreeItemTypeId, false); TreeItemDataT *ti = (TreeItemDataT *)w->data; // ti->selected tracks multi-select state (Ctrl-click, shift-click // range). In single-select mode, the current selection lives on // the TreeView's tv->selectedItem pointer instead -- walk up to // the owning TreeView and compare, so callers see the expected // answer for both modes. if (ti->selected) { return true; } for (WidgetT *p = w->parent; p; p = p->parent) { if (p->type == sTreeViewTypeId) { TreeViewDataT *tv = (TreeViewDataT *)p->data; return tv && tv->selectedItem == w; } } return false; } void wgtTreeItemSetExpanded(WidgetT *w, bool expanded) { VALIDATE_WIDGET_VOID(w, sTreeItemTypeId); TreeItemDataT *ti = (TreeItemDataT *)w->data; ti->expanded = expanded; invalidateTreeDims(w); wgtInvalidate(w); } void wgtTreeItemSetSelected(WidgetT *w, bool selected) { VALIDATE_WIDGET_VOID(w, sTreeItemTypeId); TreeItemDataT *ti = (TreeItemDataT *)w->data; ti->selected = selected; wgtInvalidatePaint(w); } WidgetT *wgtTreeView(WidgetT *parent) { WidgetT *w = widgetAlloc(parent, sTreeViewTypeId); if (w) { TreeViewDataT *tv = (TreeViewDataT *)calloc(1, sizeof(TreeViewDataT)); if (tv) { w->data = tv; } w->weight = WGT_WEIGHT_FILL; } return w; } WidgetT *wgtTreeViewGetSelected(const WidgetT *w) { VALIDATE_WIDGET(w, sTreeViewTypeId, NULL); TreeViewDataT *tv = (TreeViewDataT *)w->data; return tv->selectedItem; } void wgtTreeViewSetMultiSelect(WidgetT *w, bool multi) { VALIDATE_WIDGET_VOID(w, sTreeViewTypeId); TreeViewDataT *tv = (TreeViewDataT *)w->data; tv->multiSelect = multi; } void wgtTreeViewSetReorderable(WidgetT *w, bool reorderable) { VALIDATE_WIDGET_VOID(w, sTreeViewTypeId); TreeViewDataT *tv = (TreeViewDataT *)w->data; tv->reorderable = reorderable; } void wgtTreeViewSetSelected(WidgetT *w, WidgetT *item) { VALIDATE_WIDGET_VOID(w, sTreeViewTypeId); TreeViewDataT *tv = (TreeViewDataT *)w->data; // Expand all ancestors so the item is visible in the tree. // Invalidate cached dims so the scroll-into-view below sees the // correct post-expansion totalH / item Y coordinate. if (item) { bool expandedAny = false; for (WidgetT *p = item->parent; p && p != w; p = p->parent) { if (p->type == sTreeItemTypeId) { TreeItemDataT *pti = (TreeItemDataT *)p->data; if (pti && !pti->expanded) { pti->expanded = true; expandedAny = true; } } } if (expandedAny) { tv->dimsValid = false; } } setSelectedItem(w, item); if (tv->multiSelect && item) { clearAllSelections(w); TreeItemDataT *ti = (TreeItemDataT *)item->data; ti->selected = true; tv->anchorItem = item; } // Scroll so the selected item is inside the visible band. Manual // expand+click goes through widgetTreeViewOnMouse / OnKey which // have their own scroll-into-view logic; programmatic callers // (e.g. dvxhelp syncing the TOC from navigateToTopic) need this. if (item) { AppContextT *ctx = wgtGetContext(w); if (ctx) { const BitmapFontT *font = &ctx->font; int32_t itemY = treeItemYPos(w, item, 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 < tv->scrollPos) { tv->scrollPos = itemY; } else if (itemY + font->charHeight > tv->scrollPos + innerH) { tv->scrollPos = itemY + font->charHeight - innerH; } } } } wgtInvalidate(w); } static void widgetTreeItemDestroy(WidgetT *w) { TreeItemDataT *ti = (TreeItemDataT *)w->data; free((void *)ti->text); free(w->data); w->data = NULL; } const char *widgetTreeItemGetText(const WidgetT *w) { TreeItemDataT *ti = (TreeItemDataT *)w->data; return ti->text ? ti->text : ""; } void widgetTreeItemSetText(WidgetT *w, const char *text) { TreeItemDataT *ti = (TreeItemDataT *)w->data; free((void *)ti->text); ti->text = text ? strdup(text) : NULL; invalidateTreeDims(w); } // Min size: wide enough for one indent level + expand icon + 6 chars // of text + border + vertical scrollbar. Tall enough for TREE_MIN_ROWS // (4) items. This ensures the tree is usable even when space is tight, // while the weight=100 default allows it to grow to fill available space. 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; } static void widgetTreeViewDestroy(WidgetT *w) { free(w->data); w->data = NULL; } // Layout assigns (x, y, w, h) to all visible tree items at their // scroll-adjusted screen coordinates. Item width extends to the // full inner width (or total content width if wider), so selection // highlight bars span the full visible width. Auto-selects the first // item if nothing is selected yet, providing a reasonable default. void widgetTreeViewLayout(WidgetT *w, const BitmapFontT *font) { TreeViewDataT *tv = (TreeViewDataT *)w->data; // Auto-select first item if nothing is selected. This runs at // layout time (not user action), so set tv->selectedItem directly // instead of going through setSelectedItem -- the latter fires // onChange as if the user clicked, which triggers callers (e.g. // dvxhelp's TOC sync) to re-navigate to the first tree item and // clobber any programmatic selection the host is about to make. if (!tv->selectedItem) { WidgetT *first = firstVisibleItem(w); tv->selectedItem = first; if (tv->multiSelect && first) { ((TreeItemDataT *)first->data)->selected = true; tv->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); } // Non-static wrapper around nextVisibleItem for use by // widgetReorderUpdate() in widgetEvent.c. WidgetT *widgetTreeViewNextVisible(WidgetT *item, WidgetT *treeView) { return nextVisibleItem(item, treeView); } void widgetTreeViewOnChildChanged(WidgetT *parent, WidgetT *child) { (void)child; TreeViewDataT *tv = (TreeViewDataT *)parent->data; tv->dimsValid = false; } static void widgetTreeViewOnDragEnd(WidgetT *w, WidgetT *root, int32_t x, int32_t y) { (void)root; (void)x; (void)y; TreeViewDataT *tv = (TreeViewDataT *)w->data; if (tv->dragItem) { widgetTreeViewReorderDrop(w); tv->dragItem = NULL; } wgtInvalidatePaint(w); } static void widgetTreeViewOnDragUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y) { TreeViewDataT *tv = (TreeViewDataT *)w->data; if (tv->dragItem) { widgetTreeViewReorderUpdate(w, root, x, y); } else { widgetTreeViewScrollDragUpdate(w, tv->sbDragOrient, tv->sbDragOff, x, y); } } // Keyboard navigation follows Windows Explorer conventions: // - Up/Down: move cursor to prev/next visible item // - Right: expand collapsed node, or move to first child if expanded // - Left: collapse expanded node, or move to parent if collapsed // - Enter: toggle expand/collapse for parents, onClick for leaves // - Space (multi-select): toggle selection of cursor item // // After cursor movement, the view auto-scrolls to keep the cursor // visible. Multi-select range extension (Shift+arrow) clears existing // selections and selects the range from anchor to cursor. // // The Right/Left expand/collapse behavior provides efficient keyboard // tree navigation: Right drills in, Left backs out, without needing // separate expand/collapse keys. void widgetTreeViewOnKey(WidgetT *w, int32_t key, int32_t mod) { VALIDATE_WIDGET_VOID(w, sTreeViewTypeId); TreeViewDataT *tv = (TreeViewDataT *)w->data; bool multi = tv->multiSelect; bool shift = (mod & KEY_MOD_SHIFT) != 0; WidgetT *sel = tv->selectedItem; if (key == KEY_DOWN) { // Down arrow -- next visible item if (!sel) { setSelectedItem(w, firstVisibleItem(w)); } else { WidgetT *next = nextVisibleItem(sel, w); if (next) { setSelectedItem(w, next); } } } else if (key == KEY_UP) { // Up arrow -- previous visible item if (!sel) { setSelectedItem(w, firstVisibleItem(w)); } else { WidgetT *prev = prevVisibleItem(sel, w); if (prev) { setSelectedItem(w, prev); } } } else if (key == KEY_RIGHT) { // Right arrow -- expand if collapsed, else move to first child if (sel) { TreeItemDataT *selTi = (TreeItemDataT *)sel->data; bool hasChildren = false; for (WidgetT *c = sel->firstChild; c; c = c->nextSibling) { if (c->type == sTreeItemTypeId) { hasChildren = true; break; } } if (hasChildren) { if (!selTi->expanded) { selTi->expanded = true; tv->dimsValid = false; if (sel->onChange) { sel->onChange(sel); } } else { WidgetT *next = nextVisibleItem(sel, w); if (next) { setSelectedItem(w, next); } } } } } else if (key == KEY_LEFT) { // Left arrow -- collapse if expanded, else move to parent if (sel) { TreeItemDataT *selTi = (TreeItemDataT *)sel->data; if (selTi->expanded) { selTi->expanded = false; tv->dimsValid = false; if (sel->onChange) { sel->onChange(sel); } } else if (sel->parent && sel->parent != w && sel->parent->type == sTreeItemTypeId) { setSelectedItem(w, sel->parent); } } } else if (key == 0x0D) { // Enter -- toggle expand/collapse for parents, onClick for leaves if (sel) { TreeItemDataT *selTi = (TreeItemDataT *)sel->data; bool hasChildren = false; for (WidgetT *c = sel->firstChild; c; c = c->nextSibling) { if (c->type == sTreeItemTypeId) { hasChildren = true; break; } } if (hasChildren) { selTi->expanded = !selTi->expanded; tv->dimsValid = false; if (sel->onChange) { sel->onChange(sel); } } else { if (sel->onDblClick) { sel->onDblClick(sel); } else if (sel->onClick) { sel->onClick(sel); } } } } else if (key == ' ' && multi) { // Space -- toggle selection of current item in multi-select if (sel) { TreeItemDataT *selTi = (TreeItemDataT *)sel->data; selTi->selected = !selTi->selected; tv->anchorItem = sel; } } else if (key >= 0x20 && key < 0x7F) { // Type-ahead: search visible items for next match char upper = (char)key; if (upper >= 'a' && upper <= 'z') { upper -= 32; } WidgetT *start = sel ? sel : firstVisibleItem(w); if (start) { WidgetT *cur = nextVisibleItem(start, w); if (!cur) { cur = firstVisibleItem(w); } while (cur && cur != start) { TreeItemDataT *ti = (TreeItemDataT *)cur->data; if (ti->text && ti->text[0]) { char first = ti->text[0]; if (first >= 'a' && first <= 'z') { first -= 32; } if (first == upper) { setSelectedItem(w, cur); break; } } cur = nextVisibleItem(cur, w); if (!cur) { cur = firstVisibleItem(w); } } // Check the start item itself if we wrapped all the way around if (cur == start && sel) { TreeItemDataT *ti = (TreeItemDataT *)start->data; if (ti->text && ti->text[0]) { char first = ti->text[0]; if (first >= 'a' && first <= 'z') { first -= 32; } if (first == upper && cur != sel) { setSelectedItem(w, cur); } } } } } else { return; } // Update multi-select state after Up/Down navigation WidgetT *newSel = tv->selectedItem; if (multi && newSel != sel && newSel) { if (shift && tv->anchorItem) { // Shift+arrow: range from anchor to new cursor clearAllSelections(w); selectRange(w, tv->anchorItem, newSel); } // Plain arrow: just move cursor, leave selections untouched } // Set anchor on first selection if not set if (multi && !tv->anchorItem && newSel) { tv->anchorItem = newSel; } // Scroll to keep selected item visible sel = tv->selectedItem; if (sel) { AppContextT *ctx = wgtGetContext(w); 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 < tv->scrollPos) { tv->scrollPos = itemY; } else if (itemY + font->charHeight > tv->scrollPos + innerH) { tv->scrollPos = itemY + font->charHeight - innerH; } } } wgtInvalidate(w); } // Mouse handling priority: V scrollbar > H scrollbar > dead corner > // tree item. Item clicks are resolved by treeItemAtY which walks the // visible tree converting Y coordinates to items. The item's depth // is computed by walking up the parent chain to determine the expand // icon's X position and check if the click landed on it. // // Multi-select mouse behavior: // - Plain click: select only this item (clear others), set anchor // - Ctrl+click: toggle item, update anchor // - Shift+click: range-select from anchor to clicked item // // Collapsing a node re-checks scroll bounds because content height // may have decreased, and the current scroll position may now be // past the new maximum. // // Drag-reorder initiation: plain click (no modifier, not on expand // icon) sets dragItem and sDragReorder. The actual reorder drag // tracking happens in widgetEvent.c during mouse-move. void widgetTreeViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) { TreeViewDataT *tv = (TreeViewDataT *)hit->data; sFocusedWidget = hit; 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; } tv->scrollPos = clampInt(tv->scrollPos, 0, maxScrollV); tv->scrollPosH = clampInt(tv->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, tv->scrollPos); if (sh == ScrollHitArrowDecE) { tv->scrollPos -= font->charHeight; } else if (sh == ScrollHitArrowIncE) { tv->scrollPos += font->charHeight; } else if (sh == ScrollHitPageDecE) { tv->scrollPos -= pageSize; } else if (sh == ScrollHitPageIncE) { tv->scrollPos += pageSize; } else if (sh == ScrollHitThumbE) { int32_t trackLen = innerH - WGT_SB_W * 2; int32_t thumbPos; int32_t thumbSize; widgetScrollbarThumb(trackLen, totalH, innerH, tv->scrollPos, &thumbPos, &thumbSize); sDragWidget = hit; tv->sbDragOrient = 0; tv->sbDragOff = relY - WGT_SB_W - thumbPos; } tv->scrollPos = clampInt(tv->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, tv->scrollPosH); if (sh == ScrollHitArrowDecE) { tv->scrollPosH -= font->charWidth; } else if (sh == ScrollHitArrowIncE) { tv->scrollPosH += font->charWidth; } else if (sh == ScrollHitPageDecE) { tv->scrollPosH -= pageSize; } else if (sh == ScrollHitPageIncE) { tv->scrollPosH += pageSize; } else if (sh == ScrollHitThumbE) { int32_t trackLen = innerW - WGT_SB_W * 2; int32_t thumbPos; int32_t thumbSize; widgetScrollbarThumb(trackLen, totalW, innerW, tv->scrollPosH, &thumbPos, &thumbSize); sDragWidget = hit; tv->sbDragOrient = 1; tv->sbDragOff = relX - WGT_SB_W - thumbPos; } tv->scrollPosH = clampInt(tv->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 - tv->scrollPos; WidgetT *item = treeItemAtY(hit, vy, &curY, font); if (!item) { return; } // Update selection bool multi = tv->multiSelect; bool shift = (ctx->keyModifiers & KEY_MOD_SHIFT) != 0; bool ctrl = (ctx->keyModifiers & KEY_MOD_CTRL) != 0; setSelectedItem(hit, item); TreeItemDataT *itemTi = (TreeItemDataT *)item->data; if (multi) { if (ctrl) { // Ctrl+click: toggle item, update anchor itemTi->selected = !itemTi->selected; tv->anchorItem = item; } else if (shift && tv->anchorItem) { // Shift+click: range from anchor to clicked clearAllSelections(hit); selectRange(hit, tv->anchorItem, item); } else { // Plain click: select only this item, update anchor clearAllSelections(hit); itemTi->selected = true; tv->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 == sTreeItemTypeId) { hasChildren = true; break; } } if (hasChildren) { // Calculate indent depth int32_t depth = 0; WidgetT *p = item->parent; while (p && p->type == sTreeItemTypeId) { depth++; p = p->parent; } int32_t iconX = hit->x + TREE_BORDER + depth * TREE_INDENT - tv->scrollPosH; if (vx >= iconX && vx < iconX + TREE_EXPAND_SIZE) { clickedExpandIcon = true; itemTi->expanded = !itemTi->expanded; tv->dimsValid = false; // Clamp scroll positions if collapsing reduced content size if (!itemTi->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; } tv->scrollPos = clampInt(tv->scrollPos, 0, newMaxScrlV); tv->scrollPosH = clampInt(tv->scrollPosH, 0, newMaxScrlH); } if (item->onChange) { item->onChange(item); } } else { int32_t clicks = multiClickDetect(vx, vy); if (clicks >= 2 && item->onDblClick) { item->onDblClick(item); } else if (item->onClick) { item->onClick(item); } } } else { int32_t clicks = multiClickDetect(vx, vy); if (clicks >= 2 && item->onDblClick) { item->onDblClick(item); } else if (item->onClick) { item->onClick(item); } } // Initiate drag-reorder if enabled (not from expand icon or modifier clicks) if (tv->reorderable && !clickedExpandIcon && !shift && !ctrl) { tv->dragItem = item; tv->dropTarget = NULL; sDragWidget = hit; } } // Paint: sunken border, then clipped content area for items + // reorder indicator, then scrollbars outside the clip rect. The clip // rect excludes the scrollbar area so items don't paint over scrollbars. // Both scroll axes are applied to baseX/itemY so the tree content // shifts correctly in both dimensions. void widgetTreeViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) { TreeViewDataT *tv = (TreeViewDataT *)w->data; 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; } tv->scrollPos = clampInt(tv->scrollPos, 0, maxScrollV); tv->scrollPosH = clampInt(tv->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 - tv->scrollPos; int32_t baseX = w->x + TREE_BORDER - tv->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, tv->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, tv->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 == sFocusedWidget) { 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); } } static void widgetTreeViewReorderDrop(WidgetT *w) { TreeViewDataT *tv = (TreeViewDataT *)w->data; WidgetT *dragItem = tv->dragItem; WidgetT *dropTarget = tv->dropTarget; bool dropAfter = tv->dropAfter; tv->dragItem = NULL; tv->dropTarget = NULL; if (!dragItem || !dropTarget || dragItem == dropTarget) { return; } // Remove dragged item from its current parent WidgetT *oldParent = dragItem->parent; bool dropInto = tv->dropInto; tv->dropInto = false; if (oldParent) { widgetRemoveChild(oldParent, dragItem); } if (dropInto) { // Reparent: make dragItem the last child of dropTarget dragItem->nextSibling = NULL; dragItem->parent = dropTarget; if (dropTarget->lastChild) { dropTarget->lastChild->nextSibling = dragItem; } else { dropTarget->firstChild = dragItem; } dropTarget->lastChild = dragItem; } else { // Insert at the drop position (sibling reorder) WidgetT *newParent = dropTarget->parent; if (!newParent) { return; } dragItem->nextSibling = NULL; dragItem->parent = newParent; if (!dropAfter) { // Insert before dropTarget WidgetT *prev = NULL; for (WidgetT *c = newParent->firstChild; c; c = c->nextSibling) { if (c == dropTarget) { dragItem->nextSibling = dropTarget; if (prev) { prev->nextSibling = dragItem; } else { newParent->firstChild = dragItem; } break; } prev = c; } } else { // Insert after dropTarget dragItem->nextSibling = dropTarget->nextSibling; dropTarget->nextSibling = dragItem; if (newParent->lastChild == dropTarget) { newParent->lastChild = dragItem; } } } tv->dimsValid = false; if (w->onChange) { w->onChange(w); } } static void widgetTreeViewReorderUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y) { (void)root; (void)x; TreeViewDataT *tv = (TreeViewDataT *)w->data; AppContextT *ctx = wgtGetContext(w); const BitmapFontT *font = &ctx->font; int32_t innerY = w->y + TREE_BORDER; int32_t relY = y - innerY + tv->scrollPos; // Find which item the mouse is over int32_t curY = 0; WidgetT *target = treeItemAtY(w, relY, &curY, font); if (target) { tv->dropTarget = target; // Three-zone drop: top third = before, middle third = into, bottom third = after int32_t itemY = treeItemYPos(w, target, font); int32_t third = font->charHeight / 3; int32_t offset = relY - itemY; if (offset < third) { tv->dropAfter = false; tv->dropInto = false; } else if (offset < third * 2) { tv->dropAfter = false; tv->dropInto = true; } else { tv->dropAfter = true; tv->dropInto = false; } } else { // Below all items -- drop after the last item tv->dropTarget = w->lastChild; tv->dropAfter = true; } // Auto-scroll when dragging near edges int32_t innerH = w->h - TREE_BORDER * 2; int32_t visRows = innerH / font->charHeight; int32_t mouseRelY = y - innerY; if (mouseRelY < font->charHeight && tv->scrollPos > 0) { tv->scrollPos--; } else if (mouseRelY >= (visRows - 1) * font->charHeight) { tv->scrollPos++; } } // Handle scrollbar thumb drag for vertical and horizontal scrollbars. static void widgetTreeViewScrollDragUpdate(WidgetT *w, int32_t orient, int32_t dragOff, int32_t mouseX, int32_t mouseY) { TreeViewDataT *tv = (TreeViewDataT *)w->data; AppContextT *ctx = wgtGetContext(w); const BitmapFontT *font = &ctx->font; 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 (orient == 0) { // Vertical scrollbar drag int32_t maxScroll = totalH - innerH; if (maxScroll <= 0) { return; } int32_t trackLen = innerH - WGT_SB_W * 2; int32_t thumbPos; int32_t thumbSize; widgetScrollbarThumb(trackLen, totalH, innerH, tv->scrollPos, &thumbPos, &thumbSize); int32_t sbY = w->y + TREE_BORDER; int32_t relMouse = mouseY - sbY - WGT_SB_W - dragOff; int32_t newScroll = (trackLen > thumbSize) ? (maxScroll * relMouse) / (trackLen - thumbSize) : 0; tv->scrollPos = clampInt(newScroll, 0, maxScroll); } else if (orient == 1) { // Horizontal scrollbar drag int32_t maxScroll = totalW - innerW; if (maxScroll <= 0) { return; } int32_t trackLen = innerW - WGT_SB_W * 2; int32_t thumbPos; int32_t thumbSize; widgetScrollbarThumb(trackLen, totalW, innerW, tv->scrollPosH, &thumbPos, &thumbSize); int32_t sbX = w->x + TREE_BORDER; int32_t relMouse = mouseX - sbX - WGT_SB_W - dragOff; int32_t newScroll = (trackLen > thumbSize) ? (maxScroll * relMouse) / (trackLen - thumbSize) : 0; tv->scrollPosH = clampInt(newScroll, 0, maxScroll); } } static const WidgetClassT sClassTreeView = { .version = WGT_CLASS_VERSION, .flags = WCLASS_FOCUSABLE | WCLASS_PAINTS_CHILDREN | WCLASS_NO_HIT_RECURSE | WCLASS_SCROLLABLE, .handlers = { [WGT_METHOD_PAINT] = (void *)widgetTreeViewPaint, [WGT_METHOD_CALC_MIN_SIZE] = (void *)widgetTreeViewCalcMinSize, [WGT_METHOD_LAYOUT] = (void *)widgetTreeViewLayout, [WGT_METHOD_ON_MOUSE] = (void *)widgetTreeViewOnMouse, [WGT_METHOD_ON_KEY] = (void *)widgetTreeViewOnKey, [WGT_METHOD_DESTROY] = (void *)widgetTreeViewDestroy, [WGT_METHOD_ON_CHILD_CHANGED] = (void *)widgetTreeViewOnChildChanged, [WGT_METHOD_ON_DRAG_UPDATE] = (void *)widgetTreeViewOnDragUpdate, [WGT_METHOD_ON_DRAG_END] = (void *)widgetTreeViewOnDragEnd, } }; static const WidgetClassT sClassTreeItem = { .version = WGT_CLASS_VERSION, .flags = 0, .handlers = { [WGT_METHOD_DESTROY] = (void *)widgetTreeItemDestroy, [WGT_METHOD_GET_TEXT] = (void *)widgetTreeItemGetText, [WGT_METHOD_SET_TEXT] = (void *)widgetTreeItemSetText, } }; static const struct { WidgetT *(*create)(WidgetT *parent); WidgetT *(*getSelected)(const WidgetT *w); void (*setSelected)(WidgetT *w, WidgetT *item); void (*setMultiSelect)(WidgetT *w, bool multi); void (*setReorderable)(WidgetT *w, bool reorderable); WidgetT *(*item)(WidgetT *parent, const char *text); void (*itemSetExpanded)(WidgetT *w, bool expanded); bool (*itemIsExpanded)(const WidgetT *w); bool (*itemIsSelected)(const WidgetT *w); void (*itemSetSelected)(WidgetT *w, bool selected); } sApi = { .create = wgtTreeView, .getSelected = wgtTreeViewGetSelected, .setSelected = wgtTreeViewSetSelected, .setMultiSelect = wgtTreeViewSetMultiSelect, .setReorderable = wgtTreeViewSetReorderable, .item = wgtTreeItem, .itemSetExpanded = wgtTreeItemSetExpanded, .itemIsExpanded = wgtTreeItemIsExpanded, .itemIsSelected = wgtTreeItemIsSelected, .itemSetSelected = wgtTreeItemSetSelected }; static const WgtMethodDescT sMethods[] = { { "AddChildItem", WGT_SIG_INT_STR, (void *)basAddChildItem }, { "AddItem", WGT_SIG_STR, (void *)basAddItem }, { "Clear", WGT_SIG_VOID, (void *)basClear }, { "GetItemText", WGT_SIG_RET_STR_INT, (void *)basGetItemText }, { "IsExpanded", WGT_SIG_RET_BOOL_INT, (void *)basIsExpanded }, { "IsItemSelected", WGT_SIG_RET_BOOL_INT, (void *)basIsItemSelected }, { "ItemCount", WGT_SIG_RET_INT, (void *)basItemCount }, { "SetExpanded", WGT_SIG_INT_BOOL, (void *)basSetExpanded }, { "SetItemSelected", WGT_SIG_INT_BOOL, (void *)basSetItemSelected }, { "SetMultiSelect", WGT_SIG_BOOL, (void *)wgtTreeViewSetMultiSelect }, { "SetReorderable", WGT_SIG_BOOL, (void *)wgtTreeViewSetReorderable }, }; static const WgtIfaceT sIface = { .basName = "TreeView", .props = NULL, .propCount = 0, .methods = sMethods, .methodCount = 11, .events = NULL, .eventCount = 0, .createSig = WGT_CREATE_PARENT, .defaultEvent = "Click" }; void wgtRegister(void) { sTreeViewTypeId = wgtRegisterClass(&sClassTreeView); sTreeItemTypeId = wgtRegisterClass(&sClassTreeItem); wgtRegisterApi("treeview", &sApi); wgtRegisterIface("treeview", &sIface); }