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

// ideDesigner.c -- DVX BASIC form designer implementation
//
// WYSIWYG design surface using real DVX widgets positioned
// identically to how the form runtime lays them out. Selection
// handles and grid dots are drawn in the window's onPaint callback.

#include "ideDesigner.h"
#include "../formrt/formrt.h"
#include "../formrt/frmParser.h"
#include "dvxDraw.h"
#include "dvxVideo.h"
#include "dvxWm.h"
#include "stb_ds_wrap.h"

#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>

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

#define DEFAULT_CTRL_W        100
#define DEFAULT_CTRL_H        30
#define MIN_CTRL_SIZE         8
#define DSGN_INDENT_SPACES    4
#define DSGN_MAX_INDENT_BUF   32
#define DSGN_SEL_HANDLE_COUNT 8   // resize handles drawn around a selected control
#define DSGN_NEWLINE_ROOM     2   // "\n" before and after the code section

// Names of the .frm keys handled outside the integer table.
#define DSGN_KEY_CAPTION      "Caption"
#define DSGN_KEY_TEXT         "Text"
#define DSGN_KEY_LAYOUT       "Layout"
#define DSGN_KEY_VISIBLE      "Visible"
#define DSGN_KEY_ENABLED      "Enabled"
#define DSGN_KEY_NAME         "Name"
#define DSGN_KEY_LEFT         "Left"
#define DSGN_KEY_TOP          "Top"
#define DSGN_KEY_WIDTH        "Width"
#define DSGN_KEY_HEIGHT       "Height"
#define DSGN_KEY_AUTOSIZE     "AutoSize"
#define DSGN_KEY_RESIZABLE    "Resizable"
#define DSGN_KEY_CENTERED     "Centered"
#define DSGN_KEY_HELPTOPIC    "HelpTopic"

// The Form type is not a widget, so it has no interface default event.
static const char *FORM_DEFAULT_EVENT = "Load";

// ============================================================
// Built-in integer control properties (see DsgnIntPropT)
// ============================================================

static const DsgnIntPropT sIntProps[] = {
    { "Left",      NULL,     offsetof(DsgnControlT, left),      DSGN_SAVE_IF_NONZERO  },
    { "Top",       NULL,     offsetof(DsgnControlT, top),       DSGN_SAVE_IF_NONZERO  },
    { "MinWidth",  "Width",  offsetof(DsgnControlT, width),     DSGN_SAVE_ALWAYS      },
    { "MinHeight", "Height", offsetof(DsgnControlT, height),    DSGN_SAVE_ALWAYS      },
    { "MaxWidth",  NULL,     offsetof(DsgnControlT, maxWidth),  DSGN_SAVE_IF_POSITIVE },
    { "MaxHeight", NULL,     offsetof(DsgnControlT, maxHeight), DSGN_SAVE_IF_POSITIVE },
    { "Weight",    NULL,     offsetof(DsgnControlT, weight),    DSGN_SAVE_IF_POSITIVE }
};

#define DSGN_INT_PROP_COUNT ((int32_t)(sizeof(sIntProps) / sizeof(sIntProps[0])))

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

static WidgetT     *containerChildParent(DsgnControlT *pc);
static int32_t      emitControlHead(const DsgnControlT *ctrl, char *buf, int32_t bufSize, int32_t pos, const char *pad);
static int32_t      emitPad(char *buf, int32_t bufSize, int32_t pos, int32_t count);
static int32_t      emitPropLine(char *buf, int32_t bufSize, int32_t pos, const char *pad, const char *name, uint8_t type, const char *value);
static int32_t      hitTestControl(const DsgnStateT *ds, int32_t x, int32_t y);
static DsgnHandleE  hitTestHandles(const DsgnControlT *ctrl, int32_t x, int32_t y);
static int32_t      intPropGet(const DsgnControlT *ctrl, const DsgnIntPropT *ip);
static void         intPropSet(DsgnControlT *ctrl, const DsgnIntPropT *ip, int32_t val);
static void         loadOnCtrlBegin(void *userData, const char *typeName, const char *name);
static void         loadOnCtrlEnd(void *userData);
static void         loadOnCtrlProp(void *userData, const char *key, const char *value);
static bool         loadOnFormBegin(void *userData, const char *name);
static void         loadOnFormEnd(void *userData, const char *trailingSrc, int32_t trailingLen);
static void         loadOnFormProp(void *userData, const char *key, const char *value);
static void         loadOnMenuBegin(void *userData, const char *name, int32_t level);
static void         loadOnMenuEnd(void *userData);
static void         loadOnMenuProp(void *userData, const char *key, const char *value);
static DsgnFormT   *newFormDefaults(const char *name, bool autoSize);
static int32_t      saveControls(const DsgnFormT *form, char *buf, int32_t bufSize, int32_t pos, const char *parentName, int32_t indent);
static void         trySwapNeighbor(DsgnStateT *ds, int32_t y, int32_t dir);


// ============================================================
// .frm parsing context (used by the loadOn* callbacks)
// ============================================================
//
// parentStack mirrors the parser's Begin/End block nesting: every control
// block pushes its name, so a child's parentName is always the enclosing
// block whether or not that type's widget is loaded.  nestDepth keeps
// counting past FRM_MAX_NESTING so pushes and pops stay paired; only
// the stack writes and reads are bounded.

typedef struct {
    DsgnFormT     *form;
    DsgnControlT  *current;
    int32_t        curMenuItemIdx;
    char           parentStack[FRM_MAX_NESTING][DSGN_MAX_NAME];
    int32_t        nestDepth;
} DsgnFrmLoadCtxT;


// Widget that a container's children attach to.  Containers with a
// non-VBox Layout property nest their children inside a content box tagged
// with the control pointer so it is created exactly once.
static WidgetT *containerChildParent(DsgnControlT *pc) {
    WidgetT    *parent = pc->widget;
    const char *layout = dsgnControlGetPropValue(pc, DSGN_KEY_LAYOUT);

    if (!layout || !layout[0] || strcasecmp(layout, DSGN_VBOX_LAYOUT) == 0) {
        return parent;
    }

    // pc is non-NULL, so a NULL firstChild->userData also fails the == pc
    // test -- no separate NULL check needed.
    if (parent->firstChild && parent->firstChild->userData == (void *)pc) {
        return parent->firstChild;
    }

    WidgetT *box = basFormRtCreateContentBox(parent, layout);

    // basFormRtCreateContentBox returns the container itself when the
    // layout type is not a loaded parent container; only tag a real box.
    if (box != parent) {
        box->userData = (void *)pc;
    }

    return box;
}


void dsgnAutoName(const DsgnStateT *ds, const char *typeName, char *buf, int32_t bufSize) {
    // Look up the name prefix from the widget interface descriptor.
    // Falls back to the type name itself if no prefix is registered.
    const char *prefix  = typeName;
    const char *wgtName = wgtFindByBasName(typeName);

    if (wgtName) {
        const WgtIfaceT *iface = wgtGetIface(wgtName);

        if (iface && iface->namePrefix) {
            prefix = iface->namePrefix;
        }
    }

    int32_t highest   = 0;
    int32_t prefixLen = (int32_t)strlen(prefix);
    int32_t count     = ds->form ? (int32_t)arrlen(ds->form->controls) : 0;

    for (int32_t i = 0; i < count; i++) {
        if (strncasecmp(ds->form->controls[i]->name, prefix, prefixLen) == 0) {
            int32_t num = atoi(ds->form->controls[i]->name + prefixLen);

            if (num > highest) {
                highest = num;
            }
        }
    }

    snprintf(buf, bufSize, "%s%d", prefix, (int)(highest + 1));
}


