// 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.

// dvxDialog.c  -- Modal dialogs for DVX GUI
//
// Provides two standard dialog types: message boxes and file dialogs.
// Both use the nested-event-loop modal pattern: the dialog creates a
// window, sets it as the AppContext's modalWindow, then runs dvxUpdate
// in a tight loop until the user dismisses the dialog. This blocks the
// caller's code flow, which is the simplest possible modal API  -- the
// caller gets the result as a return value, no callbacks needed.
//
// The nested loop approach works because dvxUpdate is re-entrant: it
// polls input, dispatches events, and composites. The modalWindow field
// causes handleMouseButton to reject clicks on non-modal windows, so
// only the dialog receives interaction. This is exactly how Windows
// MessageBox and GetOpenFileName work internally.
//
// State for each dialog type is stored in a single static struct (sMsgBox,
// sFd) rather than on the heap. This means only one dialog of each type
// can be active at a time, but that's fine  -- you never need two file
// dialogs simultaneously. The static approach avoids malloc/free and
// keeps the state accessible to callback functions without threading
// context pointers through every widget callback.

#include "dvxDlg.h"
#include "dvxPlat.h"
#include "dvxWgt.h"
#include "dvxWgtP.h"
#include "../../../widgets/kpunch/box/box.h"
#include "../../../widgets/kpunch/button/button.h"
#include "../../../widgets/kpunch/dropdown/dropdown.h"
#include "../../../widgets/kpunch/label/label.h"
#include "../../../widgets/kpunch/listBox/listBox.h"
#include "../../../widgets/kpunch/spacer/spacer.h"
#include "../../../widgets/kpunch/spinner/spinner.h"
#include "../../../widgets/kpunch/textInput/textInpt.h"

#include <ctype.h>
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

// ============================================================
// Constants
// ============================================================

// Message box layout constants. MSG_MAX_WIDTH caps dialog width so long
// messages wrap rather than producing absurdly wide dialogs.
#define MSG_MAX_WIDTH      320
#define MSG_PADDING        8
#define ICON_AREA_WIDTH    40
#define BUTTON_WIDTH       80
#define BUTTON_HEIGHT      24
#define BUTTON_GAP         8

// Icon glyph constants. Icons are drawn procedurally (pixel by pixel)
// rather than using bitmap resources. This avoids needing to ship and
// load icon files, and the glyphs scale-by-design since they're
// defined in terms of geometric shapes. The circle shapes use the
// distance-squared test (d2 between INNER_R2 and OUTER_R2) to draw
// a ring without needing floating-point sqrt.
#define ICON_GLYPH_SIZE    24    // icon glyph drawing area (pixels)
#define ICON_GLYPH_CENTER  (ICON_GLYPH_SIZE / 2)
#define ICON_OUTER_R      ICON_GLYPH_CENTER
#define ICON_INNER_R     (ICON_GLYPH_CENTER - 2)
#define ICON_OUTER_R2    (ICON_OUTER_R * ICON_OUTER_R)
#define ICON_INNER_R2    (ICON_INNER_R * ICON_INNER_R)
#define FD_DIALOG_WIDTH    360   // file dialog window width
#define FD_DIALOG_HEIGHT   340   // file dialog window height
#define FD_MAX_FILTERS     16    // cap on filter labels shown in dropdown
#define FD_PATTERN_MAX     128   // max filter-pattern buffer length
#define FD_EXT_MAX         16    // max appended extension buffer length

// Input box layout constants
#define IB_DIALOG_WIDTH    300
#define IB_INPUT_MAXLEN    256
#define IB_PADDING         8

// Choice dialog layout constants
#define CB_DIALOG_WIDTH  200
#define CB_LIST_HEIGHT   120
#define CB_PADDING       8


// ============================================================
// Word-wrap iterator types
// ============================================================
//
// The greedy word-wrap state machine lives in a single iterator
// (wordWrapIterate) that invokes a per-line callback. Drawing and
// height-measuring are both thin wrappers over that iterator.

typedef void (*WordWrapLineCbT)(const char *line, int32_t lineLen, int32_t lineIndex, void *userData);

typedef struct {
    DisplayT          *d;
    const BlitOpsT    *ops;
    const BitmapFontT *font;
    int32_t            x;
    int32_t            y;
    int32_t            lineH;
    uint32_t           fg;
    uint32_t           bg;
} WordWrapDrawCtxT;


// ============================================================
// State types and static instances
// ============================================================
//
// Static because only one dialog of each kind can be active at a
// time. The 'done' flag is set by button clicks or window close, which
// breaks the nested dvxUpdate loop in each dvx*Dialog function.

typedef struct {
    AppContextT *ctx;
    int32_t      result;      // ID_OK, ID_CANCEL, ID_YES, ID_NO, etc.
    bool         done;        // set true to break the modal loop
    const char  *message;
    int32_t      iconType;
    int32_t      textX;       // pre-computed text origin (accounts for icon)
    int32_t      textY;
    int32_t      textMaxW;    // max text width for word wrapping
    int32_t      msgAreaH;    // height of the message+icon area above the buttons
} MsgBoxStateT;

typedef struct {
    AppContextT *ctx;
    bool         done;
    bool         accepted;     // true = OK, false = Cancel
    WidgetT     *input;        // text input widget
    char        *outBuf;       // caller's output buffer
    int32_t      outBufSize;
} InputBoxStateT;

typedef struct {
    bool     done;
    bool     accepted;
    int32_t *outVal;
    WidgetT *spinner;
} IntBoxStateT;

typedef struct {
    bool     done;
    bool     accepted;
    int32_t *outIdx;
    WidgetT *listBox;
} ChoiceBoxStateT;

typedef struct {
    AppContextT     *ctx;
    bool             done;           // set true to break modal loop
    bool             accepted;       // true if user clicked OK/Open/Save
    int32_t          flags;          // FD_SAVE, etc.
    char             curDir[DVX_MAX_PATH];
    const FileFilterT *filters;      // caller-provided filter list
    int32_t          filterCount;
    int32_t          activeFilter;   // index into filters[]
    char           **entryNames;    // dynamic array of heap-allocated strings
    bool            *entryIsDir;    // dynamic array, parallel to entryNames
    int32_t          entryCount;
    const char     **listItems;     // dynamic array, pointers into entryNames
    WidgetT         *fileList;
    WidgetT         *pathInput;
    WidgetT         *nameInput;
    WidgetT         *filterDd;
    char             outPath[DVX_MAX_PATH];
} FileDialogStateT;

static MsgBoxStateT     sMsgBox;
static InputBoxStateT   sInputBox;
static IntBoxStateT     sIntBox;
static ChoiceBoxStateT  sChoiceBox;
static FileDialogStateT sFd;


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

static void        cbOnCancel(WidgetT *w);
static void        cbOnClose(WindowT *win);
static void        cbOnDblClick(WidgetT *w);
static void        cbOnOk(WidgetT *w);
static void        drawIconCircle(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, uint32_t color);
static void        drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color);
static bool        fdAcceptFile(const char *name);
static void        fdDisplayToDirName(const char *display, char *out, int32_t outSize);
static int         fdEntryCompare(const void *a, const void *b);
static const char *fdExtractPattern(const FileFilterT *f, char *buf, int32_t bufSize);
static bool        fdFilterMatch(const char *name, const char *pattern);
static void        fdFreeEntries(void);
static void        fdLoadDir(void);
static bool        fdMatchSingle(const char *name, const char *pat);
static void        fdNavigate(const char *path);
static void        fdOnCancel(WidgetT *w);
static void        fdOnClose(WindowT *win);
static void        fdOnFilterChange(WidgetT *w);
static void        fdOnListClick(WidgetT *w);
static void        fdOnListDblClick(WidgetT *w);
static void        fdOnOk(WidgetT *w);
static void        fdOnPathKey(WidgetT *w, int32_t keyCode, int32_t shift);
static bool        fdValidateFilename(const char *name);
static void        ibOnCancel(WidgetT *w);
static void        ibOnClose(WindowT *win);
static void        ibOnOk(WidgetT *w);
static void        iibOnCancel(WidgetT *w);
static void        iibOnClose(WindowT *win);
static void        iibOnOk(WidgetT *w);
static void        onButtonClick(WidgetT *w);
static void        onMsgBoxClose(WindowT *win);
static void        onMsgBoxPaint(WindowT *win, RectT *dirtyArea);
static void        restoreModal(AppContextT *ctx, WindowT *prevModal);
static void        wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t maxW, uint32_t fg, uint32_t bg);
static void        wordWrapDrawLine(const char *line, int32_t lineLen, int32_t lineIndex, void *userData);
static int32_t     wordWrapHeight(const BitmapFontT *font, const char *text, int32_t maxW);
static void        wordWrapHeightLine(const char *line, int32_t lineLen, int32_t lineIndex, void *userData);
static void        wordWrapIterate(const BitmapFontT *font, const char *text, int32_t maxW, WordWrapLineCbT cb, void *userData);


