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

// ideProperties.c -- DVX BASIC form designer properties window
//
// A floating window with a TreeView listing all controls on the
// form (for selection and drag-reorder) and a ListView showing
// editable properties of the selected control. Double-click a
// property value to edit it via an InputBox dialog.

#include "ideProperties.h"
#include "../formrt/formrt.h"
#include "../formrt/frmParser.h"
#include "dvxDlg.h"
#include "dvxWm.h"
#include "box/box.h"
#include "listView/listView.h"
#include "splitter/splitter.h"
#include "treeView/treeView.h"

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

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

#define PRP_WIN_W     220
#define PRP_WIN_H     400
#define PRP_WIN_RIGHT_MARGIN 10   // gap between the window and the screen edge
#define PRP_WIN_Y     30
#define PRP_INT_BUF   32          // decimal int32_t text
#define PRP_QUERY_BUF        512  // SQL probe scratch buffer
#define PRP_PROMPT_BUF       128  // input-dialog prompt buffer
#define PRP_TITLE_SUFFIX_PAD 16   // pad over DSGN_MAX_TEXT for " [Design]" suffix
#define DSGN_NO_INDEX        (-1) // tree label has no "(idx)" / control not in an array

#define PROP_TYPE_STRING  WGT_IFACE_STRING
#define PROP_TYPE_INT     WGT_IFACE_INT
#define PROP_TYPE_BOOL    WGT_IFACE_BOOL
#define PROP_TYPE_ENUM    WGT_IFACE_ENUM
#define PROP_TYPE_READONLY    255
#define PROP_TYPE_LAYOUT      251
#define PROP_TYPE_DATASOURCE  254
#define PROP_TYPE_DATAFIELD   253
#define PROP_TYPE_RECORDSRC   252

// No fixed caps: field and table arrays are heap-allocated and sized to the
// actual count reported by SQL.

// ============================================================
// SQL engine function-pointer types (resolved at runtime via dlsym so the
// designer does not hard-link the optional dvxSql* engine).  Declared once
// here; the column-name and table-name probes resolve the subset they use.
// ============================================================

typedef int32_t     (*SqlOpenFnT)(const char *);
typedef void        (*SqlCloseFnT)(int32_t);
typedef int32_t     (*SqlQueryFnT)(int32_t, const char *);
typedef int32_t     (*SqlFieldCountFnT)(int32_t);
typedef const char *(*SqlFieldNameFnT)(int32_t, int32_t);
typedef bool        (*SqlNextFnT)(int32_t);
typedef const char *(*SqlFieldTextFnT)(int32_t, int32_t);
typedef void        (*SqlFreeResultFnT)(int32_t);

// ============================================================
// Module state
// ============================================================

static DsgnStateT  *sDs         = NULL;
static WindowT     *sPrpWin     = NULL;
static WidgetT     *sTree       = NULL;
static WidgetT     *sPropList   = NULL;
static AppContextT *sPrpCtx     = NULL;
static bool         sUpdating   = false;
static char       **sTreeLabels = NULL; // stb_ds array of strdup'd strings

#define PRP_CELL_COLUMNS 2  // property grid: name column + value column
#define PRP_MAX_LAYOUT_NAMES 32  // designer dropdown: max layout container types
#define PRP_MAX_DATA_NAMES   16  // designer dropdown: max Data controls (excludes "(none)")
#define PRP_SELECT_PREFIX    "SELECT "  // RecordSource that is already a query
#define PRP_SELECT_PREFIX_LEN ((int32_t)(sizeof(PRP_SELECT_PREFIX) - 1))
#define PRP_DIALOG_TITLE     "Properties"

// Tree parent recorded for a control by collectTreeOrder, parallel to the
// collected control array.
typedef struct {
    char name[DSGN_MAX_NAME];
} PrpParentT;

static char       **sCellData   = NULL; // stb_ds array of strdup'd strings

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

static void               addPropRow(const char *name, const char *value);
static void               cascadeToChildren(DsgnStateT *ds, const char *parentName, bool visible, bool enabled, int32_t depth);
static void               collectTreeOrder(WidgetT *parent, DsgnControlT **srcArr, int32_t srcCount, DsgnControlT ***outArr, PrpParentT **outParents, const char *parentName);
static WidgetT            *findTreeItemByName(WidgetT *parent, const char *name, int32_t index);
static void               freeCellData(void);
static void               freeTreeLabels(void);
static int32_t            getDataFieldNames(const DsgnStateT *ds, const char *dataSourceName, char (**outNames)[DSGN_MAX_NAME]);
static uint8_t            getPropType(const char *propName, const char *typeName);
static int32_t            getTableNames(const char *dbName, char (**outNames)[DSGN_MAX_NAME]);
static void               onPropDblClick(WidgetT *w);
static void               onPrpClose(WindowT *win);
static void               onTreeChange(WidgetT *w);
static void               onTreeItemClick(WidgetT *w);
static void               parseTreeLabel(const char *label, char *outName, int32_t outNameSize, int32_t *outIndex);
static bool               propDropdownOrInput(AppContextT *ctx, const char *propName, const char *selectPrompt, char (*names)[DSGN_MAX_NAME], int32_t count, const char *curValue, char *newValue, int32_t newValueSize);
static void               resolveDbPath(const char *dbName, char *out, int32_t outSize);
static bool               treeOrderMatches(void);
static bool               validateNewName(const char *name, int32_t maxLen, const char *exceptName);


static void addPropRow(const char *name, const char *value) {
    arrput(sCellData, strdup(name));
    arrput(sCellData, strdup(value ? value : ""));
}


// Recursively apply Visible or Enabled to all descendants of a
// container control.
static void cascadeToChildren(DsgnStateT *ds, const char *parentName, bool visible, bool enabled, int32_t depth) {
    int32_t count = (int32_t)arrlen(ds->form->controls);

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

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

        if (child->widget) {
            wgtSetVisible(child->widget, visible);
            wgtSetEnabled(child->widget, enabled);
        }

        // Recurse into nested containers.  Guard against a self-parenting
        // container and runaway cycles (A->B->A) via a depth cap so the
        // recursion always terminates.
        if (dsgnIsContainer(child->typeName) &&
            strcasecmp(child->name, parentName) != 0 &&
            depth < DSGN_MAX_NEST_DEPTH) {
            cascadeToChildren(ds, child->name, visible, enabled, depth + 1);
        }
    }
}