void dsgnBuildPreviewMenuBar(WindowT *win, const DsgnFormT *form) {
    if (!win || !form) {
        return;
    }

    int32_t menuCount = (int32_t)arrlen(form->menuItems);

    if (menuCount <= 0) {
        return;
    }

    MenuBarT *bar = wmAddMenuBar(win);

    if (!bar) {
        return;
    }

    MenuT *menuStack[DSGN_MENU_STACK_DEPTH];
    memset(menuStack, 0, sizeof(menuStack));

    for (int32_t i = 0; i < menuCount; i++) {
        const DsgnMenuItemT *mi = &form->menuItems[i];
        bool isSep       = (mi->caption[0] == '-');
        bool isSubParent = (i + 1 < menuCount && form->menuItems[i + 1].level > mi->level);

        if (mi->level == 0) {
            // Only show top-level menus with Visible=True on the bar; popup-only
            // menus (Visible=False) become standalone popups at runtime, so keep
            // them and their children off the preview bar by leaving parent NULL.
            menuStack[0] = mi->visible ? wmAddMenu(bar, mi->caption) : NULL;

            // Clear deeper slots so a new top-level menu can't inherit a stale
            // submenu pointer from the previous menu (otherwise an invisible
            // menu's deep children would attach under the prior visible menu).
            for (int32_t d = 1; d < DSGN_MENU_STACK_DEPTH; d++) {
                menuStack[d] = NULL;
            }

            continue;
        }

        MenuT *parentMenu = (mi->level > 0 && mi->level < DSGN_MENU_STACK_DEPTH) ? menuStack[mi->level - 1] : NULL;

        if (!parentMenu) {
            continue;
        }

        if (isSep) {
            wmAddMenuSeparator(parentMenu);
        } else if (isSubParent) {
            menuStack[mi->level] = wmAddSubMenu(parentMenu, mi->caption);
        } else {
            int32_t id = DSGN_MENU_ID_BASE + i;

            if (mi->radioCheck) {
                wmAddMenuRadioItem(parentMenu, mi->caption, id, mi->checked);
            } else if (mi->checked) {
                wmAddMenuCheckItem(parentMenu, mi->caption, id, true);
            } else {
                wmAddMenuItem(parentMenu, mi->caption, id);
            }
        }
    }
}


const char *dsgnControlGetPropValue(const DsgnControlT *ctrl, const char *name) {
    for (int32_t i = 0; i < ctrl->propCount; i++) {
        if (strcasecmp(ctrl->props[i].name, name) == 0) {
            return ctrl->props[i].value;
        }
    }

    return NULL;
}


// Create a real DVX widget for design-time display.  Uses the shared
// createWidgetByIface switch from formrt; design-time refuses
// WGT_CREATE_PARENT_DATA widgets (Image/ImageButton) since we have no
// pixel data.
WidgetT *dsgnCreateDesignWidget(const char *vbTypeName, WidgetT *parent) {
    const char *wgtName = resolveTypeName(vbTypeName);

    if (!wgtName) {
        return NULL;
    }

    const void *api = wgtGetApi(wgtName);

    if (!api) {
        return NULL;
    }

    return createWidgetByIface(wgtGetIface(wgtName), api, parent, false);
}


// Single pass in controls[] order: a container always precedes its children
// (the loader, the tree reorder, and the sibling-only canvas reorder all
// preserve that), so each child finds its parent's widget already created.
void dsgnCreateWidgets(DsgnStateT *ds, WidgetT *contentBox) {
    if (!ds->form || !contentBox) {
        return;
    }

    ds->form->contentBox = contentBox;
    int32_t count = (int32_t)arrlen(ds->form->controls);

    for (int32_t i = 0; i < count; i++) {
        DsgnControlT *ctrl = ds->form->controls[i];

        if (ctrl->widget) {
            continue;
        }

        WidgetT *parent = contentBox;

        if (ctrl->parentName[0]) {
            for (int32_t j = 0; j < count; j++) {
                if (j != i && ds->form->controls[j]->widget &&
                    strcasecmp(ds->form->controls[j]->name, ctrl->parentName) == 0) {
                    parent = containerChildParent(ds->form->controls[j]);
                    break;
                }
            }
        }

        WidgetT *w = dsgnCreateDesignWidget(ctrl->typeName, parent);

        if (!w) {
            continue;
        }

        ctrl->widget = w;
        wgtSetName(w, ctrl->name);

        // Set Caption/Text
        const char *caption = dsgnControlGetPropValue(ctrl, DSGN_KEY_CAPTION);
        const char *text    = dsgnControlGetPropValue(ctrl, DSGN_KEY_TEXT);

        if (caption) {
            wgtSetText(w, caption);
        }

        if (text) {
            wgtSetText(w, text);
        }

        dsgnSyncWidgetGeom(ctrl);

        // Do not apply ctrl->visible here: hidden widgets get no layout
        // geometry, which would make a Visible=False control invisible
        // and unclickable on the design canvas.  Visible is stored on the
        // control and applied by the runtime loader instead.
        wgtSetEnabled(w, ctrl->enabled);

        // Apply interface properties (Alignment, etc.) from FRM data
        const char *wgtName = wgtFindByBasName(ctrl->typeName);
        const WgtIfaceT *iface = wgtName ? wgtGetIface(wgtName) : NULL;

        if (iface) {
            for (int32_t pi = 0; pi < iface->propCount; pi++) {
                const WgtPropDescT *p = &iface->props[pi];

                if (!p->setFn) {
                    continue;
                }

                const char *val = dsgnControlGetPropValue(ctrl, p->name);

                if (val) {
                    wgtApplyPropFromString(w, p, val);
                }
            }
        }
    }
}


const char *dsgnDefaultEvent(const char *typeName) {
    if (strcasecmp(typeName, "Form") == 0) {
        return FORM_DEFAULT_EVENT;
    }

    const char *wgtName = wgtFindByBasName(typeName);

    if (wgtName) {
        const WgtIfaceT *iface = wgtGetIface(wgtName);

        if (iface && iface->defaultEvent) {
            return iface->defaultEvent;
        }
    }

    return "Click";
}


int32_t dsgnEmitControl(const DsgnControlT *ctrl, char *buf, int32_t bufSize, int32_t pos, int32_t indent) {
    char    pad[DSGN_MAX_INDENT_BUF];
    int32_t padLen = indent * DSGN_INDENT_SPACES;

    if (padLen > DSGN_MAX_INDENT_BUF - 1) {
        padLen = DSGN_MAX_INDENT_BUF - 1;
    }

    memset(pad, ' ', padLen);
    pad[padLen] = '\0';

    pos = emitControlHead(ctrl, buf, bufSize, pos, pad);
    return emitClamped(buf, bufSize, pos, "%sEnd\n", pad);
}


const DsgnIntPropT *dsgnFindIntProp(const char *name) {
    for (int32_t i = 0; i < DSGN_INT_PROP_COUNT; i++) {
        const DsgnIntPropT *ip = &sIntProps[i];

        if (strcasecmp(ip->name, name) == 0 || (ip->alias && strcasecmp(ip->alias, name) == 0)) {
            return ip;
        }
    }

    return NULL;
}


