// 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
// widgetEvent.c  -- Window event handlers for widget system
//
// This file routes window-level events (mouse, keyboard, paint, resize,
// scroll) into the widget tree. It serves as the bridge between the
// window manager (dvxWm) and the widget system.
//
// Event handling follows a priority system for mouse events:
//   1. Active drag/interaction states (slider drag, button press tracking,
//      text selection, canvas drawing, column resize, drag-reorder,
//      splitter drag) are checked first and handled directly.
//   2. Open popups (dropdown/combobox lists) intercept clicks next.
//   3. Normal hit testing routes clicks to the target widget.
//
// This priority ordering ensures that ongoing interactions complete
// correctly even if the mouse moves outside the originating widget.
// For example, dragging a slider and moving the mouse above the slider
// still adjusts the value, because sDragSlider captures the event
// before hit testing runs.

#include "dvxWgtP.h"
#include "platform/dvxPlat.h"

// Widget whose popup was just closed by click-outside  -- prevents
// immediate re-open on the same click. Without this, clicking the
// dropdown button to close its popup would immediately hit-test the
// button again and re-open the popup in the same event.
WidgetT *sClosedPopup = NULL;

// Mouse state for tracking button transitions and movement
static int32_t sPrevMouseButtons = 0;
static int32_t sPrevMouseX       = -1;
static int32_t sPrevMouseY       = -1;


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

static void dispatchButtonEdges(WidgetT *hit, uint32_t snapGen, int32_t buttons, int32_t prevButtons, int32_t relX, int32_t relY);
static void widgetOnMouseInner(WindowT *win, WidgetT *root, int32_t x, int32_t y, int32_t buttons);
static void widgetSetRootGeometry(const WindowT *win, WidgetT *root);


// Dispatch mouse button press/release edges to the widget under the cursor.
// Each transition between prevButtons and buttons fires the widget's
// onMouseDown (press edge) or onMouseUp (release edge) callback with the
// 1-based button ordinal (1 left, 2 right, 3 middle).  Any callback may
// destroy widgets, so every dereference is gated on the caller's generation
// snapshot still matching sWidgetGen.
//
// The left release edge is delivered first so both callers keep their
// original per-path ordering: on the left-release path (buttons has
// MOUSE_LEFT clear) this fires the left onMouseUp before any right/middle
// edge, matching the pre-extraction leftUp-first order; on the press-time
// path (MOUSE_LEFT set) the left-release edge never fires, so the remaining
// edges deliver left/right/middle presses then right/middle releases exactly
// as before.

static void dispatchButtonEdges(WidgetT *hit, uint32_t snapGen, int32_t buttons, int32_t prevButtons, int32_t relX, int32_t relY) {
    // Left release edge first (leftUp-first order on the release path;
    // unreachable on the press path where MOUSE_LEFT is set)
    if (sWidgetGen == snapGen && !(buttons & MOUSE_LEFT) && (prevButtons & MOUSE_LEFT) && hit->onMouseUp) {
        hit->onMouseUp(hit, 1, relX, relY);
    }

    // Press edges (left, right, middle)
    if (sWidgetGen == snapGen && (buttons & MOUSE_LEFT) && !(prevButtons & MOUSE_LEFT) && hit->onMouseDown) {
        hit->onMouseDown(hit, 1, relX, relY);
    }

    if (sWidgetGen == snapGen && (buttons & MOUSE_RIGHT) && !(prevButtons & MOUSE_RIGHT) && hit->onMouseDown) {
        hit->onMouseDown(hit, 2, relX, relY);
    }

    if (sWidgetGen == snapGen && (buttons & MOUSE_MIDDLE) && !(prevButtons & MOUSE_MIDDLE) && hit->onMouseDown) {
        hit->onMouseDown(hit, 3, relX, relY);
    }

    // Remaining release edges (right, middle)
    if (sWidgetGen == snapGen && !(buttons & MOUSE_RIGHT) && (prevButtons & MOUSE_RIGHT) && hit->onMouseUp) {
        hit->onMouseUp(hit, 2, relX, relY);
    }

    if (sWidgetGen == snapGen && !(buttons & MOUSE_MIDDLE) && (prevButtons & MOUSE_MIDDLE) && hit->onMouseUp) {
        hit->onMouseUp(hit, 3, relX, relY);
    }
}