// Walk tree items recursively, collecting controls in tree order together
// with the tree parent each one sits under.  The controls themselves are
// not modified; the caller decides whether to apply the new parents.
static void collectTreeOrder(WidgetT *parent, DsgnControlT **srcArr, int32_t srcCount, DsgnControlT ***outArr, PrpParentT **outParents, const char *parentName) {
    for (WidgetT *item = parent->firstChild; item; item = item->nextSibling) {
        const char *label = (const char *)item->userData;

        if (!label) {
            continue;
        }

        char itemName[DSGN_MAX_NAME];
        int32_t itemIndex = DSGN_NO_INDEX;

        parseTreeLabel(label, itemName, DSGN_MAX_NAME, &itemIndex);

        for (int32_t i = 0; i < srcCount; i++) {
            if (strcmp(srcArr[i]->name, itemName) == 0 && srcArr[i]->index == itemIndex) {
                PrpParentT tp;

                snprintf(tp.name, DSGN_MAX_NAME, "%s", parentName);
                arrput(*outArr, srcArr[i]);
                arrput(*outParents, tp);

                // Recurse into children (for containers)
                if (item->firstChild) {
                    collectTreeOrder(item, srcArr, srcCount, outArr, outParents, itemName);
                }

                break;
            }
        }
    }
}


// Walk tree items recursively to find the one matching a control name.
static WidgetT *findTreeItemByName(WidgetT *parent, const char *name, int32_t index) {
    for (WidgetT *item = parent->firstChild; item; item = item->nextSibling) {
        const char *label = (const char *)item->userData;

        if (label) {
            char itemName[DSGN_MAX_NAME];
            int32_t itemIndex = DSGN_NO_INDEX;

            parseTreeLabel(label, itemName, DSGN_MAX_NAME, &itemIndex);

            if (itemIndex == index && strcmp(itemName, name) == 0) {
                return item;
            }
        }

        // Recurse into children (containers)
        WidgetT *found = findTreeItemByName(item, name, index);

        if (found) {
            return found;
        }
    }

    return NULL;
}


static void freeCellData(void) {
    int32_t count = (int32_t)arrlen(sCellData);

    for (int32_t i = 0; i < count; i++) {
        free(sCellData[i]);
    }

    arrsetlen(sCellData, 0);
}


static void freeTreeLabels(void) {
    int32_t count = (int32_t)arrlen(sTreeLabels);

    for (int32_t i = 0; i < count; i++) {
        free(sTreeLabels[i]);
    }

    arrsetlen(sTreeLabels, 0);
}


// getDataFieldNames -- query column names from a Data control's database
//
// Finds the named Data control in the designer, reads its DatabaseName
// and RecordSource properties, opens the database via dvxSql* (resolved
// through dlsym), and heap-allocates *outNames sized to the actual column
// count. Caller must free *outNames. Returns the count of names (0 if
// anything fails).
static int32_t getDataFieldNames(const DsgnStateT *ds, const char *dataSourceName, char (**outNames)[DSGN_MAX_NAME]) {
    *outNames = NULL;

    if (!ds || !ds->form || !dataSourceName || !dataSourceName[0]) {
        return 0;
    }

    // Find the Data control in the designer
    const char *dbName = NULL;
    const char *recSrc = NULL;
    int32_t ctrlCount  = (int32_t)arrlen(ds->form->controls);

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

        if (strcasecmp(ctrl->typeName, "Data") != 0 || strcasecmp(ctrl->name, dataSourceName) != 0) {
            continue;
        }

        dbName = dsgnControlGetPropValue(ctrl, "DatabaseName");
        recSrc = dsgnControlGetPropValue(ctrl, "RecordSource");
        break;
    }

    if (!dbName || !dbName[0] || !recSrc || !recSrc[0]) {
        return 0;
    }

    // Resolve SQL functions via dlsym
    SqlOpenFnT       sqlOpen       = (SqlOpenFnT)dlsym(NULL, "_dvxSqlOpen");
    SqlCloseFnT      sqlClose      = (SqlCloseFnT)dlsym(NULL, "_dvxSqlClose");
    SqlQueryFnT      sqlQuery      = (SqlQueryFnT)dlsym(NULL, "_dvxSqlQuery");
    SqlFieldCountFnT sqlFieldCount = (SqlFieldCountFnT)dlsym(NULL, "_dvxSqlFieldCount");
    SqlFieldNameFnT  sqlFieldName  = (SqlFieldNameFnT)dlsym(NULL, "_dvxSqlFieldName");
    SqlFreeResultFnT sqlFreeResult = (SqlFreeResultFnT)dlsym(NULL, "_dvxSqlFreeResult");

    if (!sqlOpen || !sqlClose || !sqlQuery || !sqlFieldCount || !sqlFieldName || !sqlFreeResult) {
        return 0;
    }

    char fullPath[DVX_MAX_PATH];
    resolveDbPath(dbName, fullPath, sizeof(fullPath));

    int32_t db = sqlOpen(fullPath);

    if (db <= 0) {
        return 0;
    }

    // Query with LIMIT 0 to get column names without fetching rows
    char query[PRP_QUERY_BUF];

    if (strncasecmp(recSrc, PRP_SELECT_PREFIX, PRP_SELECT_PREFIX_LEN) == 0) {
        snprintf(query, sizeof(query), "%s LIMIT 0", recSrc);
    } else {
        snprintf(query, sizeof(query), "SELECT * FROM %s LIMIT 0", recSrc);
    }

    int32_t rs = sqlQuery(db, query);

    if (rs <= 0) {
        sqlClose(db);
        return 0;
    }

    int32_t colCount = sqlFieldCount(rs);

    if (colCount <= 0) {
        sqlFreeResult(rs);
        sqlClose(db);
        return 0;
    }

    char (*names)[DSGN_MAX_NAME] = (char (*)[DSGN_MAX_NAME])calloc(colCount, DSGN_MAX_NAME);

    if (!names) {
        sqlFreeResult(rs);
        sqlClose(db);
        return 0;
    }

    int32_t count = 0;

    for (int32_t i = 0; i < colCount; i++) {
        const char *name = sqlFieldName(rs, i);

        if (name) {
            snprintf(names[count++], DSGN_MAX_NAME, "%s", name);
        }
    }

    sqlFreeResult(rs);
    sqlClose(db);
    *outNames = names;
    return count;
}