// Text value of one runtime form property as the designer stores it.
// Returns false for properties the designer has no field for (Visible,
// ContextMenu: runtime-only), so both the .frm writer and the property
// grid can walk basFormRtFormProps() and skip those.
bool dsgnFormPropValue(const DsgnFormT *form, const char *name, char *out, int32_t outSize) {
    const char *text   = NULL;
    int32_t     num    = 0;
    bool        flag   = false;
    bool        isNum  = false;
    bool        isFlag = false;

    if (strcasecmp(name, DSGN_KEY_NAME) == 0) {
        text = form->name;
    } else if (strcasecmp(name, DSGN_KEY_CAPTION) == 0) {
        text = form->caption;
    } else if (strcasecmp(name, DSGN_KEY_LAYOUT) == 0) {
        text = form->layout;
    } else if (strcasecmp(name, DSGN_KEY_HELPTOPIC) == 0) {
        text = form->helpTopic;
    } else if (strcasecmp(name, DSGN_KEY_LEFT) == 0) {
        num   = form->left;
        isNum = true;
    } else if (strcasecmp(name, DSGN_KEY_TOP) == 0) {
        num   = form->top;
        isNum = true;
    } else if (strcasecmp(name, DSGN_KEY_WIDTH) == 0) {
        num   = form->width;
        isNum = true;
    } else if (strcasecmp(name, DSGN_KEY_HEIGHT) == 0) {
        num   = form->height;
        isNum = true;
    } else if (strcasecmp(name, DSGN_KEY_AUTOSIZE) == 0) {
        flag   = form->autoSize;
        isFlag = true;
    } else if (strcasecmp(name, DSGN_KEY_RESIZABLE) == 0) {
        flag   = form->resizable;
        isFlag = true;
    } else if (strcasecmp(name, DSGN_KEY_CENTERED) == 0) {
        flag   = form->centered;
        isFlag = true;
    }

    if (text) {
        snprintf(out, outSize, "%s", text);
    } else if (isNum) {
        snprintf(out, outSize, "%d", (int)num);
    } else if (isFlag) {
        snprintf(out, outSize, "%s", flag ? "True" : "False");
    } else {
        return false;
    }

    return true;
}


void dsgnFree(DsgnStateT *ds) {
    if (ds->form) {
        for (int32_t i = 0; i < arrlen(ds->form->controls); i++) {
            free(ds->form->controls[i]);
        }
        arrfree(ds->form->controls);
        arrfree(ds->form->menuItems);
        free(ds->form->code);
        free(ds->form);
        ds->form = NULL;
    }
}


// Returns true if the widget type registers an interface property with the
// given name.  The generic built-in Visible/Enabled designer rows yield to
// such a property so a widget can give those names its own meaning (e.g. the
// Timer's "Enabled" maps to its running state, VB-style) without producing a
// duplicate grid row or a conflicting saved line.
bool dsgnIfaceHasProp(const char *typeName, const char *propName) {
    return findIfaceProp(typeName, propName) != NULL;
}


bool dsgnIfacePropValue(const DsgnControlT *ctrl, const WgtPropDescT *p, char *out, int32_t outSize) {
    const char *stored = dsgnControlGetPropValue(ctrl, p->name);

    if (stored) {
        snprintf(out, outSize, "%s", stored);
        return true;
    }

    return wgtPropValueToString(ctrl->widget, p, out, outSize);
}


void dsgnInit(DsgnStateT *ds, AppContextT *ctx) {
    memset(ds, 0, sizeof(*ds));
    ds->selectedIdx  = -1;
    ds->activeHandle = HANDLE_NONE;
    ds->ctx          = ctx;
}


const DsgnIntPropT *dsgnIntPropAt(int32_t idx) {
    if (idx < 0 || idx >= DSGN_INT_PROP_COUNT) {
        return NULL;
    }

    return &sIntProps[idx];
}


bool dsgnIsContainer(const char *typeName) {
    const char *wgtName = wgtFindByBasName(typeName);

    if (wgtName) {
        const WgtIfaceT *iface = wgtGetIface(wgtName);

        if (iface) {
            return iface->isContainer;
        }
    }

    return false;
}


bool dsgnLoadFrm(DsgnStateT *ds, const char *source, int32_t sourceLen) {
    if (!source || sourceLen <= 0) {
        return false;
    }

    dsgnFree(ds);

    DsgnFormT *form = newFormDefaults("Form1", true);

    if (!form) {
        return false;
    }

    DsgnFrmLoadCtxT ctx;
    memset(&ctx, 0, sizeof(ctx));
    ctx.form           = form;
    ctx.curMenuItemIdx = -1;

    FrmParserCbsT cbs;
    memset(&cbs, 0, sizeof(cbs));
    cbs.userData    = &ctx;
    cbs.onFormBegin = loadOnFormBegin;
    cbs.onFormProp  = loadOnFormProp;
    cbs.onFormEnd   = loadOnFormEnd;
    cbs.onMenuBegin = loadOnMenuBegin;
    cbs.onMenuEnd   = loadOnMenuEnd;
    cbs.onMenuProp  = loadOnMenuProp;
    cbs.onCtrlBegin = loadOnCtrlBegin;
    cbs.onCtrlEnd   = loadOnCtrlEnd;
    cbs.onCtrlProp  = loadOnCtrlProp;

    ds->form        = form;
    ds->selectedIdx = -1;

    if (!frmParse(source, sourceLen, &cbs)) {
        // The callbacks may already have allocated controls/menuItems/code
        // before a mid-stream failure; dsgnFree tears all of it down.
        dsgnFree(ds);
        return false;
    }

    return true;
}


void dsgnMenuItemInit(DsgnMenuItemT *mi) {
    memset(mi, 0, sizeof(*mi));
    mi->enabled = true;
    mi->visible = true;
}


bool dsgnNameInUse(const DsgnFormT *form, const char *name, const char *exceptName, bool checkMenus) {
    if (exceptName && strcasecmp(name, exceptName) == 0) {
        return false;
    }

    if (strcasecmp(form->name, name) == 0) {
        return true;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
        if (strcasecmp(form->controls[i]->name, name) == 0) {
            return true;
        }
    }

    if (checkMenus) {
        for (int32_t i = 0; i < (int32_t)arrlen(form->menuItems); i++) {
            if (strcasecmp(form->menuItems[i].name, name) == 0) {
                return true;
            }
        }
    }

    return false;
}


void dsgnNewForm(DsgnStateT *ds, const char *name) {
    dsgnFree(ds);
    ds->form        = newFormDefaults(name, false);
    ds->selectedIdx = -1;
}


