// 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
// widgetScrollPane.c  -- ScrollPane container widget
//
// A clipping container that allows its children (laid out as a
// vertical box) to overflow and be scrolled into view. This is the
// general-purpose scrollable container -- unlike TreeView or ListBox
// which have item-specific scrolling, ScrollPane can wrap any
// collection of arbitrary child widgets.
//
// Architecture: ScrollPane is both a layout container and a paint
// container (WCLASS_PAINTS_CHILDREN flag). It lays out children at
// their virtual positions (offset by scroll position), then during
// paint, sets a clip rect to the inner content area before painting
// children. This means children are positioned at coordinates that
// may be outside the visible area -- the clip rect handles hiding
// the overflow. This is simpler and more efficient than per-widget
// visibility culling for the small widget counts typical on 486 DOS.
//
// Scrollbar visibility uses a two-pass determination: first check if
// V scrollbar is needed, then check H (accounting for the space the
// V scrollbar consumed), then re-check V in case H scrollbar's
// appearance reduced available height. This handles the mutual
// dependency where adding one scrollbar may trigger the other.
//
// Scrollbars are drawn via widgetDrawScrollbarHEx/VEx, the shared
// framework helpers parameterized by bar thickness (SP_SB_W here).

#include "dvxWgtP.h"

static int32_t sTypeId = -1;

typedef struct {
    int32_t scrollPosV;
    int32_t scrollPosH;
    int32_t sbDragOrient;
    int32_t sbDragOff;
    bool    noBorder;
} ScrollPaneDataT;

#define SP_BORDER   2
#define SP_SB_W     14
#define SP_PAD      0


// ============================================================
// Prototypes
// ============================================================

static int32_t  spBorder(const WidgetT *w);
static void     spCalcNeeds(WidgetT *w, const BitmapFontT *font, int32_t *contentMinW, int32_t *contentMinH, int32_t *innerW, int32_t *innerH, bool *needVSb, bool *needHSb);
static bool     spGetNoBorder(const WidgetT *w);
static void     spSetNoBorder(WidgetT *w, bool v);
void            wgtRegister(void);
WidgetT        *wgtScrollPane(WidgetT *parent);
void            wgtScrollPaneScrollToChild(WidgetT *w, const WidgetT *child);
void            wgtScrollPaneScrollToTop(WidgetT *w);
void            wgtScrollPaneSetNoBorder(WidgetT *w, bool noBorder);
void            widgetScrollPaneCalcMinSize(WidgetT *w, const BitmapFontT *font);
void            widgetScrollPaneLayout(WidgetT *w, const BitmapFontT *font);
static void     widgetScrollPaneOnDragUpdate(WidgetT *w, WidgetT *root, int32_t mouseX, int32_t mouseY);
void            widgetScrollPaneOnKey(WidgetT *w, int32_t key, int32_t mod);
void            widgetScrollPaneOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy);
void            widgetScrollPanePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);


static int32_t spBorder(const WidgetT *w) {
    const ScrollPaneDataT *sp = (const ScrollPaneDataT *)w->data;
    return sp->noBorder ? 0 : SP_BORDER;
}


// Central sizing function called by layout, paint, and mouse handlers.
// Computes the total content min size from children, then determines
// which scrollbars are needed and adjusts inner dimensions accordingly.
// The two-pass scrollbar dependency resolution handles the case where
// adding a V scrollbar shrinks the width enough to need an H scrollbar,
// which in turn shrinks the height enough to need a V scrollbar.
static void spCalcNeeds(WidgetT *w, const BitmapFontT *font, int32_t *contentMinW, int32_t *contentMinH, int32_t *innerW, int32_t *innerH, bool *needVSb, bool *needHSb) {
    // Measure children
    int32_t totalMinW = 0;
    int32_t totalMinH = 0;
    int32_t pad       = wgtResolveSize(w->padding, 0, font->charWidth);
    int32_t gap       = wgtResolveSize(w->spacing, 0, font->charWidth);
    int32_t count     = 0;

    for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
        if (!c->visible) {
            continue;
        }

        totalMinH += c->calcMinH;
        totalMinW = DVX_MAX(totalMinW, c->calcMinW);
        count++;
    }

    if (count > 1) {
        totalMinH += gap * (count - 1);
    }

    totalMinW += pad * 2;
    totalMinH += pad * 2;

    *contentMinW = totalMinW;
    *contentMinH = totalMinH;

    // Available inner area
    *innerW = w->w - spBorder(w) * 2;
    *innerH = w->h - spBorder(w) * 2;

    // Determine scrollbar needs (two-pass for mutual dependency)
    *needVSb = (totalMinH > *innerH);
    *needHSb = false;

    if (*needVSb) {
        *innerW -= SP_SB_W;
    }

    if (totalMinW > *innerW) {
        *needHSb = true;
        *innerH -= SP_SB_W;

        if (!*needVSb && totalMinH > *innerH) {
            *needVSb = true;
            *innerW -= SP_SB_W;
        }
    }

    if (*innerW < 0) {
        *innerW = 0;
    }

    if (*innerH < 0) {
        *innerH = 0;
    }
}