static void cbOnCancel(WidgetT *w) {
    (void)w;
    sChoiceBox.done = true;
}


static void cbOnClose(WindowT *win) {
    (void)win;
    sChoiceBox.done = true;
}


static void cbOnDblClick(WidgetT *w) {
    (void)w;
    cbOnOk(w);
}


static void cbOnOk(WidgetT *w) {
    (void)w;

    // Accept only when the selection was actually written -- if the
    // listbox failed to create, returning true would hand the caller
    // an unwritten *outIdx.
    if (sChoiceBox.listBox && sChoiceBox.outIdx) {
        *sChoiceBox.outIdx  = wgtListBoxGetSelected(sChoiceBox.listBox);
        sChoiceBox.accepted = true;
    }

    sChoiceBox.done = true;
}


// Draws the shared 2-pixel ring used by the circular message-box icons
// (info, error, question) via the integer distance-squared test.
static void drawIconCircle(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, uint32_t color) {
    for (int32_t row = 0; row < ICON_GLYPH_SIZE; row++) {
        for (int32_t col = 0; col < ICON_GLYPH_SIZE; col++) {
            int32_t dx = col - ICON_GLYPH_CENTER;
            int32_t dy = row - ICON_GLYPH_CENTER;
            int32_t d2 = dx * dx + dy * dy;

            if (d2 <= ICON_OUTER_R2 && d2 >= ICON_INNER_R2) {
                rectFill(d, ops, x + col, y + row, 1, 1, color);
            }
        }
    }
}


// Procedurally draws message box icons using only integer math:
//   MB_ICONINFO:     circle with 'i' (information)
//   MB_ICONWARNING:  triangle with '!' (warning)
//   MB_ICONERROR:    circle with 'X' (error)
//   MB_ICONQUESTION: circle with '?' (question)
//
// Circles use the integer distance-squared test: for each pixel, compute
// dx*dx + dy*dy and check if it falls between INNER_R2 and OUTER_R2
// to draw a 2-pixel-wide ring. This is a brute-force O(n^2) approach
// but n is only 24 pixels, so it's 576 iterations  -- trivial even on a 486.
//
// The inner symbols (i, !, X, ?) are drawn with hardcoded rectFill calls
// at specific pixel offsets. This is less elegant than using the font
// renderer, but it gives precise control over the glyph appearance at
// this small size where the 8x16 bitmap font would look too blocky.
//
// Drawing 1x1 rects for individual pixels is intentional: it goes
// through the clip rect check in rectFill, so we don't need separate
// bounds checking here.

static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color) {
    if (iconType == MB_ICONINFO) {
        // Circle outline with 'i'
        drawIconCircle(d, ops, x, y, color);

        rectFill(d, ops, x + 11, y + 6, 2, 2, color);
        rectFill(d, ops, x + 11, y + 10, 2, 8, color);
    } else if (iconType == MB_ICONWARNING) {
        // Triangle outline with '!'
        for (int32_t row = 0; row < ICON_GLYPH_SIZE; row++) {
            int32_t halfW = row / 2;
            int32_t lx    = ICON_GLYPH_CENTER - halfW;
            int32_t rx    = ICON_GLYPH_CENTER + halfW;

            rectFill(d, ops, x + lx, y + row, 1, 1, color);
            rectFill(d, ops, x + rx, y + row, 1, 1, color);

            if (row == ICON_GLYPH_SIZE - 1) {
                drawHLine(d, ops, x + lx, y + row, rx - lx + 1, color);
            }
        }

        rectFill(d, ops, x + 11, y + 8, 2, 9, color);
        rectFill(d, ops, x + 11, y + 19, 2, 2, color);
    } else if (iconType == MB_ICONERROR) {
        // Circle outline with X
        drawIconCircle(d, ops, x, y, color);

        for (int32_t i = 0; i < ICON_GLYPH_CENTER; i++) {
            rectFill(d, ops, x + 6 + i, y + 6 + i, 2, 2, color);
            rectFill(d, ops, x + 18 - i, y + 6 + i, 2, 2, color);
        }
    } else if (iconType == MB_ICONQUESTION) {
        // Circle outline with '?'
        drawIconCircle(d, ops, x, y, color);

        rectFill(d, ops, x + 9, y + 6, 6, 2, color);
        rectFill(d, ops, x + 13, y + 8, 2, 4, color);
        rectFill(d, ops, x + 11, y + 12, 2, 3, color);
        rectFill(d, ops, x + 11, y + 17, 2, 2, color);
    }
}


bool dvxChoiceDialog(AppContextT *ctx, const char *title, const char *prompt, const char **items, int32_t itemCount, int32_t defaultIdx, int32_t *outIdx) {
    if (!ctx || !items || itemCount <= 0 || !outIdx) {
        return false;
    }

    int32_t promptH = 0;

    if (prompt && prompt[0]) {
        int32_t textMaxW = CB_DIALOG_WIDTH - CB_PADDING * 2;
        promptH = wordWrapHeight(&ctx->font, prompt, textMaxW);
    }

    int32_t contentH = CB_PADDING + promptH + CB_PADDING + CB_LIST_HEIGHT + CB_PADDING + BUTTON_HEIGHT + CB_PADDING;
    int32_t contentW = CB_DIALOG_WIDTH;

    int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE;
    int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP;

    WindowT *win = dvxCreateWindow(ctx, title ? title : "Choose",
                                   winX, winY,
                                   contentW + CHROME_TOTAL_SIDE * 2,
                                   contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM,
                                   false);

    if (!win) {
        return false;
    }

    win->modal   = true;
    win->onClose = cbOnClose;
    win->maxW    = win->w;
    win->maxH    = win->h;

    sChoiceBox.done     = false;
    sChoiceBox.accepted = false;
    sChoiceBox.outIdx   = outIdx;
    sChoiceBox.listBox  = NULL;

    WidgetT *root = wgtInitWindow(ctx, win);

    if (root) {
        if (prompt && prompt[0]) {
            wgtLabel(root, prompt);
        }

        WidgetT *lb = wgtListBox(root);

        if (lb) {
            lb->weight     = WGT_WEIGHT_FILL;
            lb->minH       = wgtPixels(CB_LIST_HEIGHT);
            lb->onDblClick = cbOnDblClick;
            wgtListBoxSetItems(lb, items, itemCount);

            if (defaultIdx >= 0 && defaultIdx < itemCount) {
                wgtListBoxSetSelected(lb, defaultIdx);
            }

            sChoiceBox.listBox = lb;
        }

        WidgetT *btnRow = wgtHBox(root);

        if (btnRow) {
            btnRow->align = AlignCenterE;

            WidgetT *okBtn = wgtButton(btnRow, "&OK");

            if (okBtn) {
                okBtn->minW    = wgtPixels(BUTTON_WIDTH);
                okBtn->minH    = wgtPixels(BUTTON_HEIGHT);
                okBtn->onClick = cbOnOk;
            }

            WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");

            if (cancelBtn) {
                cancelBtn->minW    = wgtPixels(BUTTON_WIDTH);
                cancelBtn->minH    = wgtPixels(BUTTON_HEIGHT);
                cancelBtn->onClick = cbOnCancel;
            }
        }
    }

    dvxFitWindow(ctx, win);

    WindowT *prevModal = ctx->modalWindow;
    ctx->modalWindow   = win;

    while (!sChoiceBox.done && ctx->running) {
        dvxUpdate(ctx);
    }

    restoreModal(ctx, prevModal);
    dvxDestroyWindow(ctx, win);
    sChoiceBox.listBox = NULL;

    return sChoiceBox.accepted;
}