void dsgnOnKey(DsgnStateT *ds, int32_t key) {
    if (!ds->form) {
        return;
    }

    int32_t count = (int32_t)arrlen(ds->form->controls);

    // Delete key -- remove the selected control and any children
    if (key == KEY_DELETE && ds->selectedIdx >= 0 && ds->selectedIdx < count) {
        // Build the full set of controls to delete (the selected control and
        // every descendant, transitively).  parentName references only the
        // immediate parent, so a single-level child pass would orphan
        // grandchildren -- saveControls then silently drops them because their
        // parentName matches no surviving container.  Controls are identified
        // by name plus array index, because control array members share one
        // name and differ only by ->index.  The keys are wrapped in a struct
        // so arrput (which assigns via =) is a legal struct copy; a bare
        // char[] element type would not compile.
        typedef struct {
            char    name[DSGN_MAX_NAME];
            int32_t index;
        } DelKeyT;

        DelKeyT *delSet = NULL;
        DelKeyT  seed;

        snprintf(seed.name, DSGN_MAX_NAME, "%s", ds->form->controls[ds->selectedIdx]->name);
        seed.index = ds->form->controls[ds->selectedIdx]->index;
        arrput(delSet, seed);

        bool grew = true;

        while (grew) {
            grew = false;

            for (int32_t i = 0; i < count; i++) {
                const char *pName = ds->form->controls[i]->parentName;
                const char *cName = ds->form->controls[i]->name;
                int32_t     cIdx  = ds->form->controls[i]->index;

                if (pName[0] == '\0') {
                    continue;
                }

                bool selfInSet = false;

                for (int32_t s = 0; s < (int32_t)arrlen(delSet); s++) {
                    if (strcasecmp(delSet[s].name, cName) == 0 && delSet[s].index == cIdx) {
                        selfInSet = true;
                        break;
                    }
                }

                if (selfInSet) {
                    continue;
                }

                // parentName cannot name a specific array member, so the
                // parent counts as deleted only when every control bearing
                // that name is already in the set -- a surviving member keeps
                // the child's parent reference valid.
                int32_t nameCount = 0;
                int32_t setCount  = 0;

                for (int32_t j = 0; j < count; j++) {
                    if (strcasecmp(ds->form->controls[j]->name, pName) == 0) {
                        nameCount++;
                    }
                }

                for (int32_t s = 0; s < (int32_t)arrlen(delSet); s++) {
                    if (strcasecmp(delSet[s].name, pName) == 0) {
                        setCount++;
                    }
                }

                if (setCount > 0 && setCount == nameCount) {
                    DelKeyT add;

                    snprintf(add.name, DSGN_MAX_NAME, "%s", cName);
                    add.index = cIdx;
                    arrput(delSet, add);
                    grew = true;
                }
            }
        }

        // Delete every flagged control, highest index first so earlier
        // indices stay valid.
        for (int32_t i = count - 1; i >= 0; i--) {
            bool remove = false;

            for (int32_t s = 0; s < (int32_t)arrlen(delSet); s++) {
                if (strcasecmp(delSet[s].name, ds->form->controls[i]->name) == 0 && delSet[s].index == ds->form->controls[i]->index) {
                    remove = true;
                    break;
                }
            }

            if (remove) {
                free(ds->form->controls[i]);
                arrdel(ds->form->controls, i);
            }
        }

        arrfree(delSet);
        ds->selectedIdx = -1;
        ds->form->dirty = true;

        dsgnRebuildWidgets(ds);
    }
}


void dsgnOnMouse(DsgnStateT *ds, int32_t x, int32_t y, bool drag) {
    if (!ds->form) {
        return;
    }

    int32_t ctrlCount = (int32_t)arrlen(ds->form->controls);

    if (drag) {
        if (ds->mode == DSGN_RESIZING && ds->selectedIdx >= 0 && ds->selectedIdx < ctrlCount) {
            DsgnControlT *ctrl = ds->form->controls[ds->selectedIdx];
            int32_t dx = x - ds->dragStartX;
            int32_t dy = y - ds->dragStartY;

            switch (ds->activeHandle) {
                case HANDLE_E:
                    ctrl->width = ds->dragOrigWidth + dx;
                    break;
                case HANDLE_S:
                    ctrl->height = ds->dragOrigHeight + dy;
                    break;
                case HANDLE_SE:
                    ctrl->width  = ds->dragOrigWidth + dx;
                    ctrl->height = ds->dragOrigHeight + dy;
                    break;
                default:
                    break;
            }

            if (ctrl->width < MIN_CTRL_SIZE) {
                ctrl->width = MIN_CTRL_SIZE;
            }

            if (ctrl->height < MIN_CTRL_SIZE) {
                ctrl->height = MIN_CTRL_SIZE;
            }

            dsgnSyncWidgetGeom(ctrl);
            ds->form->dirty = true;
        } else if (ds->mode == DSGN_REORDERING && ds->selectedIdx >= 0 && ds->selectedIdx < ctrlCount) {
            // Swap with the neighbor in the drag direction once the pointer
            // passes its midpoint.
            int32_t dy = y - ds->dragStartY;

            if (dy > 0) {
                trySwapNeighbor(ds, y, 1);
            } else if (dy < 0) {
                trySwapNeighbor(ds, y, -1);
            }
        }

        return;
    }

    // Mouse click (not drag) -- end any ongoing operation
    if (ds->mode == DSGN_REORDERING || ds->mode == DSGN_RESIZING) {
        ds->mode = DSGN_IDLE;
        return;
    }

    // Pointer tool: select, start resize or reorder
    if (ds->activeTool[0] == '\0') {
        // Check grab handles of selected control first
        if (ds->selectedIdx >= 0 && ds->selectedIdx < ctrlCount) {
            DsgnHandleE handle = hitTestHandles(ds->form->controls[ds->selectedIdx], x, y);

            if (handle != HANDLE_NONE) {
                DsgnControlT *ctrl = ds->form->controls[ds->selectedIdx];
                ds->mode           = DSGN_RESIZING;
                ds->activeHandle   = handle;
                ds->dragStartX     = x;
                ds->dragStartY     = y;
                ds->dragOrigWidth  = ctrl->widget ? ctrl->widget->w : ctrl->width;
                ds->dragOrigHeight = ctrl->widget ? ctrl->widget->h : ctrl->height;
                return;
            }
        }

        // Hit test controls -- click to select, drag to reorder
        int32_t hit = hitTestControl(ds, x, y);

        if (hit >= 0) {
            ds->selectedIdx = hit;
            ds->mode        = DSGN_REORDERING;
            ds->dragStartY  = y;
        } else {
            ds->selectedIdx = -1;
        }

        return;
    }

    // Non-pointer tool: place a new control
    const char *typeName = ds->activeTool;
    DsgnControlT *cp = (DsgnControlT *)calloc(1, sizeof(DsgnControlT));

    if (!cp) {
        return;
    }

    cp->index   = -1;
    cp->visible = true;
    cp->enabled = true;
    dsgnAutoName(ds, typeName, cp->name, DSGN_MAX_NAME);
    snprintf(cp->typeName, DSGN_MAX_NAME, "%s", typeName);
    cp->width  = DEFAULT_CTRL_W;
    cp->height = DEFAULT_CTRL_H;
    dsgnSetPropValue(cp, DSGN_KEY_CAPTION, cp->name);

    // Determine parent: if click is inside a container, nest there
    WidgetT *parentWidget = ds->form->contentBox;

    for (int32_t i = ctrlCount - 1; i >= 0; i--) {
        DsgnControlT *pc = ds->form->controls[i];

        if (pc->widget && dsgnIsContainer(pc->typeName)) {
            int32_t wx = pc->widget->x;
            int32_t wy = pc->widget->y;
            int32_t ww = pc->widget->w;
            int32_t wh = pc->widget->h;

            if (x >= wx && x < wx + ww && y >= wy && y < wy + wh) {
                snprintf(cp->parentName, DSGN_MAX_NAME, "%s", pc->name);
                parentWidget = containerChildParent(pc);
                break;
            }
        }
    }

    // Create the live widget
    if (parentWidget) {
        cp->widget = dsgnCreateDesignWidget(typeName, parentWidget);
        dsgnSyncWidgetGeom(cp);
    }

    arrput(ds->form->controls, cp);
    ds->selectedIdx = (int32_t)arrlen(ds->form->controls) - 1;

    // Set text after the control is in the array; control pointers are heap-allocated and stable
    DsgnControlT *stable = ds->form->controls[ds->selectedIdx];

    if (stable->widget) {
        const char *caption = dsgnControlGetPropValue(stable, DSGN_KEY_CAPTION);
        wgtSetName(stable->widget, stable->name);
        wgtSetText(stable->widget, caption ? caption : stable->name);
    }
    // Leave the same state a normal selection press leaves (selectedIdx is
    // already the new control).  The immediate mouse-release then matches the
    // REORDERING guard and resets to IDLE, instead of re-arming REORDERING and
    // swallowing the user's next click.
    ds->activeTool[0] = '\0';
    ds->mode          = DSGN_REORDERING;
    ds->dragStartY    = y;
    ds->form->dirty   = true;
}