// Manages automatic scrollbar addition/removal for widget-based windows.
// Called on every invalidation to ensure scrollbars match the current
// widget tree's minimum size requirements.
//
// The algorithm:
//   1. Measure the full widget tree to get its minimum size.
//   2. Remove all existing scrollbars to measure the full available area.
//   3. Compare min size vs available area to decide if scrollbars are needed.
//   4. Account for scrollbar interaction: adding a vertical scrollbar
//      reduces horizontal space, which may require a horizontal scrollbar
//      (and vice versa). This mutual dependency is handled with a single
//      extra check rather than iterating to convergence.
//   5. Preserve scroll positions across scrollbar recreation.
//   6. Layout at the virtual content size (max of available and minimum).
//
// The virtual content size concept is key: if the widget tree needs
// 800px but only 600px is available, the tree is laid out at 800px
// and the window scrolls to show the visible portion. This means
// widget positions can be negative (scrolled above the viewport)
// or extend past the window edge (scrolled below).

void widgetManageScrollbars(WindowT *win, AppContextT *ctx) {
    WidgetT *root = win->widgetRoot;

    if (!root) {
        return;
    }

    // Measure the tree without any layout pass
    widgetCalcMinSizeTree(root, &ctx->font);

    // Save old scroll positions before removing scrollbars
    int32_t oldVValue  = win->vScroll ? win->vScroll->value : 0;
    int32_t oldHValue  = win->hScroll ? win->hScroll->value : 0;
    bool    hadVScroll = (win->vScroll != NULL);
    bool    hadHScroll = (win->hScroll != NULL);

    // Remove existing scrollbars to measure full available area
    wmRemoveScrollbars(win);
    wmUpdateContentRect(win);

    int32_t availW = win->contentW;
    int32_t availH = win->contentH;
    int32_t minW   = root->calcMinW;
    int32_t minH   = root->calcMinH;

    bool needV = (minH > availH);
    bool needH = (minW > availW);

    // Adding one scrollbar reduces space, which may require the other
    if (needV && !needH) {
        needH = (minW > availW - SCROLLBAR_WIDTH);
    }

    if (needH && !needV) {
        needV = (minH > availH - SCROLLBAR_WIDTH);
    }

    bool changed = (needV != hadVScroll) || (needH != hadHScroll);

    if (needV) {
        int32_t pageV = needH ? availH - SCROLLBAR_WIDTH : availH;
        int32_t maxV  = minH - pageV;

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

        wmAddVScrollbar(win, 0, maxV, pageV);

        // wmAddVScrollbar leaves win->vScroll NULL if its allocation failed.
        if (win->vScroll) {
            win->vScroll->value = DVX_MIN(oldVValue, maxV);
        }
    }

    if (needH) {
        int32_t pageH = needV ? availW - SCROLLBAR_WIDTH : availW;
        int32_t maxH  = minW - pageH;

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

        wmAddHScrollbar(win, 0, maxH, pageH);

        if (win->hScroll) {
            win->hScroll->value = DVX_MIN(oldHValue, maxH);
        }
    }

    if (changed) {
        // wmAddVScrollbar/wmAddHScrollbar already call wmUpdateContentRect
        wmReallocContentBuf(win, &ctx->display);
    }

    // Install scroll handler
    win->onScroll = widgetOnScroll;

    // Arrange at the virtual content size using the measure pass already
    // done above (wgtLayout would re-measure the whole tree).  The next
    // PAINT_FULL arranges again at the same geometry; arranging here too
    // keeps widget positions valid for callers that read them before then.
    widgetSetRootGeometry(win, root);
    widgetLayoutChildren(root, &ctx->font);
}