static bool spGetNoBorder(const WidgetT *w) {
    return ((ScrollPaneDataT *)w->data)->noBorder;
}


static void spSetNoBorder(WidgetT *w, bool v) {
    wgtScrollPaneSetNoBorder(w, v);
}


// ============================================================
// DXE registration
// ============================================================

static const WidgetClassT sClassScrollPane = {
    .version = WGT_CLASS_VERSION,
    .flags   = WCLASS_PAINTS_CHILDREN | WCLASS_NO_HIT_RECURSE | WCLASS_SCROLL_CONTAINER | WCLASS_RELAYOUT_ON_SCROLL,
    .handlers = {
        [WGT_METHOD_PAINT]                  = (void *)widgetScrollPanePaint,
        [WGT_METHOD_CALC_MIN_SIZE]          = (void *)widgetScrollPaneCalcMinSize,
        [WGT_METHOD_LAYOUT]                 = (void *)widgetScrollPaneLayout,
        [WGT_METHOD_ON_MOUSE]               = (void *)widgetScrollPaneOnMouse,
        [WGT_METHOD_ON_KEY]                 = (void *)widgetScrollPaneOnKey,
        [WGT_METHOD_SCROLL_CHILD_INTO_VIEW] = (void *)wgtScrollPaneScrollToChild,
        [WGT_METHOD_ON_DRAG_UPDATE]         = (void *)widgetScrollPaneOnDragUpdate,
    }
};


static const struct {
    WidgetT *(*create)(WidgetT *parent);
    void     (*scrollToChild)(WidgetT *sp, const WidgetT *child);
    void     (*setNoBorder)(WidgetT *w, bool noBorder);
    void     (*scrollToTop)(WidgetT *w);
} sApi = {
    .create        = wgtScrollPane,
    .scrollToChild = wgtScrollPaneScrollToChild,
    .setNoBorder   = wgtScrollPaneSetNoBorder,
    .scrollToTop   = wgtScrollPaneScrollToTop
};

static const WgtPropDescT sProps[] = {
    { "NoBorder", WGT_IFACE_BOOL, (void *)spGetNoBorder, (void *)spSetNoBorder, NULL },
};

static const WgtIfaceT sIface = {
    .basName      = "ScrollPane",
    .props        = sProps,
    .propCount    = 1,
    .methods      = NULL,
    .methodCount  = 0,
    .events       = NULL,
    .eventCount   = 0,
    .createSig    = WGT_CREATE_PARENT,
    .isContainer  = true,
    .namePrefix   = "Scroll",
};

void wgtRegister(void) {
    sTypeId = wgtRegisterClass(&sClassScrollPane);
    wgtRegisterApi("scrollpane", &sApi);
    wgtRegisterIface("scrollpane", &sIface);
}


WidgetT *wgtScrollPane(WidgetT *parent) {
    WidgetT *w = widgetAlloc(parent, sTypeId);

    if (w) {
        ScrollPaneDataT *sp = (ScrollPaneDataT *)calloc(1, sizeof(ScrollPaneDataT));

        if (sp) {
            w->data = sp;
        }

        w->weight = WGT_WEIGHT_FILL;
    }

    return w;
}