// Determine the editor type of a property by name.  Designer-only rows
// and the special editors come first, then the widget's own interface
// (which wins over the runtime tables, matching setProp dispatch), then
// the runtime form/control property tables for the rest.
static uint8_t getPropType(const char *propName, const char *typeName) {
    // Designer-only read-only rows
    if (strcasecmp(propName, "Type") == 0)  { return PROP_TYPE_READONLY; }
    if (strcasecmp(propName, "Index") == 0) { return PROP_TYPE_READONLY; }

    // Designer-editable at design time even though the runtime rejects
    // assignment (Name is the object identity, Layout the container type).
    if (strcasecmp(propName, "Name") == 0)   { return PROP_TYPE_STRING; }
    if (strcasecmp(propName, "Layout") == 0) { return PROP_TYPE_LAYOUT; }

    // Special editors
    if (strcasecmp(propName, "DataSource") == 0)   { return PROP_TYPE_DATASOURCE; }
    if (strcasecmp(propName, "DataField") == 0)    { return PROP_TYPE_DATAFIELD; }
    if (strcasecmp(propName, "RecordSource") == 0) { return PROP_TYPE_RECORDSRC; }
    if (strcasecmp(propName, "KeyColumn") == 0)    { return PROP_TYPE_DATAFIELD; }
    if (strcasecmp(propName, "MasterSource") == 0) { return PROP_TYPE_DATASOURCE; }
    if (strcasecmp(propName, "MasterField") == 0)  { return PROP_TYPE_DATAFIELD; }
    if (strcasecmp(propName, "DetailField") == 0)  { return PROP_TYPE_DATAFIELD; }

    const WgtPropDescT *p = findIfaceProp(typeName, propName);

    if (p) {
        return p->setFn ? p->type : PROP_TYPE_READONLY;
    }

    // Runtime tables: the form object when no control is selected, else
    // the common control properties.  Non-writable entries are read-only.
    const BasPropDescT *pd = typeName[0] ? basFormRtFindCommonProp(propName) : basFormRtFindFormProp(propName);

    if (pd) {
        return pd->writable ? pd->type : PROP_TYPE_READONLY;
    }

    // Designer storage fields not in the runtime tables (MinWidth alias
    // Width etc. are; this covers any table drift) and unknown keys.
    return dsgnFindIntProp(propName) ? PROP_TYPE_INT : PROP_TYPE_STRING;
}


// getTableNames -- query table names from a SQLite database
//
// Heap-allocates *outNames and grows it as tables are enumerated. Caller
// must free *outNames. Returns the number of tables found (0 if anything
// fails).
static int32_t getTableNames(const char *dbName, char (**outNames)[DSGN_MAX_NAME]) {
    *outNames = NULL;

    if (!dbName || !dbName[0]) {
        return 0;
    }

    SqlOpenFnT       sqlOpen       = (SqlOpenFnT)dlsym(NULL, "_dvxSqlOpen");
    SqlCloseFnT      sqlClose      = (SqlCloseFnT)dlsym(NULL, "_dvxSqlClose");
    SqlQueryFnT      sqlQuery      = (SqlQueryFnT)dlsym(NULL, "_dvxSqlQuery");
    SqlNextFnT       sqlNext       = (SqlNextFnT)dlsym(NULL, "_dvxSqlNext");
    SqlFieldTextFnT  sqlFieldText  = (SqlFieldTextFnT)dlsym(NULL, "_dvxSqlFieldText");
    SqlFreeResultFnT sqlFreeResult = (SqlFreeResultFnT)dlsym(NULL, "_dvxSqlFreeResult");

    if (!sqlOpen || !sqlClose || !sqlQuery || !sqlNext || !sqlFieldText || !sqlFreeResult) {
        return 0;
    }

    char fullPath[DVX_MAX_PATH];
    resolveDbPath(dbName, fullPath, sizeof(fullPath));

    int32_t db = sqlOpen(fullPath);

    if (db <= 0) {
        return 0;
    }

    int32_t rs = sqlQuery(db, "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");

    if (rs <= 0) {
        sqlClose(db);
        return 0;
    }

    // Grow with realloc doubling
    char (*names)[DSGN_MAX_NAME] = NULL;
    int32_t count = 0;
    int32_t cap   = 0;

    while (sqlNext(rs)) {
        const char *name = sqlFieldText(rs, 0);

        if (!name || !name[0]) {
            continue;
        }

        if (count >= cap) {
            int32_t newCap = cap == 0 ? 16 : cap * 2;
            char (*grown)[DSGN_MAX_NAME] = (char (*)[DSGN_MAX_NAME])realloc(names, (size_t)newCap * DSGN_MAX_NAME);

            if (!grown) {
                break;
            }

            names = grown;
            cap   = newCap;
        }

        snprintf(names[count++], DSGN_MAX_NAME, "%s", name);
    }

    sqlFreeResult(rs);
    sqlClose(db);
    *outNames = names;
    return count;
}