// widgetOnBlur -- window lost focus
//
// When a widget-managed window loses WM focus (user clicked another
// window's title bar, or the window was minimized), clear the widget
// focus so the cursor/highlight is removed. Without this, the text
// cursor stays visible in the unfocused window.

void widgetOnBlur(WindowT *win) {
    // Selection is intentionally NOT cleared here: window-level blur is
    // transient (clicking another window's menu bar must not destroy the
    // selection a menu Copy/Cut command is about to act on), and keeping
    // the selection visible in an inactive window matches convention.
    // Selection clears happen on widget-to-widget focus transitions.
    //
    // The widget is remembered on the window so widgetOnPaint can hand
    // focus back to it (firing onFocus) when the window regains WM focus.
    if (sFocusedWidget && sFocusedWidget->window == win) {
        WidgetT *prev = sFocusedWidget;

        sFocusedWidget       = NULL;
        win->lastFocusWidget = prev;
        wgtInvalidatePaint(prev);
        widgetFireFocusChange(prev, NULL);
    }
}


// widgetOnFocus -- window gained focus
//
// Mark the window's content dirty so the compositor knows to
// refresh its minimized icon thumbnail if needed.

void widgetOnFocus(WindowT *win) {
    win->iconNeedsRefresh = true;
}


// Keyboard event dispatch. Unlike mouse events which use hit testing,
// keyboard events go directly to the focused widget (sFocusedWidget).
// The cached pointer avoids an O(n) tree walk to find the focused
// widget on every keypress.
//
// There is no keyboard event bubbling  -- if the focused widget doesn't
// handle a key, it's simply dropped. Accelerators (Alt+key) are
// handled at a higher level in the app event loop, not here.

void widgetOnKey(WindowT *win, int32_t key, int32_t mod) {
    WidgetT *root = win->widgetRoot;

    if (!root) {
        return;
    }

    // Use cached focus pointer  -- O(1) instead of O(n) tree walk
    WidgetT *focus = sFocusedWidget;

    if (!focus || focus->window != win) {
        return;
    }

    // Don't dispatch keys to disabled or hidden widgets
    if (!focus->enabled || !widgetIsShown(focus)) {
        return;
    }

    // Attribute allocations during event handling to the owning app
    AppContextT *ctx = (AppContextT *)root->userData;

    if (!ctx) {
        return;
    }

    int32_t prevAppId = ctx->currentAppId;
    ctx->currentAppId = win->appId;

    // Snapshot the destroy generation: any handler below may destroy the
    // focused widget (a BASIC Change handler unloading its form), after
    // which focus is dangling and must not be dereferenced again.
    uint32_t gen = sWidgetGen;

    wclsOnKey(focus, key, mod);

    // Fire user callbacks after the widget's internal handler
    if (sWidgetGen == gen && key >= KEY_ASCII_PRINT_FIRST && key <= KEY_ASCII_PRINT_LAST && focus->onKeyPress) {
        focus->onKeyPress(focus, key);
    }

    if (sWidgetGen == gen && focus->onKeyDown) {
        focus->onKeyDown(focus, key, mod);
    }

    ctx->currentAppId = prevAppId;
}


void widgetOnKeyUp(WindowT *win, int32_t scancode, int32_t mod) {
    WidgetT *root = win->widgetRoot;

    if (!root) {
        return;
    }

    WidgetT *focus = sFocusedWidget;

    if (!focus || focus->window != win) {
        return;
    }

    if (!focus->enabled || !widgetIsShown(focus)) {
        return;
    }

    if (focus->onKeyUp) {
        AppContextT *ctx = (AppContextT *)root->userData;

        if (!ctx) {
            return;
        }

        int32_t prevAppId = ctx->currentAppId;
        ctx->currentAppId = win->appId;

        focus->onKeyUp(focus, scancode, mod);

        ctx->currentAppId = prevAppId;
    }
}