void wgtScrollPaneScrollToChild(WidgetT *w, const WidgetT *child) {
    VALIDATE_WIDGET_VOID(w, sTypeId);
    ScrollPaneDataT *sp = (ScrollPaneDataT *)w->data;
    AppContextT *ctx    = wgtGetContext(w);
    const BitmapFontT *font = &ctx->font;

    int32_t contentMinW;
    int32_t contentMinH;
    int32_t innerW;
    int32_t innerH;
    bool    needVSb;
    bool    needHSb;

    spCalcNeeds(w, font, &contentMinW, &contentMinH, &innerW, &innerH, &needVSb, &needHSb);

    // Child's virtual offset within the scrollable content
    int32_t childOffY = child->y - w->y - spBorder(w) + sp->scrollPosV;
    int32_t maxScrollV = contentMinH - innerH;

    if (maxScrollV < 0) {
        maxScrollV = 0;
    }

    if (childOffY < sp->scrollPosV) {
        sp->scrollPosV = childOffY;
    } else if (childOffY + child->h > sp->scrollPosV + innerH) {
        sp->scrollPosV = childOffY + child->h - innerH;
    }

    sp->scrollPosV = clampInt(sp->scrollPosV, 0, maxScrollV);
}


void wgtScrollPaneScrollToTop(WidgetT *w) {
    VALIDATE_WIDGET_VOID(w, sTypeId);
    ScrollPaneDataT *sp = (ScrollPaneDataT *)w->data;
    sp->scrollPosV = 0;
    sp->scrollPosH = 0;
}


void wgtScrollPaneSetNoBorder(WidgetT *w, bool noBorder) {
    VALIDATE_WIDGET_VOID(w, sTypeId);
    ScrollPaneDataT *sp = (ScrollPaneDataT *)w->data;
    sp->noBorder = noBorder;
    wgtInvalidate(w);
}


// The scroll pane reports a deliberately small min size (just enough
// for the scrollbar chrome) because its whole purpose is to contain
// content that doesn't fit. However, children still need their min
// sizes computed so spCalcNeeds can determine scrollbar visibility
// and the layout pass can distribute space correctly.
void widgetScrollPaneCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    // Recursively measure children so they have valid calcMinW/H
    for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
        widgetCalcMinSizeTree(c, font);
    }

    // The scroll pane's own min size is small  -- the whole point is scrolling
    w->calcMinW = SP_SB_W * 3 + spBorder(w) * 2;
    w->calcMinH = SP_SB_W * 3 + spBorder(w) * 2;
}