// Creates a modal file dialog using the widget system. The layout is:
//   - Path input (with Enter-to-navigate)
//   - File listbox (single-click selects, double-click opens/accepts)
//   - Optional filter dropdown (only shown if filters are provided)
//   - Filename input
//   - OK/Cancel button row
//
// The filter dropdown uses a static label array because the dropdown
// widget takes const char** and the filter labels need to persist for
// the dialog's lifetime. Static is safe since only one file dialog can
// be active at a time.

bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const char *initialDir, const FileFilterT *filters, int32_t filterCount, char *outPath, int32_t outPathSize) {
    if (!ctx || !outPath || outPathSize <= 0) {
        return false;
    }

    memset(&sFd, 0, sizeof(sFd));
    sFd.ctx          = ctx;
    sFd.flags        = flags;
    sFd.filters      = filters;
    sFd.filterCount  = filterCount;
    sFd.activeFilter = 0;

    // Set initial directory
    if (initialDir && initialDir[0]) {
        strncpy(sFd.curDir, initialDir, DVX_MAX_PATH - 1);
    } else {
        if (!getcwd(sFd.curDir, DVX_MAX_PATH)) {
            // Unknown cwd: resolve accepted names relative to '.' rather
            // than the filesystem root.
            strcpy(sFd.curDir, ".");
        }
    }

    sFd.curDir[DVX_MAX_PATH - 1] = '\0';

    // Create dialog window
    int32_t dlgW = FD_DIALOG_WIDTH;
    int32_t dlgH = FD_DIALOG_HEIGHT;
    int32_t winX = (ctx->display.width - dlgW) / 2;
    int32_t winY = (ctx->display.height - dlgH) / 2;

    WindowT *win = dvxCreateWindow(ctx, title ? title : ((flags & FD_SAVE) ? "Save As" : "Open"), winX, winY, dlgW, dlgH, false);

    if (!win) {
        return false;
    }

    win->modal   = true;
    win->onClose = fdOnClose;

    WidgetT *root = wgtInitWindow(ctx, win);

    if (!root) {
        dvxDestroyWindow(ctx, win);
        return false;
    }

    // Path row
    WidgetT *pathRow = wgtHBox(root);

    if (pathRow) {
        wgtLabel(pathRow, "&Path:");
        sFd.pathInput = wgtTextInput(pathRow, DVX_MAX_PATH);

        // Navigate on Enter, not on every keystroke: onChange fires per
        // character, so the user could never type a path -- a transiently
        // valid directory string would navigate and rewrite the input
        // mid-edit (and stat() on each key).
        if (sFd.pathInput) {
            sFd.pathInput->onKeyDown = fdOnPathKey;
        }
    }

    // File list
    sFd.fileList = wgtListBox(root);

    if (sFd.fileList) {
        sFd.fileList->weight     = WGT_WEIGHT_FILL;
        sFd.fileList->onChange   = fdOnListClick;
        sFd.fileList->onDblClick = fdOnListDblClick;
    }

    // Filter row (if filters provided)
    if (filters && filterCount > 0) {
        WidgetT *filterRow = wgtHBox(root);

        if (filterRow) {
            wgtLabel(filterRow, "F&ilter:");
            sFd.filterDd = wgtDropdown(filterRow);

            // Build filter label array (static  -- lives for dialog lifetime)
            static const char *filterLabels[FD_MAX_FILTERS];
            int32_t fc = filterCount < FD_MAX_FILTERS ? filterCount : FD_MAX_FILTERS;

            for (int32_t i = 0; i < fc; i++) {
                filterLabels[i] = filters[i].label;
            }

            wgtDropdownSetItems(sFd.filterDd, filterLabels, fc);
            wgtDropdownSetSelected(sFd.filterDd, 0);
            sFd.filterDd->onChange = fdOnFilterChange;
        }
    }

    // Filename row
    WidgetT *nameRow = wgtHBox(root);

    if (nameRow) {
        wgtLabel(nameRow, "File &name:");
        sFd.nameInput = wgtTextInput(nameRow, DVX_MAX_PATH);
    }

    // Button row
    WidgetT *btnRow = wgtHBox(root);

    if (btnRow) {
        btnRow->align = AlignEndE;

        WidgetT *okBtn = wgtButton(btnRow, (flags & FD_SAVE) ? "&Save" : "&Open");

        if (okBtn) {
            okBtn->onClick = fdOnOk;
            okBtn->minW    = wgtPixels(BUTTON_WIDTH);
        }

        WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");

        if (cancelBtn) {
            cancelBtn->onClick = fdOnCancel;
            cancelBtn->minW    = wgtPixels(BUTTON_WIDTH);
        }
    }

    // Load initial directory
    fdLoadDir();
    wgtInvalidate(root);

    // Modal loop.  Save/restore the previous modal so a file dialog
    // opened from inside another modal (e.g. a Browse button) does not
    // clear the outer modal's input protection on close.
    WindowT *prevModal = ctx->modalWindow;
    ctx->modalWindow   = win;

    while (!sFd.done && ctx->running) {
        dvxUpdate(ctx);
    }

    restoreModal(ctx, prevModal);

    // Build result path
    bool result = false;

    if (sFd.accepted) {
        const char *name = wgtGetText(sFd.nameInput);

        if (name && name[0]) {
            char tmp[DVX_MAX_PATH * 2];
            snprintf(tmp, sizeof(tmp), "%s" DVX_PATH_SEP "%s", sFd.curDir, name);
            strncpy(sFd.outPath, tmp, DVX_MAX_PATH - 1);
            sFd.outPath[DVX_MAX_PATH - 1] = '\0';

            // outPath/outPathSize are validated non-NULL/positive at entry.
            strncpy(outPath, sFd.outPath, outPathSize - 1);
            outPath[outPathSize - 1] = '\0';

            result = true;
        }
    }

    // Cleanup
    dvxDestroyWindow(ctx, win);
    fdFreeEntries();

    return result;
}


// Modal input dialog with prompt, text field, OK/Cancel buttons.
// Follows the same nested-event-loop pattern as dvxMessageBox.