// Main mouse event handler. This is the most complex event handler
// because it must manage multiple overlapping interaction states.
//
// The function is structured as a series of early-return checks:
// each active interaction (drag, press, popup) is checked in priority
// order. If the interaction handles the event, it returns immediately.
// Only if no interaction is active does the event fall through to
// normal hit testing.
//
// Coordinates (x, y) are in content-buffer space  -- the window manager
// has already subtracted the window chrome offset. Widget positions
// are also in content-buffer space (set during layout), so no
// coordinate transform is needed for hit testing.

void widgetOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) {
    WidgetT *root = win->widgetRoot;
    sClosedPopup = NULL;

    if (!root) {
        return;
    }

    // Attribute allocations during event handling to the owning app
    AppContextT *ctx = (AppContextT *)root->userData;

    if (!ctx) {
        return;
    }

    int32_t prevAppId = ctx->currentAppId;
    ctx->currentAppId = win->appId;

    widgetOnMouseInner(win, root, x, y, buttons);

    ctx->currentAppId = prevAppId;
}


static void widgetOnMouseInner(WindowT *win, WidgetT *root, int32_t x, int32_t y, int32_t buttons) {
    // Close popups from other windows
    if (sOpenPopup && sOpenPopup->window != win) {
        wclsClosePopup(sOpenPopup);
        sOpenPopup = NULL;
    }

    // Handle drag release
    if (sDragWidget && !(buttons & MOUSE_LEFT)) {
        wclsOnDragEnd(sDragWidget, root, x, y);

        wgtInvalidatePaint(sDragWidget);
        sDragWidget = NULL;

        // Record the release edge.  Leaving a stale LEFT bit here would
        // make the next stationary press fail the leftPressEdge gate
        // (swallowing the click) and make the next move event deliver a
        // phantom left onMouseUp to whatever widget is under the cursor.
        sPrevMouseButtons = buttons;
        return;
    }

    // Handle drag move
    if (sDragWidget && (buttons & MOUSE_LEFT)) {
        wclsOnDragUpdate(sDragWidget, root, x, y);

        // quickRepaint fast path for text drag (dirty rect instead of full repaint)
        if (wclsHas(sDragWidget, WGT_METHOD_QUICK_REPAINT)) {
            int32_t dirtyY = 0;
            int32_t dirtyH = 0;

            if (wclsQuickRepaint(sDragWidget, &dirtyY, &dirtyH) > 0) {
                AppContextT *ctx  = (AppContextT *)root->userData;
                int32_t scrollY2  = win->vScroll ? win->vScroll->value : 0;
                int32_t rectX     = win->x + win->contentX;
                int32_t rectY     = win->y + win->contentY + dirtyY - scrollY2;
                int32_t rectW     = win->contentW;
                win->iconNeedsRefresh = true;
                dirtyListAdd(&ctx->dirty, rectX, rectY, rectW, dirtyH);
                return;
            }
        }

        // Scroll containers need full relayout
        if (sDragWidget->wclass && (sDragWidget->wclass->flags & WCLASS_RELAYOUT_ON_SCROLL)) {
            wgtInvalidate(sDragWidget);
        } else {
            wgtInvalidatePaint(sDragWidget);
        }

        return;
    }

    // Handle open popup clicks
    if (sOpenPopup && (buttons & MOUSE_LEFT)) {
        AppContextT *ctx = (AppContextT *)root->userData;
        const BitmapFontT *font = &ctx->font;

        int32_t popX;
        int32_t popY;
        int32_t popW;
        int32_t popH;

        if (!wclsHas(sOpenPopup, WGT_METHOD_GET_POPUP_RECT)) {
            sOpenPopup = NULL;
            return;
        }

        wclsGetPopupRect(sOpenPopup, font, win->contentH, &popX, &popY, &popW, &popH);

        if (x >= popX && x < popX + popW && y >= popY && y < popY + popH) {
            // Click on popup area -- dispatch to widget's onMouse.
            // The widget may keep the popup open (e.g. scrollbar click)
            // or close it (item selection sets sOpenPopup = NULL).
            WidgetT *popupWidget = sOpenPopup;
            wclsOnMouse(popupWidget, root, x, y);

            // If the popup closed, need a full repaint to erase the
            // overlay (it's painted outside widget bounds).
            if (!sOpenPopup) {
                wgtInvalidate(root);
            } else {
                wgtInvalidatePaint(root);
            }

            return;
        }

        // Click outside popup  -- close it and remember which widget it was
        sClosedPopup = sOpenPopup;

        wclsClosePopup(sOpenPopup);
        sOpenPopup = NULL;
        wgtInvalidate(root);
        // Fall through to normal click handling
    }

    // Left button is up.  If it was just released, deliver the left-button
    // MouseUp to the widget under the cursor (the remainder of this handler
    // is press-time logic and is skipped on release).  This is the only path
    // that reaches a left onMouseUp, which the early return otherwise left
    // unreachable.
    if (!(buttons & MOUSE_LEFT)) {
        // Deliver the left-button MouseUp (release edge) and any right/middle
        // press/release edges to the widget under the cursor, then return --
        // the rest of this handler is left-press logic.  Self-contained so the
        // prevFocus/clearSelection block below does not run for a pure
        // right/middle click (the WM already raised/focused the window).
        WidgetT *upHit = widgetHitTest(root, x, y);

        if (upHit && upHit->enabled) {
            int32_t relX = x - upHit->x - upHit->contentOffX;
            int32_t relY = y - upHit->y - upHit->contentOffY;

            // Snapshot the destroy generation: any callback may destroy
            // upHit (a BASIC MouseUp handler rebuilding its form), and several
            // button edges can coalesce into one poll, so dispatchButtonEdges
            // gates every dereference on it.  MOUSE_LEFT is clear here, so this
            // delivers the left onMouseUp plus any right/middle edges.
            uint32_t upGen = sWidgetGen;

            dispatchButtonEdges(upHit, upGen, buttons, sPrevMouseButtons, relX, relY);

            // MouseMove: plain hover motion with no left button held
            if (sWidgetGen == upGen && (x != sPrevMouseX || y != sPrevMouseY) && upHit->onMouseMove) {
                upHit->onMouseMove(upHit, buttons, relX, relY);
            }
        }

        sPrevMouseButtons = buttons;
        sPrevMouseX       = x;
        sPrevMouseY       = y;
        return;
    }

    // Widget positions are already in content-buffer space (widgetOnPaint
    // sets root to -scrollX/-scrollY), so use raw content-relative coords.
    int32_t vx = x;
    int32_t vy = y;

    WidgetT *hit = widgetHitTest(root, vx, vy);

    if (!hit) {
        return;
    }

    // Press-time dispatch must fire only on the left-button DOWN edge, not on
    // every held-and-move event.  Otherwise a non-drag-capturing widget
    // (checkbox, spinner, radio) re-runs its onMouse on every move pixel while
    // the button is held, re-toggling or re-stepping state.  Drag widgets set
    // sDragWidget on this first edge and are then handled by the sDragWidget
    // fast paths above, so the edge gate still fires their initial press.
    bool leftPressEdge = (buttons & MOUSE_LEFT) && !(sPrevMouseButtons & MOUSE_LEFT);

    // Clear focus from the previously focused widget. Must set
    // sFocusedWidget to NULL BEFORE invalidating so the inline paint
    // sees the widget as unfocused and erases its highlight.  Only on
    // the press edge -- wclsOnMouse (which re-establishes focus) is
    // edge-gated below, so clearing on a held-and-move event would blur
    // the widget mid-click with no way to get focus back.  Only clear
    // the selection when the click landed on a DIFFERENT widget --
    // same-widget clicks (drag-select, shift-click) manage their own
    // selection in the widget's mouse handler.
    WidgetT *prevFocus = sFocusedWidget;

    if (leftPressEdge && sFocusedWidget) {
        sFocusedWidget = NULL;

        if (hit != prevFocus) {
            wclsClearSelection(prevFocus);
        }

        wgtInvalidatePaint(prevFocus);
    }

    // Snapshot the destroy generation: any callback below may destroy
    // widgets (a BASIC Click handler unloading its form, a help link
    // rebuilding the page).  A changed generation means hit / prevFocus
    // may be dangling, so every later dereference is gated on it.
    uint32_t gen = sWidgetGen;

    if (leftPressEdge) {
        // Dispatch to the hit widget's mouse handler via vtable. The handler
        // is responsible for setting sFocusedWidget if it wants focus.
        if (hit->enabled) {
            wclsOnMouse(hit, root, vx, vy);

            // Repaint the hit widget after its handler runs so press-driven
            // visual state (checkbox toggle, dropdown popup, slider thumb)
            // shows immediately.  Centralized here because the dispatcher
            // otherwise invalidates only the previous focus owner, forcing
            // each widget class to self-invalidate.  Guarded: the handler
            // may have destroyed hit.
            if (sWidgetGen == gen) {
                wgtInvalidatePaint(hit);
            }
        }

        // Universal click/double-click callbacks  -- fire for ALL widget types
        // after the type-specific handler has run.  Buttons and image buttons
        // are excluded from onClick because they use press-release semantics
        // (onClick fires on button-up, not button-down) and already handle it
        // in the release handler above.  They still get onDblClick here.
        if (sWidgetGen == gen && hit->enabled) {
            int32_t clicks = multiClickDetect(vx, vy);
            bool    isBtn  = (hit->wclass && (hit->wclass->flags & WCLASS_PRESS_RELEASE));

            if (clicks >= 2 && hit->onDblClick) {
                hit->onDblClick(hit);
            } else if (!isBtn && hit->onClick) {
                hit->onClick(hit);
            }
        }
    }

    // Fire mouse event callbacks (content-relative coordinates)
    if (sWidgetGen == gen && hit->enabled) {
        int32_t relX = vx - hit->x - hit->contentOffX;
        int32_t relY = vy - hit->y - hit->contentOffY;

        // Button press/release edges.  MOUSE_LEFT is always set once execution
        // reaches here, so dispatchButtonEdges delivers the left onMouseDown
        // plus any right/middle edges; the left release edge is owned by the
        // early-return release path above.
        dispatchButtonEdges(hit, gen, buttons, sPrevMouseButtons, relX, relY);

        // MouseMove: position changed
        if (vx != sPrevMouseX || vy != sPrevMouseY) {
            if (sWidgetGen == gen && hit->onMouseMove) {
                hit->onMouseMove(hit, buttons, relX, relY);
            }
        }
    }

    sPrevMouseButtons = buttons;
    sPrevMouseX       = vx;
    sPrevMouseY       = vy;

    // sFocusedWidget is now set directly by the widget's mouse handler.
    // Fire the blur/focus callbacks for that transition.  Skipped entirely
    // if a callback destroyed widgets -- prevFocus may be dangling then.
    if (sWidgetGen == gen) {
        widgetFireFocusChange(prevFocus, sFocusedWidget);
    }
}