// Layout is a vertical box layout offset by the scroll position.
// Children are positioned at their "virtual" coordinates (baseX/baseY
// incorporate the negative scroll offset), so they may have negative
// or very large Y values. The paint pass clips to the visible area.
// This means child coordinates are always absolute screen coords,
// keeping the draw path simple -- no coordinate translation needed
// at paint time.
//
// Extra space distribution uses the same weight-based algorithm as
// the generic box layout: each child gets a share of surplus space
// proportional to its weight/totalWeight ratio. This allows stretch
// children inside the scrollable area.
void widgetScrollPaneLayout(WidgetT *w, const BitmapFontT *font) {
    ScrollPaneDataT *sp = (ScrollPaneDataT *)w->data;

    int32_t contentMinW;
    int32_t contentMinH;
    int32_t innerW;
    int32_t innerH;
    bool    needVSb;
    bool    needHSb;

    spCalcNeeds(w, font, &contentMinW, &contentMinH, &innerW, &innerH, &needVSb, &needHSb);

    // Clamp scroll positions
    int32_t maxScrollV = contentMinH - innerH;
    int32_t maxScrollH = contentMinW - innerW;

    if (maxScrollV < 0) {
        maxScrollV = 0;
    }

    if (maxScrollH < 0) {
        maxScrollH = 0;
    }

    sp->scrollPosV = clampInt(sp->scrollPosV, 0, maxScrollV);
    sp->scrollPosH = clampInt(sp->scrollPosH, 0, maxScrollH);

    // Layout children as a vertical box at virtual size
    int32_t pad       = wgtResolveSize(w->padding, 0, font->charWidth);
    int32_t gap       = wgtResolveSize(w->spacing, 0, font->charWidth);
    int32_t virtualW  = DVX_MAX(innerW, contentMinW);
    int32_t virtualH  = DVX_MAX(innerH, contentMinH);
    int32_t baseX     = w->x + spBorder(w) - sp->scrollPosH;
    int32_t baseY     = w->y + spBorder(w) - sp->scrollPosV;
    int32_t childW    = virtualW - pad * 2;
    int32_t pos       = baseY + pad;

    if (childW < 0) {
        childW = 0;
    }

    // Sum min sizes and weights for distribution
    int32_t totalMin    = 0;
    int32_t totalWeight = 0;

    for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
        if (!c->visible) {
            continue;
        }

        totalMin += c->calcMinH;
        totalWeight += c->weight;
    }

    int32_t count = widgetCountVisibleChildren(w);
    int32_t totalGap = (count > 1) ? gap * (count - 1) : 0;
    int32_t availMain = virtualH - pad * 2 - totalGap;
    int32_t extraSpace = availMain - totalMin;

    if (extraSpace < 0) {
        extraSpace = 0;
    }

    for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
        if (!c->visible) {
            continue;
        }

        int32_t mainSize = c->calcMinH;

        if (totalWeight > 0 && c->weight > 0 && extraSpace > 0) {
            mainSize += (extraSpace * c->weight) / totalWeight;
        }

        c->x = baseX + pad;
        c->y = pos;
        c->w = childW;
        c->h = mainSize;

        if (c->maxW) {
            int32_t maxPx = wgtResolveSize(c->maxW, childW, font->charWidth);

            if (c->w > maxPx) {
                c->w = maxPx;
            }
        }

        if (c->maxH) {
            int32_t maxPx = wgtResolveSize(c->maxH, mainSize, font->charWidth);

            if (c->h > maxPx) {
                c->h = maxPx;
            }
        }

        pos += mainSize + gap;

        // Recurse into child containers
        widgetLayoutChildren(c, font);
    }

    // Children with custom layout (e.g., WrapBox) may have updated
    // their calcMinH after layout. Re-check if scrollbars are needed.
    {
        int32_t newMinW2;
        int32_t newMinH2;
        int32_t newInnerW2;
        int32_t newInnerH2;
        bool    newNeedV2;
        bool    newNeedH2;

        spCalcNeeds(w, font, &newMinW2, &newMinH2, &newInnerW2, &newInnerH2, &newNeedV2, &newNeedH2);

        if (newNeedV2 != needVSb || newNeedH2 != needHSb) {
            // Scrollbar needs changed — redo layout with updated sizes
            contentMinH = newMinH2;
            contentMinW = newMinW2;
            innerW      = newInnerW2;
            innerH      = newInnerH2;

            maxScrollV = contentMinH - innerH;
            maxScrollH = contentMinW - innerW;

            if (maxScrollV < 0) {
                maxScrollV = 0;
            }

            if (maxScrollH < 0) {
                maxScrollH = 0;
            }

            sp->scrollPosV = clampInt(sp->scrollPosV, 0, maxScrollV);
            sp->scrollPosH = clampInt(sp->scrollPosH, 0, maxScrollH);

            virtualW = DVX_MAX(innerW, contentMinW);
            virtualH = DVX_MAX(innerH, contentMinH);
            baseX    = w->x + spBorder(w) - sp->scrollPosH;
            baseY    = w->y + spBorder(w) - sp->scrollPosV;
            childW   = virtualW - pad * 2;

            if (childW < 0) {
                childW = 0;
            }

            pos = baseY + pad;

            for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
                if (!c->visible) {
                    continue;
                }

                int32_t ms = c->calcMinH;

                if (totalWeight > 0 && c->weight > 0 && extraSpace > 0) {
                    ms += (extraSpace * c->weight) / totalWeight;
                }

                c->x = baseX + pad;
                c->y = pos;
                c->w = childW;
                c->h = ms;

                // Apply the same maxW/maxH cap as the first placement loop;
                // omitting it stretched constrained children to full size in
                // exactly the frames where the scrollbar set toggled (flicker).
                if (c->maxW) {
                    int32_t maxPx = wgtResolveSize(c->maxW, childW, font->charWidth);

                    if (c->w > maxPx) {
                        c->w = maxPx;
                    }
                }

                if (c->maxH) {
                    int32_t maxPx = wgtResolveSize(c->maxH, ms, font->charWidth);

                    if (c->h > maxPx) {
                        c->h = maxPx;
                    }
                }

                pos += ms + gap;

                widgetLayoutChildren(c, font);
            }
        }
    }
}