static void onPropDblClick(WidgetT *w) {
    if (!sDs || !sDs->form || !sPropList || !sPrpCtx) {
        return;
    }

    int32_t row = wgtListViewGetSelected(w);

    int32_t rowCount = (int32_t)arrlen(sCellData) / PRP_CELL_COLUMNS;

    if (row < 0 || row >= rowCount) {
        return;
    }

    const char *propName = sCellData[row * PRP_CELL_COLUMNS];
    const char *curValue = sCellData[row * PRP_CELL_COLUMNS + 1];

    // Layout -- select from discovered layout containers
    if (strcasecmp(propName, "Layout") == 0) {
        // Discover available layout types from loaded widget interfaces.
        // A layout container is isContainer with WGT_CREATE_PARENT (no extra args).
        const char *layoutNames[PRP_MAX_LAYOUT_NAMES];
        int32_t layoutCount = 0;
        int32_t ifaceTotal  = wgtIfaceCount();

        for (int32_t i = 0; i < ifaceTotal && layoutCount < PRP_MAX_LAYOUT_NAMES; i++) {
            const WgtIfaceT *iface = wgtIfaceAt(i, NULL);

            if (iface && iface->isContainer && iface->createSig == WGT_CREATE_PARENT && iface->basName) {
                layoutNames[layoutCount++] = iface->basName;
            }
        }

        if (layoutCount == 0) {
            return;
        }

        // Determine whose layout we're changing
        char *layoutField = NULL;

        if (sDs->selectedIdx < 0) {
            layoutField = sDs->form->layout;
        } else {
            DsgnControlT *ctrl = sDs->form->controls[sDs->selectedIdx];

            // A freshly placed container has no Layout entry yet; create
            // the default so the grid row is editable.
            if (!dsgnControlGetPropValue(ctrl, "Layout")) {
                dsgnSetPropValue(ctrl, "Layout", DSGN_VBOX_LAYOUT);
            }

            for (int32_t pi = 0; pi < ctrl->propCount; pi++) {
                if (strcasecmp(ctrl->props[pi].name, "Layout") == 0) {
                    layoutField = ctrl->props[pi].value;
                    break;
                }
            }

            if (!layoutField) {
                return;
            }
        }

        // Find current selection
        int32_t defIdx = 0;

        for (int32_t i = 0; i < layoutCount; i++) {
            if (strcasecmp(layoutField, layoutNames[i]) == 0) {
                defIdx = i;
                break;
            }
        }

        int32_t chosenIdx = 0;

        if (!dvxChoiceDialog(sPrpCtx, "Layout", "Select layout type:", layoutNames, layoutCount, defIdx, &chosenIdx)) {
            return;
        }

        snprintf(layoutField, DSGN_MAX_NAME, "%s", layoutNames[chosenIdx]);
        sDs->form->dirty = true;

        // Rebuild the form designer to apply the new layout
        if (sDs->formWin && sDs->formWin->widgetRoot) {
            WidgetT *root = sDs->formWin->widgetRoot;

            wgtDestroyChildren(root);

            // The root content box must use the FORM's layout, not whatever
            // layoutField points at (it points at a selected container's
            // Layout prop when one is selected).  Per-container layouts are
            // applied separately inside dsgnCreateWidgets.
            sDs->form->contentBox = basFormRtCreateContentBox(root, sDs->form->layout);
            dsgnRebuildWidgets(sDs);
            dvxInvalidateWindow(sPrpCtx, sDs->formWin);
        }

        prpRefresh(sDs);
        return;
    }

    // Get the control's type name for iface lookup
    const char *ctrlTypeName = "";
    int32_t ctrlCount = (int32_t)arrlen(sDs->form->controls);

    if (sDs->selectedIdx >= 0 && sDs->selectedIdx < ctrlCount) {
        ctrlTypeName = sDs->form->controls[sDs->selectedIdx]->typeName;
    }

    uint8_t propType = getPropType(propName, ctrlTypeName);

    if (propType == PROP_TYPE_READONLY) {
        return;
    }

    char newValue[DSGN_MAX_TEXT];

    if (propType == PROP_TYPE_BOOL) {
        // Toggle boolean on double-click -- no input box
        bool cur = frmParseBool(curValue);
        snprintf(newValue, sizeof(newValue), "%s", cur ? "False" : "True");
    } else if (propType == PROP_TYPE_ENUM) {
        // Enum: cycle to next value on double-click
        const WgtPropDescT *pd = findIfaceProp(ctrlTypeName, propName);

        if (!pd || !pd->enumNames) {
            return;
        }

        // Find current value and advance to next
        int32_t enumCount = 0;
        int32_t curIdx    = 0;

        while (pd->enumNames[enumCount]) {
            if (strcasecmp(pd->enumNames[enumCount], curValue) == 0) {
                curIdx = enumCount;
            }
            enumCount++;
        }

        if (enumCount == 0) {
            return;
        }

        int32_t nextIdx = (curIdx + 1) % enumCount;
        snprintf(newValue, sizeof(newValue), "%s", pd->enumNames[nextIdx]);
    } else if (propType == PROP_TYPE_DATASOURCE) {
        // Show dropdown of Data control names on the form
        int32_t formCtrlCount = (int32_t)arrlen(sDs->form->controls);
        const char *dataNames[PRP_MAX_DATA_NAMES + 1];
        int32_t dataCount = 0;

        // First entry is "(none)" to clear binding
        dataNames[dataCount++] = "(none)";

        for (int32_t i = 0; i < formCtrlCount && dataCount < PRP_MAX_DATA_NAMES; i++) {
            if (strcasecmp(sDs->form->controls[i]->typeName, "Data") == 0) {
                dataNames[dataCount++] = sDs->form->controls[i]->name;
            }
        }

        if (dataCount <= 1) {
            return;
        }

        // Find current selection
        int32_t defIdx = 0;

        for (int32_t i = 1; i < dataCount; i++) {
            if (strcasecmp(dataNames[i], curValue) == 0) {
                defIdx = i;
                break;
            }
        }

        int32_t chosenIdx = 0;

        if (!dvxChoiceDialog(sPrpCtx, "DataSource", "Select Data control:", dataNames, dataCount, defIdx, &chosenIdx)) {
            return;
        }

        if (chosenIdx == 0) {
            newValue[0] = '\0';
        } else {
            snprintf(newValue, sizeof(newValue), "%s", dataNames[chosenIdx]);
        }
    } else if (propType == PROP_TYPE_DATAFIELD) {
        // Show dropdown of column names from the Data control's database.
        // If the selected control IS a Data control (e.g. editing KeyColumn),
        // use its own name. Otherwise look up its DataSource property.
        int32_t selCount = (int32_t)arrlen(sDs->form->controls);
        const char *dataSrc = "";

        if (sDs->selectedIdx >= 0 && sDs->selectedIdx < selCount) {
            DsgnControlT *selCtrl = sDs->form->controls[sDs->selectedIdx];

            if (strcasecmp(selCtrl->typeName, "Data") == 0) {
                dataSrc = selCtrl->name;
            } else {
                const char *dataSrcProp = dsgnControlGetPropValue(selCtrl, "DataSource");

                if (dataSrcProp) {
                    dataSrc = dataSrcProp;
                }
            }
        }

        char (*fieldNames)[DSGN_MAX_NAME] = NULL;
        int32_t fieldCount = getDataFieldNames(sDs, dataSrc, &fieldNames);
        bool    ok         = propDropdownOrInput(sPrpCtx, propName, "Select column:", fieldNames, fieldCount, curValue, newValue, sizeof(newValue));

        free(fieldNames);

        if (!ok) {
            return;
        }
    } else if (propType == PROP_TYPE_RECORDSRC) {
        // Show dropdown of table names from the Data control's database
        // Find DatabaseName on this control (which is a Data control)
        int32_t selCount = (int32_t)arrlen(sDs->form->controls);
        const char *dbName = "";

        if (sDs->selectedIdx >= 0 && sDs->selectedIdx < selCount) {
            DsgnControlT *selCtrl = sDs->form->controls[sDs->selectedIdx];
            const char  *dbNameProp = dsgnControlGetPropValue(selCtrl, "DatabaseName");

            if (dbNameProp) {
                dbName = dbNameProp;
            }
        }

        char (*tableNames)[DSGN_MAX_NAME] = NULL;
        int32_t tableCount = getTableNames(dbName, &tableNames);
        bool    ok         = propDropdownOrInput(sPrpCtx, propName, "Select table:", tableNames, tableCount, curValue, newValue, sizeof(newValue));

        free(tableNames);

        if (!ok) {
            return;
        }
    } else if (propType == PROP_TYPE_INT) {
        // Spinner dialog for integers
        char prompt[PRP_PROMPT_BUF];
        snprintf(prompt, sizeof(prompt), "%s:", propName);
        int32_t intVal = atoi(curValue);

        if (!dvxIntInputBox(sPrpCtx, "Edit Property", prompt, intVal, INT32_MIN, INT32_MAX, 1, &intVal)) {
            return;
        }

        snprintf(newValue, sizeof(newValue), "%d", (int)intVal);
    } else {
        // Text input for strings
        char prompt[PRP_PROMPT_BUF];
        snprintf(prompt, sizeof(prompt), "%s:", propName);
        snprintf(newValue, sizeof(newValue), "%s", curValue);

        if (!dvxInputBox(sPrpCtx, "Edit Property", prompt, curValue, newValue, sizeof(newValue))) {
            return;
        }
    }

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

    if (sDs->selectedIdx >= 0 && sDs->selectedIdx < count) {
        DsgnControlT *ctrl = sDs->form->controls[sDs->selectedIdx];

        if (strcasecmp(propName, "Name") == 0) {
            char oldName[DSGN_MAX_NAME];
            snprintf(oldName, sizeof(oldName), "%s", ctrl->name);

            if (!validateNewName(newValue, DSGN_MAX_NAME, oldName)) {
                return;
            }

            // Rename all members of a control array, not just the selected one
            for (int32_t i = 0; i < count; i++) {
                DsgnControlT *c = sDs->form->controls[i];

                if (strcasecmp(c->name, oldName) == 0) {
                    snprintf(c->name, DSGN_MAX_NAME, "%s", newValue);

                    if (c->widget) {
                        wgtSetName(c->widget, c->name);
                    }
                }
            }

            // Children reference their container by parentName; if the
            // renamed control was a container, update them too, or they
            // would be orphaned -- dropped from the saved .frm and
            // reparented to the form node in the tree.
            for (int32_t i = 0; i < count; i++) {
                DsgnControlT *c = sDs->form->controls[i];

                if (strcasecmp(c->parentName, oldName) == 0) {
                    snprintf(c->parentName, DSGN_MAX_NAME, "%s", newValue);
                }
            }

            // If this is a Data control, update DataSource and MasterSource
            // references on all other controls that pointed to the old name
            if (strcasecmp(ctrl->typeName, "Data") == 0) {
                for (int32_t i = 0; i < count; i++) {
                    DsgnControlT *c = sDs->form->controls[i];

                    for (int32_t j = 0; j < c->propCount; j++) {
                        if ((strcasecmp(c->props[j].name, "DataSource") == 0 ||
                             strcasecmp(c->props[j].name, "MasterSource") == 0) &&
                            strcasecmp(c->props[j].value, oldName) == 0) {
                            snprintf(c->props[j].value, DSGN_MAX_TEXT, "%s", newValue);
                        }
                    }
                }
            }

            ideRenameInCode(oldName, newValue);
            prpRebuildTree(sDs);
        } else if (dsgnFindIntProp(propName)) {
            const DsgnIntPropT *ip = dsgnFindIntProp(propName);

            *(int32_t *)((char *)ctrl + ip->offset) = atoi(newValue);
            dsgnSyncWidgetGeom(ctrl);
        } else if (strcasecmp(propName, "Visible") == 0 && !dsgnIfaceHasProp(ctrl->typeName, "Visible")) {
            bool val = frmParseBool(newValue);
            ctrl->visible = val;

            if (ctrl->widget) {
                wgtSetVisible(ctrl->widget, val);
            }

            if (dsgnIsContainer(ctrl->typeName)) {
                cascadeToChildren(sDs, ctrl->name, val, ctrl->enabled, 0);
            }
        } else if (strcasecmp(propName, "Enabled") == 0 && !dsgnIfaceHasProp(ctrl->typeName, "Enabled")) {
            bool val = frmParseBool(newValue);
            ctrl->enabled = val;

            if (ctrl->widget) {
                wgtSetEnabled(ctrl->widget, val);
            }

            if (dsgnIsContainer(ctrl->typeName)) {
                cascadeToChildren(sDs, ctrl->name, ctrl->visible, val, 0);
            }
        } else if (strcasecmp(propName, "HelpTopic") == 0) {
            snprintf(ctrl->helpTopic, DSGN_MAX_NAME, "%s", newValue);
        } else {
            // Try widget iface setter first
            bool ifaceHandled = false;

            if (ctrl->widget) {
                const char *wgtName = wgtFindByBasName(ctrl->typeName);

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

                    if (iface) {
                        const WgtPropDescT *p = wgtIfaceFindProp(iface, propName);

                        if (p && p->setFn) {
                            // props[] is the design-time store (it survives a
                            // widget rebuild and is what gets saved); the live
                            // widget mirrors it.  Strings are passed from the
                            // props[] copy so they outlive this function.
                            dsgnSetPropValue(ctrl, propName, newValue);

                            const char *stored = dsgnControlGetPropValue(ctrl, propName);

                            if (stored) {
                                wgtApplyPropFromString(ctrl->widget, p, stored);
                            }

                            ifaceHandled = true;
                        }
                    }
                }
            }

            if (!ifaceHandled) {
                // Custom prop storage
                dsgnSetPropValue(ctrl, propName, newValue);

                // Update widget text from the persistent props array
                if (ctrl->widget && (strcasecmp(propName, "Caption") == 0 || strcasecmp(propName, "Text") == 0)) {
                    const char *stored = dsgnControlGetPropValue(ctrl, propName);

                    if (stored) {
                        wgtSetText(ctrl->widget, stored);
                    }
                }
            }
        }

        sDs->form->dirty = true;

        if (sDs->formWin) {
            dvxInvalidateWindow(sPrpCtx, sDs->formWin);
        }
    } else {
        if (strcasecmp(propName, "Name") == 0) {
            char oldName[BAS_MAX_IDENT];
            snprintf(oldName, sizeof(oldName), "%s", sDs->form->name);

            if (!validateNewName(newValue, BAS_MAX_IDENT, oldName)) {
                return;
            }

            snprintf(sDs->form->name, sizeof(sDs->form->name), "%s", newValue);
            ideRenameInCode(oldName, sDs->form->name);
            prpRebuildTree(sDs);
        } else if (strcasecmp(propName, "Caption") == 0) {
            snprintf(sDs->form->caption, DSGN_MAX_TEXT, "%s", newValue);

            if (sDs->formWin) {
                char winTitle[DSGN_MAX_TEXT + PRP_TITLE_SUFFIX_PAD];
                snprintf(winTitle, sizeof(winTitle), "%s [Design]", sDs->form->caption);
                dvxSetTitle(sPrpCtx, sDs->formWin, winTitle);
            }
        } else if (strcasecmp(propName, "AutoSize") == 0) {
            sDs->form->autoSize = frmParseBool(newValue);

            if (sDs->form->autoSize && sDs->formWin) {
                dvxFitWindow(sPrpCtx, sDs->formWin);
                sDs->form->width  = sDs->formWin->w;
                sDs->form->height = sDs->formWin->h;
            }
        } else if (strcasecmp(propName, "Resizable") == 0) {
            sDs->form->resizable = frmParseBool(newValue);

            if (sDs->formWin) {
                sDs->formWin->resizable = sDs->form->resizable;
                dvxInvalidateWindow(sPrpCtx, sDs->formWin);
            }
        } else if (strcasecmp(propName, "Centered") == 0) {
            sDs->form->centered = frmParseBool(newValue);
        } else if (strcasecmp(propName, "Left") == 0) {
            sDs->form->left = atoi(newValue);
        } else if (strcasecmp(propName, "Top") == 0) {
            sDs->form->top = atoi(newValue);
        } else if (strcasecmp(propName, "Width") == 0) {
            sDs->form->width    = atoi(newValue);
            sDs->form->autoSize = false;
        } else if (strcasecmp(propName, "Height") == 0) {
            sDs->form->height   = atoi(newValue);
            sDs->form->autoSize = false;
        } else if (strcasecmp(propName, "HelpTopic") == 0) {
            snprintf(sDs->form->helpTopic, DSGN_MAX_NAME, "%s", newValue);
        }

        sDs->form->dirty = true;

        // Resize the form designer window
        if (sDs->formWin) {
            if (sDs->form->autoSize) {
                dvxFitWindow(sPrpCtx, sDs->formWin);
                sDs->form->width  = sDs->formWin->w;
                sDs->form->height = sDs->formWin->h;
            } else {
                dvxResizeWindow(sPrpCtx, sDs->formWin, sDs->form->width, sDs->form->height);
            }
        }
    }

    prpRefresh(sDs);
}