// Paints the entire widget tree into the window's content buffer.
// Called whenever the window needs a redraw (invalidation, scroll,
// resize).
//
// Sets up a temporary DisplayT context that points at the window's
// content buffer instead of the screen backbuffer. This means all
// draw operations (drawText, rectFill, drawBevel, etc.) write
// directly into the per-window content buffer, which the compositor
// later blits to the screen backbuffer.
//
// Scroll offset is applied by shifting the root widget's position
// to negative coordinates (-scrollX, -scrollY). This elegantly makes
// scrolling work without any special scroll handling in individual
// widgets  -- their positions are simply offset, and the clip rect
// on the DisplayT limits drawing to the visible area.
//
// The conditional re-layout avoids redundant layout passes when only
// the paint is needed (e.g. cursor blink, selection change).

void widgetOnPaint(WindowT *win, RectT *dirtyArea) {
    (void)dirtyArea;
    WidgetT *root = win->widgetRoot;

    if (!root) {
        return;
    }

    // Get context from root's userData
    AppContextT *ctx = (AppContextT *)root->userData;

    if (!ctx) {
        return;
    }

    // Set up a display context pointing at the content buffer
    DisplayT cd = ctx->display;
    cd.backBuf  = win->contentBuf;
    cd.width    = win->contentW;
    cd.height   = win->contentH;
    cd.pitch    = win->contentPitch;
    cd.clipX    = 0;
    cd.clipY    = 0;
    cd.clipW    = win->contentW;
    cd.clipH    = win->contentH;

    bool full = (win->paintNeeded >= PAINT_FULL);
    win->paintNeeded = PAINT_NONE;

    if (full) {
        // Full repaint: clear background, relayout, paint everything
        rectFill(&cd, &ctx->blitOps, 0, 0, win->contentW, win->contentH, ctx->colors.contentBg);
    }

    // Apply scroll offset and re-layout at virtual size
    widgetSetRootGeometry(win, root);

    if (full) {
        widgetLayoutChildren(root, &ctx->font);
    }

    // Restore widget focus if nothing has focus yet, but only for the
    // window that actually holds WM focus. Background and just-blurred
    // windows are repainted by the deferred-paint loop too, and must not
    // steal global widget focus from the active window.  The widget that
    // held focus when the window last blurred gets it back (so GotFocus
    // pairs with the LostFocus the blur fired); otherwise the first
    // focusable widget does.
    if (!sFocusedWidget && win->focused) {
        WidgetT *target = win->lastFocusWidget;

        win->lastFocusWidget = NULL;

        if (!target || !target->enabled || !widgetIsShown(target)) {
            target = widgetFindNextFocusable(root, NULL);
        }

        // onFocus may destroy widgets; the tree is not safe to paint then.
        if (target && !widgetTransferFocus(target)) {
            return;
        }
    }

    // Paint widget tree (full = all widgets, partial = only dirty ones)
    wgtPaint(root, &cd, &ctx->blitOps, &ctx->font, &ctx->colors, full);

    // Paint overlay popups (dropdown/combobox)
    widgetPaintOverlays(root, &cd, &ctx->blitOps, &ctx->font, &ctx->colors);
}