// Handle scrollbar thumb drag for vertical and horizontal scrollbars.
// Uses spCalcNeeds to determine content and viewport dimensions.
static void widgetScrollPaneOnDragUpdate(WidgetT *w, WidgetT *root, int32_t mouseX, int32_t mouseY) {
    (void)root;
    ScrollPaneDataT *sp = (ScrollPaneDataT *)w->data;
    int32_t orient = sp->sbDragOrient;
    int32_t dragOff = sp->sbDragOff;
    AppContextT       *ctx  = wgtGetContext(w);
    const BitmapFontT *font = &ctx->font;

    int32_t contentMinW;
    int32_t contentMinH;
    int32_t innerW;
    int32_t innerH;
    bool    needVSb;
    bool    needHSb;

    spCalcNeeds(w, font, &contentMinW, &contentMinH, &innerW, &innerH, &needVSb, &needHSb);

    if (orient == 0) {
        // Vertical scrollbar drag
        int32_t maxScroll = contentMinH - innerH;

        if (maxScroll <= 0) {
            return;
        }

        int32_t trackLen = innerH - SP_SB_W * 2;
        int32_t sbY      = w->y + spBorder(w);
        int32_t relMouse = mouseY - sbY - SP_SB_W - dragOff;

        sp->scrollPosV = widgetScrollbarThumbDragScroll(trackLen, contentMinH, innerH, relMouse, maxScroll);
    } else if (orient == 1) {
        // Horizontal scrollbar drag
        int32_t maxScroll = contentMinW - innerW;

        if (maxScroll <= 0) {
            return;
        }

        int32_t trackLen = innerW - SP_SB_W * 2;
        int32_t sbX      = w->x + spBorder(w);
        int32_t relMouse = mouseX - sbX - SP_SB_W - dragOff;

        sp->scrollPosH = widgetScrollbarThumbDragScroll(trackLen, contentMinW, innerW, relMouse, maxScroll);
    }
}


// Keyboard scrolling uses font metrics for step sizes: charHeight for
// vertical line scroll, charWidth for horizontal, and the full inner
// height for page scroll. This makes scroll distance proportional to
// content size, which feels natural. The early return for unhandled
// keys avoids unnecessary invalidation.
void widgetScrollPaneOnKey(WidgetT *w, int32_t key, int32_t mod) {
    (void)mod;

    ScrollPaneDataT   *sp   = (ScrollPaneDataT *)w->data;
    AppContextT       *ctx  = wgtGetContext(w);
    const BitmapFontT *font = &ctx->font;

    int32_t contentMinW;
    int32_t contentMinH;
    int32_t innerW;
    int32_t innerH;
    bool    needVSb;
    bool    needHSb;

    spCalcNeeds(w, font, &contentMinW, &contentMinH, &innerW, &innerH, &needVSb, &needHSb);

    int32_t maxScrollV = contentMinH - innerH;
    int32_t maxScrollH = contentMinW - innerW;

    if (maxScrollV < 0) {
        maxScrollV = 0;
    }

    if (maxScrollH < 0) {
        maxScrollH = 0;
    }

    int32_t step = font->charHeight;

    if (key == KEY_UP) {
        // Up
        sp->scrollPosV -= step;
    } else if (key == KEY_DOWN) {
        // Down
        sp->scrollPosV += step;
    } else if (key == KEY_PGUP) {
        // Page Up
        sp->scrollPosV -= innerH;
    } else if (key == KEY_PGDN) {
        // Page Down
        sp->scrollPosV += innerH;
    } else if (key == KEY_HOME) {
        // Home
        sp->scrollPosV = 0;
    } else if (key == KEY_END) {
        // End
        sp->scrollPosV = maxScrollV;
    } else if (key == KEY_LEFT) {
        // Left
        sp->scrollPosH -= font->charWidth;
    } else if (key == KEY_RIGHT) {
        // Right
        sp->scrollPosH += font->charWidth;
    } else {
        return;
    }

    sp->scrollPosV = clampInt(sp->scrollPosV, 0, maxScrollV);
    sp->scrollPosH = clampInt(sp->scrollPosH, 0, maxScrollH);
    wgtInvalidate(w);
}