// Draw selection handles on the window's painted surface.
// Called after widgets have painted, using direct display drawing.
void dsgnPaintOverlay(DsgnStateT *ds) {
    if (!ds->form || !ds->form->controls || !ds->ctx || !ds->formWin || !ds->formWin->contentBuf) {
        return;
    }

    int32_t count = (int32_t)arrlen(ds->form->controls);

    if (ds->selectedIdx < 0 || ds->selectedIdx >= count) {
        return;
    }

    DsgnControlT *ctrl = ds->form->controls[ds->selectedIdx];

    if (!ctrl->widget || !ctrl->widget->visible || ctrl->widget->w <= 0 || ctrl->widget->h <= 0) {
        return;
    }

    // Draw into the window's content buffer (same coordinate space as widgets)
    DisplayT cd = ds->ctx->display;
    cd.backBuf  = ds->formWin->contentBuf;
    cd.width    = ds->formWin->contentW;
    cd.height   = ds->formWin->contentH;
    cd.pitch    = ds->formWin->contentPitch;
    cd.clipX    = 0;
    cd.clipY    = 0;
    cd.clipW    = ds->formWin->contentW;
    cd.clipH    = ds->formWin->contentH;

    const BlitOpsT *ops = &ds->ctx->blitOps;
    uint32_t black      = packColor(&cd, 0, 0, 0);
    uint32_t gray       = packColor(&cd, 128, 128, 128);

    int32_t cx = ctrl->widget->x;
    int32_t cy = ctrl->widget->y;
    int32_t cw = ctrl->widget->w;
    int32_t ch = ctrl->widget->h;
    int32_t hs = DSGN_HANDLE_SIZE;

    // All 8 handles: NW, N, NE, E, SE, S, SW, W
    int32_t hx[DSGN_SEL_HANDLE_COUNT] = { cx - hs/2, cx + cw/2 - hs/2, cx + cw - hs/2, cx + cw - hs/2, cx + cw - hs/2, cx + cw/2 - hs/2, cx - hs/2, cx - hs/2 };
    int32_t hy[DSGN_SEL_HANDLE_COUNT] = { cy - hs/2, cy - hs/2, cy - hs/2, cy + ch/2 - hs/2, cy + ch - hs/2, cy + ch - hs/2, cy + ch - hs/2, cy + ch/2 - hs/2 };

    // E (idx 3), S (idx 5), SE (idx 4) are active (black); rest are inactive (gray)
    for (int32_t i = 0; i < DSGN_SEL_HANDLE_COUNT; i++) {
        bool active = (i == 3 || i == 4 || i == 5);
        rectFill(&cd, ops, hx[i], hy[i], hs, hs, active ? black : gray);
    }
}


void dsgnRebuildWidgets(DsgnStateT *ds) {
    WidgetT *parent = ds->form ? ds->form->contentBox : NULL;

    if (!parent) {
        return;
    }

    // Destroy all existing widget children before recreating them, so
    // the old subtrees are freed instead of leaked.
    wgtDestroyChildren(parent);

    int32_t count = (int32_t)arrlen(ds->form->controls);

    for (int32_t i = 0; i < count; i++) {
        ds->form->controls[i]->widget = NULL;
    }

    dsgnCreateWidgets(ds, parent);
}


int32_t dsgnSaveFrm(const DsgnStateT *ds, char *buf, int32_t bufSize) {
    if (!ds->form || !buf || bufSize <= 0) {
        return -1;
    }

    int32_t pos = 0;

    pos = emitClamped(buf, bufSize, pos, "VERSION DVX 1.00\n");
    pos = emitClamped(buf, bufSize, pos, "Begin Form %s\n", ds->form->name);

    // Form header: every runtime form property the designer stores, in
    // table order.  Name comes from the Begin line; Left/Top are implied
    // by Centered and Width/Height by AutoSize; HelpTopic is optional.
    // Layout is read-only at runtime but is the loader's container type.
    int32_t             formPropCount = 0;
    const BasPropDescT *formProps     = basFormRtFormProps(&formPropCount);

    for (int32_t i = 0; i < formPropCount; i++) {
        const BasPropDescT *pd = &formProps[i];
        char                valBuf[DSGN_MAX_TEXT];

        if (strcasecmp(pd->name, DSGN_KEY_NAME) == 0) {
            continue;
        }

        if (ds->form->centered && (strcasecmp(pd->name, DSGN_KEY_LEFT) == 0 || strcasecmp(pd->name, DSGN_KEY_TOP) == 0)) {
            continue;
        }

        if (ds->form->autoSize && (strcasecmp(pd->name, DSGN_KEY_WIDTH) == 0 || strcasecmp(pd->name, DSGN_KEY_HEIGHT) == 0)) {
            continue;
        }

        if (!dsgnFormPropValue(ds->form, pd->name, valBuf, sizeof(valBuf)) || (strcasecmp(pd->name, DSGN_KEY_HELPTOPIC) == 0 && !valBuf[0])) {
            continue;
        }

        pos = emitPropLine(buf, bufSize, pos, "", pd->name, pd->type, valBuf);
    }

    // Output menu items as nested Begin Menu blocks
    {
        int32_t menuCount = (int32_t)arrlen(ds->form->menuItems);
        int32_t curLevel  = 0;

        for (int32_t i = 0; i < menuCount; i++) {
            DsgnMenuItemT *mi = &ds->form->menuItems[i];

            // Close blocks back to this item's level
            while (curLevel > mi->level) {
                curLevel--;

                pos = emitPad(buf, bufSize, pos, (curLevel + 1) * DSGN_INDENT_SPACES);
                pos = emitClamped(buf, bufSize, pos, "End\n");
            }

            // Indent: (level + 1) * 4 spaces (one extra for being inside Form block)
            pos = emitPad(buf, bufSize, pos, (mi->level + 1) * DSGN_INDENT_SPACES);
            pos = emitClamped(buf, bufSize, pos, "Begin Menu %s\n", mi->name);

            // Caption
            pos = emitPad(buf, bufSize, pos, (mi->level + 2) * DSGN_INDENT_SPACES);
            pos = emitClamped(buf, bufSize, pos, "Caption = \"%s\"\n", mi->caption);

            // Optional properties
            if (mi->checked) {
                pos = emitPad(buf, bufSize, pos, (mi->level + 2) * DSGN_INDENT_SPACES);
                pos = emitClamped(buf, bufSize, pos, "Checked = True\n");
            }

            if (mi->radioCheck) {
                pos = emitPad(buf, bufSize, pos, (mi->level + 2) * DSGN_INDENT_SPACES);
                pos = emitClamped(buf, bufSize, pos, "RadioCheck = True\n");
            }

            if (!mi->enabled) {
                pos = emitPad(buf, bufSize, pos, (mi->level + 2) * DSGN_INDENT_SPACES);
                pos = emitClamped(buf, bufSize, pos, "Enabled = False\n");
            }

            // Visible is only meaningful on top-level menus -- False
            // marks it as a popup (not on the menu bar).  Never
            // serialize the default True.
            if (mi->level == 0 && !mi->visible) {
                pos = emitPad(buf, bufSize, pos, (mi->level + 2) * DSGN_INDENT_SPACES);
                pos = emitClamped(buf, bufSize, pos, "Visible = False\n");
            }

            curLevel = mi->level + 1;
        }

        // Close any remaining open menu blocks
        while (curLevel > 0) {
            curLevel--;

            pos = emitPad(buf, bufSize, pos, (curLevel + 1) * DSGN_INDENT_SPACES);
            pos = emitClamped(buf, bufSize, pos, "End\n");
        }
    }

    // Output top-level controls (and recurse into containers)
    pos = saveControls(ds->form, buf, bufSize, pos, "", 1);

    pos = emitClamped(buf, bufSize, pos, "End\n");

    // emitClamped and emitPad both park pos at bufSize-1 once the buffer
    // is full, so reaching it means the form text was cut short.
    if (pos >= bufSize - 1) {
        return -1;
    }

    // Append code section if present
    if (ds->form->code && ds->form->code[0]) {
        int32_t codeLen = (int32_t)strlen(ds->form->code);
        int32_t avail   = bufSize - pos - DSGN_NEWLINE_ROOM - 1;

        if (codeLen > avail) {
            return -1;
        }

        buf[pos++] = '\n';
        memcpy(buf + pos, ds->form->code, codeLen);
        pos += codeLen;

        // Ensure trailing newline
        if (buf[pos - 1] != '\n') {
            buf[pos++] = '\n';
        }
    }

    buf[pos] = '\0';
    return pos;
}