static void onPrpClose(WindowT *win) {
    dvxHideWindow(sPrpCtx, win);
}


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

    if (!sDs || !sDs->form || !sTree || sUpdating) {
        return;
    }

    // If the order hasn't changed, this is just a selection or expand/collapse.
    // The onClick handler on individual items handles selection updates.
    if (treeOrderMatches()) {
        return;
    }

    // Actual reorder happened -- rebuild the controls array from tree order.
    int32_t        count      = (int32_t)arrlen(sDs->form->controls);
    DsgnControlT **newArr     = NULL;
    PrpParentT    *newParents = NULL;
    WidgetT       *formItem   = sTree->firstChild;

    if (!formItem) {
        return;
    }

    collectTreeOrder(formItem, sDs->form->controls, count, &newArr, &newParents, "");

    // Revert if items were lost (dragged above the form) or a control was
    // dropped into something that cannot hold children: only containers
    // are written with nested blocks, so a non-container parent would drop
    // the control from the saved .frm.
    bool valid = ((int32_t)arrlen(newArr) == count);

    for (int32_t i = 0; valid && i < count; i++) {
        const char *pName = newParents[i].name;

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

        valid = false;

        for (int32_t j = 0; j < count; j++) {
            if (strcasecmp(newArr[j]->name, pName) == 0) {
                valid = dsgnIsContainer(newArr[j]->typeName);
                break;
            }
        }
    }

    if (!valid) {
        arrfree(newArr);
        arrfree(newParents);
        prpRebuildTree(sDs);
        return;
    }

    for (int32_t i = 0; i < count; i++) {
        snprintf(newArr[i]->parentName, DSGN_MAX_NAME, "%s", newParents[i].name);
    }

    arrfree(newParents);
    arrfree(sDs->form->controls);
    sDs->form->controls = newArr;
    sDs->form->dirty    = true;

    dsgnRebuildWidgets(sDs);
    prpRebuildTree(sDs);

    if (sDs->formWin) {
        dvxInvalidateWindow(sPrpCtx, sDs->formWin);
    }

    prpRefresh(sDs);
}


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

    if (!sDs || !sDs->form || !sTree || sUpdating) {
        return;
    }

    // Check if it's the form item
    WidgetT *formItem = sTree->firstChild;

    if (w == formItem) {
        sDs->selectedIdx = -1;

        if (sDs->formWin) {
            dvxInvalidateWindow(sPrpCtx, sDs->formWin);
        }

        prpRefresh(sDs);
        return;
    }

    // Match by label text against control names
    const char *label = (const char *)w->userData;

    if (!label) {
        return;
    }

    // Extract name and control-array index from "Name(idx) (Type)"
    char clickedName[DSGN_MAX_NAME];
    int32_t clickedIndex = DSGN_NO_INDEX;

    parseTreeLabel(label, clickedName, DSGN_MAX_NAME, &clickedIndex);

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

    for (int32_t i = 0; i < count; i++) {
        if (strcmp(sDs->form->controls[i]->name, clickedName) == 0 && sDs->form->controls[i]->index == clickedIndex) {
            sDs->selectedIdx = i;

            if (sDs->formWin) {
                dvxInvalidateWindow(sPrpCtx, sDs->formWin);
            }

            prpRefresh(sDs);
            return;
        }
    }
}


