// 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 {
    // Fields are ordered by alignment to minimize struct padding.
    int32_t  scrollPos;
    int32_t  scrollPosH;
    WidgetT *selectedItem;
    WidgetT *anchorItem;
    WidgetT *dragItem;
    WidgetT *dropTarget;
    int32_t  cachedTotalHeight;
    int32_t  cachedMaxWidth;
    int32_t  sbDragOrient;
    int32_t  sbDragOff;
    bool     multiSelect;
    bool     reorderable;
    bool     dropAfter;
    bool     dropInto;
    bool     dimsValid;
} TreeViewDataT;

typedef struct {
    const char *text;
    WidgetT    *treeView;   // owning treeView, cached at creation for destroy cleanup
    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 bool treeItemInSubtree(WidgetT *item, WidgetT *candidate);
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)(ti->text ? strlen(ti->text) : 0) * 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;
    int32_t        fillX  = treeView->x + TREE_BORDER;

    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, fillX, 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  -- ti->text may be NULL (item created with no label)
        const char *label = ti->text ? ti->text : "";
        drawText(d, ops, font, textX, iy, label, 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, fillX, 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;

            // Cache the owning treeView so widgetTreeItemDestroy can clear
            // selection/drag pointers that reference this item.  wgtDestroy
            // unlinks the subtree root before the destroy callbacks run, so
            // the parent chain cannot be walked at destroy time.
            for (WidgetT *p = parent; p; p = p->parent) {
                if (p->type == sTreeViewTypeId) {
                    ti->treeView = p;
                    break;
                }
            }
        }

        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;

    // This item is being freed (unlike a mere visibility toggle, which
    // must keep the selection), so drop any treeView pointer that would
    // otherwise dangle at it.  Every item of a destroyed subtree gets its
    // own destroy call, so pointer equality covers whole subtrees.  The
    // treeView widget and its data outlive its items on every destroy
    // path (children are destroyed depth-first before their ancestors).
    if (ti->treeView && ti->treeView->data) {
        TreeViewDataT *tv = (TreeViewDataT *)ti->treeView->data;

        if (tv->selectedItem == w) {
            tv->selectedItem = NULL;
        }

        if (tv->anchorItem == w) {
            tv->anchorItem = NULL;
        }

        if (tv->dragItem == w) {
            tv->dragItem = NULL;
        }

        if (tv->dropTarget == w) {
            tv->dropTarget = NULL;
        }
    }

    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);
}


// True if item is candidate or any descendant of candidate.  Used by
// reorder drop to refuse dropping an item into its own subtree.
static bool treeItemInSubtree(WidgetT *item, WidgetT *candidate) {
    for (WidgetT *p = item; p; p = p->parent) {
        if (p == candidate) {
            return true;
        }
    }

    return false;
}


void widgetTreeViewOnChildChanged(WidgetT *parent, WidgetT *child) {
    (void)child;
    TreeViewDataT *tv = (TreeViewDataT *)parent->data;
    tv->dimsValid = false;

    // This fires for BOTH wgtDestroy and wgtSetVisible, so it must not
    // clear the selection/drag pointers here: a mere visibility toggle
    // on the selected item (or an ancestor of it) would silently discard
    // the selection even though the item still exists.  Dangling-pointer
    // cleanup for destroyed items lives in widgetTreeItemDestroy, which
    // runs for every item of a destroyed subtree.
}


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);
                }
            }
        }
    } 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) {
        tv->dropInto = false;
        return;
    }

    // Reject dropping an item onto (or into) its own subtree: reparenting
    // dragItem under one of its descendants makes dragItem both an
    // ancestor and a child of dropTarget -- a parent-pointer cycle that
    // hangs the next tree traversal (nextVisibleItem walks parents).
    if (treeItemInSubtree(dropTarget, dragItem)) {
        tv->dropInto = false;
        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.  Must clear
        // dropInto too, or a leftover true from a prior dropInto hover
        // would reparent the dragged item under the last item on release.
        tv->dropTarget = w->lastChild;
        tv->dropAfter  = true;
        tv->dropInto   = false;
    }

    // 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 sbY      = w->y + TREE_BORDER;
        int32_t relMouse = mouseY - sbY - WGT_SB_W - dragOff;

        tv->scrollPos = widgetScrollbarThumbDragScroll(trackLen, totalH, innerH, relMouse, 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 sbX      = w->x + TREE_BORDER;
        int32_t relMouse = mouseX - sbX - WGT_SB_W - dragOff;

        tv->scrollPosH = widgetScrollbarThumbDragScroll(trackLen, totalW, innerW, relMouse, 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);
}