bool dvxInputBox(AppContextT *ctx, const char *title, const char *prompt, const char *defaultText, char *outBuf, int32_t outBufSize) {
    if (!ctx || !outBuf || outBufSize <= 0) {
        return false;
    }

    int32_t promptH = 0;

    if (prompt && prompt[0]) {
        int32_t textMaxW = IB_DIALOG_WIDTH - IB_PADDING * 2;
        promptH = wordWrapHeight(&ctx->font, prompt, textMaxW);
    }

    int32_t contentH = IB_PADDING + promptH + IB_PADDING + ctx->font.charHeight + 4 + IB_PADDING + BUTTON_HEIGHT + IB_PADDING;
    int32_t contentW = IB_DIALOG_WIDTH;

    int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE;
    int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP;

    WindowT *win = dvxCreateWindow(ctx, title ? title : "Input",
                                   winX, winY,
                                   contentW + CHROME_TOTAL_SIDE * 2,
                                   contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM,
                                   false);

    if (!win) {
        return false;
    }

    win->modal   = true;
    win->onClose = ibOnClose;
    win->maxW    = win->w;
    win->maxH    = win->h;

    sInputBox.ctx       = ctx;
    sInputBox.done      = false;
    sInputBox.accepted  = false;
    sInputBox.outBuf    = outBuf;
    sInputBox.outBufSize = outBufSize;

    WidgetT *root = wgtInitWindow(ctx, win);

    if (root) {
        // Prompt label
        if (prompt && prompt[0]) {
            WidgetT *lbl = wgtLabel(root, prompt);
            (void)lbl;
        }

        // Text input
        WidgetT *input = wgtTextInput(root, IB_INPUT_MAXLEN);

        if (input) {
            input->weight   = 0;
            sInputBox.input = input;

            if (defaultText) {
                wgtSetText(input, defaultText);
            }
        }

        // Button row
        WidgetT *btnRow = wgtHBox(root);

        if (btnRow) {
            btnRow->align = AlignCenterE;

            WidgetT *okBtn = wgtButton(btnRow, "&OK");

            if (okBtn) {
                okBtn->minW    = wgtPixels(BUTTON_WIDTH);
                okBtn->minH    = wgtPixels(BUTTON_HEIGHT);
                okBtn->onClick = ibOnOk;
            }

            WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");

            if (cancelBtn) {
                cancelBtn->minW    = wgtPixels(BUTTON_WIDTH);
                cancelBtn->minH    = wgtPixels(BUTTON_HEIGHT);
                cancelBtn->onClick = ibOnCancel;
            }
        }
    }

    dvxFitWindow(ctx, win);

    WindowT *prevModal = ctx->modalWindow;
    ctx->modalWindow   = win;

    while (!sInputBox.done && ctx->running) {
        dvxUpdate(ctx);
    }

    restoreModal(ctx, prevModal);
    dvxDestroyWindow(ctx, win);
    sInputBox.input = NULL;

    return sInputBox.accepted;
}


bool dvxIntInputBox(AppContextT *ctx, const char *title, const char *prompt, int32_t defaultVal, int32_t minVal, int32_t maxVal, int32_t step, int32_t *outVal) {
    if (!ctx || !outVal) {
        return false;
    }

    int32_t promptH = 0;

    if (prompt && prompt[0]) {
        int32_t textMaxW = IB_DIALOG_WIDTH - IB_PADDING * 2;
        promptH = wordWrapHeight(&ctx->font, prompt, textMaxW);
    }

    int32_t contentH = IB_PADDING + promptH + IB_PADDING + ctx->font.charHeight + 8 + IB_PADDING + BUTTON_HEIGHT + IB_PADDING;
    int32_t contentW = IB_DIALOG_WIDTH;

    int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE;
    int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP;

    WindowT *win = dvxCreateWindow(ctx, title ? title : "Input",
                                   winX, winY,
                                   contentW + CHROME_TOTAL_SIDE * 2,
                                   contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM,
                                   false);

    if (!win) {
        return false;
    }

    win->modal   = true;
    win->onClose = iibOnClose;
    win->maxW    = win->w;
    win->maxH    = win->h;

    sIntBox.done     = false;
    sIntBox.accepted = false;
    sIntBox.outVal   = outVal;
    sIntBox.spinner  = NULL;

    WidgetT *root = wgtInitWindow(ctx, win);

    if (root) {
        if (prompt && prompt[0]) {
            wgtLabel(root, prompt);
        }

        WidgetT *spinner = wgtSpinner(root, minVal, maxVal, step);

        if (spinner) {
            wgtSpinnerSetValue(spinner, defaultVal);
            sIntBox.spinner = spinner;
        }

        WidgetT *btnRow = wgtHBox(root);

        if (btnRow) {
            btnRow->align = AlignCenterE;

            WidgetT *okBtn = wgtButton(btnRow, "&OK");

            if (okBtn) {
                okBtn->minW    = wgtPixels(BUTTON_WIDTH);
                okBtn->minH    = wgtPixels(BUTTON_HEIGHT);
                okBtn->onClick = iibOnOk;
            }

            WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");

            if (cancelBtn) {
                cancelBtn->minW    = wgtPixels(BUTTON_WIDTH);
                cancelBtn->minH    = wgtPixels(BUTTON_HEIGHT);
                cancelBtn->onClick = iibOnCancel;
            }
        }
    }

    dvxFitWindow(ctx, win);

    WindowT *prevModal = ctx->modalWindow;
    ctx->modalWindow   = win;

    while (!sIntBox.done && ctx->running) {
        dvxUpdate(ctx);
    }

    restoreModal(ctx, prevModal);
    dvxDestroyWindow(ctx, win);
    sIntBox.spinner = NULL;

    return sIntBox.accepted;
}


// Creates and runs a modal message box. The flags parameter is a bitmask:
// low nibble selects button set (MB_OK, MB_YESNO, etc.), next nibble
// selects icon type (MB_ICONINFO, MB_ICONERROR, etc.). This is the same
// flag encoding Windows MessageBox uses, which makes porting code easier.
//
// The dialog is auto-sized to fit the word-wrapped message text plus the
// button row. Non-resizable (maxW/maxH clamped to initial size) because
// resizing a message box serves no purpose.
//
// Button labels use '&' to mark accelerator keys (e.g., "&OK" makes
// Alt+O activate the button). Button IDs are stored in widget->userData
// via intptr_t cast  -- a common pattern when you need to associate a
// small integer with a widget without allocating a separate struct.

int32_t dvxErrorBox(AppContextT *ctx, const char *title, const char *message) {
    return dvxMessageBox(ctx, title ? title : "Error", message, MB_OK | MB_ICONERROR);
}


int32_t dvxInfoBox(AppContextT *ctx, const char *title, const char *message) {
    return dvxMessageBox(ctx, title ? title : "Information", message, MB_OK | MB_ICONINFO);
}