// Parse a tree label "name (Type)" or "name(idx) (Type)" into the bare name
// and, when present, the array index used to disambiguate control-array
// members (which all share one name but have distinct ->index).
static void parseTreeLabel(const char *label, char *outName, int32_t outNameSize, int32_t *outIndex) {
    int32_t ni = 0;

    while (label[ni] && label[ni] != ' ' && label[ni] != '(' && ni < outNameSize - 1) {
        outName[ni] = label[ni];
        ni++;
    }

    outName[ni] = '\0';
    *outIndex   = DSGN_NO_INDEX;

    if (label[ni] == '(') {
        *outIndex = (int32_t)atoi(&label[ni + 1]);
    }
}


// propDropdownOrInput -- shared editor for the DataField / RecordSource
// properties.  When count <= 0 it falls back to a free-text input box;
// otherwise it offers a "(none)"-prefixed dropdown of names, preselecting
// the entry matching curValue.  Writes the chosen value (empty for "(none)")
// into newValue.  Returns false if the user cancelled.
static bool propDropdownOrInput(AppContextT *ctx, const char *propName, const char *selectPrompt, char (*names)[DSGN_MAX_NAME], int32_t count, const char *curValue, char *newValue, int32_t newValueSize) {
    if (count <= 0) {
        // No names available -- fall back to text input
        char prompt[PRP_PROMPT_BUF];
        snprintf(prompt, sizeof(prompt), "%s:", propName);
        snprintf(newValue, newValueSize, "%s", curValue);

        return dvxInputBox(ctx, "Edit Property", prompt, curValue, newValue, newValueSize);
    }

    const char **ptrs = (const char **)calloc((size_t)count + 1, sizeof(const char *));

    if (!ptrs) {
        return false;
    }

    ptrs[0] = "(none)";

    for (int32_t i = 0; i < count; i++) {
        ptrs[i + 1] = names[i];
    }

    int32_t defIdx = 0;

    for (int32_t i = 0; i < count; i++) {
        if (strcasecmp(names[i], curValue) == 0) {
            defIdx = i + 1;
            break;
        }
    }

    int32_t chosenIdx = 0;
    bool    ok        = dvxChoiceDialog(ctx, propName, selectPrompt, ptrs, count + 1, defIdx, &chosenIdx);

    free(ptrs);

    if (!ok) {
        return false;
    }

    if (chosenIdx == 0) {
        newValue[0] = '\0';
    } else {
        snprintf(newValue, newValueSize, "%s", names[chosenIdx - 1]);
    }

    return true;
}