const char *dsgnSelectedName(const DsgnStateT *ds) {
    if (!ds->form) {
        return "";
    }

    if (ds->selectedIdx >= 0 && ds->selectedIdx < (int32_t)arrlen(ds->form->controls)) {
        return ds->form->controls[ds->selectedIdx]->name;
    }

    return ds->form->name;
}


void dsgnSetPropValue(DsgnControlT *ctrl, const char *name, const char *value) {
    for (int32_t i = 0; i < ctrl->propCount; i++) {
        if (strcasecmp(ctrl->props[i].name, name) == 0) {
            snprintf(ctrl->props[i].value, DSGN_MAX_TEXT, "%s", value);
            return;
        }
    }

    if (ctrl->propCount < DSGN_MAX_PROPS) {
        snprintf(ctrl->props[ctrl->propCount].name, DSGN_MAX_NAME, "%s", name);
        snprintf(ctrl->props[ctrl->propCount].value, DSGN_MAX_TEXT, "%s", value);
        ctrl->propCount++;
    }
}


// Update the live widget's size hints from the design data.  minW/minH set
// the floor; maxW/maxH cap the size (0 = no cap).
void dsgnSyncWidgetGeom(DsgnControlT *ctrl) {
    if (!ctrl->widget) {
        return;
    }

    if (ctrl->width > 0) {
        ctrl->widget->minW = wgtPixels(ctrl->width);
    }

    if (ctrl->height > 0) {
        ctrl->widget->minH = wgtPixels(ctrl->height);
    }

    ctrl->widget->maxW   = ctrl->maxWidth  > 0 ? wgtPixels(ctrl->maxWidth)  : 0;
    ctrl->widget->maxH   = ctrl->maxHeight > 0 ? wgtPixels(ctrl->maxHeight) : 0;
    ctrl->widget->weight = ctrl->weight;
}


// Clamped formatted append into a fixed buffer.  Returns pos unchanged once
// pos has reached bufSize; otherwise performs a size-clamped vsnprintf and
// returns the new position, capped at bufSize-1 on truncation.  This avoids
// the snprintf-accumulation overrun where bufSize-pos wraps negative.
// Shared by ideDesigner.c and ideMain.c via ideDesigner.h.
int32_t emitClamped(char *buf, int32_t bufSize, int32_t pos, const char *fmt, ...) {
    va_list args;
    int32_t avail;
    int32_t written;

    if (!buf || pos < 0 || pos >= bufSize) {
        return pos;
    }

    avail = bufSize - pos;
    va_start(args, fmt);
    written = (int32_t)vsnprintf(buf + pos, (size_t)avail, fmt, args);
    va_end(args);

    if (written < 0) {
        return pos;
    }

    if (written >= avail) {
        // Truncated: vsnprintf wrote avail-1 chars + NUL. Advance to
        // the last writable byte before the NUL so we never exceed bufSize-1.
        return bufSize - 1;
    }

    return pos + written;
}


// Emit "Begin Type Name" and every property line of one control, without
// the closing End so the .frm writer can nest children first.
//
// Every value is written in the canonical form for its type so the runtime
// loader can classify it the same way whether it came from a fresh
// placement, a loaded .frm, or the property grid: STRING quoted, BOOL as
// True/False, INT/FLOAT/ENUM unquoted.  Interface-typed keys are never
// written from the raw props[] strings; only keys unknown to the widget
// interface go out as quoted strings.
static int32_t emitControlHead(const DsgnControlT *ctrl, char *buf, int32_t bufSize, int32_t pos, const char *pad) {
    pos = emitClamped(buf, bufSize, pos, "%sBegin %s %s\n", pad, ctrl->typeName, ctrl->name);

    if (ctrl->index >= 0) {
        pos = emitClamped(buf, bufSize, pos, "%s    Index = %d\n", pad, (int)ctrl->index);
    }

    const char *caption = dsgnControlGetPropValue(ctrl, DSGN_KEY_CAPTION);
    const char *text    = dsgnControlGetPropValue(ctrl, DSGN_KEY_TEXT);

    if (caption) {
        pos = emitPropLine(buf, bufSize, pos, pad, DSGN_KEY_CAPTION, WGT_IFACE_STRING, caption);
    }

    if (text) {
        pos = emitPropLine(buf, bufSize, pos, pad, DSGN_KEY_TEXT, WGT_IFACE_STRING, text);
    }

    for (int32_t i = 0; i < DSGN_INT_PROP_COUNT; i++) {
        const DsgnIntPropT *ip  = &sIntProps[i];
        int32_t             val = intPropGet(ctrl, ip);
        bool                emit;

        switch (ip->saveRule) {
            case DSGN_SAVE_IF_NONZERO:
                emit = (val != 0);
                break;
            case DSGN_SAVE_IF_POSITIVE:
                emit = (val > 0);
                break;
            default:
                emit = true;
                break;
        }

        if (emit) {
            pos = emitClamped(buf, bufSize, pos, "%s    %s = %d\n", pad, ip->name, (int)val);
        }
    }

    if (ctrl->helpTopic[0]) {
        pos = emitPropLine(buf, bufSize, pos, pad, DSGN_KEY_HELPTOPIC, WGT_IFACE_STRING, ctrl->helpTopic);
    }

    if (!ctrl->visible && !dsgnIfaceHasProp(ctrl->typeName, DSGN_KEY_VISIBLE)) {
        pos = emitClamped(buf, bufSize, pos, "%s    %s = False\n", pad, DSGN_KEY_VISIBLE);
    }

    if (!ctrl->enabled && !dsgnIfaceHasProp(ctrl->typeName, DSGN_KEY_ENABLED)) {
        pos = emitClamped(buf, bufSize, pos, "%s    %s = False\n", pad, DSGN_KEY_ENABLED);
    }

    // Keys the widget interface does not know: raw strings, quoted.
    for (int32_t j = 0; j < ctrl->propCount; j++) {
        const char *name = ctrl->props[j].name;

        if (strcasecmp(name, DSGN_KEY_CAPTION) == 0 || strcasecmp(name, DSGN_KEY_TEXT) == 0) {
            continue;
        }

        if (findIfaceProp(ctrl->typeName, name)) {
            continue;
        }

        pos = emitPropLine(buf, bufSize, pos, pad, name, WGT_IFACE_STRING, ctrl->props[j].value);
    }

    // Interface properties, typed.
    const char      *wgtName = wgtFindByBasName(ctrl->typeName);
    const WgtIfaceT *iface   = wgtName ? wgtGetIface(wgtName) : NULL;

    if (iface) {
        for (int32_t j = 0; j < iface->propCount; j++) {
            const WgtPropDescT *p = &iface->props[j];
            char                valBuf[DSGN_MAX_TEXT];

            if (!p->setFn) {
                continue;
            }

            if (strcasecmp(p->name, DSGN_KEY_CAPTION) == 0 || strcasecmp(p->name, DSGN_KEY_TEXT) == 0) {
                continue;
            }

            if (dsgnIfacePropValue(ctrl, p, valBuf, sizeof(valBuf))) {
                pos = emitPropLine(buf, bufSize, pos, pad, p->name, p->type, valBuf);
            }
        }
    }

    return pos;
}