int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags) {
    if (!ctx || !message) {
        return ID_CANCEL;
    }

    int32_t btnFlags  = flags & MB_BUTTON_MASK;
    int32_t iconFlags = flags & MB_ICON_MASK;

    // Determine button labels and IDs
    const char *btnLabels[3];
    int32_t     btnIds[3];
    int32_t     btnCount = 0;

    switch (btnFlags) {
        case MB_OK:
            btnLabels[0] = "&OK";
            btnIds[0]    = ID_OK;
            btnCount     = 1;
            break;

        case MB_OKCANCEL:
            btnLabels[0] = "&OK";
            btnIds[0]    = ID_OK;
            btnLabels[1] = "&Cancel";
            btnIds[1]    = ID_CANCEL;
            btnCount     = 2;
            break;

        case MB_YESNO:
            btnLabels[0] = "&Yes";
            btnIds[0]    = ID_YES;
            btnLabels[1] = "&No";
            btnIds[1]    = ID_NO;
            btnCount     = 2;
            break;

        case MB_YESNOCANCEL:
            btnLabels[0] = "&Yes";
            btnIds[0]    = ID_YES;
            btnLabels[1] = "&No";
            btnIds[1]    = ID_NO;
            btnLabels[2] = "&Cancel";
            btnIds[2]    = ID_CANCEL;
            btnCount     = 3;
            break;

        case MB_RETRYCANCEL:
            btnLabels[0] = "&Retry";
            btnIds[0]    = ID_RETRY;
            btnLabels[1] = "&Cancel";
            btnIds[1]    = ID_CANCEL;
            btnCount     = 2;
            break;

        default:
            btnLabels[0] = "&OK";
            btnIds[0]    = ID_OK;
            btnCount     = 1;
            break;
    }

    // Calculate message text dimensions
    bool    hasIcon  = (iconFlags != 0);
    int32_t textMaxW = MSG_MAX_WIDTH - MSG_PADDING * 2 - (hasIcon ? ICON_AREA_WIDTH : 0);
    int32_t textH    = wordWrapHeight(&ctx->font, message, textMaxW);

    // Calculate content area sizes
    int32_t msgAreaH  = textH + MSG_PADDING * 2;
    int32_t iconAreaH = hasIcon ? (ICON_GLYPH_SIZE + MSG_PADDING * 2) : 0;

    if (msgAreaH < iconAreaH) {
        msgAreaH = iconAreaH;
    }

    int32_t buttonsW = btnCount * BUTTON_WIDTH + (btnCount - 1) * BUTTON_GAP;
    int32_t contentW = MSG_MAX_WIDTH;

    if (buttonsW + MSG_PADDING * 2 > contentW) {
        contentW = buttonsW + MSG_PADDING * 2;
    }

    int32_t contentH = msgAreaH + BUTTON_HEIGHT + MSG_PADDING * 3;

    // Create the dialog window (non-resizable)
    int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE;
    int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP;

    WindowT *win = dvxCreateWindow(ctx, title, winX, winY,
                                   contentW + CHROME_TOTAL_SIDE * 2,
                                   contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM,
                                   false);

    if (!win) {
        return ID_CANCEL;
    }

    win->modal = true;

    // Set up state
    sMsgBox.ctx      = ctx;
    sMsgBox.result   = ID_CANCEL;
    sMsgBox.done     = false;
    sMsgBox.message  = message;
    sMsgBox.iconType = iconFlags;
    sMsgBox.textX    = MSG_PADDING + (hasIcon ? ICON_AREA_WIDTH : 0);
    sMsgBox.textY    = MSG_PADDING;
    sMsgBox.textMaxW = textMaxW;
    sMsgBox.msgAreaH = msgAreaH;

    // Create button widgets using wgtInitWindow for proper root setup
    // (sets onPaint, onMouse, onKey, onResize, userData on root)
    WidgetT *root = wgtInitWindow(ctx, win);

    // Override onPaint with our custom handler, set window-level state
    win->userData = &sMsgBox;
    win->onPaint  = onMsgBoxPaint;
    win->onClose  = onMsgBoxClose;
    win->maxW     = win->w;
    win->maxH     = win->h;

    if (root) {
        // Spacer for message area (text/icon drawn by onPaint)
        WidgetT *msgSpacer = wgtSpacer(root);

        if (msgSpacer) {
            msgSpacer->minH = wgtPixels(msgAreaH);
        }

        // Button row centered
        WidgetT *btnRow = wgtHBox(root);

        if (btnRow) {
            btnRow->align = AlignCenterE;

            for (int32_t i = 0; i < btnCount; i++) {
                WidgetT *btn = wgtButton(btnRow, btnLabels[i]);

                if (btn) {
                    btn->minW     = wgtPixels(BUTTON_WIDTH);
                    btn->minH     = wgtPixels(BUTTON_HEIGHT);
                    btn->userData = (void *)(intptr_t)btnIds[i];
                    btn->onClick  = onButtonClick;
                }
            }
        }

        // Bottom padding
        WidgetT *bottomSpacer = wgtSpacer(root);

        if (bottomSpacer) {
            bottomSpacer->minH = wgtPixels(MSG_PADDING);
        }
    }

    // Initial paint (window is already correctly sized, don't call dvxFitWindow)
    RectT fullRect = { 0, 0, win->contentW, win->contentH };
    win->onPaint(win, &fullRect);

    // Save previous modal so stacked modals work correctly. This happens
    // when a message box opens from within a file dialog (e.g., overwrite
    // confirmation)  -- the file dialog's modal is pushed and restored
    // when the message box closes.
    WindowT *prevModal = ctx->modalWindow;
    ctx->modalWindow   = win;

    // Nested event loop  -- blocks here until user clicks a button or closes.
    // dvxUpdate handles all input/rendering; sMsgBox.done is set by the
    // button onClick callback or the window close callback.
    while (!sMsgBox.done && ctx->running) {
        dvxUpdate(ctx);
    }

    restoreModal(ctx, prevModal);
    dvxDestroyWindow(ctx, win);

    return sMsgBox.result;
}


int32_t dvxPromptSave(AppContextT *ctx, const char *title) {
    int32_t result = dvxMessageBox(ctx, title ? title : "Save",
        "Save changes?", MB_YESNOCANCEL | MB_ICONQUESTION);

    if (result == ID_YES) {
        return DVX_SAVE_YES;
    }

    if (result == ID_NO) {
        return DVX_SAVE_NO;
    }

    return DVX_SAVE_CANCEL;
}


// Validates the filename (platform-specific rules), then checks for
// confirmation scenarios: save dialog + existing file = "overwrite?",
// open dialog + missing file = "create?". The nested dvxMessageBox calls
// work because the modal system supports stacking (prevModal is saved and
// restored). Returns false if the user cancelled at any confirmation step.

static bool fdAcceptFile(const char *name) {
    if (!fdValidateFilename(name)) {
        return false;
    }

    char fullPath[DVX_MAX_PATH * 2];
    snprintf(fullPath, sizeof(fullPath), "%s" DVX_PATH_SEP "%s", sFd.curDir, name);

    struct stat st;
    bool exists = (stat(fullPath, &st) == 0 && S_ISREG(st.st_mode));

    if ((sFd.flags & FD_SAVE) && exists) {
        // Save dialog: confirm overwrite of existing file
        char msg[DVX_MAX_PATH * 2 + 64];
        snprintf(msg, sizeof(msg), "%s already exists.\nDo you want to replace it?", name);

        if (dvxMessageBox(sFd.ctx, "Confirm Save", msg, MB_YESNO | MB_ICONQUESTION) != ID_YES) {
            return false;
        }
    } else if (!(sFd.flags & FD_SAVE) && !exists) {
        // Open dialog: confirm creation of non-existent file
        char msg[DVX_MAX_PATH * 2 + 64];
        snprintf(msg, sizeof(msg), "%s does not exist.\nDo you want to create it?", name);

        if (dvxMessageBox(sFd.ctx, "Confirm Create", msg, MB_YESNO | MB_ICONQUESTION) != ID_YES) {
            return false;
        }
    }

    sFd.accepted = true;
    sFd.done     = true;
    return true;
}


// Convert a list display name to a real directory name by stripping the
// surrounding brackets used for visual distinction ("[name]" -> "name").
static void fdDisplayToDirName(const char *display, char *out, int32_t outSize) {
    if (display[0] == '[') {
        strncpy(out, display + 1, outSize - 1);
        out[outSize - 1] = '\0';
        char *bracket = strchr(out, ']');

        if (bracket) {
            *bracket = '\0';
        }
    } else {
        strncpy(out, display, outSize - 1);
        out[outSize - 1] = '\0';
    }
}


// Sort comparator for the indirect sort in fdLoadDir. Uses an index
// array rather than sorting the entryNames/entryIsDir arrays directly
// because qsort would need a struct or the comparator would need to
// move both arrays in sync. Indirect sort with an index array is
// simpler and avoids that coordination problem.

static int fdEntryCompare(const void *a, const void *b) {
    int32_t ia = *(const int32_t *)a;
    int32_t ib = *(const int32_t *)b;

    // Dirs before files
    if (sFd.entryIsDir[ia] != sFd.entryIsDir[ib]) {
        return sFd.entryIsDir[ia] ? -1 : 1;
    }

    return stricmp(sFd.entryNames[ia], sFd.entryNames[ib]);
}


// Extract glob pattern from inside parentheses in label.

static const char *fdExtractPattern(const FileFilterT *f, char *buf, int32_t bufSize) {
    const char *open = strchr(f->label, '(');
    const char *close = open ? strchr(open, ')') : NULL;

    if (open && close && close > open + 1) {
        int32_t len = (int32_t)(close - open - 1);

        if (len >= bufSize) {
            len = bufSize - 1;
        }

        memcpy(buf, open + 1, len);
        buf[len] = '\0';
        return buf;
    }

    // No parens -- use the whole label as the pattern
    snprintf(buf, bufSize, "%s", f->label);
    return buf;
}


// Check if filename matches a glob pattern.
//
// Supports only the most common DOS file filter patterns: "*.*", "*",
// and "*.ext". Full glob matching isn't needed because file dialogs
// historically only use extension-based filters. The case-insensitive
// extension compare handles DOS's case-insensitive filesystem behavior.