// Mouse handling has priority order: V scrollbar > H scrollbar > dead
// corner > child content. The dead corner (where H and V scrollbars
// meet) is explicitly handled to prevent clicks from falling through
// to content behind it. Content clicks do recursive hit-testing into
// children and forward the mouse event, handling focus management
// along the way. This is necessary because scroll pane has
// WCLASS_NO_HIT_RECURSE -- the generic hit-test doesn't descend
// into scroll pane children since their coordinates may be outside
// the pane's visible bounds.
void widgetScrollPaneOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
    const WidgetT     *w    = hit; // alias for spBorder()
    ScrollPaneDataT   *sp   = (ScrollPaneDataT *)hit->data;
    AppContextT       *ctx  = (AppContextT *)root->userData;
    const BitmapFontT *font = &ctx->font;

    int32_t contentMinW;
    int32_t contentMinH;
    int32_t innerW;
    int32_t innerH;
    bool    needVSb;
    bool    needHSb;

    spCalcNeeds(hit, font, &contentMinW, &contentMinH, &innerW, &innerH, &needVSb, &needHSb);

    // Clamp scroll positions
    int32_t maxScrollV = contentMinH - innerH;
    int32_t maxScrollH = contentMinW - innerW;

    if (maxScrollV < 0) {
        maxScrollV = 0;
    }

    if (maxScrollH < 0) {
        maxScrollH = 0;
    }

    sp->scrollPosV = clampInt(sp->scrollPosV, 0, maxScrollV);
    sp->scrollPosH = clampInt(sp->scrollPosH, 0, maxScrollH);

    // Check vertical scrollbar
    if (needVSb) {
        int32_t sbX = hit->x + hit->w - spBorder(w) - SP_SB_W;

        if (vx >= sbX && vy >= hit->y + spBorder(w) && vy < hit->y + spBorder(w) + innerH) {
            int32_t sbY      = hit->y + spBorder(w);
            int32_t sbH      = innerH;
            int32_t relY     = vy - sbY;
            int32_t trackLen = sbH - SP_SB_W * 2;
            int32_t pageSize = innerH - font->charHeight;

            if (pageSize < font->charHeight) {
                pageSize = font->charHeight;
            }

            if (relY < SP_SB_W) {
                sp->scrollPosV -= font->charHeight;
            } else if (relY >= sbH - SP_SB_W) {
                sp->scrollPosV += font->charHeight;
            } else {
                int32_t thumbPos;
                int32_t thumbSize;
                widgetScrollbarThumb(trackLen, contentMinH, innerH, sp->scrollPosV, &thumbPos, &thumbSize);

                int32_t trackRelY = relY - SP_SB_W;

                if (trackRelY < thumbPos) {
                    sp->scrollPosV -= pageSize;
                } else if (trackRelY >= thumbPos + thumbSize) {
                    sp->scrollPosV += pageSize;
                } else {
                    sDragWidget       = hit;
                    sp->sbDragOrient = 0;
                    sp->sbDragOff    = trackRelY - thumbPos;
                    return;
                }
            }

            sp->scrollPosV = clampInt(sp->scrollPosV, 0, maxScrollV);
            wgtInvalidate(hit);
            return;
        }
    }

    // Check horizontal scrollbar
    if (needHSb) {
        int32_t sbY = hit->y + hit->h - spBorder(w) - SP_SB_W;

        if (vy >= sbY && vx >= hit->x + spBorder(w) && vx < hit->x + spBorder(w) + innerW) {
            int32_t sbX      = hit->x + spBorder(w);
            int32_t sbW      = innerW;
            int32_t relX     = vx - sbX;
            int32_t trackLen = sbW - SP_SB_W * 2;
            int32_t pageSize = innerW - font->charWidth;

            if (pageSize < font->charWidth) {
                pageSize = font->charWidth;
            }

            if (relX < SP_SB_W) {
                sp->scrollPosH -= font->charWidth;
            } else if (relX >= sbW - SP_SB_W) {
                sp->scrollPosH += font->charWidth;
            } else {
                int32_t thumbPos;
                int32_t thumbSize;
                widgetScrollbarThumb(trackLen, contentMinW, innerW, sp->scrollPosH, &thumbPos, &thumbSize);

                int32_t trackRelX = relX - SP_SB_W;

                if (trackRelX < thumbPos) {
                    sp->scrollPosH -= pageSize;
                } else if (trackRelX >= thumbPos + thumbSize) {
                    sp->scrollPosH += pageSize;
                } else {
                    sDragWidget       = hit;
                    sp->sbDragOrient = 1;
                    sp->sbDragOff    = trackRelX - thumbPos;
                    return;
                }
            }

            sp->scrollPosH = clampInt(sp->scrollPosH, 0, maxScrollH);
            wgtInvalidate(hit);
            return;
        }
    }

    // Dead corner
    if (needVSb && needHSb) {
        int32_t cornerX = hit->x + hit->w - spBorder(w) - SP_SB_W;
        int32_t cornerY = hit->y + hit->h - spBorder(w) - SP_SB_W;

        if (vx >= cornerX && vy >= cornerY) {
            return;
        }
    }

    // Click on content area  -- forward to child widgets
    // Children are already positioned at scroll-adjusted coordinates
    WidgetT *child = NULL;

    for (WidgetT *c = hit->firstChild; c; c = c->nextSibling) {
        WidgetT *ch = widgetHitTest(c, vx, vy);

        if (ch) {
            child = ch;
        }
    }

    if (child && child->enabled && wclsHas(child, WGT_METHOD_ON_MOUSE)) {
        wclsOnMouse(child, root, vx, vy);
    } else {
        sFocusedWidget = hit;
    }

    wgtInvalidatePaint(hit);
}