WindowT *prpCreate(AppContextT *ctx, DsgnStateT *ds) {
    sDs     = ds;
    sPrpCtx = ctx;

    int32_t winX = ctx->display.width - PRP_WIN_W - PRP_WIN_RIGHT_MARGIN;
    WindowT *win = dvxCreateWindow(ctx, PRP_DIALOG_TITLE, winX, PRP_WIN_Y, PRP_WIN_W, PRP_WIN_H, true);

    if (!win) {
        return NULL;
    }

    win->onClose = onPrpClose;
    sPrpWin = win;

    WidgetT *root = wgtInitWindow(ctx, win);

    // Splitter: tree on top, property list on bottom
    WidgetT *splitter = wgtSplitter(root, false);
    splitter->weight = WGT_WEIGHT_FILL;
    wgtSplitterSetPos(splitter, (PRP_WIN_H - CHROME_TOTAL_TOP - CHROME_TOTAL_BOTTOM) / 2);

    // Control tree (top pane)
    sTree = wgtTreeView(splitter);
    sTree->onChange = onTreeChange;
    wgtTreeViewSetReorderable(sTree, true);

    // Property ListView (bottom pane)
    sPropList = wgtListView(splitter);
    sPropList->onDblClick = onPropDblClick;

    static const ListViewColT cols[PRP_CELL_COLUMNS] = {
        { "Property", 0, ListViewAlignLeftE },
        { "Value",    0, ListViewAlignLeftE }
    };

    wgtListViewSetColumns(sPropList, cols, PRP_CELL_COLUMNS);

    prpRebuildTree(ds);
    prpRefresh(ds);
    return win;
}


void prpDestroy(AppContextT *ctx, WindowT *win) {
    freeTreeLabels();
    arrfree(sTreeLabels);
    sTreeLabels = NULL;

    freeCellData();
    arrfree(sCellData);
    sCellData = NULL;

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

    sPrpWin   = NULL;
    sTree     = NULL;
    sPropList = NULL;
    sDs       = NULL;
}


void prpRebuildTree(DsgnStateT *ds) {
    if (!sTree || !ds || !ds->form) {
        return;
    }

    sUpdating = true;

    freeTreeLabels();
    wgtDestroyChildren(sTree);

    // Form entry at the top
    char *formLabel = strdup(ds->form->name);
    arrput(sTreeLabels, formLabel);

    WidgetT *formItem  = wgtTreeItem(sTree, formLabel);
    formItem->userData = formLabel;
    formItem->onClick  = onTreeItemClick;
    wgtTreeItemSetExpanded(formItem, true);

    if (ds->selectedIdx < 0) {
        wgtTreeItemSetSelected(formItem, true);
    }

    // Control entries -- nest children under container parents
    int32_t count = (int32_t)arrlen(ds->form->controls);

    // Temporary array to map control index -> tree item
    WidgetT **treeItems = NULL;

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

        if (ctrl->index >= 0) {
            snprintf(buf, sizeof(buf), "%s(%d) (%s)", ctrl->name, (int)ctrl->index, ctrl->typeName);
        } else {
            snprintf(buf, sizeof(buf), "%s (%s)", ctrl->name, ctrl->typeName);
        }

        char *label = strdup(buf);
        arrput(sTreeLabels, label);

        // Find the tree parent: form item or a container's tree item
        WidgetT *treeParent = formItem;

        if (ctrl->parentName[0]) {
            for (int32_t j = 0; j < i; j++) {
                if (strcasecmp(ds->form->controls[j]->name, ctrl->parentName) == 0) {
                    treeParent = treeItems[j];
                    break;
                }
            }
        }

        WidgetT *item = wgtTreeItem(treeParent, label);
        item->userData = label;
        item->onClick  = onTreeItemClick;
        arrput(treeItems, item);

        if (dsgnIsContainer(ctrl->typeName)) {
            wgtTreeItemSetExpanded(item, true);
        }

        if (i == ds->selectedIdx) {
            wgtTreeItemSetSelected(item, true);
        }
    }

    arrfree(treeItems);

    sUpdating = false;
}