// Match a filename against a pattern string that may contain multiple
// semicolon-delimited patterns (e.g. "*.bmp;*.jpg;*.png").
static bool fdFilterMatch(const char *name, const char *pattern) {
    if (!pattern || pattern[0] == '\0') {
        return true;
    }

    // Work on a copy so we can tokenize with NUL
    char buf[FD_PATTERN_MAX];
    strncpy(buf, pattern, sizeof(buf) - 1);
    buf[sizeof(buf) - 1] = '\0';

    char *p = buf;

    while (*p) {
        // Find the end of this token
        char *semi = strchr(p, ';');

        if (semi) {
            *semi = '\0';
        }

        // Trim leading whitespace
        while (*p == ' ') {
            p++;
        }

        if (fdMatchSingle(name, p)) {
            return true;
        }

        if (!semi) {
            break;
        }

        p = semi + 1;
    }

    return false;
}


// Free allocated entry name strings.

static void fdFreeEntries(void) {
    for (int32_t i = 0; i < sFd.entryCount; i++) {
        free(sFd.entryNames[i]);
    }

    free(sFd.entryNames);
    free(sFd.entryIsDir);
    free(sFd.listItems);
    sFd.entryNames = NULL;
    sFd.entryIsDir = NULL;
    sFd.listItems  = NULL;
    sFd.entryCount = 0;
}


// Reads the current directory, applies the active file filter, sorts
// (directories first, then alphabetical), and updates the listbox widget.
// Entry names are strdup'd because dirent buffers are reused by readdir.
// The sort is done via an index array to avoid shuffling two parallel
// arrays; after sorting the index array, the actual arrays are rebuilt
// in sorted order with a single memcpy pass.

static void fdLoadDir(void) {
    fdFreeEntries();

    // fdFreeEntries just freed sFd.listItems, but the listbox still holds
    // that exact pointer array (wgtListBoxSetItems stores it uncopied).
    // Drop the widget's reference now so any early return below cannot
    // leave the listbox painting freed memory.
    wgtListBoxSetItems(sFd.fileList, NULL, 0);

    const char *pattern = NULL;
    char patBuf[FD_PATTERN_MAX];

    if (sFd.filters && sFd.activeFilter >= 0 && sFd.activeFilter < sFd.filterCount) {
        pattern = fdExtractPattern(&sFd.filters[sFd.activeFilter], patBuf, sizeof(patBuf));
    }

    DIR *dir = opendir(sFd.curDir);

    if (!dir) {
        return;
    }

    struct dirent *ent;

    while ((ent = readdir(dir)) != NULL) {
        // Skip "."
        if (strcmp(ent->d_name, ".") == 0) {
            continue;
        }

        // Build full path to stat
        char fullPath[DVX_MAX_PATH * 2];
        snprintf(fullPath, sizeof(fullPath), "%s" DVX_PATH_SEP "%s", sFd.curDir, ent->d_name);

        struct stat st;

        if (stat(fullPath, &st) != 0) {
            continue;
        }

        bool isDir = S_ISDIR(st.st_mode);

        // Apply filter to files only
        if (!isDir && !fdFilterMatch(ent->d_name, pattern)) {
            continue;
        }

        // Grow arrays.  realloc through temporaries so a failure keeps the
        // existing (valid) arrays instead of overwriting them with NULL --
        // entryCount is not yet incremented, so on break the arrays stay
        // consistent with entryCount and the sort below won't deref NULL.
        int32_t idx = sFd.entryCount;
        char **newNames = (char **)realloc(sFd.entryNames, (idx + 1) * sizeof(char *));

        if (!newNames) {
            dvxLog("Dialog: failed to realloc entry name array");
            break;
        }

        sFd.entryNames = newNames;

        bool *newIsDir = (bool *)realloc(sFd.entryIsDir, (idx + 1) * sizeof(bool));

        if (!newIsDir) {
            dvxLog("Dialog: failed to realloc entry dir-flag array");
            break;
        }

        sFd.entryIsDir = newIsDir;

        sFd.entryIsDir[idx] = isDir;

        if (isDir) {
            // Prefix dirs with brackets
            char buf[DVX_MAX_PATH];
            snprintf(buf, sizeof(buf), "[%s]", ent->d_name);
            sFd.entryNames[idx] = strdup(buf);
        } else {
            sFd.entryNames[idx] = strdup(ent->d_name);
        }

        // Don't commit a NULL name -- entryCount is not yet incremented,
        // so on break the arrays stay consistent and the sort comparator
        // and listbox never see a NULL entry name.
        if (!sFd.entryNames[idx]) {
            dvxLog("Dialog: failed to strdup entry name");
            break;
        }

        sFd.entryCount++;
    }

    closedir(dir);

    if (sFd.entryCount == 0) {
        wgtListBoxSetItems(sFd.fileList, NULL, 0);
        return;
    }

    // Sort: build index array, sort, reorder. On alloc failure, leave
    // entries in their unsorted-but-valid order rather than failing the
    // whole listing.
    int32_t *sortIdx  = (int32_t *)malloc(sFd.entryCount * sizeof(int32_t));
    char   **tmpNames = (char **)malloc(sFd.entryCount * sizeof(char *));
    bool    *tmpIsDir = (bool *)malloc(sFd.entryCount * sizeof(bool));

    if (!sortIdx || !tmpNames || !tmpIsDir) {
        dvxLog("Dialog: failed to allocate sort arrays");
    } else {
        for (int32_t i = 0; i < sFd.entryCount; i++) {
            sortIdx[i] = i;
        }

        qsort(sortIdx, sFd.entryCount, sizeof(int32_t), fdEntryCompare);

        for (int32_t i = 0; i < sFd.entryCount; i++) {
            tmpNames[i] = sFd.entryNames[sortIdx[i]];
            tmpIsDir[i] = sFd.entryIsDir[sortIdx[i]];
        }

        memcpy(sFd.entryNames, tmpNames, sizeof(char *) * sFd.entryCount);
        memcpy(sFd.entryIsDir, tmpIsDir, sizeof(bool) * sFd.entryCount);
    }

    free(sortIdx);
    free(tmpNames);
    free(tmpIsDir);

    // Build listItems pointer array for the listbox
    sFd.listItems = (const char **)realloc(sFd.listItems, sFd.entryCount * sizeof(const char *));

    if (!sFd.listItems) {
        dvxLog("Dialog: failed to realloc listItems");
        return;
    }

    for (int32_t i = 0; i < sFd.entryCount; i++) {
        sFd.listItems[i] = sFd.entryNames[i];
    }

    wgtListBoxSetItems(sFd.fileList, sFd.listItems, sFd.entryCount);
    wgtListBoxSetSelected(sFd.fileList, 0);

    // Update path display
    wgtSetText(sFd.pathInput, sFd.curDir);
}


// Match a filename against a single *.ext pattern (case-insensitive).
static bool fdMatchSingle(const char *name, const char *pat) {
    if (strcmp(pat, "*.*") == 0 || strcmp(pat, "*") == 0) {
        return true;
    }

    if (pat[0] == '*' && pat[1] == '.') {
        const char *ext = strrchr(name, '.');

        if (!ext) {
            return false;
        }

        ext++;
        const char *patExt = pat + 2;

        while (*patExt && *ext) {
            if (tolower((unsigned char)*patExt) != tolower((unsigned char)*ext)) {
                return false;
            }

            patExt++;
            ext++;
        }

        return (*patExt == '\0' && *ext == '\0');
    }

    return true;
}


// Change to a new directory.
//
// Handles both absolute and relative paths. Relative paths are resolved
// against curDir. realpath is used to canonicalize the result (resolve
// ".." components, symlinks) so the path display always shows a clean
// absolute path. The stat check ensures we don't try to navigate into
// a file or nonexistent path.