// Paint saves and restores the clip rect around child painting.
// Children are painted with a clip rect that excludes the scrollbar
// area, so children that extend past the visible content area are
// automatically clipped. Scrollbars are painted after restoring the
// clip rect so they're always fully visible. The dead corner (when
// both scrollbars are present) is filled with windowFace color.
void widgetScrollPanePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    ScrollPaneDataT *sp = (ScrollPaneDataT *)w->data;
    uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;

    int32_t contentMinW;
    int32_t contentMinH;
    int32_t innerW;
    int32_t innerH;
    bool    needVSb;
    bool    needHSb;

    spCalcNeeds(w, font, &contentMinW, &contentMinH, &innerW, &innerH, &needVSb, &needHSb);

    // Clamp scroll
    int32_t maxScrollV = contentMinH - innerH;
    int32_t maxScrollH = contentMinW - innerW;

    if (maxScrollV < 0) {
        maxScrollV = 0;
    }

    if (maxScrollH < 0) {
        maxScrollH = 0;
    }

    sp->scrollPosV = clampInt(sp->scrollPosV, 0, maxScrollV);
    sp->scrollPosH = clampInt(sp->scrollPosH, 0, maxScrollH);

    bool selfDirty = w->paintDirty;

    // Clip to content area and paint children
    int32_t oldClipX = d->clipX;
    int32_t oldClipY = d->clipY;
    int32_t oldClipW = d->clipW;
    int32_t oldClipH = d->clipH;

    if (selfDirty) {
        // Full redraw: border + background + scrollbars
        BevelStyleT bevel = BEVEL_SUNKEN(colors, bg, spBorder(w));
        drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
        rectFill(d, ops, w->x + spBorder(w), w->y + spBorder(w), innerW, innerH, bg);
    }

    setClipRect(d, w->x + spBorder(w), w->y + spBorder(w), innerW, innerH);

    // Paint children (already positioned by layout with scroll offset)
    for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
        widgetPaintOne(c, d, ops, font, colors);
    }

    setClipRect(d, oldClipX, oldClipY, oldClipW, oldClipH);

    if (!selfDirty) {
        return;
    }

    // Draw scrollbars
    if (needVSb) {
        int32_t sbX = w->x + w->w - spBorder(w) - SP_SB_W;
        int32_t sbY = w->y + spBorder(w);
        widgetDrawScrollbarVEx(d, ops, colors, sbX, sbY, innerH, contentMinH, innerH, sp->scrollPosV, SP_SB_W);
    }

    if (needHSb) {
        int32_t sbX = w->x + spBorder(w);
        int32_t sbY = w->y + w->h - spBorder(w) - SP_SB_W;
        widgetDrawScrollbarHEx(d, ops, colors, sbX, sbY, innerW, contentMinW, innerW, sp->scrollPosH, SP_SB_W);

        if (needVSb) {
            rectFill(d, ops, sbX + innerW, sbY, SP_SB_W, SP_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);
    }
}