static int32_t emitPad(char *buf, int32_t bufSize, int32_t pos, int32_t count) {
    int32_t i;

    if (!buf || pos < 0) {
        return pos;
    }

    for (i = 0; i < count; i++) {
        if (pos >= bufSize - 1) {
            return pos;
        }

        buf[pos] = ' ';
        pos++;
    }

    return pos;
}


// Emit one "Key = Value" line in the canonical form for the value's
// interface type.
static int32_t emitPropLine(char *buf, int32_t bufSize, int32_t pos, const char *pad, const char *name, uint8_t type, const char *value) {
    switch (type) {
        case WGT_IFACE_STRING:
            return emitClamped(buf, bufSize, pos, "%s    %s = \"%s\"\n", pad, name, value);
        case WGT_IFACE_BOOL:
            return emitClamped(buf, bufSize, pos, "%s    %s = %s\n", pad, name, frmParseBool(value) ? "True" : "False");
        case WGT_IFACE_INT:
            return emitClamped(buf, bufSize, pos, "%s    %s = %d\n", pad, name, atoi(value));
        default:
            // FLOAT and ENUM: decimal text or the enum value name, unquoted.
            return emitClamped(buf, bufSize, pos, "%s    %s = %s\n", pad, name, value);
    }
}


const WgtPropDescT *findIfaceProp(const char *typeName, const char *propName) {
    if (!typeName || !typeName[0]) {
        return NULL;
    }

    const char *wgtName = wgtFindByBasName(typeName);

    if (!wgtName) {
        return NULL;
    }

    return wgtIfaceFindProp(wgtGetIface(wgtName), propName);
}


static int32_t hitTestControl(const DsgnStateT *ds, int32_t x, int32_t y) {
    int32_t count = (int32_t)arrlen(ds->form->controls);

    for (int32_t i = count - 1; i >= 0; i--) {
        const DsgnControlT *ctrl = ds->form->controls[i];

        if (!ctrl->widget || !ctrl->widget->visible) {
            continue;
        }

        // Use the widget's actual laid-out position
        int32_t wx = ctrl->widget->x;
        int32_t wy = ctrl->widget->y;
        int32_t ww = ctrl->widget->w;
        int32_t wh = ctrl->widget->h;

        if (x >= wx && x < wx + ww && y >= wy && y < wy + wh) {
            return i;
        }
    }

    return -1;
}


static DsgnHandleE hitTestHandles(const DsgnControlT *ctrl, int32_t x, int32_t y) {
    if (!ctrl->widget) {
        return HANDLE_NONE;
    }

    int32_t cx = ctrl->widget->x;
    int32_t cy = ctrl->widget->y;
    int32_t cw = ctrl->widget->w;
    int32_t ch = ctrl->widget->h;
    int32_t hs = DSGN_HANDLE_SIZE;

    // 3 handles: E (right edge), S (bottom edge), SE (corner)
    int32_t hx[HANDLE_COUNT] = { cx + cw - hs/2, cx + cw/2 - hs/2, cx + cw - hs/2 };
    int32_t hy[HANDLE_COUNT] = { cy + ch/2 - hs/2, cy + ch - hs/2, cy + ch - hs/2 };

    for (int32_t i = 0; i < HANDLE_COUNT; i++) {
        if (x >= hx[i] && x < hx[i] + hs && y >= hy[i] && y < hy[i] + hs) {
            return (DsgnHandleE)i;
        }
    }

    return HANDLE_NONE;
}


static int32_t intPropGet(const DsgnControlT *ctrl, const DsgnIntPropT *ip) {
    return *(const int32_t *)((const char *)ctrl + ip->offset);
}


static void intPropSet(DsgnControlT *ctrl, const DsgnIntPropT *ip, int32_t val) {
    *(int32_t *)((char *)ctrl + ip->offset) = val;
}


static void loadOnCtrlBegin(void *userData, const char *typeName, const char *name) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    if (!ctx->form) {
        return;
    }

    DsgnControlT *cp = (DsgnControlT *)calloc(1, sizeof(DsgnControlT));

    if (cp) {
        cp->index   = -1;
        cp->visible = true;
        cp->enabled = true;
        cp->width   = DEFAULT_CTRL_W;
        cp->height  = DEFAULT_CTRL_H;
        snprintf(cp->name, DSGN_MAX_NAME, "%s", name);
        snprintf(cp->typeName, DSGN_MAX_NAME, "%s", typeName);

        if (ctx->nestDepth > 0 && ctx->nestDepth <= FRM_MAX_NESTING) {
            snprintf(cp->parentName, DSGN_MAX_NAME, "%s", ctx->parentStack[ctx->nestDepth - 1]);
        }

        arrput(ctx->form->controls, cp);
    }

    ctx->current = cp;

    // Push even when the allocation failed so the matching CtrlEnd pop
    // keeps the depth in step with the parser.
    if (ctx->nestDepth < FRM_MAX_NESTING) {
        snprintf(ctx->parentStack[ctx->nestDepth], DSGN_MAX_NAME, "%s", name);
    }

    ctx->nestDepth++;
}


static void loadOnCtrlEnd(void *userData) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    if (ctx->nestDepth > 0) {
        ctx->nestDepth--;
    }

    ctx->current = NULL;
}


static void loadOnCtrlProp(void *userData, const char *key, const char *value) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    if (!ctx->current) {
        return;
    }

    char val[DSGN_MAX_TEXT];
    snprintf(val, sizeof(val), "%s", value);
    frmStripQuotes(val);

    DsgnControlT       *cc = ctx->current;
    const DsgnIntPropT *ip = dsgnFindIntProp(key);

    if (ip) {
        intPropSet(cc, ip, atoi(val));
    } else if (strcasecmp(key, "Index") == 0) {
        cc->index = atoi(val);
    } else if (strcasecmp(key, DSGN_KEY_HELPTOPIC) == 0) {
        snprintf(cc->helpTopic, DSGN_MAX_NAME, "%s", val);
    } else if (strcasecmp(key, DSGN_KEY_VISIBLE) == 0 && !dsgnIfaceHasProp(cc->typeName, DSGN_KEY_VISIBLE)) {
        cc->visible = frmParseBool(val);
    } else if (strcasecmp(key, DSGN_KEY_ENABLED) == 0 && !dsgnIfaceHasProp(cc->typeName, DSGN_KEY_ENABLED)) {
        cc->enabled = frmParseBool(val);
    } else if (strcasecmp(key, "TabIndex") == 0) {
        // ignored -- DVX has no tab order
    } else {
        dsgnSetPropValue(cc, key, val);
    }
}


static bool loadOnFormBegin(void *userData, const char *name) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    if (!ctx->form) {
        return false;
    }

    snprintf(ctx->form->name, sizeof(ctx->form->name), "%s", name);
    snprintf(ctx->form->caption, DSGN_MAX_TEXT, "%s", name);
    ctx->current   = NULL;
    ctx->nestDepth = 0;
    return true;
}


static void loadOnFormEnd(void *userData, const char *trailingSrc, int32_t trailingLen) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    if (!ctx->form || trailingLen <= 0) {
        return;
    }

    // Skip leading whitespace/blank lines
    const char *codeStart = trailingSrc;
    const char *codeEnd   = trailingSrc + trailingLen;

    while (codeStart < codeEnd && (*codeStart == '\r' || *codeStart == '\n' || *codeStart == ' ' || *codeStart == '\t')) {
        codeStart++;
    }

    if (codeStart >= codeEnd) {
        return;
    }

    int32_t codeLen = (int32_t)(codeEnd - codeStart);
    ctx->form->code = (char *)malloc(codeLen + 1);

    if (ctx->form->code) {
        memcpy(ctx->form->code, codeStart, codeLen);
        ctx->form->code[codeLen] = '\0';
    }
}