static void fdNavigate(const char *path) {
    char resolved[DVX_MAX_PATH];

    if (path[0] == '/' || path[0] == '\\' ||
        (path[1] == ':' && (path[2] == '/' || path[2] == '\\'))) {
        strncpy(resolved, path, DVX_MAX_PATH - 1);
        resolved[DVX_MAX_PATH - 1] = '\0';
    } else {
        char tmp[DVX_MAX_PATH * 2];
        snprintf(tmp, sizeof(tmp), "%s" DVX_PATH_SEP "%s", sFd.curDir, path);
        strncpy(resolved, tmp, DVX_MAX_PATH - 1);
        resolved[DVX_MAX_PATH - 1] = '\0';
    }

    // Verify it's a directory
    struct stat st;

    if (stat(resolved, &st) != 0 || !S_ISDIR(st.st_mode)) {
        return;
    }

    // Canonicalize the path
    char canon[DVX_MAX_PATH];

    if (realpath(resolved, canon) != NULL) {
        strncpy(sFd.curDir, canon, DVX_MAX_PATH - 1);
        sFd.curDir[DVX_MAX_PATH - 1] = '\0';
    } else {
        strncpy(sFd.curDir, resolved, DVX_MAX_PATH - 1);
        sFd.curDir[DVX_MAX_PATH - 1] = '\0';
    }

    fdLoadDir();
}


// Cancel button clicked.

static void fdOnCancel(WidgetT *w) {
    (void)w;
    sFd.accepted = false;
    sFd.done     = true;
}


// Window close button.

static void fdOnClose(WindowT *win) {
    (void)win;
    sFd.accepted = false;
    sFd.done     = true;
}


// Filter dropdown changed.

static void fdOnFilterChange(WidgetT *w) {
    sFd.activeFilter = wgtDropdownGetSelected(w);
    fdLoadDir();
}


// File list selection changed.

static void fdOnListClick(WidgetT *w) {
    int32_t sel = wgtListBoxGetSelected(w);

    if (sel < 0 || sel >= sFd.entryCount) {
        return;
    }

    if (!sFd.entryIsDir[sel]) {
        wgtSetText(sFd.nameInput, sFd.entryNames[sel]);
    }
}


// File list double-click.
//
// Double-click on a directory navigates into it. Double-click on a file
// accepts it immediately (equivalent to selecting + clicking OK). The
// bracket stripping (removing "[" and "]") is needed because directory
// names in the list are displayed as "[dirname]" for visual distinction.

static void fdOnListDblClick(WidgetT *w) {
    int32_t sel = wgtListBoxGetSelected(w);

    if (sel < 0 || sel >= sFd.entryCount) {
        return;
    }

    if (sFd.entryIsDir[sel]) {
        const char *display = sFd.entryNames[sel];
        char dirName[DVX_MAX_PATH];

        fdDisplayToDirName(display, dirName, sizeof(dirName));

        fdNavigate(dirName);
        wgtSetText(sFd.nameInput, "");
    } else {
        // Double-click on file  -- accept it (with confirmation if needed)
        wgtSetText(sFd.nameInput, sFd.entryNames[sel]);
        fdAcceptFile(sFd.entryNames[sel]);
    }
}


// OK button clicked.
//
// OK has three behaviors depending on context:
//   1. If a directory is selected in the list, navigate into it
//   2. If the typed filename resolves to a directory, navigate there
//   3. Otherwise, accept the filename (with overwrite/create confirmation)
// This matches Windows file dialog behavior where Enter/OK on a directory
// navigates rather than accepting.

static void fdOnOk(WidgetT *w) {
    (void)w;
    const char *name = wgtGetText(sFd.nameInput);

    // If the filename input is empty and a directory is selected in the
    // list, navigate into it.  But if the user has typed a filename,
    // always accept it  -- don't let the listbox selection override.
    if (!name || name[0] == '\0') {
        int32_t sel = wgtListBoxGetSelected(sFd.fileList);

        if (sel >= 0 && sel < sFd.entryCount && sFd.entryIsDir[sel]) {
            const char *display = sFd.entryNames[sel];
            char dirName[DVX_MAX_PATH];

            fdDisplayToDirName(display, dirName, sizeof(dirName));

            fdNavigate(dirName);
            return;
        }

        return;
    }

    // If user typed a directory path, navigate there
    char testPath[DVX_MAX_PATH * 2];
    snprintf(testPath, sizeof(testPath), "%s" DVX_PATH_SEP "%s", sFd.curDir, name);

    struct stat st;

    if (stat(testPath, &st) == 0 && S_ISDIR(st.st_mode)) {
        fdNavigate(testPath);
        wgtSetText(sFd.nameInput, "");
        return;
    }

    // Save dialog: if the filename has no extension, append the first
    // extension from the active filter pattern. Wildcards like "*.*" or
    // "*" don't add an extension.
    char nameWithExt[DVX_MAX_PATH];

    if ((sFd.flags & FD_SAVE) && !strchr(name, '.')) {
        char extPatBuf[FD_PATTERN_MAX];
        const char *pattern = NULL;
        const char *ext     = NULL;

        if (sFd.filters && sFd.activeFilter >= 0 && sFd.activeFilter < sFd.filterCount) {
            pattern = fdExtractPattern(&sFd.filters[sFd.activeFilter], extPatBuf, sizeof(extPatBuf));
        }

        // Find first non-wildcard extension in pattern (may be "*.txt;*.doc")
        if (pattern) {
            const char *p = pattern;

            while (*p) {
                if (p[0] == '*' && p[1] == '.') {
                    const char *e = p + 2;

                    // Skip wildcards like "*.*"
                    if (*e != '*' && *e != '\0') {
                        ext = p + 1; // points to ".ext"
                        break;
                    }
                }

                // Skip to next semicolon-delimited pattern
                const char *semi = strchr(p, ';');

                if (semi) {
                    p = semi + 1;
                } else {
                    break;
                }
            }
        }

        if (ext) {
            // Extract just the extension (up to ; or end)
            char extBuf[FD_EXT_MAX];
            int32_t ei = 0;

            while (ext[ei] && ext[ei] != ';' && ei < FD_EXT_MAX - 1) {
                extBuf[ei] = ext[ei];
                ei++;
            }

            extBuf[ei] = '\0';
            snprintf(nameWithExt, sizeof(nameWithExt), "%s%s", name, extBuf);
            name = nameWithExt;
            wgtSetText(sFd.nameInput, name);
        }
    }

    // Accept the file (with confirmation if needed)
    fdAcceptFile(name);
}


// Enter pressed in path input.

static void fdOnPathKey(WidgetT *w, int32_t keyCode, int32_t shift) {
    (void)shift;

    if (keyCode != KEY_ENTER) {
        return;
    }

    const char *path = wgtGetText(w);

    if (path && path[0]) {
        fdNavigate(path);
    }
}


// Check filename is valid for target OS.

static bool fdValidateFilename(const char *name) {
    const char *error = platformValidateFilename(name);

    if (error) {
        dvxErrorBox(sFd.ctx, "Invalid Filename", error);
        return false;
    }

    return true;
}


static void ibOnCancel(WidgetT *w) {
    (void)w;
    sInputBox.accepted = false;
    sInputBox.done     = true;
}


static void ibOnClose(WindowT *win) {
    (void)win;
    sInputBox.accepted = false;
    sInputBox.done     = true;
}


static void ibOnOk(WidgetT *w) {
    (void)w;

    // Accept only when the text was actually written -- if the input
    // failed to create, returning true would hand the caller an
    // unwritten outBuf.
    if (sInputBox.input && sInputBox.outBuf) {
        const char *text = wgtGetText(sInputBox.input);
        snprintf(sInputBox.outBuf, sInputBox.outBufSize, "%s", text ? text : "");
        sInputBox.accepted = true;
    }

    sInputBox.done = true;
}


static void iibOnCancel(WidgetT *w) {
    (void)w;
    sIntBox.accepted = false;
    sIntBox.done     = true;
}