void prpRefresh(DsgnStateT *ds) {
    if (!ds || !ds->form) {
        return;
    }

    // Sync tree selection to match selectedIdx
    if (sTree && !sUpdating) {
        WidgetT *formItem = sTree->firstChild;

        if (formItem) {
            WidgetT *target = NULL;

            if (ds->selectedIdx < 0) {
                target = formItem;
            } else if (ds->selectedIdx < (int32_t)arrlen(ds->form->controls)) {
                target = findTreeItemByName(formItem, ds->form->controls[ds->selectedIdx]->name, ds->form->controls[ds->selectedIdx]->index);
            }

            if (target) {
                sUpdating = true;
                wgtTreeViewSetSelected(sTree, target);
                sUpdating = false;
            }
        }
    }

    // Update property ListView
    if (!sPropList) {
        return;
    }

    freeCellData();

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

    if (ds->selectedIdx >= 0 && ds->selectedIdx < count) {
        DsgnControlT *ctrl = ds->form->controls[ds->selectedIdx];
        char buf[PRP_INT_BUF];

        addPropRow("Name", ctrl->name);

        if (ctrl->index >= 0) {
            snprintf(buf, sizeof(buf), "%d", (int)ctrl->index);
            addPropRow("Index", buf);
        }

        addPropRow("Type", ctrl->typeName);

        for (int32_t i = 0; dsgnIntPropAt(i); i++) {
            const DsgnIntPropT *ip = dsgnIntPropAt(i);

            snprintf(buf, sizeof(buf), "%d", (int)*(const int32_t *)((const char *)ctrl + ip->offset));
            addPropRow(ip->name, buf);
        }

        if (!dsgnIfaceHasProp(ctrl->typeName, "Visible")) {
            addPropRow("Visible", ctrl->visible ? "True" : "False");
        }

        if (!dsgnIfaceHasProp(ctrl->typeName, "Enabled")) {
            addPropRow("Enabled", ctrl->enabled ? "True" : "False");
        }

        addPropRow("HelpTopic", ctrl->helpTopic);

        for (int32_t i = 0; i < ctrl->propCount; i++) {
            addPropRow(ctrl->props[i].name, ctrl->props[i].value);
        }

        // A container placed on the canvas has no Layout entry until the
        // user picks one; show the default so the row is there to edit.
        if (dsgnIsContainer(ctrl->typeName) && !dsgnControlGetPropValue(ctrl, "Layout")) {
            addPropRow("Layout", DSGN_VBOX_LAYOUT);
        }

        // Widget interface properties (from the .wgt descriptor)
        const char *wgtName = wgtFindByBasName(ctrl->typeName);

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

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

                    // Skip read-only runtime properties (no setter)
                    if (!p->setFn) {
                        continue;
                    }

                    // Skip if already shown as a custom prop
                    bool already = false;

                    for (int32_t j = 0; j < ctrl->propCount; j++) {
                        if (strcasecmp(ctrl->props[j].name, p->name) == 0) {
                            already = true;
                            break;
                        }
                    }

                    if (already) {
                        continue;
                    }

                    // Read the current value from the widget.  Enum values
                    // that don't map to a name are shown as "?".
                    char valBuf[DSGN_MAX_TEXT];

                    if (wgtPropValueToString(ctrl->widget, p, valBuf, sizeof(valBuf))) {
                        addPropRow(p->name, valBuf);
                    } else if (p->type == WGT_IFACE_ENUM) {
                        addPropRow(p->name, "?");
                    } else {
                        addPropRow(p->name, "");
                    }
                }
            }
        }
    } else {
        // One row per runtime form property the designer stores, in
        // table order; runtime-only ones (Visible, ContextMenu) have no
        // designer field and are skipped.
        int32_t             formPropCount = 0;
        const BasPropDescT *formProps     = basFormRtFormProps(&formPropCount);

        for (int32_t i = 0; i < formPropCount; i++) {
            char valBuf[DSGN_MAX_TEXT];

            if (dsgnFormPropValue(ds->form, formProps[i].name, valBuf, sizeof(valBuf))) {
                addPropRow(formProps[i].name, valBuf);
            }
        }
    }

    wgtListViewSetData(sPropList, (const char **)sCellData, (int32_t)arrlen(sCellData) / PRP_CELL_COLUMNS);
}


// resolveDbPath -- resolve a DatabaseName against the project directory
static void resolveDbPath(const char *dbName, char *out, int32_t outSize) {
    // If it's already an absolute path (starts with drive letter or /), use as-is
    if ((dbName[0] && dbName[1] == ':') || dbName[0] == '/' || dbName[0] == '\\') {
        snprintf(out, outSize, "%s", dbName);
        return;
    }

    // Resolve relative to project directory
    if (sDs && sDs->projectDir && sDs->projectDir[0]) {
        snprintf(out, outSize, "%s" DVX_PATH_SEP "%s", sDs->projectDir, dbName);
    } else {
        snprintf(out, outSize, "%s", dbName);
    }
}


// Check whether the tree order matches the controls array.
// Returns true if they match (no reorder AND no reparent happened).
static bool treeOrderMatches(void) {
    WidgetT *formItem = sTree->firstChild;

    if (!formItem) {
        return true;
    }

    int32_t        count      = (int32_t)arrlen(sDs->form->controls);
    DsgnControlT **newArr     = NULL;
    PrpParentT    *newParents = NULL;

    // Order is detected via pointer identity (tree DFS order vs model
    // order); a reparent shows up as a differing tree parent name.
    collectTreeOrder(formItem, sDs->form->controls, count, &newArr, &newParents, "");

    bool match = ((int32_t)arrlen(newArr) == count);

    if (match) {
        for (int32_t i = 0; i < count; i++) {
            if (newArr[i] != sDs->form->controls[i] ||
                strcasecmp(sDs->form->controls[i]->parentName, newParents[i].name) != 0) {
                match = false;
                break;
            }
        }
    }

    arrfree(newArr);
    arrfree(newParents);
    return match;
}


// Reject a control or form rename that is not a BASIC identifier, is too
// long for a maxLen-byte field, or collides with another object on the form.
// Shows the reason and returns false on rejection.
static bool validateNewName(const char *name, int32_t maxLen, const char *exceptName) {
    char msg[PRP_PROMPT_BUF];

    if (!basIsValidIdent(name)) {
        dvxErrorBox(sPrpCtx, PRP_DIALOG_TITLE, "Name must start with a letter or underscore and contain only letters, digits, and underscores.");
        return false;
    }

    if ((int32_t)strlen(name) >= maxLen) {
        snprintf(msg, sizeof(msg), "Name must be shorter than %d characters.", (int)maxLen);
        dvxErrorBox(sPrpCtx, PRP_DIALOG_TITLE, msg);
        return false;
    }

    if (dsgnNameInUse(sDs->form, name, exceptName, true)) {
        snprintf(msg, sizeof(msg), "The name %s is already in use.", name);
        dvxErrorBox(sPrpCtx, PRP_DIALOG_TITLE, msg);
        return false;
    }

    return true;
}
