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

// ============================================================
// 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
static char       **sCellData   = NULL; // stb_ds array of strdup'd strings
static int32_t      sCellRows   = 0;

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

static void               addPropRow(const char *name, const char *value);
static void               cascadeToChildren(DsgnStateT *ds, const char *parentName, bool visible, bool enabled);
static void               collectTreeOrder(WidgetT *parent, DsgnControlT **srcArr, int32_t srcCount, DsgnControlT ***outArr, const char *parentName);
static const WgtPropDescT *findIfaceProp(const char *typeName, const char *propName);
static WidgetT            *findTreeItemByName(WidgetT *parent, const char *name);
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               resolveDbPath(const char *dbName, char *out, int32_t outSize);
static bool               treeOrderMatches(void);


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


// 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 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
        if (dsgnIsContainer(child->typeName)) {
            cascadeToChildren(ds, child->name, visible, enabled);
        }
    }
}


// Walk tree items recursively, collecting control names in order.
static void collectTreeOrder(WidgetT *parent, DsgnControlT **srcArr, int32_t srcCount, DsgnControlT ***outArr, 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 ni = 0;

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

        itemName[ni] = '\0';

        for (int32_t i = 0; i < srcCount; i++) {
            if (strcmp(srcArr[i]->name, itemName) == 0) {
                snprintf(srcArr[i]->parentName, DSGN_MAX_NAME, "%s", parentName);
                arrput(*outArr, srcArr[i]);

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

                break;
            }
        }
    }
}


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

    const char *wgtName = wgtFindByBasName(typeName);

    if (!wgtName) {
        return NULL;
    }

    const WgtIfaceT *iface = wgtGetIface(wgtName);

    if (!iface) {
        return NULL;
    }

    for (int32_t i = 0; i < iface->propCount; i++) {
        if (strcasecmp(iface->props[i].name, propName) == 0) {
            return &iface->props[i];
        }
    }

    return NULL;
}


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

        if (label) {
            // Labels are "Name (Type)" -- match the name portion
            int32_t len = 0;

            while (label[len] && label[len] != ' ') {
                len++;
            }

            if ((int32_t)strlen(name) == len && strncmp(label, name, len) == 0) {
                return item;
            }
        }

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

        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);
    sCellRows = 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;
        }

        for (int32_t j = 0; j < ctrl->propCount; j++) {
            if (strcasecmp(ctrl->props[j].name, "DatabaseName") == 0) {
                dbName = ctrl->props[j].value;
            } else if (strcasecmp(ctrl->props[j].name, "RecordSource") == 0) {
                recSrc = ctrl->props[j].value;
            }
        }

        break;
    }

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

    // Resolve SQL functions via dlsym
    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 void        (*SqlFreeResultFnT)(int32_t);

    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[512];

    if (strncasecmp(recSrc, "SELECT ", 7) == 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 data type of a property by name. Checks built-in
// properties first, then looks up the widget's interface descriptor.
// Returns WGT_IFACE_STRING, WGT_IFACE_INT, or WGT_IFACE_BOOL.
static uint8_t getPropType(const char *propName, const char *typeName) {
    // Read-only properties
    if (strcasecmp(propName, "Type") == 0)  { return PROP_TYPE_READONLY; }
    if (strcasecmp(propName, "Index") == 0) { return PROP_TYPE_READONLY; }
    if (strcasecmp(propName, "BOF") == 0)   { return PROP_TYPE_READONLY; }
    if (strcasecmp(propName, "EOF") == 0)   { return PROP_TYPE_READONLY; }

    // Known built-in types
    if (strcasecmp(propName, "Name") == 0)      { return PROP_TYPE_STRING; }
    if (strcasecmp(propName, "Caption") == 0)    { return PROP_TYPE_STRING; }
    if (strcasecmp(propName, "MinWidth") == 0)    { return PROP_TYPE_INT; }
    if (strcasecmp(propName, "MinHeight") == 0)  { return PROP_TYPE_INT; }
    if (strcasecmp(propName, "MaxWidth") == 0)   { return PROP_TYPE_INT; }
    if (strcasecmp(propName, "MaxHeight") == 0)  { return PROP_TYPE_INT; }
    if (strcasecmp(propName, "Weight") == 0)     { return PROP_TYPE_INT; }
    if (strcasecmp(propName, "Left") == 0)       { return PROP_TYPE_INT; }
    if (strcasecmp(propName, "Top") == 0)        { return PROP_TYPE_INT; }
    if (strcasecmp(propName, "AutoSize") == 0)   { return PROP_TYPE_BOOL; }
    if (strcasecmp(propName, "Resizable") == 0)  { return PROP_TYPE_BOOL; }
    if (strcasecmp(propName, "Centered") == 0)   { return PROP_TYPE_BOOL; }
    if (strcasecmp(propName, "Visible") == 0)    { return PROP_TYPE_BOOL; }
    if (strcasecmp(propName, "Enabled") == 0)    { return PROP_TYPE_BOOL; }
    if (strcasecmp(propName, "Layout") == 0)     { return PROP_TYPE_LAYOUT; }
    if (strcasecmp(propName, "HelpTopic") == 0)      { return PROP_TYPE_STRING; }
    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; }

    // Look up in the widget's interface descriptor
    if (typeName && typeName[0]) {
        const char *wgtName = wgtFindByBasName(typeName);

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

            if (iface) {
                for (int32_t i = 0; i < iface->propCount; i++) {
                    if (strcasecmp(iface->props[i].name, propName) == 0) {
                        return iface->props[i].type;
                    }
                }
            }
        }
    }

    return 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;
    }

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

    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);

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

    const char *propName = sCellData[row * 2];
    const char *curValue = sCellData[row * 2 + 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[32];
        int32_t layoutCount = 0;
        int32_t ifaceTotal  = wgtIfaceCount();

        for (int32_t i = 0; i < ifaceTotal && layoutCount < 32; 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];

            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;

            root->firstChild = NULL;
            root->lastChild  = NULL;

            WidgetT *contentBox = dsgnCreateContentBox(root, layoutField);

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

            for (int32_t ci = 0; ci < cc; ci++) {
                sDs->form->controls[ci]->widget = NULL;
            }

            dsgnCreateWidgets(sDs, contentBox);

            if (sDs->formWin) {
                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[17];
        int32_t dataCount = 0;

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

        for (int32_t i = 0; i < formCtrlCount && dataCount < 16; 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 {
                for (int32_t i = 0; i < selCtrl->propCount; i++) {
                    if (strcasecmp(selCtrl->props[i].name, "DataSource") == 0) {
                        dataSrc = selCtrl->props[i].value;
                        break;
                    }
                }
            }
        }

        char (*fieldNames)[DSGN_MAX_NAME] = NULL;
        int32_t fieldCount = getDataFieldNames(sDs, dataSrc, &fieldNames);

        if (fieldCount <= 0) {
            // No columns found -- fall back to text input
            char prompt[128];
            snprintf(prompt, sizeof(prompt), "%s:", propName);
            snprintf(newValue, sizeof(newValue), "%s", curValue);

            if (!dvxInputBox(sPrpCtx, "Edit Property", prompt, curValue, newValue, sizeof(newValue))) {
                free(fieldNames);
                return;
            }
        } else {
            const char **fieldPtrs = (const char **)calloc((size_t)fieldCount + 1, sizeof(const char *));

            if (!fieldPtrs) {
                free(fieldNames);
                return;
            }

            fieldPtrs[0] = "(none)";

            for (int32_t i = 0; i < fieldCount; i++) {
                fieldPtrs[i + 1] = fieldNames[i];
            }

            int32_t defIdx = 0;

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

            int32_t chosenIdx = 0;

            if (!dvxChoiceDialog(sPrpCtx, propName, "Select column:", fieldPtrs, fieldCount + 1, defIdx, &chosenIdx)) {
                free(fieldPtrs);
                free(fieldNames);
                return;
            }

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

            free(fieldPtrs);
        }

        free(fieldNames);
    } 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];

            for (int32_t i = 0; i < selCtrl->propCount; i++) {
                if (strcasecmp(selCtrl->props[i].name, "DatabaseName") == 0) {
                    dbName = selCtrl->props[i].value;
                    break;
                }
            }
        }

        char (*tableNames)[DSGN_MAX_NAME] = NULL;
        int32_t tableCount = getTableNames(dbName, &tableNames);

        if (tableCount <= 0) {
            // No tables or can't open DB -- fall back to text input
            char prompt[128];
            snprintf(prompt, sizeof(prompt), "%s:", propName);
            snprintf(newValue, sizeof(newValue), "%s", curValue);

            if (!dvxInputBox(sPrpCtx, "Edit Property", prompt, curValue, newValue, sizeof(newValue))) {
                free(tableNames);
                return;
            }
        } else {
            const char **tablePtrs = (const char **)calloc((size_t)tableCount + 1, sizeof(const char *));

            if (!tablePtrs) {
                free(tableNames);
                return;
            }

            tablePtrs[0] = "(none)";

            for (int32_t i = 0; i < tableCount; i++) {
                tablePtrs[i + 1] = tableNames[i];
            }

            int32_t defIdx = 0;

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

            int32_t chosenIdx = 0;

            if (!dvxChoiceDialog(sPrpCtx, "RecordSource", "Select table:", tablePtrs, tableCount + 1, defIdx, &chosenIdx)) {
                free(tablePtrs);
                free(tableNames);
                return;
            }

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

            free(tablePtrs);
        }

        free(tableNames);
    } else if (propType == PROP_TYPE_INT) {
        // Spinner dialog for integers
        char prompt[128];
        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[128];
        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);

            // 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, "%.31s", newValue);

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

            // 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 (strcasecmp(propName, "MinWidth") == 0) {
            ctrl->width = atoi(newValue);

            if (ctrl->widget) {
                ctrl->widget->minW = wgtPixels(ctrl->width);
            }
        } else if (strcasecmp(propName, "MinHeight") == 0) {
            ctrl->height = atoi(newValue);

            if (ctrl->widget) {
                ctrl->widget->minH = wgtPixels(ctrl->height);
            }
        } else if (strcasecmp(propName, "MaxWidth") == 0) {
            ctrl->maxWidth = atoi(newValue);

            if (ctrl->widget) {
                ctrl->widget->maxW = ctrl->maxWidth > 0 ? wgtPixels(ctrl->maxWidth) : 0;
            }
        } else if (strcasecmp(propName, "MaxHeight") == 0) {
            ctrl->maxHeight = atoi(newValue);

            if (ctrl->widget) {
                ctrl->widget->maxH = ctrl->maxHeight > 0 ? wgtPixels(ctrl->maxHeight) : 0;
            }
        } else if (strcasecmp(propName, "Weight") == 0) {
            ctrl->weight = atoi(newValue);

            if (ctrl->widget) {
                ctrl->widget->weight = ctrl->weight;
            }
        } else if (strcasecmp(propName, "Visible") == 0) {
            bool val = frmParseBool(newValue);

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

            if (dsgnIsContainer(ctrl->typeName)) {
                bool en = ctrl->widget ? ctrl->widget->enabled : true;
                cascadeToChildren(sDs, ctrl->name, val, en);
            }
        } else if (strcasecmp(propName, "Enabled") == 0) {
            bool val = frmParseBool(newValue);

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

            if (dsgnIsContainer(ctrl->typeName)) {
                bool vis = ctrl->widget ? ctrl->widget->visible : true;
                cascadeToChildren(sDs, ctrl->name, vis, val);
            }
        } 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) {
                        for (int32_t i = 0; i < iface->propCount; i++) {
                            const WgtPropDescT *p = &iface->props[i];

                            if (strcasecmp(p->name, propName) != 0 || !p->setFn) {
                                continue;
                            }

                            if (p->type == WGT_IFACE_STRING) {
                                // Strings must outlive this function, so the
                                // ctrl->props[] copy is what we pass to setFn
                                // (not the newValue buffer).
                                bool found = false;

                                for (int32_t j = 0; j < ctrl->propCount; j++) {
                                    if (strcasecmp(ctrl->props[j].name, propName) == 0) {
                                        snprintf(ctrl->props[j].value, DSGN_MAX_TEXT, "%s", newValue);
                                        ((void (*)(WidgetT *, const char *))p->setFn)(ctrl->widget, ctrl->props[j].value);
                                        found = true;
                                        break;
                                    }
                                }

                                if (!found && ctrl->propCount < DSGN_MAX_PROPS) {
                                    snprintf(ctrl->props[ctrl->propCount].name, DSGN_MAX_NAME, "%s", propName);
                                    snprintf(ctrl->props[ctrl->propCount].value, DSGN_MAX_TEXT, "%s", newValue);
                                    ((void (*)(WidgetT *, const char *))p->setFn)(ctrl->widget, ctrl->props[ctrl->propCount].value);
                                    ctrl->propCount++;
                                }
                            } else {
                                wgtApplyPropFromString(ctrl->widget, p, newValue);
                            }

                            ifaceHandled = true;
                            break;
                        }
                    }
                }
            }

            if (!ifaceHandled) {
                // Custom prop storage
                bool found = false;

                for (int32_t i = 0; i < ctrl->propCount; i++) {
                    if (strcasecmp(ctrl->props[i].name, propName) == 0) {
                        snprintf(ctrl->props[i].value, DSGN_MAX_TEXT, "%s", newValue);
                        found = true;
                        break;
                    }
                }

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

                // Update widget text from the persistent props array
                if (ctrl->widget && (strcasecmp(propName, "Caption") == 0 || strcasecmp(propName, "Text") == 0)) {
                    for (int32_t i = 0; i < ctrl->propCount; i++) {
                        if (strcasecmp(ctrl->props[i].name, propName) == 0) {
                            wgtSetText(ctrl->widget, ctrl->props[i].value);
                            break;
                        }
                    }
                }
            }
        }

        sDs->form->dirty = true;

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

            // Length-clamped memcpy instead of strncpy/snprintf because
            // GCC warns about both when source exceeds the buffer.
            int32_t nl = (int32_t)strlen(newValue);

            if (nl >= DSGN_MAX_NAME) {
                nl = DSGN_MAX_NAME - 1;
            }

            memcpy(sDs->form->name, newValue, nl);
            sDs->form->name[nl] = '\0';
            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[280];
                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;
    WidgetT *formItem = sTree->firstChild;

    if (!formItem) {
        return;
    }

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

    // If we lost items (dragged above form), revert
    if ((int32_t)arrlen(newArr) != count) {
        arrfree(newArr);
        prpRebuildTree(sDs);
        return;
    }

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

    if (sDs->form->contentBox) {
        sDs->form->contentBox->firstChild = NULL;
        sDs->form->contentBox->lastChild  = NULL;

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

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

        dsgnCreateWidgets(sDs, sDs->form->contentBox);
    }

    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 from "Name (Type)"
    char clickedName[DSGN_MAX_NAME];
    int32_t ni = 0;

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

    clickedName[ni] = '\0';

    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->selectedIdx = i;

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

            prpRefresh(sDs);
            return;
        }
    }
}


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

    int32_t winX = ctx->display.width - PRP_WIN_W - 10;
    WindowT *win = dvxCreateWindow(ctx, "Properties", winX, 30, 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[2] = {
        { "Property", 0, ListViewAlignLeftE },
        { "Value",    0, ListViewAlignLeftE }
    };

    wgtListViewSetColumns(sPropList, cols, 2);

    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();
    sTree->firstChild = NULL;
    sTree->lastChild  = NULL;

    // 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[128];

        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 && treeItems) {
                    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);
            }

            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[32];

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

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

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

        snprintf(buf, sizeof(buf), "%d", (int)ctrl->width);
        addPropRow("MinWidth", buf);

        snprintf(buf, sizeof(buf), "%d", (int)ctrl->height);
        addPropRow("MinHeight", buf);

        snprintf(buf, sizeof(buf), "%d", (int)ctrl->maxWidth);
        addPropRow("MaxWidth", buf);

        snprintf(buf, sizeof(buf), "%d", (int)ctrl->maxHeight);
        addPropRow("MaxHeight", buf);

        snprintf(buf, sizeof(buf), "%d", (int)ctrl->weight);
        addPropRow("Weight", buf);

        addPropRow("Visible", ctrl->widget && ctrl->widget->visible ? "True" : "False");
        addPropRow("Enabled", ctrl->widget && ctrl->widget->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);
        }

        // 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 {
        char buf[32];

        addPropRow("Name", ds->form->name);
        addPropRow("Caption", ds->form->caption);
        addPropRow("Layout", ds->form->layout);
        addPropRow("AutoSize", ds->form->autoSize ? "True" : "False");
        addPropRow("Resizable", ds->form->resizable ? "True" : "False");
        addPropRow("Centered", ds->form->centered ? "True" : "False");

        snprintf(buf, sizeof(buf), "%d", (int)ds->form->left);
        addPropRow("Left", buf);

        snprintf(buf, sizeof(buf), "%d", (int)ds->form->top);
        addPropRow("Top", buf);

        snprintf(buf, sizeof(buf), "%d", (int)ds->form->width);
        addPropRow("Width", buf);

        snprintf(buf, sizeof(buf), "%d", (int)ds->form->height);
        addPropRow("Height", buf);

        addPropRow("HelpTopic", ds->form->helpTopic);
    }

    wgtListViewSetData(sPropList, (const char **)sCellData, sCellRows);
}


// 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 happened).
static bool treeOrderMatches(void) {
    WidgetT *formItem = sTree->firstChild;

    if (!formItem) {
        return true;
    }

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

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

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

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

    arrfree(newArr);
    return match;
}