static void iibOnClose(WindowT *win) {
    (void)win;
    sIntBox.accepted = false;
    sIntBox.done     = true;
}


static void iibOnOk(WidgetT *w) {
    (void)w;

    // Accept only when the value was actually written -- if the spinner
    // failed to create, returning true would hand the caller an
    // unwritten *outVal.
    if (sIntBox.spinner && sIntBox.outVal) {
        *sIntBox.outVal  = wgtSpinnerGetValue(sIntBox.spinner);
        sIntBox.accepted = true;
    }

    sIntBox.done = true;
}


static void onButtonClick(WidgetT *w) {
    sMsgBox.result = (int32_t)(intptr_t)w->userData;
    sMsgBox.done   = true;
}


static void onMsgBoxClose(WindowT *win) {
    (void)win;
    sMsgBox.result = ID_CANCEL;
    sMsgBox.done   = true;
}


// Custom paint: background + text/icon + widgets.
//
// The message box uses a custom onPaint callback rather than pure widgets
// because the message text area with optional icon doesn't map cleanly to
// the widget model. The paint callback creates a temporary display context
// that points at the window's content buffer (not the screen backbuffer),
// draws the background/text/icon directly, then runs the widget layout
// and paint for the button row. The separator line between the message
// area and buttons uses a highlight-over-shadow pair to create a Motif
// etched-line effect.

static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea) {
    (void)dirtyArea;

    MsgBoxStateT *state = (MsgBoxStateT *)win->userData;
    AppContextT  *ctx   = state->ctx;

    // Create a temporary display context targeting the window's content
    // buffer. This is the standard pattern for drawing into a window's
    // private buffer rather than the screen backbuffer.
    DisplayT cd  = ctx->display;
    cd.lfb       = win->contentBuf;
    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;

    // Fill background with window face color (not content bg  -- dialog style)
    rectFill(&cd, &ctx->blitOps, 0, 0, win->contentW, win->contentH, ctx->colors.windowFace);

    // Draw word-wrapped message text
    wordWrapDraw(&cd, &ctx->blitOps, &ctx->font, state->textX, state->textY, state->message, state->textMaxW, ctx->colors.contentFg, ctx->colors.windowFace);

    // Draw icon
    if (state->iconType != 0) {
        drawIconGlyph(&cd, &ctx->blitOps, MSG_PADDING, MSG_PADDING, state->iconType, ctx->colors.contentFg);
    }

    // Draw separator line above buttons
    drawHLine(&cd, &ctx->blitOps, 0, state->msgAreaH, win->contentW, ctx->colors.windowShadow);
    drawHLine(&cd, &ctx->blitOps, 0, state->msgAreaH + 1, win->contentW, ctx->colors.windowHighlight);

    // Paint widget tree (buttons) on top
    if (win->widgetRoot) {
        WidgetT *root = win->widgetRoot;

        // Layout widgets
        widgetCalcMinSizeTree(root, &ctx->font);
        root->x = 0;
        root->y = 0;
        root->w = win->contentW;
        root->h = win->contentH;
        widgetLayoutChildren(root, &ctx->font);

        // Paint widgets
        wgtPaint(root, &cd, &ctx->blitOps, &ctx->font, &ctx->colors, true);
    }
}


// Restore the modal window saved before a nested dialog loop.  The loop
// may have destroyed, hidden, or deferred-destroyed the saved window
// (its form was unloaded, its app was force-killed), so validate it by
// scanning the live window stack -- pointer comparison only, since
// prevModal may point at freed memory -- before dereferencing it.  A
// stale save restores to no-modal instead of re-arming the input gate
// on a window hit-testing can never reach.

static void restoreModal(AppContextT *ctx, WindowT *prevModal) {
    ctx->modalWindow = NULL;

    if (!prevModal) {
        return;
    }

    for (int32_t i = 0; i < ctx->stack.count; i++) {
        if (ctx->stack.windows[i] == prevModal) {
            if (prevModal->visible && !prevModal->destroyPending) {
                ctx->modalWindow = prevModal;
            }

            return;
        }
    }
}


// Draw word-wrapped text. Delegates the line-breaking to wordWrapIterate
// and draws each emitted line via the wordWrapDrawLine callback.

static void wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t maxW, uint32_t fg, uint32_t bg) {
    WordWrapDrawCtxT ctx;

    ctx.d     = d;
    ctx.ops   = ops;
    ctx.font  = font;
    ctx.x     = x;
    ctx.y     = y;
    ctx.lineH = font->charHeight;
    ctx.fg    = fg;
    ctx.bg    = bg;

    wordWrapIterate(font, text, maxW, wordWrapDrawLine, &ctx);
}


// Per-line draw callback for wordWrapIterate. Computes the line's pixel Y
// from its index and draws it. Empty lines (lineLen == 0, from explicit
// newlines) draw nothing because drawTextN no-ops on a zero count.

static void wordWrapDrawLine(const char *line, int32_t lineLen, int32_t lineIndex, void *userData) {
    WordWrapDrawCtxT *ctx  = (WordWrapDrawCtxT *)userData;
    int32_t           curY = ctx->y + lineIndex * ctx->lineH;

    drawTextN(ctx->d, ctx->ops, ctx->font, ctx->x, curY, line, lineLen, ctx->fg, ctx->bg, true);
}


// Compute height of word-wrapped text. Delegates the line-breaking to
// wordWrapIterate and counts the emitted lines via wordWrapHeightLine.

static int32_t wordWrapHeight(const BitmapFontT *font, const char *text, int32_t maxW) {
    int32_t lines = 0;

    wordWrapIterate(font, text, maxW, wordWrapHeightLine, &lines);

    if (lines == 0) {
        lines = 1;
    }

    return lines * font->charHeight;
}


// Per-line count callback for wordWrapIterate. Increments the caller's
// line counter; the line contents are not needed for measuring.

static void wordWrapHeightLine(const char *line, int32_t lineLen, int32_t lineIndex, void *userData) {
    (void)line;
    (void)lineLen;
    (void)lineIndex;
    *(int32_t *)userData += 1;
}


// Greedy word-wrap state machine.
//
// Fills each line with as many characters as fit within maxW, breaking at
// the last space if the line overflows. If a single word is longer than
// maxW, it gets its own line (may be clipped). Explicit newlines are
// honored. This is a fixed-width font, so "max chars per line" is just
// maxW / charWidth  -- no per-character width accumulation needed.
//
// For each completed line the callback is invoked with the line's start
// pointer, length, and zero-based index. The callbacks compute any pixel
// offsets from the index, so no Y math lives here.

static void wordWrapIterate(const BitmapFontT *font, const char *text, int32_t maxW, WordWrapLineCbT cb, void *userData) {
    int32_t charW     = font->charWidth;
    int32_t maxChars  = maxW / charW;
    int32_t lineIndex = 0;

    if (maxChars < 1) {
        maxChars = 1;
    }

    while (*text) {
        // Skip leading spaces
        while (*text == ' ') {
            text++;
        }

        if (*text == '\0') {
            break;
        }

        // Handle explicit newlines (emit an empty line slot)
        if (*text == '\n') {
            cb(text, 0, lineIndex, userData);
            lineIndex++;
            text++;
            continue;
        }

        // Find how many characters fit on this line
        int32_t lineLen   = 0;
        int32_t lastSpace = -1;

        while (text[lineLen] && text[lineLen] != '\n' && lineLen < maxChars) {
            if (text[lineLen] == ' ') {
                lastSpace = lineLen;
            }

            lineLen++;
        }

        // If we didn't reach end and didn't hit newline, wrap at word boundary
        if (text[lineLen] && text[lineLen] != '\n' && lastSpace > 0) {
            lineLen = lastSpace;
        }

        cb(text, lineLen, lineIndex, userData);
        lineIndex++;
        text += lineLen;

        // Skip the newline that ended this line (if any) so the
        // explicit \n handler at the top doesn't double-advance.
        if (*text == '\n') {
            text++;
        }
    }
}