// Called when the window is resized. Triggers a full scrollbar
// re-evaluation and relayout, since the available content area
// changed and scrollbars may need to be added or removed.

void widgetOnResize(WindowT *win, int32_t newW, int32_t newH) {
    (void)newW;
    (void)newH;
    WidgetT *root = win->widgetRoot;

    if (!root) {
        return;
    }

    AppContextT *ctx = (AppContextT *)root->userData;

    if (!ctx) {
        return;
    }

    widgetManageScrollbars(win, ctx);
}


// Called by the WM when a window scrollbar value changes (user dragged
// the thumb, clicked the track, or used arrow buttons). Triggers a
// full repaint so the widget tree is redrawn at the new scroll offset.
// The actual scroll offset is read from win->vScroll/hScroll in the
// paint handler, so the orient and value parameters aren't directly used.

void widgetOnScroll(WindowT *win, ScrollbarOrientE orient, int32_t value) {
    (void)orient;
    (void)value;

    // Repaint with new scroll position.  dvxInvalidateWindow sets
    // PAINT_FULL so widgetOnPaint re-lays out children at the new
    // root x/y offset.
    if (win->widgetRoot) {
        AppContextT *ctx = wgtGetContext(win->widgetRoot);

        if (ctx) {
            dvxInvalidateWindow(ctx, win);
        }
    }
}


// Positions the root at the negative scroll offset and sizes it to the
// virtual content size: the larger of the available content area and the
// widget tree's measured minimum.  Shared by widgetManageScrollbars and
// widgetOnPaint so the geometry the two arrange at never diverges.
static void widgetSetRootGeometry(const WindowT *win, WidgetT *root) {
    root->x = -(win->hScroll ? win->hScroll->value : 0);
    root->y = -(win->vScroll ? win->vScroll->value : 0);
    root->w = DVX_MAX(win->contentW, root->calcMinW);
    root->h = DVX_MAX(win->contentH, root->calcMinH);
}