static void loadOnFormProp(void *userData, const char *key, const char *value) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    if (!ctx->form) {
        return;
    }

    char val[DSGN_MAX_TEXT];
    snprintf(val, sizeof(val), "%s", value);
    frmStripQuotes(val);

    DsgnFormT *ff = ctx->form;

    if (strcasecmp(key, DSGN_KEY_CAPTION) == 0) {
        snprintf(ff->caption, DSGN_MAX_TEXT, "%s", val);
    } else if (strcasecmp(key, DSGN_KEY_LAYOUT) == 0) {
        snprintf(ff->layout, DSGN_MAX_NAME, "%s", val);
    } else if (strcasecmp(key, DSGN_KEY_AUTOSIZE) == 0) {
        ff->autoSize = frmParseBool(val);
    } else if (strcasecmp(key, DSGN_KEY_RESIZABLE) == 0) {
        ff->resizable = frmParseBool(val);
    } else if (strcasecmp(key, DSGN_KEY_CENTERED) == 0) {
        ff->centered = frmParseBool(val);
    } else if (strcasecmp(key, DSGN_KEY_LEFT) == 0) {
        ff->left = atoi(val);
    } else if (strcasecmp(key, DSGN_KEY_TOP) == 0) {
        ff->top = atoi(val);
    } else if (strcasecmp(key, DSGN_KEY_WIDTH) == 0) {
        ff->width    = atoi(val);
        ff->autoSize = false;
    } else if (strcasecmp(key, DSGN_KEY_HEIGHT) == 0) {
        ff->height   = atoi(val);
        ff->autoSize = false;
    } else if (strcasecmp(key, DSGN_KEY_HELPTOPIC) == 0) {
        snprintf(ff->helpTopic, DSGN_MAX_NAME, "%s", val);
    }
}


static void loadOnMenuBegin(void *userData, const char *name, int32_t level) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    if (!ctx->form) {
        return;
    }

    DsgnMenuItemT mi;
    dsgnMenuItemInit(&mi);
    snprintf(mi.name, DSGN_MAX_NAME, "%s", name);
    mi.level = level;
    arrput(ctx->form->menuItems, mi);
    ctx->curMenuItemIdx = (int32_t)arrlen(ctx->form->menuItems) - 1;
    ctx->current        = NULL;
}


static void loadOnMenuEnd(void *userData) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    ctx->curMenuItemIdx = -1;
}


static void loadOnMenuProp(void *userData, const char *key, const char *value) {
    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;

    if (!ctx->form ||
        ctx->curMenuItemIdx < 0 ||
        ctx->curMenuItemIdx >= (int32_t)arrlen(ctx->form->menuItems)) {
        return;
    }

    // Resolve pointer fresh each write -- arrput on nested menus may
    // have reallocated the array.
    DsgnMenuItemT *mip = &ctx->form->menuItems[ctx->curMenuItemIdx];
    char           val[DSGN_MAX_TEXT];
    snprintf(val, sizeof(val), "%s", value);
    frmStripQuotes(val);

    if (strcasecmp(key, DSGN_KEY_CAPTION) == 0) {
        snprintf(mip->caption, DSGN_MAX_TEXT, "%s", val);
    } else if (strcasecmp(key, "Checked") == 0) {
        mip->checked = frmParseBool(val);
    } else if (strcasecmp(key, "RadioCheck") == 0) {
        mip->radioCheck = frmParseBool(val);
    } else if (strcasecmp(key, DSGN_KEY_ENABLED) == 0) {
        // Default-true flags: the shared parser rule keeps the loader and
        // the runtime (formrt.c) in agreement.
        mip->enabled = frmParseBoolDefault(val, true);
    } else if (strcasecmp(key, DSGN_KEY_VISIBLE) == 0) {
        mip->visible = frmParseBoolDefault(val, true);
    }
}


// Allocate a form carrying the defaults every new or loaded form starts
// from.  Returns NULL when out of memory.
static DsgnFormT *newFormDefaults(const char *name, bool autoSize) {
    DsgnFormT *form = (DsgnFormT *)calloc(1, sizeof(DsgnFormT));

    if (!form) {
        return NULL;
    }

    form->width     = BAS_DEFAULT_FORM_W;
    form->height    = BAS_DEFAULT_FORM_H;
    form->centered  = true;
    form->autoSize  = autoSize;
    form->resizable = true;
    snprintf(form->layout, DSGN_MAX_NAME, "%s", DSGN_VBOX_LAYOUT);
    snprintf(form->name, sizeof(form->name), "%s", name);
    snprintf(form->caption, DSGN_MAX_TEXT, "%s", name);
    return form;
}


// Write the controls whose parentName matches, each followed by its own
// children.  Every control is offered as a parent so a child of a container
// whose widget type is not loaded is still written, not silently dropped.
// The self-parent test and depth cap keep a cycle (A->B->A) finite.
static int32_t saveControls(const DsgnFormT *form, char *buf, int32_t bufSize, int32_t pos, const char *parentName, int32_t indent) {
    int32_t count = (int32_t)arrlen(form->controls);

    for (int32_t i = 0; i < count; i++) {
        const DsgnControlT *ctrl = form->controls[i];

        if (strcasecmp(ctrl->parentName, parentName) != 0) {
            continue;
        }

        char    pad[DSGN_MAX_INDENT_BUF];
        int32_t padLen = indent * DSGN_INDENT_SPACES;

        if (padLen > DSGN_MAX_INDENT_BUF - 1) {
            padLen = DSGN_MAX_INDENT_BUF - 1;
        }

        memset(pad, ' ', padLen);
        pad[padLen] = '\0';

        pos = emitControlHead(ctrl, buf, bufSize, pos, pad);

        if (strcasecmp(ctrl->name, parentName) != 0 && indent < DSGN_MAX_NEST_DEPTH) {
            pos = saveControls(form, buf, bufSize, pos, ctrl->name, indent + 1);
        }

        pos = emitClamped(buf, bufSize, pos, "%sEnd\n", pad);
    }

    return pos;
}


// Canvas drag-reorder step: swap the selected control with its array
// neighbor in direction dir (+1 = down, -1 = up) once the pointer passes
// that neighbor's midpoint.  Only siblings (same parentName) swap, so a
// child can never move ahead of its own container in controls[].
static void trySwapNeighbor(DsgnStateT *ds, int32_t y, int32_t dir) {
    int32_t       count = (int32_t)arrlen(ds->form->controls);
    int32_t       other = ds->selectedIdx + dir;
    DsgnControlT *ctrl  = ds->form->controls[ds->selectedIdx];

    if (other < 0 || other >= count || !ctrl->widget) {
        return;
    }

    DsgnControlT *neighbor = ds->form->controls[other];

    if (!neighbor->widget || strcasecmp(neighbor->parentName, ctrl->parentName) != 0) {
        return;
    }

    int32_t mid = neighbor->widget->y + neighbor->widget->h / 2;

    if ((dir > 0 && y <= mid) || (dir < 0 && y >= mid)) {
        return;
    }

    ds->form->controls[ds->selectedIdx] = neighbor;
    ds->form->controls[other]           = ctrl;
    dsgnRebuildWidgets(ds);
    ds->selectedIdx = other;
    ds->dragStartY  = y;
    ds->form->dirty = true;
}
