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

// ideMain.c -- DVX BASIC IDE application
//
// A DVX app that edits, compiles, runs and debugs BASIC projects.
// PRINT output goes to a scrollable TextArea widget. Compile and
// runtime errors are displayed with file/procedure line numbers.

#include "dvxApp.h"
#include "dvxCur.h"
#include "dvxPlat.h"
#include "dvxDlg.h"
#include "dvxPrefs.h"
#include "dvxWgt.h"
#include "dvxWgtP.h"
#include "dvxWm.h"
#include "shellApp.h"
#include "box/box.h"
#include "checkbox/checkbox.h"
#include "imageButton/imgBtn.h"
#include "label/label.h"
#include "radio/radio.h"
#include "textInput/textInpt.h"
#include "dropdown/dropdown.h"
#include "canvas/canvas.h"
#include "listBox/listBox.h"
#include "slider/slider.h"
#include "tabControl/tabCtrl.h"
#include "button/button.h"
#include "splitter/splitter.h"
#include "statusBar/statBar.h"
#include "listView/listView.h"
#include "separator/separatr.h"
#include "toolbar/toolbar.h"

#include "ideDesigner.h"
#include "ideProject.h"
#include "ideMenuEditor.h"
#include "ideToolbox.h"
#include "ideProperties.h"

#include "../basBuild.h"
#include "../basRes.h"
#include "../compiler/basEvents.h"
#include "../compiler/parser.h"
#include "../formrt/formrt.h"
#include "../formrt/frmParser.h"
#include "dvxRes.h"
#include "../../../../libs/kpunch/sql/dvxSql.h"
#include "../runtime/vm.h"
#include "../runtime/values.h"

#include "stb_ds_wrap.h"

#include <dlfcn.h>
#include <stdint.h>
#include <stdbool.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>

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

#define IDE_MAX_SOURCE     65536
#define IDE_MAX_OUTPUT     32768
#define IDE_MAX_EVT_LABELS 64     // rows in event-dropdown label buffer
#define IDE_EVT_LABEL_LEN  32     // bytes per event label
#define IDE_WORD_BUF       32     // single-word scratch buffer
#define IDE_SOURCE_MARGIN  64
#define IDE_FULL_NAME_BUF  (BAS_MAX_IDENT * 2) // "Obj.Evt" / "Obj_Evt" combined names
#define IDE_TITLE_BUF      (PRJ_MAX_NAME + 32)    // "DVX BASIC - [name] *"
#define IDE_FORM_TITLE_BUF (DSGN_MAX_TEXT + 32)   // "caption [Design] *"
#define IDE_MSG_BUF        (DVX_MAX_PATH + 32)    // path plus short message
#define IDE_PREF_KEY_BUF   32     // "colorN" style preference keys
#define IDE_IMM_LINE_BUF   512    // one Immediate-window line
#define IDE_IMM_WRAP_BUF   (IDE_IMM_LINE_BUF + 8) // line plus "PRINT " prefix
#define IDE_SKELETON_BUF   512    // generated "Sub ... End Sub" skeleton
#define IDE_VALUE_BUF      256    // formatted variable value / watch text
#define IDE_FIND_TEXT_BUF  256    // find / replace text
#define IDE_LINE_NUM_BUF   16     // decimal line number text
#define IDE_TYPE_NAME_BUF  16     // "Integer()" style type column text
#define IDE_RGB_LABEL_BUF  8      // "255"
#define IDE_TAB_WIDTH_BUF  8      // tab width text input
#define IDE_OUTPUT_LINE_RESERVE 256 // headroom kept in sOutputBuf per error line
#define IDE_QUOTE_OVERHEAD 3      // two quotes plus NUL around a string value
#define IDE_ARRAY_DIM_RESERVE 10  // room kept for ") [n]" after array dims
#define IDE_FRM_LINE_CHROME  8    // "    key = \"value\"\n" overhead per .frm line
#define IDE_FRM_BLOCK_CHROME 512  // Begin/End lines and built-in sizes of a .frm block

#define IDE_MAIN_TITLE      "DVX BASIC"
#define IDE_GENERAL_SECTION "(General)"
#define IDE_GLOBAL_SECTION  "(Global)"
#define IDE_MODULE_FRAME    "(module)"
#define IDE_BREAK_TAG       " [break]"
#define IDE_DEFAULT_FORM    "Form1"
#define IDE_HELP_FILE       "dvxbasic.hlp"
#define IDE_PREFS_FILE      "dvxbasic.ini"

// Keyword tags for procedure scanning (case-insensitive prefix match)
#define KW_SUB          "SUB "
#define KW_FUNCTION     "FUNCTION "
#define KW_END_SUB      "END SUB"
#define KW_END_FUNCTION "END FUNCTION"
#define KW_REM          "REM"
#define KW_LET          "LET "
#define KW_PRINT        "PRINT"
#define KW_DIM          "DIM"
#define KW_LINE_PREFIX  "Line "
#define FRM_BEGIN_TAG   "Begin "
#define FRM_END_TAG     "End"
#define KW_LEN(s)       ((int32_t)sizeof(s) - 1)

// Preference sections/keys used from more than one place
#define IDE_PREF_KEY_TAB_WIDTH     "tabWidth"
#define IDE_PREF_KEY_USE_SPACES    "useSpaces"
#define IDE_PREF_KEY_RENAME_SKIP   "renameSkipComments"
#define PREF_SEC_SYNTAX        "syntax"
#define PREF_KEY_COLOR_FMT     "color%d"
#define PREF_SEC_RUN           "run"
#define PREF_KEY_SAVE_ON_RUN   "saveOnRun"
#define PREF_KEY_OUTPUT_TO_LOG "outputToLog"
#define PREF_SEC_VIEW          "view"
#define PREF_KEY_TOOLBAR       "toolbar"
#define PREF_KEY_STATUSBAR     "statusbar"
#define PREF_SEC_RECENT        "recent"
#define PREF_KEY_RECENT_FMT    "file%ld"
#define IDE_DEFAULT_TAB_WIDTH  3
#define IDE_MIN_TAB_WIDTH      1
#define IDE_MAX_TAB_WIDTH      8

// Editor colors
#define IDE_BP_GUTTER_R    200
#define IDE_BP_GUTTER_G    0
#define IDE_BP_GUTTER_B    0
#define IDE_DBG_LINE_R     255
#define IDE_DBG_LINE_G     255
#define IDE_DBG_LINE_B     128

// Window geometry
#define IDE_WIN_MARGIN          10   // gap from screen edge
#define IDE_TOOLBAR_GAP         2    // gap below the toolbar window
#define IDE_TOOLBAR_FALLBACK_Y  60   // toolbar bottom when no toolbar window
#define IDE_TOOLBAR_SEP_W       10   // toolbar group separator width
#define IDE_TOOLBAR_INIT_H      200  // toolbar window height before fit
#define IDE_PROJECT_WIN_OFFSET  25   // project window y below toolbar bottom
#define IDE_BP_WIN_W            320
#define IDE_DEBUG_WIN_H         180  // breakpoints / call stack / watch height
#define IDE_CALLSTACK_WIN_W     220
#define IDE_CALLSTACK_WIN_Y_OFF 210  // below toolbar bottom
#define IDE_LOCALS_WIN_W        250
#define IDE_LOCALS_WIN_H        200
#define IDE_WATCH_WIN_W         280
#define IDE_WATCH_WIN_X_GAP     260  // watch window sits left of locals
#define IDE_FIND_WIN_W          320
#define IDE_FIND_WIN_H          210
#define IDE_FIND_ROOT_SPACING   3
#define IDE_PREFS_WIN_W         420
#define IDE_PREFS_WIN_H         440
#define IDE_PREFS_LABEL_W       80
#define IDE_PREFS_TAB_INPUT_W   40
#define IDE_PREFS_TAB_INPUT_MAX 4
#define IDE_PREFS_TEXT_MAX      64
#define IDE_PREFS_VERSION_MAX   16
#define IDE_PREFS_DESC_MAX      512
#define IDE_PREFS_BUTTON_W      60
#define IDE_PREFS_DESC_H        48
#define IDE_SWATCH_W            64
#define IDE_SWATCH_H            24
#define IDE_WIDGET_SPACING      4
#define IDE_WIDGET_SPACING_SM   2
#define IDE_WIDGET_SPACING_LG   8

// Menu command IDs
#define CMD_OPEN         100
#define CMD_RUN          101
#define CMD_STOP         102
#define CMD_CLEAR        103
#define CMD_EXIT         104
#define CMD_RUN_NOCMP    105
#define CMD_VIEW_CODE    106
#define CMD_VIEW_DESIGN  107
#define CMD_SAVE         108
#define CMD_WIN_CODE     109
#define CMD_WIN_OUTPUT   110
#define CMD_WIN_IMM      111
#define CMD_WIN_TOOLBOX  112
#define CMD_WIN_PROPS    113
#define CMD_DELETE       114
#define CMD_CUT          115
#define CMD_COPY         116
#define CMD_PASTE        117
#define CMD_SELECT_ALL   118
#define CMD_VIEW_TOOLBAR 119
#define CMD_VIEW_STATUS  120
#define CMD_SAVE_ALL     129
#define CMD_SAVE_ON_RUN  139
#define CMD_PRJ_NEW      130
#define CMD_PRJ_OPEN     131
#define CMD_PRJ_SAVE     132
#define CMD_PRJ_CLOSE    133
#define CMD_PRJ_REMOVE   136
#define CMD_PRJ_PROPS    138
#define CMD_WIN_PROJECT  137
#define CMD_HELP_ABOUT   140
#define CMD_FIND         141
#define CMD_REPLACE      142
#define CMD_FIND_NEXT    143
#define CMD_MENU_EDITOR  144
#define CMD_PREFERENCES  145
#define CMD_STEP_INTO    146
#define CMD_STEP_OVER    147
#define CMD_STEP_OUT     148
#define CMD_TOGGLE_BP    149
#define CMD_RUN_TO_CURSOR 150
#define CMD_WIN_LOCALS    151
#define CMD_DEBUG         152
#define CMD_WIN_CALLSTACK 153
#define CMD_WIN_WATCH     154
#define CMD_WIN_BREAKPOINTS 155
#define CMD_DEBUG_LAYOUT    156
#define CMD_OUTPUT_TO_LOG   159
#define CMD_RECENT_BASE     160  // 160-175 reserved for recent files
#define CMD_RECENT_MAX      16   // Keep cap: menu command-ID range is a fixed slice
#define CMD_MAKE_EXE        176
#define CMD_HELP_CONTENTS   157
#define CMD_HELP_API        158
#define IDE_MAX_IMM      1024
#define IDE_KEY_CTRL_A   0x01   // Ctrl+A (select all); KEY_CTRL_A not in dvxTypes.h
#define IDE_OUTPUT_WIN_H   120  // Output/Immediate window height
#define IDE_OUTPUT_WIN_GAP 2    // gap between code window and output strip

// Syntax color indices (used by basicColorize / classifyWord)
#define SYNTAX_DEFAULT   0
#define SYNTAX_KEYWORD   1
#define SYNTAX_STRING    2
#define SYNTAX_COMMENT   3
#define SYNTAX_NUMBER    4
#define SYNTAX_OPERATOR  5
#define SYNTAX_TYPE      6
#define SYNTAX_COLOR_COUNT 7

// View mode for activateFile
typedef enum {
    ViewAutoE,    // .frm -> design, .bas -> code
    ViewCodeE,    // force code view
    ViewDesignE   // force design view
} IdeViewModeE;

typedef enum {
    ScopeFuncE,
    ScopeObjE,
    ScopeFileE,
    ScopeProjE
} FindScopeE;

// Debug state
typedef enum {
    DBG_IDLE,       // no program loaded
    DBG_RUNNING,    // program executing
    DBG_PAUSED      // stopped at breakpoint/step
} IdeDebugStateE;

typedef struct {
    int32_t fileIdx;     // project file index
    int32_t codeLine;    // line within file's code section (1-based)
    char    procName[IDE_FULL_NAME_BUF]; // "obj.evt" combined name
} IdeBreakpointT;

// Procedure table for Object/Event dropdowns
typedef struct {
    char    objName[BAS_MAX_IDENT];
    char    evtName[BAS_MAX_IDENT];
    int32_t lineNum;     // line of the SUB / FUNCTION declaration
    int32_t endLineNum;  // line of the END SUB / END FUNCTION
} IdeProcEntryT;

typedef struct {
    char    *key;
    uint8_t  value;
} SyntaxMapEntryT;

typedef struct {
    const char *keyword;
    const char *topic;
} HelpMapEntryT;

// Row storage for the debug ListViews.  Cell text is strdup'd row-major
// into strs; cells holds the same pointers in the layout
// wgtListViewSetData expects.
typedef struct {
    char       **strs;   // stb_ds: owned cell text
    const char **cells;  // stb_ds: borrowed pointers into strs
} IdeListRowsT;

// Compile-time CtrlName.Member validator context
typedef struct {
    char name[BAS_MAX_IDENT];
    char wgtType[BAS_MAX_IDENT]; // "Form" for the form itself, else iface basName
} IdeCtrlMapEntryT;

typedef struct {
    char ctrlName[BAS_MAX_IDENT]; // control that has the bad type
    char typeName[BAS_MAX_IDENT]; // the unrecognised type name
    char formName[BAS_MAX_IDENT]; // enclosing form (for error message)
} IdeBadTypeT;

typedef struct {
    IdeCtrlMapEntryT *entries;     // stb_ds: known controls
    IdeBadTypeT      *badTypes;    // stb_ds: unknown widget types encountered
    char              currentForm[BAS_MAX_IDENT]; // most recent Begin Form
} IdeValidatorCtxT;

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

static void activateFile(int32_t fileIdx, IdeViewModeE view);
static bool addFileToProject(const char *path);
static void applySyntaxColors(void);
static void basicColorize(const char *line, int32_t lineLen, uint8_t *colors, void *ctx);
static BasVmT *buildSnippetVm(const char *src, BasModuleT **outMod, char *errBuf, int32_t errBufSize);
static void buildVmBreakpoints(void);
static void buildWindow(void);
static uint8_t classifyWord(const char *word, int32_t wordLen);
static void cleanupFormWin(void);
static void clearAllBreakpoints(void);
static void clearOutput(void);
static void clearVmStepState(void);
static void closeFindDialog(void);
static void closeProject(void);
static void closeProjectUi(void);
static int cmpEvtPtrs(const void *a, const void *b);
static int cmpStrPtrs(const void *a, const void *b);
static void compileAndRun(void);
static bool compileProject(void);
static int32_t countLines(const char *text);
static void createEventSubSkeleton(const char *objName, const char *evtName);
static uint32_t debugLineDecorator(int32_t lineNum, uint32_t *gutterColor, void *ctx);
static void debugNavigateToLine(int32_t concatLine);
static void debugResume(const char *status);
static void debugSetBreakTitles(bool paused);
static void debugStartOrResume(int32_t cmd);
static void debugStop(void);
static void debugUpdateWindows(void);
static void deleteSelectedControl(void);
static bool doEventsCallback(void *ctx);
static void dsgnCopySelected(void);
static void dsgnPasteControl(void);
static int32_t editorLineToCodeLine(int32_t editorLine);
static void ensureProject(const char *filePath);
static void ensureProjectWindow(bool raise);
static void evaluateImmediate(const char *expr);
static bool evalWatchExpr(const char *expr, char *outBuf, int32_t outBufSize);
static bool evtLabelMatches(const char *label, const char *evtName);
static char *extractNewProcs(const char *buf);
static const BasDebugVarT *findDebugVar(const char *name);
static bool findInProject(const char *needle, bool caseSensitive, bool forward);
static const char *findSubstrNoCase(const char *haystack, const char *needle, int32_t needleLen);
static int32_t findUdtFieldIdx(int32_t typeId, const char *fieldName);
static void formatValue(const BasValueT *v, char *buf, int32_t bufSize);
static void freeProcBufs(void);
static BasValueT *getDebugVarSlot(const BasDebugVarT *dv);
static const char *getEventParams(const char *evtName);
static bool getFindForward(void);
static bool getFindMatchCase(void);
static FindScopeE getFindScope(void);
static const char *getFullSource(void);
static WindowT *getLastFocusWin(void);
static void handleEditCmd(int32_t cmd);
static void handleFileCmd(int32_t cmd);
static void handleProjectCmd(int32_t cmd);
static void handleRunCmd(int32_t cmd);
static void handleViewCmd(int32_t cmd);
static void handleWindowCmd(int32_t cmd);
static bool hasProject(void);
static bool hasUnsavedData(void);
static void helpBuildCtrlTopic(const char *typeName, char *buf, int32_t bufSize);
static const char *helpLookupKeyword(const char *word);
static void helpQueryHandler(void *ctx);
static void helpSetCtrlTopic(const char *typeName);
void ideRenameInCode(const char *oldName, const char *newName);
static bool immParseScalarFromStr(const char *rhs, const BasValueT *target, BasValueT *outVal);
static void immPrintCallback(void *ctx, const char *text, bool newline);
static bool immTryAssign(const char *expr);
static void initSyntaxMap(void);
static bool inputCallback(void *ctx, const char *prompt, char *buf, int32_t bufSize);
static bool isCtrlArrayInDesigner(const char *ctrlName);
static bool isInStringOrComment(const char *src, int32_t i);
static bool isMangledDebugName(const char *name);
static bool isReplaceEnabled(void);
static char *joinProcArrays(const char *general, char **procs);
static const char *joinProcBufs(void);
static bool kwMatch(const char *p, const char *kw);
static void listRowsAdd(IdeListRowsT *rows, const char *text);
static void listRowsCommit(const IdeListRowsT *rows, WidgetT *list, int32_t rowCount);
static void listRowsFree(IdeListRowsT *rows);
static void listRowsReset(IdeListRowsT *rows);
static char *loadCompileSource(int32_t fileIdx);
static void loadFile(void);
static void loadFormCodeIntoEditor(void);
static void loadFrmFiles(BasFormRtT *rt);
static WidgetT *loadTbIcon(WidgetT *parent, const char *resName, const char *fallbackText);
static int32_t localToConcatLine(int32_t editorLine);
static bool lookupWatchVar(const char *expr, BasValueT *outVal);
static void makeExecutable(void);
static void navigateToBreakpoint(int32_t bpIdx);
static void navigateToCodeLine(int32_t fileIdx, int32_t codeLine, const char *procName, bool setDbgLine);
static void navigateToEventSub(void);
static void navigateToNamedEventSub(const char *ctrlName, const char *eventName);
static void newProject(void);
static void onBreakpointHit(void *ctx, int32_t line);
static void onBreakpointListDblClick(WidgetT *w);
static void onBreakpointListKeyDown(WidgetT *w, int32_t keyCode, int32_t shift);
static void onBreakpointWinClose(WindowT *win);
static void onCallStackClose(WindowT *win);
static void onClose(WindowT *win);
static void onCodeWinClose(WindowT *win);
static void onColorListChange(WidgetT *w);
static void onColorSliderChange(WidgetT *w);
static void onContentFocus(WindowT *win);
static void onEditorChange(WidgetT *w);
static void onEvtDropdownChange(WidgetT *w);
static void onFindClose(WindowT *win);
static void onFindCloseBtn(WidgetT *w);
static void onFindNext(WidgetT *w);
static void onFormWinClose(WindowT *win);
static int32_t onFormWinCursorQuery(WindowT *win, int32_t x, int32_t y);
static void onFormWinKey(WindowT *win, int32_t key, int32_t mod);
static void onFormWinMenu(WindowT *win, int32_t menuId);
static void onFormWinMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons);
static void onFormWinPaint(WindowT *win, RectT *dirtyArea);
static void onFormWinResize(WindowT *win, int32_t newW, int32_t newH);
static void onGutterClick(WidgetT *w, int32_t lineNum);
static void onImmediateChange(WidgetT *w);
static void onLocalsClose(WindowT *win);
static void onMenu(WindowT *win, int32_t menuId);
static void onObjDropdownChange(WidgetT *w);
static void onPrefsCancel(WidgetT *w);
static void onPrefsOk(WidgetT *w);
static void onPrjFileDblClick(int32_t fileIdx, bool isForm);
static void onProjectWinClose(WindowT *win);
static void onReplace(WidgetT *w);
static void onReplaceAll(WidgetT *w);
static void onReplCheckChange(WidgetT *w);
static void onTbCode(WidgetT *w);
static void onTbDebug(WidgetT *w);
static void onTbDesign(WidgetT *w);
static void onTbOpen(WidgetT *w);
static void onTbRun(WidgetT *w);
static void onTbRunToCur(WidgetT *w);
static void onTbSave(WidgetT *w);
static void onTbStepInto(WidgetT *w);
static void onTbStepOut(WidgetT *w);
static void onTbStepOver(WidgetT *w);
static void onTbStop(WidgetT *w);
static void onWatchClose(WindowT *win);
static void onWatchInputKeyDown(WidgetT *w, int32_t keyCode, int32_t shift);
static void onWatchListDblClick(WidgetT *w);
static void onWatchListKeyDown(WidgetT *w, int32_t keyCode, int32_t shift);
static void openFindDialog(bool showReplace);
static void openProject(void);
static void openProjectPath(const char *path);
static void openSinglePath(const char *path);
static char *packProcLayout(const char *source);
static const char *parseIndexList(const char *p, int32_t *indices, int32_t *outCount);
static void parseProcs(const char *source);
static void prefsSetRgbLabels(uint8_t r, uint8_t g, uint8_t b);
static void prefsUpdateColorSliders(void);
static void prefsUpdateSwatch(void);
static void printCallback(void *ctx, const char *text, bool newline);
static bool procBufContains(const char *hay, const char *needle, int32_t needleLen, bool caseSensitive);
static const char *procDeclAt(const char *p, bool *outIsSub);
static void procNameFromDecl(const char *afterKw, char *out, int32_t outSize);
static bool promptAndSave(void);
static void rebuildProcTable(const char *src);
static void recentAdd(const char *path);
static void recentLoad(void);
static void recentOpen(int32_t index);
static void recentRebuildMenu(void);
static void recentSave(void);
static void removeBreakpointsForFile(int32_t fileIdx);
static char *renameInBuffer(const char *src, const char *oldName, const char *newName);
static BasValueT *resolveVarPath(const char *path, const char **endPtr);
static void runCached(void);
static void runModule(BasModuleT *mod);
static bool saveActiveFile(void);
static bool saveAllModified(void);
static bool saveCurProc(void);
static void saveFile(void);
static bool saveProjectFile(void);
static void selectDropdowns(const char *objName, const char *evtName);
static void setOutputText(const char *text);
static void setStatus(const char *text);
static void showBreakpointWindow(void);
static void showCallStackWindow(void);
static void showCodeWindow(void);
static void showCompileError(const char *status);
static void showImmediateWindow(void);
static void showLocalsWindow(void);
static void showOutputWindow(void);
static void showPreferencesDialog(void);
static void showProc(int32_t procIdx);
static bool showProcAndFind(int32_t procIdx, const char *needle, bool caseSensitive, bool forward);
static void showWatchWindow(void);
static const char *skipLine(const char *p);
static const char *skipToEndProc(const char *declLine, bool isSub, int32_t *outEndLineOffset);
static void splitProcs(const char *source, char **outGeneral, char ***outProcs);
static void stashCurrentFile(void);
static void stashDesignerState(void);
static void stashFormCode(void);
static void switchToDesign(void);
static void syncFormDirty(void);
static void syncVmBreakpoints(void);
static void teardownFormWin(void);
static void toggleBreakpoint(void);
static void toggleBreakpointLine(int32_t editorLine);
static int32_t toolbarBottom(void);
static const char *typeNameStr(uint8_t dt);
static void updateBreakpointWindow(void);
static void updateCallStackWindow(void);
static void updateDirtyIndicators(void);
static void updateDropdowns(void);
static void updateLocalsWindow(void);
static void updateMainTitle(void);
static void updateProjectMenuState(void);
static void updateWatchWindow(void);
static void validatorBuildCtrlMap(IdeValidatorCtxT *ctx);
static bool validatorIsMethodValid(void *ctx, const char *wgtType, const char *methodName);
static bool validatorIsPropValid(void *ctx, const char *wgtType, const char *propName);
static const char *validatorLookupCtrlType(void *ctx, const char *ctrlName);
static void validatorOnCtrlBegin(void *ud, const char *typeName, const char *name);
static bool validatorOnFormBegin(void *ud, const char *name);
static void validatorOnMenuBegin(void *ud, const char *name, int32_t level);
static int32_t vmProcIndexForPc(int32_t pc);
static const char *vmProcNameForPc(int32_t pc);
static void watchEditSelected(void);
static void watchPrintCallback(void *ctx, const char *text, bool newline);
static bool writeProjectFile(int32_t idx);
int32_t appMain(DxeAppContextT *ctx);

// ============================================================
// Keyword-to-topic lookup for context-sensitive help (F1)
// ============================================================

static const HelpMapEntryT sHelpMap[] = {
    // Data types
    {"Boolean",     "lang.datatypes"},
    {"Double",      "lang.datatypes"},
    {"Integer",     "lang.datatypes"},
    {"Long",        "lang.datatypes"},
    {"Single",      "lang.datatypes"},
    {"String",      "lang.datatypes"},

    // Declarations
    {"ByRef",       "lang.declarations"},
    {"ByVal",       "lang.declarations"},
    {"Const",       "lang.declarations"},
    {"Declare",     "lang.declarations"},
    {"Dim",         "lang.declarations"},
    {"Option",      "lang.declarations"},
    {"ReDim",       "lang.declarations"},
    {"Shared",      "lang.declarations"},
    {"Static",      "lang.declarations"},
    {"Type",        "lang.declarations"},

    // Operators
    {"And",         "lang.operators"},
    {"Mod",         "lang.operators"},
    {"Not",         "lang.operators"},
    {"Or",          "lang.operators"},
    {"Xor",         "lang.operators"},

    // Conditionals
    {"Case",        "lang.conditionals"},
    {"Else",        "lang.conditionals"},
    {"ElseIf",      "lang.conditionals"},
    {"If",          "lang.conditionals"},
    {"Select",      "lang.conditionals"},
    {"Then",        "lang.conditionals"},

    // Loops
    {"Do",          "lang.loops"},
    {"For",         "lang.loops"},
    {"Loop",        "lang.loops"},
    {"Next",        "lang.loops"},
    {"Step",        "lang.loops"},
    {"Until",       "lang.loops"},
    {"Wend",        "lang.loops"},
    {"While",       "lang.loops"},

    // Procedures
    {"Call",        "lang.procedures"},
    {"Def",         "lang.procedures"},
    {"End",         "lang.procedures"},
    {"Function",    "lang.procedures"},
    {"Sub",         "lang.procedures"},

    // Flow control
    {"Exit",        "lang.flow"},
    {"GoSub",       "lang.flow"},
    {"GoTo",        "lang.flow"},
    {"On",          "lang.flow"},
    {"Resume",      "lang.flow"},
    {"Return",      "lang.flow"},

    // I/O statements
    {"Data",        "lang.io"},
    {"Input",       "lang.io"},
    {"Print",       "lang.io"},
    {"Read",        "lang.io"},
    {"Rem",         "lang.io"},
    {"Write",       "lang.io"},

    // Misc statements
    {"DoEvents",    "lang.misc"},
    {"Load",        "lang.misc"},
    {"Shell",       "lang.misc"},
    {"Sleep",       "lang.misc"},
    {"Unload",      "lang.misc"},

    // File I/O
    {"Close",       "lang.fileio"},
    {"Eof",         "lang.func.fileio"},
    {"FreeFile",    "lang.func.fileio"},
    {"Loc",         "lang.func.fileio"},
    {"Lof",         "lang.func.fileio"},
    {"Open",        "lang.fileio"},
    {"Seek",        "lang.func.fileio"},

    // String functions
    {"Asc",         "lang.func.string"},
    {"Chr",         "lang.func.string"},
    {"Chr$",        "lang.func.string"},
    {"Environ",     "lang.func.string"},
    {"Environ$",    "lang.func.string"},
    {"Format",      "lang.func.string"},
    {"Format$",     "lang.func.string"},
    {"InStr",       "lang.func.string"},
    {"LCase",       "lang.func.string"},
    {"LCase$",      "lang.func.string"},
    {"LTrim",       "lang.func.string"},
    {"LTrim$",      "lang.func.string"},
    {"Left",        "lang.func.string"},
    {"Left$",       "lang.func.string"},
    {"Len",         "lang.func.string"},
    {"Mid",         "lang.func.string"},
    {"Mid$",        "lang.func.string"},
    {"RTrim",       "lang.func.string"},
    {"RTrim$",      "lang.func.string"},
    {"Right",       "lang.func.string"},
    {"Right$",      "lang.func.string"},
    {"Spc",         "lang.func.string"},
    {"Space",       "lang.func.string"},
    {"Space$",      "lang.func.string"},
    {"String$",     "lang.func.string"},
    {"Tab",         "lang.func.string"},
    {"Trim",        "lang.func.string"},
    {"Trim$",       "lang.func.string"},
    {"UCase",       "lang.func.string"},
    {"UCase$",      "lang.func.string"},

    // Math functions
    {"Abs",         "lang.func.math"},
    {"Atn",         "lang.func.math"},
    {"Cos",         "lang.func.math"},
    {"Exp",         "lang.func.math"},
    {"Fix",         "lang.func.math"},
    {"Int",         "lang.func.math"},
    {"Log",         "lang.func.math"},
    {"Randomize",   "lang.func.math"},
    {"Rnd",         "lang.func.math"},
    {"Sgn",         "lang.func.math"},
    {"Sin",         "lang.func.math"},
    {"Sqr",         "lang.func.math"},
    {"Tan",         "lang.func.math"},
    {"Timer",       "lang.func.math"},

    // Conversion functions
    {"CBool",       "lang.func.conversion"},
    {"CDbl",        "lang.func.conversion"},
    {"CInt",        "lang.func.conversion"},
    {"CLng",        "lang.func.conversion"},
    {"CSng",        "lang.func.conversion"},
    {"CStr",        "lang.func.conversion"},
    {"Hex",         "lang.func.conversion"},
    {"Hex$",        "lang.func.conversion"},
    {"Str",         "lang.func.conversion"},
    {"Str$",        "lang.func.conversion"},
    {"Val",         "lang.func.conversion"},

    // Misc functions
    {"InputBox",    "lang.func.misc"},
    {"InputBox$",   "lang.func.misc"},
    {"MsgBox",      "lang.func.misc"},

    // SQL functions
    {"SQLAffected", "lang.sql"},
    {"SQLClose",    "lang.sql"},
    {"SQLEof",      "lang.sql"},
    {"SQLError",    "lang.sql"},
    {"SQLError$",   "lang.sql"},
    {"SQLExec",     "lang.sql"},
    {"SQLField",    "lang.sql"},
    {"SQLField$",   "lang.sql"},
    {"SQLFieldCount", "lang.sql"},
    {"SQLFieldDbl", "lang.sql"},
    {"SQLFieldInt", "lang.sql"},
    {"SQLFreeResult", "lang.sql"},
    {"SQLNext",     "lang.sql"},
    {"SQLOpen",     "lang.sql"},
    {"SQLQuery",    "lang.sql"},

    // App object
    {"App",         "lang.app"},

    // INI functions
    {"IniRead",     "lang.ini"},
    {"IniWrite",    "lang.ini"},

    // Constants
    {"False",       "lang.constants"},
    {"True",        "lang.constants"},
    {"vbCancel",    "lang.constants"},
    {"vbCritical",  "lang.constants"},
    {"vbModal",     "lang.constants"},
    {"vbOK",        "lang.constants"},
    {"vbOKCancel",  "lang.constants"},
    {"vbYesNo",     "lang.constants"},

    // Form/control statements
    {"Me",          "lang.forms"},
    {"Set",         "lang.forms"},
};

#define HELP_MAP_COUNT (sizeof(sHelpMap) / sizeof(sHelpMap[0]))

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

static DxeAppContextT *sCtx     = NULL;
static char            sIdeHelpFile[DVX_MAX_PATH]; // IDE help file (restored after program run)
static AppContextT    *sAc      = NULL;
static PrefsHandleT   *sPrefs   = NULL;
static WindowT        *sWin     = NULL;    // Main toolbar window
static WindowT        *sCodeWin = NULL;    // Code editor window
static WindowT        *sOutWin  = NULL;    // Output window
static WindowT        *sImmWin  = NULL;    // Immediate window
static WidgetT        *sEditor      = NULL;
static WidgetT        *sOutput      = NULL;
static WidgetT        *sImmediate   = NULL;
static WidgetT        *sObjDropdown = NULL;
static WidgetT        *sEvtDropdown = NULL;
static WidgetT        *sToolbar     = NULL;
static WidgetT        *sStatusBar   = NULL;
static WidgetT        *sStatus      = NULL;
static WidgetT        *sTbRun       = NULL;
static WidgetT        *sTbStop      = NULL;
static WidgetT        *sTbDebug     = NULL;
static WidgetT        *sTbStepInto  = NULL;
static WidgetT        *sTbStepOver  = NULL;
static WidgetT        *sTbStepOut   = NULL;
static WidgetT        *sTbRunToCur  = NULL;
static WidgetT        *sTbCode      = NULL;
static WidgetT        *sTbDesign    = NULL;
static BasVmT         *sVm          = NULL;  // VM instance (non-NULL while running)
static BasModuleT     *sCachedModule = NULL; // Last compiled module (for Ctrl+F5)
static DsgnStateT      sDesigner;
static WindowT        *sFormWin     = NULL;  // Form designer window (separate)
static WindowT        *sToolboxWin  = NULL;
static WindowT        *sPropsWin    = NULL;
static WindowT        *sProjectWin  = NULL;
static PrjStateT       sProject;
static WindowT        *sLastFocusWin = NULL; // last focused non-toolbar window

static char    sOutputBuf[IDE_MAX_OUTPUT];
static int32_t sOutputLen = 0;

// Procedure view state -- the editor shows one procedure at a time.
// Each procedure is stored in its own malloc'd buffer. The editor
// swaps directly between buffers with no splicing needed.
//
// The canonical text of a file is joinProcArrays(sGeneralBuf, sProcBufs):
// the (General) section, a blank line, then each procedure separated by
// one blank line.  Everything that reads line numbers (sProcTable,
// breakpoints, the compiler's source map) works in that layout, and
// compileProject normalizes every file into it, so there is exactly one
// line-number space.
static char   *sGeneralBuf      = NULL;  // (General) section: module-level code
static char  **sProcBufs        = NULL;  // stb_ds array: one buffer per procedure
static char   *sFullSourceCache = NULL;  // last joinProcBufs() result (owned here)
static int32_t sCurProcIdx      = -2;    // which buffer is in the editor (-1=General, -2=none)
static int32_t sEditorFileIdx   = -1;    // which project file owns sProcBufs (-1=none)
static int32_t sEditorLineCount = 0;     // line count for breakpoint adjustment on edit

// Find/Replace state
static char sFindText[IDE_FIND_TEXT_BUF]    = "";
static char sReplaceText[IDE_FIND_TEXT_BUF] = "";
// Find/Replace dialog state (modeless)
static WindowT *sFindWin      = NULL;
static WidgetT *sFindInput    = NULL;
static WidgetT *sReplInput    = NULL;
static WidgetT *sReplCheck    = NULL;
static WidgetT *sBtnReplace   = NULL;
static WidgetT *sBtnReplAll   = NULL;
static WidgetT *sCaseCheck    = NULL;
static WidgetT *sScopeGroup   = NULL;  // radio group: 0=Func, 1=Obj, 2=File, 3=Proj
static WidgetT *sDirGroup     = NULL;  // radio group: 0=Fwd, 1=Back

static IdeProcEntryT *sProcTable    = NULL; // stb_ds dynamic array (rebuilt by joinProcBufs)
static const char   **sObjItems     = NULL; // stb_ds dynamic array
static const char   **sEvtItems     = NULL; // stb_ds dynamic array
static bool           sDropdownNavSuppressed = false;
static bool           sStopRequested         = false;
static bool           sOutputToLog           = false;

// Recent files list (stored in dvxbasic.ini [recent] section)
// stb_ds dynamic array of strdup'd paths. Capped at CMD_RECENT_MAX by the menu
// command-ID range.
static char         **sRecentFiles  = NULL;
static MenuT         *sFileMenu     = NULL;
static int32_t        sFileMenuBase = 0;  // item count before recent files

static IdeDebugStateE  sDbgState        = DBG_IDLE;
static IdeBreakpointT *sBreakpoints     = NULL;  // stb_ds array
static int32_t        *sVmBreakpoints   = NULL;  // stb_ds array of concat line numbers (built at compile time)
static int32_t         sDbgCurrentLine  = -1;    // line where paused (-1 = none)
static BasFormRtT     *sDbgFormRt       = NULL;  // form runtime for debug session
static BasModuleT     *sDbgModule       = NULL;  // module for debug session
static bool            sDbgBreakOnStart = false; // break at first statement
static bool            sDbgEnabled      = false; // true = debug mode (breakpoints active)
static WindowT        *sLocalsWin       = NULL;  // Locals window
static WidgetT        *sLocalsList      = NULL;  // Locals ListView widget
static WindowT        *sCallStackWin    = NULL;  // Call stack window
static WidgetT        *sCallStackList   = NULL;  // Call stack ListView widget
static WindowT        *sWatchWin        = NULL;  // Watch window
static WidgetT        *sWatchList       = NULL;  // Watch ListView widget
static WidgetT        *sWatchInput      = NULL;  // Watch expression input
static char          **sWatchExprs      = NULL;  // stb_ds dynamic array of strdup'd watch expressions
static WindowT        *sBreakpointWin   = NULL;  // Breakpoints window
static WidgetT        *sBreakpointList  = NULL;  // Breakpoints ListView widget

// Per-refresh display rows for the debug ListViews
static IdeListRowsT sBpRows;
static IdeListRowsT sLocalsRows;
static IdeListRowsT sCallRows;
static IdeListRowsT sWatchRows;

// ============================================================
// Syntax highlighting tables (shared by several functions)
// ============================================================

static SyntaxMapEntryT *sSyntaxMap = NULL;

// Color entry names for the Colors tab (matches SYNTAX_* indices)
static const char *sSyntaxColorNames[] = {
    "Default Text",  // 0 = SYNTAX_DEFAULT
    "Keywords",      // 1 = SYNTAX_KEYWORD
    "Strings",       // 2 = SYNTAX_STRING
    "Comments",      // 3 = SYNTAX_COMMENT
    "Numbers",       // 4 = SYNTAX_NUMBER
    "Operators",     // 5 = SYNTAX_OPERATOR
    "Types",         // 6 = SYNTAX_TYPE
};

// Default syntax colors (0x00RRGGBB; 0 = use widget default).
// Designated initializers keep the SYNTAX_* index and the color aligned
// so there's no drift if indices are reordered later.
static const uint32_t sDefaultSyntaxColors[SYNTAX_COLOR_COUNT] = {
    [SYNTAX_DEFAULT]  = 0,           // widget's fg color
    [SYNTAX_KEYWORD]  = 0x00000080,  // dark blue
    [SYNTAX_STRING]   = 0x00800000,  // dark red
    [SYNTAX_COMMENT]  = 0x00008000,  // dark green
    [SYNTAX_NUMBER]   = 0x00800080,  // purple
    [SYNTAX_OPERATOR] = 0x00808000,  // dark yellow
    [SYNTAX_TYPE]     = 0x00008080,  // teal
};

// Preferences dialog state (used by several onPrefs* handlers)
static struct {
    bool     done;
    bool     accepted;
    // General tab
    WidgetT *renameSkipComments;
    WidgetT *optionExplicit;
    WidgetT *tabWidthInput;
    WidgetT *useSpaces;
    WidgetT *defAuthor;
    WidgetT *defPublisher;
    WidgetT *defVersion;
    WidgetT *defCopyright;
    WidgetT *defDescription;
    // Colors tab
    WidgetT *colorList;
    WidgetT *sliderR;
    WidgetT *sliderG;
    WidgetT *sliderB;
    WidgetT *lblR;
    WidgetT *lblG;
    WidgetT *lblB;
    WidgetT *colorSwatch;
    uint32_t syntaxColors[SYNTAX_COLOR_COUNT];
} sPrefsDlg;

// Watch-expression scratch buffer (used by evalWatchExpr + watchPrintCallback)
static char    sWatchPrintBuf[IDE_VALUE_BUF];
static int32_t sWatchPrintLen;

// ============================================================
// Event table for the Object/Event dropdowns and handler skeletons
// ============================================================
//
// Expanded from the master BAS_EVENT_LIST in compiler/basEvents.h so the
// dropdowns, the generated parameter lists and the runtime dispatcher
// can never disagree.

static const BasEventInfoT sEventTable[] = { BAS_EVENT_LIST(BAS_EVENT_ROW) };

#define IDE_EVENT_COUNT ((int32_t)(sizeof(sEventTable) / sizeof(sEventTable[0])))

// Menu items only fire Click
#define IDE_MENU_EVENT "Click"

// Buffer for event dropdown labels (with [] for unimplemented)
static char sEvtLabelBufs[IDE_MAX_EVT_LABELS][IDE_EVT_LABEL_LEN];

// ============================================================
// App descriptor
// ============================================================

AppDescriptorT appDescriptor = {
    .name          = "DVX BASIC",
    .hasMainLoop   = false,
    .multiInstance  = false,
    .stackSize     = SHELL_STACK_DEFAULT,
    .priority      = 0
};


static void activateFile(int32_t fileIdx, IdeViewModeE view) {
    if (fileIdx < 0 || fileIdx >= sProject.fileCount) {
        return;
    }

    PrjFileT *target = &sProject.files[fileIdx];

    // Resolve ViewAutoE
    if (view == ViewAutoE) {
        view = target->isForm ? ViewDesignE : ViewCodeE;
    }

    // If already active, just ensure the right view is showing
    if (fileIdx == sProject.activeFileIdx) {
        if (view == ViewDesignE) {
            switchToDesign();
        } else if (view == ViewCodeE) {
            if (sCodeWin) {
                dvxRaiseWindow(sAc, sCodeWin);
            } else {
                // Code window was closed -- reopen it and reload the file
                showCodeWindow();

                if (sCodeWin) {
                    dvxRaiseWindow(sAc, sCodeWin);
                }

                if (target->isForm) {
                    loadFormCodeIntoEditor();
                } else if (sEditor && target->buffer) {
                    parseProcs(target->buffer);
                    sEditorFileIdx = fileIdx;
                    updateDropdowns();
                    showProc(-1);
                }
            }
        }
        return;
    }

    // PHASE 1: Stash the outgoing file
    stashCurrentFile();

    // PHASE 2: Load the incoming file
    if (target->isForm) {
        // Load form into designer
        const char *frmSrc = target->buffer;
        char *diskBuf = NULL;

        if (!frmSrc) {
            // Try disk
            char fullPath[DVX_MAX_PATH];
            prjFullPath(&sProject, fileIdx, fullPath, sizeof(fullPath));
            int32_t diskLen = 0;
            diskBuf = platformReadFile(fullPath, &diskLen);

            if (diskBuf && (diskLen <= 0 || diskLen >= IDE_MAX_SOURCE)) {
                free(diskBuf);
                diskBuf = NULL;
            }

            if (diskBuf) {
                frmSrc = diskBuf;
            }
        }

        teardownFormWin();
        dsgnFree(&sDesigner);

        if (frmSrc) {
            dsgnLoadFrm(&sDesigner, frmSrc, (int32_t)strlen(frmSrc));
            free(diskBuf);
        } else {
            // New blank form -- derive name from filename
            char formName[BAS_MAX_IDENT];
            const char *base = platformPathBaseName(target->path);
            int32_t bl = (int32_t)strlen(base);

            if (bl >= BAS_MAX_IDENT) {
                bl = BAS_MAX_IDENT - 1;
            }

            memcpy(formName, base, bl);
            formName[bl] = '\0';
            char *dot = strrchr(formName, '.');

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

            dsgnNewForm(&sDesigner, formName);
            target->modified = true;
        }

        if (sDesigner.form) {
            snprintf(target->formName, sizeof(target->formName), "%s", sDesigner.form->name);
        }

        sProject.activeFileIdx = fileIdx;

        if (view == ViewDesignE) {
            switchToDesign();

            // The editor must never stay bound to the previous file: an open
            // code window now shows this form's code, otherwise the stale
            // proc buffers are released so nothing can be edited into limbo.
            if (sCodeWin) {
                loadFormCodeIntoEditor();
            } else {
                freeProcBufs();
            }
        } else {
            loadFormCodeIntoEditor();
        }
    } else {
        // Load .bas into editor
        if (!sCodeWin) {
            showCodeWindow();
        }

        if (sCodeWin) {
            dvxRaiseWindow(sAc, sCodeWin);
        }

        const char *source = target->buffer;
        char *diskBuf = NULL;

        if (!source) {
            char fullPath[DVX_MAX_PATH];
            prjFullPath(&sProject, fileIdx, fullPath, sizeof(fullPath));
            int32_t diskLen = 0;
            diskBuf = platformReadFile(fullPath, &diskLen);

            if (diskBuf && (diskLen <= 0 || diskLen >= IDE_MAX_SOURCE)) {
                free(diskBuf);
                diskBuf = NULL;
            }

            if (diskBuf) {
                source = diskBuf;
            }
        }

        if (!source) {
            source = "";
            target->modified = true;
        }

        parseProcs(source);
        free(diskBuf);
        sEditorFileIdx         = fileIdx;
        sProject.activeFileIdx = fileIdx;
        updateDropdowns();
        showProc(-1);
    }

    updateProjectMenuState();
    updateDirtyIndicators();
}


// Adds path to the open project (or activates it when already listed).
// Returns false when the file lives outside the project directory --
// PrjFileT.path is project-relative, so such a file could never be
// read back and the next save would silently create an empty copy.
static bool addFileToProject(const char *path) {
    const char *fileName = platformPathBaseName(path);
    const char *ext      = strrchr(path, '.');
    bool        isForm   = (ext && strcasecmp(ext, ".frm") == 0);
    char        dir[DVX_MAX_PATH];

    snprintf(dir, sizeof(dir), "%s", path);
    char *sep = platformPathDirEnd(dir);

    if (sep) {
        *sep = '\0';
    } else {
        dir[0] = '.';
        dir[1] = '\0';
    }

    if (strcasecmp(dir, sProject.projectDir) != 0) {
        dvxErrorBox(sAc, NULL, "Files must be located in the project directory.");
        return false;
    }

    for (int32_t i = 0; i < sProject.fileCount; i++) {
        if (strcasecmp(sProject.files[i].path, fileName) == 0) {
            activateFile(i, ViewAutoE);
            return true;
        }
    }

    prjAddFile(&sProject, fileName, isForm);
    prjRebuildTree(&sProject);
    activateFile(sProject.fileCount - 1, ViewAutoE);
    return true;
}


static void applySyntaxColors(void) {
    if (!sEditor) {
        return;
    }

    wgtTextAreaSetSyntaxColors(sEditor, sPrefsDlg.syntaxColors, SYNTAX_COLOR_COUNT);
}


static void basicColorize(const char *line, int32_t lineLen, uint8_t *colors, void *ctx) {
    (void)ctx;
    int32_t i = 0;

    while (i < lineLen) {
        char ch = line[i];

        // Comment: ' or REM
        if (ch == '\'') {
            while (i < lineLen) {
                colors[i++] = SYNTAX_COMMENT;
            }
            return;
        }

        // String literal
        if (ch == '"') {
            colors[i++] = SYNTAX_STRING;

            while (i < lineLen && line[i] != '"') {
                colors[i++] = SYNTAX_STRING;
            }

            if (i < lineLen) {
                colors[i++] = SYNTAX_STRING;
            }

            continue;
        }

        // Number
        if (isdigit((unsigned char)ch) || (ch == '.' && i + 1 < lineLen && isdigit((unsigned char)line[i + 1]))) {
            while (i < lineLen && (isdigit((unsigned char)line[i]) || line[i] == '.')) {
                colors[i++] = SYNTAX_NUMBER;
            }

            continue;
        }

        // Identifier or keyword
        if (isalpha((unsigned char)ch) || ch == '_') {
            int32_t start = i;

            while (i < lineLen && (basIsIdentChar(line[i]) || basIsTypeSuffixChar(line[i]))) {
                i++;
            }

            int32_t wordLen = i - start;

            // Check for REM comment
            if (wordLen == KW_LEN(KW_REM) && strncasecmp(line + start, KW_REM, KW_LEN(KW_REM)) == 0) {
                for (int32_t j = start; j < lineLen; j++) {
                    colors[j] = SYNTAX_COMMENT;
                }
                return;
            }

            uint8_t c = classifyWord(line + start, wordLen);

            for (int32_t j = start; j < i; j++) {
                colors[j] = c;
            }

            continue;
        }

        // Operators
        if (ch == '=' || ch == '<' || ch == '>' || ch == '+' || ch == '-' || ch == '*' || ch == '/' || ch == '\\' || ch == '&') {
            colors[i++] = SYNTAX_OPERATOR;
            continue;
        }

        // Default (whitespace, parens, etc.)
        colors[i++] = SYNTAX_DEFAULT;
    }
}


// buildSnippetVm -- compile a one-line snippet and return a VM ready to
// run it.  When paused in the debugger, the paused VM's variables are
// added to the snippet's symbol table (as globals) and their current
// values copied in, so expressions can reference them by name.  On
// failure returns NULL and copies the error text into errBuf.

static BasVmT *buildSnippetVm(const char *src, BasModuleT **outMod, char *errBuf, int32_t errBufSize) {
    *outMod   = NULL;
    errBuf[0] = '\0';

    BasParserT *parser = (BasParserT *)malloc(sizeof(BasParserT));

    if (!parser) {
        snprintf(errBuf, errBufSize, "Out of memory");
        return NULL;
    }

    basParserInit(parser, src, (int32_t)strlen(src));

    // Track which debug vars we added and their assigned global indices
    int32_t varMap[BAS_VM_MAX_GLOBALS]; // maps temp global idx -> debug var idx
    int32_t varMapCount = 0;
    bool    paused      = (sDbgState == DBG_PAUSED && sVm && sDbgModule && sDbgModule->debugVars);

    if (paused) {
        int32_t curProcIdx = vmProcIndexForPc(sVm->pc);

        for (int32_t i = 0; i < sDbgModule->debugVarCount && varMapCount < BAS_VM_MAX_GLOBALS; i++) {
            const BasDebugVarT *dv = &sDbgModule->debugVars[i];

            // Skip locals from other procs
            if (dv->scope == SCOPE_LOCAL && dv->procIndex != curProcIdx) {
                continue;
            }

            if (dv->scope == SCOPE_FORM && !sVm->currentFormVars) {
                continue;
            }

            if (isMangledDebugName(dv->name)) {
                continue;
            }

            // Add to parser's symbol table as a global
            BasSymbolT *sym = basSymTabAdd(&parser->sym, dv->name, SYM_VARIABLE, dv->dataType);

            if (sym) {
                sym->scope          = SCOPE_GLOBAL;
                sym->index          = varMapCount;
                varMap[varMapCount] = i;
                varMapCount++;
            }
        }

        parser->cg.globalCount = varMapCount;
    }

    if (!basParse(parser)) {
        snprintf(errBuf, errBufSize, "%s", parser->error);
        basParserFree(parser);
        free(parser);
        return NULL;
    }

    BasModuleT *mod = basParserBuildModule(parser);
    basParserFree(parser);
    free(parser);

    if (!mod) {
        snprintf(errBuf, errBufSize, "Failed to build module");
        return NULL;
    }

    BasVmT *vm = basVmCreate();

    if (!vm) {
        basModuleFree(mod);
        snprintf(errBuf, errBufSize, "Out of memory");
        return NULL;
    }

    basVmLoadModule(vm, mod);
    vm->callStack[0].localCount = mod->globalCount > BAS_VM_MAX_LOCALS ? BAS_VM_MAX_LOCALS : mod->globalCount;
    vm->callDepth               = 1;

    // Copy values from the paused VM into the temp VM's globals
    for (int32_t g = 0; g < varMapCount; g++) {
        BasValueT *slot = getDebugVarSlot(&sDbgModule->debugVars[varMap[g]]);

        if (slot) {
            vm->globals[g] = basValCopy(*slot);
        }
    }

    *outMod = mod;
    return vm;
}


// ============================================================
// buildVmBreakpoints -- convert IDE breakpoints to VM concat line numbers
// ============================================================
//
// Called after compilation when the source map is fresh. Converts
// each (fileIdx, codeLine) pair to a concatenated source line number
// that matches OP_LINE values in the compiled bytecode.

static void buildVmBreakpoints(void) {
    arrfree(sVmBreakpoints);
    sVmBreakpoints = NULL;

    for (int32_t i = 0; i < (int32_t)arrlen(sBreakpoints); i++) {
        int32_t fileIdx  = sBreakpoints[i].fileIdx;
        int32_t codeLine = sBreakpoints[i].codeLine;

        // Find this file in the source map
        for (int32_t m = 0; m < sProject.sourceMapCount; m++) {
            if (sProject.sourceMap[m].fileIdx == fileIdx) {
                int32_t vmLine = sProject.sourceMap[m].startLine + codeLine - 1;
                arrput(sVmBreakpoints, vmLine);
                break;
            }
        }
    }
}


static void buildWindow(void) {
    // ---- Main toolbar window (top of screen) ----
    sWin = dvxCreateWindow(sAc, IDE_MAIN_TITLE, 0, 0, sAc->display.width, IDE_TOOLBAR_INIT_H, true);

    if (!sWin) {
        return;
    }

    sWin->onClose = onClose;
    sWin->onMenu  = onMenu;

    // Menu bar
    MenuBarT *menuBar = wmAddMenuBar(sWin);
    sFileMenu = wmAddMenu(menuBar, "&File");
    MenuT *fileMenu = sFileMenu;
    wmAddMenuItem(fileMenu, "New &Project...", CMD_PRJ_NEW);
    wmAddMenuItem(fileMenu, "&Open Project...", CMD_PRJ_OPEN);
    wmAddMenuItem(fileMenu, "Save Projec&t", CMD_PRJ_SAVE);
    wmAddMenuItem(fileMenu, "&Close Project", CMD_PRJ_CLOSE);
    wmAddMenuSeparator(fileMenu);
    wmAddMenuItem(fileMenu, "Project Propert&ies...", CMD_PRJ_PROPS);
    wmAddMenuSeparator(fileMenu);
    wmAddMenuItem(fileMenu, "&Add File...\tCtrl+O", CMD_OPEN);
    wmAddMenuItem(fileMenu, "&Save File\tCtrl+S", CMD_SAVE);
    wmAddMenuItem(fileMenu, "Save A&ll", CMD_SAVE_ALL);
    wmAddMenuSeparator(fileMenu);
    wmAddMenuItem(fileMenu, "&Make Executable...", CMD_MAKE_EXE);
    wmAddMenuSeparator(fileMenu);
    wmAddMenuItem(fileMenu, "&Remove File", CMD_PRJ_REMOVE);
    wmAddMenuSeparator(fileMenu);
    wmAddMenuItem(fileMenu, "E&xit", CMD_EXIT);
    sFileMenuBase = sFileMenu->itemCount;

    MenuT *editMenu = wmAddMenu(menuBar, "&Edit");
    wmAddMenuItem(editMenu, "Cu&t\tCtrl+X", CMD_CUT);
    wmAddMenuItem(editMenu, "&Copy\tCtrl+C", CMD_COPY);
    wmAddMenuItem(editMenu, "&Paste\tCtrl+V", CMD_PASTE);
    wmAddMenuSeparator(editMenu);
    wmAddMenuItem(editMenu, "Select &All\tCtrl+A", CMD_SELECT_ALL);
    wmAddMenuSeparator(editMenu);
    wmAddMenuItem(editMenu, "&Delete\tDel", CMD_DELETE);
    wmAddMenuSeparator(editMenu);
    wmAddMenuItem(editMenu, "&Find...\tCtrl+F", CMD_FIND);
    wmAddMenuItem(editMenu, "Find &Next\tF3", CMD_FIND_NEXT);
    wmAddMenuItem(editMenu, "&Replace...\tCtrl+H", CMD_REPLACE);

    MenuT *runMenu = wmAddMenu(menuBar, "&Run");
    wmAddMenuItem(runMenu, "&Run\tF5", CMD_RUN);
    wmAddMenuItem(runMenu, "&Debug\tShift+F5", CMD_DEBUG);
    wmAddMenuItem(runMenu, "Run &Without Recompile\tCtrl+F5", CMD_RUN_NOCMP);
    wmAddMenuItem(runMenu, "&Stop\tEsc", CMD_STOP);
    wmAddMenuSeparator(runMenu);
    wmAddMenuItem(runMenu, "Step &Into\tF8", CMD_STEP_INTO);
    wmAddMenuItem(runMenu, "Step &Over\tShift+F8", CMD_STEP_OVER);
    wmAddMenuItem(runMenu, "Step Ou&t\tCtrl+Shift+F8", CMD_STEP_OUT);
    wmAddMenuItem(runMenu, "Run to &Cursor\tCtrl+F8", CMD_RUN_TO_CURSOR);
    wmAddMenuSeparator(runMenu);
    wmAddMenuCheckItem(runMenu, "Output Window to &Log", CMD_OUTPUT_TO_LOG, false);
    wmAddMenuSeparator(runMenu);
    wmAddMenuItem(runMenu, "Toggle &Breakpoint\tF9", CMD_TOGGLE_BP);
    wmAddMenuSeparator(runMenu);
    wmAddMenuItem(runMenu, "Cl&ear Output", CMD_CLEAR);
    wmAddMenuSeparator(runMenu);
    wmAddMenuCheckItem(runMenu, "S&ave on Run", CMD_SAVE_ON_RUN, true);

    MenuT *viewMenu = wmAddMenu(menuBar, "&View");
    wmAddMenuItem(viewMenu, "&Code\tF7", CMD_VIEW_CODE);
    wmAddMenuItem(viewMenu, "&Designer\tShift+F7", CMD_VIEW_DESIGN);
    wmAddMenuSeparator(viewMenu);
    wmAddMenuCheckItem(viewMenu, "&Toolbar", CMD_VIEW_TOOLBAR, true);
    wmAddMenuCheckItem(viewMenu, "&Status Bar", CMD_VIEW_STATUS, true);
    wmAddMenuSeparator(viewMenu);
    wmAddMenuItem(viewMenu, "&Menu Editor...\tCtrl+E", CMD_MENU_EDITOR);

    MenuT *winMenu = wmAddMenu(menuBar, "&Window");
    wmAddMenuItem(winMenu, "&Code Editor", CMD_WIN_CODE);
    wmAddMenuItem(winMenu, "&Output", CMD_WIN_OUTPUT);
    wmAddMenuItem(winMenu, "&Immediate", CMD_WIN_IMM);
    wmAddMenuItem(winMenu, "&Locals", CMD_WIN_LOCALS);
    wmAddMenuItem(winMenu, "Call &Stack", CMD_WIN_CALLSTACK);
    wmAddMenuItem(winMenu, "&Watch", CMD_WIN_WATCH);
    wmAddMenuItem(winMenu, "&Breakpoints", CMD_WIN_BREAKPOINTS);
    wmAddMenuSeparator(winMenu);
    wmAddMenuItem(winMenu, "&Project Explorer", CMD_WIN_PROJECT);
    wmAddMenuItem(winMenu, "&Toolbox", CMD_WIN_TOOLBOX);
    wmAddMenuItem(winMenu, "P&roperties", CMD_WIN_PROPS);

    MenuT *toolsMenu = wmAddMenu(menuBar, "&Tools");
    wmAddMenuItem(toolsMenu, "&Preferences...", CMD_PREFERENCES);
    wmAddMenuSeparator(toolsMenu);
    wmAddMenuCheckItem(toolsMenu, "Debug &Layout", CMD_DEBUG_LAYOUT, false);

    MenuT *helpMenu = wmAddMenu(menuBar, "&Help");
    wmAddMenuItem(helpMenu, "&DVX BASIC Help\tF1", CMD_HELP_CONTENTS);
    wmAddMenuItem(helpMenu, "DVX &API Reference", CMD_HELP_API);
    wmAddMenuSeparator(helpMenu);
    wmAddMenuItem(helpMenu, "A&bout DVX BASIC...", CMD_HELP_ABOUT);

    AccelTableT *accel = dvxCreateAccelTable();
    dvxAddAccel(accel, 'O', ACCEL_CTRL, CMD_OPEN);
    dvxAddAccel(accel, 'S', ACCEL_CTRL, CMD_SAVE);
    dvxAddAccel(accel, KEY_F5, 0, CMD_RUN);
    dvxAddAccel(accel, KEY_F5, ACCEL_SHIFT, CMD_DEBUG);
    dvxAddAccel(accel, KEY_F5, ACCEL_CTRL, CMD_RUN_NOCMP);
    dvxAddAccel(accel, KEY_F7, 0, CMD_VIEW_CODE);
    dvxAddAccel(accel, KEY_F7, ACCEL_SHIFT, CMD_VIEW_DESIGN);
    dvxAddAccel(accel, KEY_ESCAPE, 0, CMD_STOP);
    dvxAddAccel(accel, 'F', ACCEL_CTRL, CMD_FIND);
    dvxAddAccel(accel, 'H', ACCEL_CTRL, CMD_REPLACE);
    dvxAddAccel(accel, KEY_F3, 0, CMD_FIND_NEXT);
    dvxAddAccel(accel, 'E', ACCEL_CTRL, CMD_MENU_EDITOR);
    dvxAddAccel(accel, KEY_F8, 0, CMD_STEP_INTO);
    dvxAddAccel(accel, KEY_F8, ACCEL_SHIFT, CMD_STEP_OVER);
    dvxAddAccel(accel, KEY_F8, ACCEL_CTRL | ACCEL_SHIFT, CMD_STEP_OUT);
    dvxAddAccel(accel, KEY_F8, ACCEL_CTRL, CMD_RUN_TO_CURSOR);
    dvxAddAccel(accel, KEY_F9, 0, CMD_TOGGLE_BP);
    sWin->accelTable = accel;

    WidgetT *tbRoot = wgtInitWindow(sAc, sWin);
    sToolbar = wgtToolbar(tbRoot);
    WidgetT *tb = sToolbar;

    // File group
    WidgetT *tbOpen = loadTbIcon(tb, "tb_open", "Open");
    tbOpen->onClick = onTbOpen;
    // Button opens the project; Ctrl+O is bound to Add File, so the tooltip
    // must not advertise a shortcut the button doesn't perform.
    wgtSetTooltip(tbOpen, "Open Project...");

    WidgetT *tbSave = loadTbIcon(tb, "tb_save", "Save");
    tbSave->onClick = onTbSave;
    wgtSetTooltip(tbSave, "Save (Ctrl+S)");

    WidgetT *sep1 = wgtVSeparator(tb);
    sep1->minW = wgtPixels(IDE_TOOLBAR_SEP_W);

    // Run group
    sTbRun = loadTbIcon(tb, "tb_run", "Run");
    sTbRun->onClick = onTbRun;
    wgtSetTooltip(sTbRun, "Run (F5)");

    sTbStop = loadTbIcon(tb, "tb_stop", "Stop");
    sTbStop->onClick = onTbStop;
    wgtSetTooltip(sTbStop, "Stop (Esc)");

    WidgetT *sep2 = wgtVSeparator(tb);
    sep2->minW = wgtPixels(IDE_TOOLBAR_SEP_W);

    // Debug group
    sTbDebug = loadTbIcon(tb, "tb_debug", "Debug");
    sTbDebug->onClick = onTbDebug;
    wgtSetTooltip(sTbDebug, "Debug (Shift+F5)");

    sTbStepInto = loadTbIcon(tb, "tb_stepin", "Into");
    sTbStepInto->onClick = onTbStepInto;
    wgtSetTooltip(sTbStepInto, "Step Into (F8)");

    sTbStepOver = loadTbIcon(tb, "tb_stepov", "Over");
    sTbStepOver->onClick = onTbStepOver;
    wgtSetTooltip(sTbStepOver, "Step Over (Shift+F8)");

    sTbStepOut = loadTbIcon(tb, "tb_stepou", "Out");
    sTbStepOut->onClick = onTbStepOut;
    wgtSetTooltip(sTbStepOut, "Step Out (Ctrl+Shift+F8)");

    sTbRunToCur = loadTbIcon(tb, "tb_runtoc", "Cursor");
    sTbRunToCur->onClick = onTbRunToCur;
    wgtSetTooltip(sTbRunToCur, "Run to Cursor (Ctrl+F8)");

    WidgetT *sep3 = wgtVSeparator(tb);
    sep3->minW = wgtPixels(IDE_TOOLBAR_SEP_W);

    // View group
    sTbCode = loadTbIcon(tb, "tb_code", "Code");
    sTbCode->onClick = onTbCode;
    wgtSetTooltip(sTbCode, "Code View (F7)");

    sTbDesign = loadTbIcon(tb, "tb_design", "Design");
    sTbDesign->onClick = onTbDesign;
    wgtSetTooltip(sTbDesign, "Design View (Shift+F7)");

    sStatusBar = wgtStatusBar(tbRoot);
    WidgetT *statusBar = sStatusBar;
    sStatus = wgtLabel(statusBar, "");
    sStatus->weight = WGT_WEIGHT_FILL;

    // Fit height to content, keeping full screen width
    dvxFitWindowH(sAc, sWin);

    // Initialize designer (form window created on demand)
    dsgnInit(&sDesigner, sAc);
    sDesigner.projectDir = sProject.projectDir;

    showOutputWindow();
    showImmediateWindow();
    dvxRaiseWindow(sAc, sWin);
}


// classifyWord -- returns syntax color for an identifier.
// Converts to uppercase once, then does a single hash lookup.

static uint8_t classifyWord(const char *word, int32_t wordLen) {
    char upper[IDE_WORD_BUF];

    if (wordLen <= 0 || wordLen >= IDE_WORD_BUF) {
        return SYNTAX_DEFAULT;
    }

    for (int32_t i = 0; i < wordLen; i++) {
        upper[i] = (char)toupper((unsigned char)word[i]);
    }

    upper[wordLen] = '\0';

    initSyntaxMap();

    int32_t idx = shgeti(sSyntaxMap, upper);

    if (idx >= 0) {
        return sSyntaxMap[idx].value;
    }

    return SYNTAX_DEFAULT;
}


// cleanupFormWin -- release designer-related state without destroying
// the form window itself (the caller handles that).
static void cleanupFormWin(void) {
    sFormWin = NULL;
    sDesigner.formWin = NULL;

    // Null out widget pointers -- the widgets were destroyed with the window.
    // Without this, dsgnCreateWidgets skips controls that have non-NULL
    // widget pointers, resulting in an empty form on the second open.
    if (sDesigner.form) {
        int32_t count = (int32_t)arrlen(sDesigner.form->controls);

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

        sDesigner.form->contentBox = NULL;
    }

    if (sToolboxWin) {
        tbxDestroy(sAc, sToolboxWin);
        sToolboxWin = NULL;
    }

    if (sPropsWin) {
        prpDestroy(sAc, sPropsWin);
        sPropsWin = NULL;
    }
}


static void clearAllBreakpoints(void) {
    arrsetlen(sBreakpoints, 0);

    // A live VM still points at sVmBreakpoints; detach before freeing.
    if (sVm) {
        basVmSetBreakpoints(sVm, NULL, 0);
    }

    arrfree(sVmBreakpoints);
    sVmBreakpoints = NULL;
    updateBreakpointWindow();
}


static void clearOutput(void) {
    sOutputBuf[0] = '\0';
    sOutputLen    = 0;
    setOutputText("");
}


// clearVmStepState -- cancel any pending step/run-to-cursor request
static void clearVmStepState(void) {
    if (!sVm) {
        return;
    }

    sVm->debugBreak      = false;
    sVm->stepOverDepth   = -1;
    sVm->stepOutDepth    = -1;
    sVm->runToCursorLine = -1;
}


static void closeFindDialog(void) {
    if (sFindWin) {
        dvxDestroyWindow(sAc, sFindWin);
        sFindWin    = NULL;
        sFindInput  = NULL;
        sReplInput  = NULL;
        sReplCheck  = NULL;
        sBtnReplace = NULL;
        sBtnReplAll = NULL;
        sCaseCheck  = NULL;
        sScopeGroup = NULL;
        sDirGroup   = NULL;
    }
}


// closeProject -- callers prompt for unsaved file edits first (promptAndSave);
// only the .dbp itself is written here.
static void closeProject(void) {
    if (!hasProject()) {
        return;
    }

    closeFindDialog();

    if (sProject.dirty) {
        saveProjectFile();
    }

    closeProjectUi();
    prjClose(&sProject);
    updateMainTitle();
    setStatus("Project closed.");
    updateProjectMenuState();
}


// closeProjectUi -- destroy every per-project window and buffer.  Safe to
// call when nothing is open (every step checks its own state).
static void closeProjectUi(void) {
    teardownFormWin();

    // Toolbox/properties can exist without a form window (Window menu)
    if (sToolboxWin) {
        tbxDestroy(sAc, sToolboxWin);
        sToolboxWin = NULL;
    }

    if (sPropsWin) {
        prpDestroy(sAc, sPropsWin);
        sPropsWin = NULL;
    }

    dsgnFree(&sDesigner);

    if (sCodeWin) {
        dvxDestroyWindow(sAc, sCodeWin);
        sCodeWin     = NULL;
        sEditor      = NULL;
        sObjDropdown = NULL;
        sEvtDropdown = NULL;
    }

    freeProcBufs();
    clearAllBreakpoints();

    if (sProjectWin) {
        prjDestroyWindow(sAc, sProjectWin);
        sProjectWin = NULL;
    }
}


// Sort event names: implemented (no brackets) first, then unimplemented
// ([brackets]), alphabetically within each group.

static int cmpEvtPtrs(const void *a, const void *b) {
    const char *sa = *(const char **)a;
    const char *sb = *(const char **)b;
    bool aImpl = (sa[0] != '[');
    bool bImpl = (sb[0] != '[');

    if (aImpl != bImpl) {
        return aImpl ? -1 : 1;
    }

    // Skip brackets for alphabetical comparison
    if (sa[0] == '[') {
        sa++;
    }

    if (sb[0] == '[') {
        sb++;
    }

    return strcasecmp(sa, sb);
}


static int cmpStrPtrs(const void *a, const void *b) {
    const char *sa = *(const char **)a;
    const char *sb = *(const char **)b;
    return strcasecmp(sa, sb);
}


static void compileAndRun(void) {
    if (compileProject()) {
        runModule(sCachedModule);
    }
}


static bool compileProject(void) {
    // Save all dirty files before compiling if Save on Run is enabled
    if (sWin && sWin->menuBar && wmMenuItemIsChecked(sWin->menuBar, CMD_SAVE_ON_RUN)) {
        saveAllModified();
    }

    clearOutput();
    setStatus("Compiling...");
    dvxSetBusy(sAc, true);
    dvxUpdate(sAc);

    // Build source: either concatenate project files or use editor contents
    char *concatBuf = NULL;
    const char *src = NULL;
    int32_t srcLen  = 0;

    if (hasProject() && sProject.fileCount > 0) {
        // Stash current editor/designer state into project buffers
        // (for a form this also writes the editor back to form->code)
        stashCurrentFile();

        // Concatenate all files (each normalized to the canonical proc layout)
        concatBuf = (char *)malloc(IDE_MAX_SOURCE);

        if (!concatBuf) {
            setStatus("Out of memory.");
            dvxSetBusy(sAc, false);
            return false;
        }

        int32_t pos  = 0;
        int32_t line = 1;
        arrfree(sProject.sourceMap);
        sProject.sourceMap      = NULL;
        sProject.sourceMapCount = 0;

        // Two passes: .bas modules first (so CONST declarations are
        // available), then .frm code sections.
        for (int32_t pass = 0; pass < 2; pass++) {
            for (int32_t i = 0; i < sProject.fileCount; i++) {
                // Pass 0: modules only.  Pass 1: forms only.
                if ((pass == 0) == sProject.files[i].isForm) {
                    continue;
                }

                char *fileSrc = loadCompileSource(i);

                if (!fileSrc) {
                    sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "COMPILE ERROR:\nCannot read %s (missing or larger than %d bytes).\n", sProject.files[i].path, (int)IDE_MAX_SOURCE);
                    showCompileError("Compilation failed: cannot read file.");
                    free(concatBuf);
                    return false;
                }

                int32_t fileLen = (int32_t)strlen(fileSrc);

                // BEGINFORM/ENDFORM directives plus a trailing newline
                if (pos + fileLen + IDE_SOURCE_MARGIN >= IDE_MAX_SOURCE) {
                    sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "COMPILE ERROR:\nProject source exceeds %d bytes at %s.\n", (int)IDE_MAX_SOURCE, sProject.files[i].path);
                    showCompileError("Compilation failed: source too large.");
                    free(fileSrc);
                    free(concatBuf);
                    return false;
                }

                // Inject BEGINFORM directive for .frm code sections
                if (sProject.files[i].isForm && sProject.files[i].formName[0]) {
                    pos = emitClamped(concatBuf, IDE_MAX_SOURCE, pos, "BEGINFORM \"%s\"\n", sProject.files[i].formName);
                    line++;
                }

                // Record startLine AFTER injected directives so the source
                // map lines match what the editor shows (not the synthetic lines).
                int32_t startLine = line;

                memcpy(concatBuf + pos, fileSrc, fileLen);
                pos += fileLen;

                // Count lines
                for (int32_t j = 0; j < fileLen; j++) {
                    if (fileSrc[j] == '\n') {
                        line++;
                    }
                }

                free(fileSrc);

                // Ensure a trailing newline between files
                if (fileLen > 0 && concatBuf[pos - 1] != '\n') {
                    concatBuf[pos++] = '\n';
                    line++;
                }

                // Record source map BEFORE injected ENDFORM directive
                PrjSourceMapT mapEntry;
                mapEntry.fileIdx   = i;
                mapEntry.startLine = startLine;
                mapEntry.lineCount = line - startLine;
                arrput(sProject.sourceMap, mapEntry);
                sProject.sourceMapCount = (int32_t)arrlen(sProject.sourceMap);

                // Inject ENDFORM directive
                if (sProject.files[i].isForm && sProject.files[i].formName[0]) {
                    pos = emitClamped(concatBuf, IDE_MAX_SOURCE, pos, "ENDFORM\n");
                    line++;
                }

                dvxUpdate(sAc);
            }
        }

        concatBuf[pos] = '\0';
        src    = concatBuf;
        srcLen = pos;
    } else {
        // No project files -- compile the full source
        src = getFullSource();

        if (!src || *src == '\0') {
            setStatus("No source code to compile.");
            dvxSetBusy(sAc, false);
            return false;
        }

        srcLen = (int32_t)strlen(src);
    }

    // Compile (heap-allocated -- BasParserT is ~300KB, too large for stack)
    BasParserT *parser = (BasParserT *)malloc(sizeof(BasParserT));

    if (!parser) {
        free(concatBuf);
        setStatus("Out of memory.");
        dvxSetBusy(sAc, false);
        return false;
    }

    basParserInit(parser, src, srcLen);
    parser->optionExplicit = sProject.optionExplicit;

    // Build a name -> widget-type map from the project's .frm files
    // and attach a validator so the parser can reject CtrlName.Member
    // typos at compile time.  The validator falls back silently for
    // dynamically-created controls (not in the map).
    IdeValidatorCtxT validatorCtx;
    memset(&validatorCtx, 0, sizeof(validatorCtx));
    validatorBuildCtrlMap(&validatorCtx);

    // Reject unknown widget types up front -- the .frm parse records
    // any `Begin <Type> <Name>` whose type isn't registered by a .wgt
    // DXE.  Those would silently drop the control at runtime and
    // produce a misleading "control not found" error on first access.
    if (arrlen(validatorCtx.badTypes) > 0) {
        int32_t n = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "COMPILE ERROR:\nUnknown widget type(s) in .frm files:\n");

        for (int32_t i = 0; i < (int32_t)arrlen(validatorCtx.badTypes) && n < IDE_MAX_OUTPUT - IDE_OUTPUT_LINE_RESERVE; i++) {
            IdeBadTypeT *b = &validatorCtx.badTypes[i];
            n = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, n,
                            "  Form '%s' control '%s': type '%s' is not registered.\n",
                            b->formName, b->ctrlName, b->typeName);
        }

        n = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, n,
                        "\nDVX uses VB6-style widget names (e.g. SpinButton, OptionButton, HScrollBar, CheckBox, DropDown).\n");
        sOutputLen = n;
        showCompileError("Compilation failed: unknown widget type.");
        basParserFree(parser);
        free(parser);
        free(concatBuf);
        arrfree(validatorCtx.entries);
        arrfree(validatorCtx.badTypes);
        return false;
    }

    BasCtrlValidatorT validator;
    validator.lookupCtrlType = validatorLookupCtrlType;
    validator.isMethodValid  = validatorIsMethodValid;
    validator.isPropValid    = validatorIsPropValid;
    validator.ctx            = &validatorCtx;
    basParserSetValidator(parser, &validator);

    if (!basParse(parser)) {
        // Translate global error line to local file/line for display
        int32_t errFileIdx   = -1;
        int32_t errLocalLine = parser->errorLine;
        const char *errFile  = "";

        if (parser->errorLine > 0 && sProject.fileCount > 0) {
            prjMapLine(&sProject, parser->errorLine, &errFileIdx, &errLocalLine);

            if (errFileIdx >= 0 && errFileIdx < sProject.fileCount) {
                errFile = sProject.files[errFileIdx].path;
            }
        }

        // Navigate to error location and build a user-friendly error message
        char procName[IDE_FULL_NAME_BUF] = {0};
        int32_t procLine                 = errLocalLine;

        if (parser->errorLine > 0 && errFileIdx >= 0) {
            activateFile(errFileIdx, ViewCodeE);

            int32_t procCount = (int32_t)arrlen(sProcTable);

            for (int32_t i = procCount - 1; i >= 0; i--) {
                if (errLocalLine >= sProcTable[i].lineNum) {
                    snprintf(procName, sizeof(procName), "%s.%s",
                             sProcTable[i].objName, sProcTable[i].evtName);
                    procLine = errLocalLine - sProcTable[i].lineNum + 1;
                    break;
                }
            }

            navigateToCodeLine(errFileIdx, errLocalLine, procName[0] ? procName : NULL, false);
        }

        // Strip the "Line NNN: " prefix from the parser error
        const char *msg = parser->error;

        if (kwMatch(msg, KW_LINE_PREFIX)) {
            while (*msg && *msg != ':') {
                msg++;
            }

            if (*msg == ':') {
                msg++;
            }

            msg = dvxSkipWs(msg);
        }

        // Show the error with procedure name and proc-relative line
        if (procName[0]) {
            sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "COMPILE ERROR:\n%s line %d: %s\n",
                                     procName, (int)procLine, msg);
        } else {
            sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "COMPILE ERROR:\n%s line %d: %s\n",
                                     errFile, (int)errLocalLine, msg);
        }

        showCompileError("Compilation failed.");
        basParserFree(parser);
        free(parser);
        free(concatBuf);
        arrfree(validatorCtx.entries);
        arrfree(validatorCtx.badTypes);
        return false;
    }

    free(concatBuf);
    arrfree(validatorCtx.entries);
    arrfree(validatorCtx.badTypes);

    BasModuleT *mod = basParserBuildModule(parser);
    basParserFree(parser);
    free(parser);

    if (!mod) {
        setStatus("Failed to build module.");
        dvxSetBusy(sAc, false);
        return false;
    }

    dvxSetBusy(sAc, false);

    // Cache the compiled module for Ctrl+F5
    if (sCachedModule) {
        basModuleFree(sCachedModule);
    }

    sCachedModule = mod;

    // Update Object/Event dropdowns
    updateDropdowns();

    return true;
}


static int32_t countLines(const char *text) {
    if (!text || !text[0]) {
        return 1;
    }

    int32_t n = 1;

    for (const char *p = text; *p; p++) {
        if (*p == '\n') {
            n++;
        }
    }

    return n;
}


// createEventSubSkeleton -- append an empty "Sub Obj_Evt ... End Sub" to
// the proc buffers and show it in the editor.  Not marked dirty yet;
// saveCurProc discards it again if the user adds no code.
static void createEventSubSkeleton(const char *objName, const char *evtName) {
    char        subName[IDE_SKELETON_BUF];
    char        skeleton[IDE_SKELETON_BUF];
    const char *params = getEventParams(evtName);

    snprintf(subName, sizeof(subName), "%s_%s", objName, evtName);

    // Control-array handlers take a leading Index argument
    if (isCtrlArrayInDesigner(objName)) {
        snprintf(skeleton, sizeof(skeleton), "Sub %s (Index As Integer%s%s)\n\nEnd Sub\n", subName, params[0] ? ", " : "", params);
    } else {
        snprintf(skeleton, sizeof(skeleton), "Sub %s (%s)\n\nEnd Sub\n", subName, params);
    }

    char *buf = strdup(skeleton);

    if (!buf) {
        return;
    }

    arrput(sProcBufs, buf);

    // Show the new procedure (it's the last one); showProc stashes the
    // outgoing editor text and adjusts the index if that discarded an
    // earlier empty skeleton.  Then refresh sProcTable so breakpoints
    // and line translation know about the new proc.
    showProc((int32_t)arrlen(sProcBufs) - 1);
    joinProcBufs();
}


static uint32_t debugLineDecorator(int32_t lineNum, uint32_t *gutterColor, void *ctx) {
    AppContextT *ac = (AppContextT *)ctx;

    // Convert editor line to file code line
    int32_t codeLine = editorLineToCodeLine(lineNum);
    int32_t fileIdx  = sEditorFileIdx;

    // Breakpoint: red gutter dot
    for (int32_t i = 0; i < (int32_t)arrlen(sBreakpoints); i++) {
        if (sBreakpoints[i].fileIdx == fileIdx && sBreakpoints[i].codeLine == codeLine) {
            *gutterColor = packColor(&ac->display, IDE_BP_GUTTER_R, IDE_BP_GUTTER_G, IDE_BP_GUTTER_B);
            break;
        }
    }

    // Current debug line: yellow background (sDbgCurrentLine is editor-local)
    if (sDbgState == DBG_PAUSED && lineNum == sDbgCurrentLine) {
        return packColor(&ac->display, IDE_DBG_LINE_R, IDE_DBG_LINE_G, IDE_DBG_LINE_B);
    }

    return 0;
}


static void debugNavigateToLine(int32_t concatLine) {
    if (concatLine <= 0) {
        return;
    }

    int32_t fileIdx   = -1;
    int32_t localLine = concatLine;

    // Map concatenated line to file and file-local line (code section).
    // prjMapLine already returns a line relative to the code section
    // (startLine in the source map is recorded AFTER the injected
    // BEGINFORM directive), so no further adjustment is needed.  The
    // compile-error path uses the result as-is and this path should too.
    if (sProject.sourceMapCount > 0) {
        prjMapLine(&sProject, concatLine, &fileIdx, &localLine);
    }

    // Find which procedure we're in using the VM's PC and the compiled
    // module's proc table, then build a dot-separated name for navigateToCodeLine.
    // When a runtime error has halted the program, vm->pc has already
    // been restored by runSubLoop to the caller's saved PC (outside the
    // event handler that actually faulted), so use the snapshot
    // captured in vm->errorPc instead.  Without this, the proc lookup
    // lands on whatever sub owns the saved PC -- typically "(General)"
    // or the wrong handler -- and the editor jumps to the wrong place.
    const char *procName = NULL;
    char procBuf[IDE_FULL_NAME_BUF];

    if (sVm && sDbgModule) {
        int32_t lookupPc = (sVm->errorMsg[0] && sVm->errorPc > 0) ? sVm->errorPc : sVm->pc;
        int32_t procIdx  = vmProcIndexForPc(lookupPc);

        // Convert compiled name (Obj_Evt) to dot-separated (Obj.Evt) for matching
        if (procIdx >= 0) {
            const char *compiledName = sDbgModule->procs[procIdx].name;
            int32_t     procCount    = (int32_t)arrlen(sProcTable);

            for (int32_t i = 0; i < procCount; i++) {
                char fullName[IDE_FULL_NAME_BUF];

                if (sProcTable[i].objName[0] && strcmp(sProcTable[i].objName, IDE_GENERAL_SECTION) != 0) {
                    snprintf(fullName, sizeof(fullName), "%s_%s", sProcTable[i].objName, sProcTable[i].evtName);
                } else {
                    snprintf(fullName, sizeof(fullName), "%s", sProcTable[i].evtName);
                }

                if (strcasecmp(fullName, compiledName) == 0) {
                    snprintf(procBuf, sizeof(procBuf), "%s.%s", sProcTable[i].objName, sProcTable[i].evtName);
                    procName = procBuf;
                    break;
                }
            }
        }
    }

    navigateToCodeLine(fileIdx, localLine, procName, true);
}


// debugResume -- common tail of every continue/step command: the caller
// has already configured the VM's step state.
static void debugResume(const char *status) {
    sDbgCurrentLine = -1;
    sDbgState       = DBG_RUNNING;
    debugSetBreakTitles(false);

    if (sVm) {
        sVm->debugPaused = false;
        sVm->running     = true;
    }

    if (sEditor) {
        wgtInvalidatePaint(sEditor);
    }

    updateProjectMenuState();
    setStatus(status);
}


static void debugSetBreakTitles(bool paused) {
    if (!sDbgFormRt) {
        return;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(sDbgFormRt->forms); i++) {
        BasFormT *form = sDbgFormRt->forms[i];

        if (!form->window) {
            continue;
        }

        char *title = form->window->title;
        const char *tag = IDE_BREAK_TAG;
        int32_t tagLen = (int32_t)strlen(tag);
        int32_t titleLen = (int32_t)strlen(title);

        if (paused) {
            // Add [break] if not already there
            if (titleLen < tagLen || strcmp(title + titleLen - tagLen, tag) != 0) {
                if (titleLen + tagLen < MAX_TITLE_LEN) {
                    strcat(title, tag);
                    dvxInvalidateWindow(sAc, form->window);
                }
            }
        } else {
            // Remove [break] if present
            if (titleLen >= tagLen && strcmp(title + titleLen - tagLen, tag) == 0) {
                title[titleLen - tagLen] = '\0';
                dvxInvalidateWindow(sAc, form->window);
            }
        }
    }
}


static void debugStartOrResume(int32_t cmd) {
    if (sDbgState == DBG_PAUSED && sVm) {
        // Already paused -- apply the appropriate step command and resume
        switch (cmd) {
            case CMD_STEP_INTO:
                basVmStepInto(sVm);
                break;

            case CMD_STEP_OVER:
                basVmStepOver(sVm);
                break;

            case CMD_STEP_OUT:
                basVmStepOut(sVm);
                break;

            case CMD_RUN_TO_CURSOR:
                if (sEditor) {
                    basVmRunToCursor(sVm, localToConcatLine(wgtTextAreaGetCursorLine(sEditor)));
                }
                break;

            default:
                break;
        }

        debugResume("Running...");
        return;
    }

    if (sDbgState == DBG_IDLE) {
        // Not running -- compile and start in debug mode.
        sDbgEnabled      = true;
        sDbgBreakOnStart = true;
        compileAndRun();
        sDbgBreakOnStart = false;
    }
}


// debugStop -- request the running program to stop.  runModule's loops and
// doEventsCallback observe sStopRequested and unwind.
static void debugStop(void) {
    sStopRequested = true;

    if (sVm) {
        sVm->running     = false;
        sVm->debugPaused = false;
    }

    sDbgState       = DBG_IDLE;
    sDbgCurrentLine = -1;
    sDbgEnabled     = false;

    if (sEditor) {
        wgtInvalidatePaint(sEditor);
    }

    updateProjectMenuState();
}


static void debugUpdateWindows(void) {
    // Auto-show debug windows if not already open
    if (!sLocalsWin) {
        showLocalsWindow();
    } else if (!sLocalsWin->visible) {
        dvxShowWindow(sAc, sLocalsWin);
    }

    if (!sCallStackWin) {
        showCallStackWindow();
    } else if (!sCallStackWin->visible) {
        dvxShowWindow(sAc, sCallStackWin);
    }

    updateLocalsWindow();
    updateCallStackWindow();
    updateWatchWindow();
}


// deleteSelectedControl -- remove the designer's selected control and
// refresh the properties panel / form window when something changed.
static void deleteSelectedControl(void) {
    if (!sDesigner.form || sDesigner.selectedIdx < 0) {
        return;
    }

    int32_t prevCount = (int32_t)arrlen(sDesigner.form->controls);
    dsgnOnKey(&sDesigner, KEY_DELETE);
    int32_t newCount = (int32_t)arrlen(sDesigner.form->controls);

    if (newCount != prevCount) {
        prpRebuildTree(&sDesigner);
        prpRefresh(&sDesigner);

        if (sFormWin) {
            dvxInvalidateWindow(sAc, sFormWin);
        }
    }
}


static bool doEventsCallback(void *ctx) {
    (void)ctx;

    // Stop if IDE window was closed, DVX is shutting down, or the user
    // pressed Stop (the VM's own pause spin only exits on false here).
    if (!sWin || !sAc->running || sStopRequested) {
        return false;
    }

    dvxUpdate(sAc);

    return sWin != NULL && sAc->running && !sStopRequested;
}


static void dsgnCopySelected(void) {
    if (!sDesigner.form || sDesigner.selectedIdx < 0) {
        return;
    }

    int32_t count = (int32_t)arrlen(sDesigner.form->controls);

    if (sDesigner.selectedIdx >= count) {
        return;
    }

    // Serialize the selected control to FRM text with the same writer the
    // .frm saver uses.  Sized to hold the worst case (DSGN_MAX_PROPS props
    // x DSGN_MAX_TEXT values + chrome); emitClamped guarantees no overflow
    // even past this.  Heap-allocated so this ~10KB buffer doesn't sit on
    // the task stack.
    int32_t bufSize = DSGN_MAX_PROPS * (DSGN_MAX_NAME + DSGN_MAX_TEXT + IDE_FRM_LINE_CHROME) + IDE_FRM_BLOCK_CHROME;
    char *buf = (char *)malloc((size_t)bufSize);

    if (!buf) {
        return;
    }

    int32_t pos = dsgnEmitControl(sDesigner.form->controls[sDesigner.selectedIdx], buf, bufSize, 0, 0);
    dvxClipboardCopy(buf, pos);
    free(buf);
}


static void dsgnPasteControl(void) {
    if (!sDesigner.form || !sDesigner.form->contentBox) {
        return;
    }

    int32_t clipLen = 0;
    const char *clip = dvxClipboardGet(&clipLen);

    if (!clip || clipLen <= 0) {
        return;
    }

    // Verify it looks like a control definition
    if (!kwMatch(clip, FRM_BEGIN_TAG)) {
        return;
    }

    // Parse type and name from "Begin TypeName CtrlName"
    const char *rest = clip + KW_LEN(FRM_BEGIN_TAG);
    char typeName[DSGN_MAX_NAME];
    char ctrlName[DSGN_MAX_NAME];

    int32_t ti = 0;

    while (*rest && *rest != ' ' && *rest != '\t' && ti < DSGN_MAX_NAME - 1) {
        typeName[ti++] = *rest++;
    }

    typeName[ti] = '\0';

    rest = dvxSkipWs(rest);

    int32_t ci = 0;

    while (*rest && *rest != ' ' && *rest != '\t' && *rest != '\r' && *rest != '\n' && ci < DSGN_MAX_NAME - 1) {
        ctrlName[ci++] = *rest++;
    }

    ctrlName[ci] = '\0';

    // Check if a control with the same name exists -- create control array
    char newName[DSGN_MAX_NAME];
    int32_t newIndex = -1;
    bool nameExists  = false;
    int32_t highIdx  = -1;
    int32_t existCount = (int32_t)arrlen(sDesigner.form->controls);

    for (int32_t i = 0; i < existCount; i++) {
        if (strcasecmp(sDesigner.form->controls[i]->name, ctrlName) == 0) {
            nameExists = true;

            if (sDesigner.form->controls[i]->index > highIdx) {
                highIdx = sDesigner.form->controls[i]->index;
            }
        }
    }

    if (nameExists) {
        // Already a control array -- just add the next element
        if (highIdx >= 0) {
            snprintf(newName, DSGN_MAX_NAME, "%s", ctrlName);
            newIndex = highIdx + 1;
        } else {
            // Not yet an array -- ask the user
            int32_t result = dvxMessageBox(sAc, "Paste",
                "A control with this name already exists.\n"
                "Create a control array?",
                MB_YESNO | MB_ICONQUESTION);

            if (result == ID_YES) {
                // Convert existing control to index 0
                for (int32_t i = 0; i < existCount; i++) {
                    if (strcasecmp(sDesigner.form->controls[i]->name, ctrlName) == 0) {
                        sDesigner.form->controls[i]->index = 0;
                        break;
                    }
                }

                snprintf(newName, DSGN_MAX_NAME, "%s", ctrlName);
                newIndex = 1;
            } else {
                // Rename to a unique name
                dsgnAutoName(&sDesigner, typeName, newName, DSGN_MAX_NAME);
            }
        }
    } else {
        dsgnAutoName(&sDesigner, typeName, newName, DSGN_MAX_NAME);
    }

    // Allocate the control record up front so a failure can't leave a
    // live widget behind in the content box.
    DsgnControlT *heapCtrl = (DsgnControlT *)malloc(sizeof(DsgnControlT));

    if (!heapCtrl) {
        return;
    }

    DsgnControlT ctrl;
    memset(&ctrl, 0, sizeof(ctrl));
    ctrl.index = newIndex;
    snprintf(ctrl.name, DSGN_MAX_NAME, "%s", newName);
    snprintf(ctrl.typeName, DSGN_MAX_NAME, "%s", typeName);

    // Parse properties from the clipboard text
    const char *line = rest;

    while (*line) {
        while (*line == '\r' || *line == '\n') {
            line++;
        }

        if (!*line) {
            break;
        }

        line = dvxSkipWs(line);

        // "End" terminates
        if (kwMatch(line, FRM_END_TAG) && (line[KW_LEN(FRM_END_TAG)] == '\0' || line[KW_LEN(FRM_END_TAG)] == '\r' || line[KW_LEN(FRM_END_TAG)] == '\n')) {
            break;
        }

        // Parse "Key = Value"
        const char *eq = strchr(line, '=');

        if (eq) {
            char key[DSGN_MAX_NAME];
            int32_t klen = 0;
            const char *kp = line;

            while (kp < eq && *kp != ' ' && *kp != '\t' && klen < DSGN_MAX_NAME - 1) {
                key[klen++] = *kp++;
            }

            key[klen] = '\0';

            const char *vp = dvxSkipWs(eq + 1);

            char val[DSGN_MAX_TEXT];
            int32_t vi = 0;

            if (*vp == '"') {
                vp++;

                while (*vp && *vp != '"' && vi < DSGN_MAX_TEXT - 1) {
                    val[vi++] = *vp++;
                }
            } else {
                while (*vp && *vp != '\r' && *vp != '\n' && vi < DSGN_MAX_TEXT - 1) {
                    val[vi++] = *vp++;
                }

                while (vi > 0 && (val[vi - 1] == ' ' || val[vi - 1] == '\t')) {
                    vi--;
                }
            }

            val[vi] = '\0';

            if (strcasecmp(key, "MinWidth") == 0 || strcasecmp(key, "Width") == 0) {
                ctrl.width = atoi(val);
            } else if (strcasecmp(key, "MinHeight") == 0 || strcasecmp(key, "Height") == 0) {
                ctrl.height = atoi(val);
            } else if (strcasecmp(key, "MaxWidth") == 0) {
                ctrl.maxWidth = atoi(val);
            } else if (strcasecmp(key, "MaxHeight") == 0) {
                ctrl.maxHeight = atoi(val);
            } else if (strcasecmp(key, "Weight") == 0) {
                ctrl.weight = atoi(val);
            } else if (ctrl.propCount < DSGN_MAX_PROPS) {
                snprintf(ctrl.props[ctrl.propCount].name, DSGN_MAX_NAME, "%s", key);
                snprintf(ctrl.props[ctrl.propCount].value, DSGN_MAX_TEXT, "%s", val);
                ctrl.propCount++;
            }
        }

        // Advance to next line
        while (*line && *line != '\n') {
            line++;
        }
    }

    // Create the live widget
    WidgetT *parentWidget = sDesigner.form->contentBox;
    ctrl.widget = dsgnCreateDesignWidget(typeName, parentWidget);

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

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

        if (ctrl.maxWidth > 0) {
            ctrl.widget->maxW = wgtPixels(ctrl.maxWidth);
        }

        if (ctrl.maxHeight > 0) {
            ctrl.widget->maxH = wgtPixels(ctrl.maxHeight);
        }

        ctrl.widget->weight = ctrl.weight;
        wgtSetName(ctrl.widget, ctrl.name);

        const char *caption = NULL;

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

        if (caption) {
            wgtSetText(ctrl.widget, caption);
        }

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

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

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

                const char *val = NULL;

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

                if (val) {
                    wgtApplyPropFromString(ctrl.widget, p, val);
                }
            }
        }
    }

    *heapCtrl = ctrl;
    arrput(sDesigner.form->controls, heapCtrl);
    sDesigner.selectedIdx = (int32_t)arrlen(sDesigner.form->controls) - 1;
    sDesigner.form->dirty = true;

    prpRebuildTree(&sDesigner);
    prpRefresh(&sDesigner);

    if (sFormWin) {
        dvxInvalidateWindow(sAc, sFormWin);
    }
}


// Converts a 1-based editor line (within the current proc's buffer) to its
// 1-based line in the file's full code.  For (General)/no-proc context
// (sCurProcIdx out of range) the editor line is the code line.
static int32_t editorLineToCodeLine(int32_t editorLine) {
    if (sCurProcIdx >= 0 && sCurProcIdx < (int32_t)arrlen(sProcTable)) {
        return sProcTable[sCurProcIdx].lineNum + editorLine - 1;
    }

    return editorLine;
}


// Auto-create an implicit project when opening a file without one.
// Derives project name and directory from the file path. Also adds
// a matching .frm file if one exists alongside the .bas.

static void ensureProject(const char *filePath) {
    if (hasProject()) {
        return;
    }

    // Derive directory and base name from path
    char dir[DVX_MAX_PATH];
    char baseName[PRJ_MAX_NAME];
    snprintf(dir, sizeof(dir), "%s", filePath);

    char *sep = platformPathDirEnd(dir);

    const char *fileName = filePath;

    if (sep) {
        fileName = sep + 1;
        *sep = '\0';
    } else {
        dir[0] = '.';
        dir[1] = '\0';
    }

    // Strip extension for project name
    // Length-clamped memcpy instead of strncpy/snprintf because
    // GCC warns about both when source (DVX_MAX_PATH) exceeds
    // the buffer (PRJ_MAX_NAME), even though truncation is safe.
    int32_t nl = (int32_t)strlen(fileName);

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

    memcpy(baseName, fileName, nl);
    baseName[nl] = '\0';

    char *dot = strrchr(baseName, '.');

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

    prjNew(&sProject, baseName, dir, sPrefs);

    // Determine if this is a .bas or .frm
    const char *ext = strrchr(filePath, '.');
    bool isForm = (ext && strcasecmp(ext, ".frm") == 0);

    prjAddFile(&sProject, fileName, isForm);

    // If it's a .bas, check for a matching .frm and add it too
    if (!isForm) {
        char frmPath[DVX_MAX_PATH];
        snprintf(frmPath, sizeof(frmPath), "%s", filePath);
        char *frmDot = strrchr(frmPath, '.');

        if (frmDot && (frmDot - frmPath) + 4 < DVX_MAX_PATH) {
            snprintf(frmDot, sizeof(frmPath) - (frmDot - frmPath), ".frm");
        } else if ((int32_t)strlen(frmPath) + 4 < DVX_MAX_PATH) {
            snprintf(frmPath + strlen(frmPath), sizeof(frmPath) - strlen(frmPath), ".frm");
        }

        FILE *frmFile = fopen(frmPath, "r");

        if (frmFile) {
            fclose(frmFile);

            // Get just the filename portion
            const char *frmName = platformPathBaseName(frmPath);
            prjAddFile(&sProject, frmName, true);
        }
    }

    sProject.dirty = false;
    sProject.activeFileIdx = -1;

    prjLoadAllFiles(&sProject, sAc);
    updateMainTitle();
    setStatus("Project created.");
    updateProjectMenuState();
}


// ensureProjectWindow -- create the Project Explorer if needed (wired to
// the shared menu/accelerator handlers), otherwise refresh its tree.
static void ensureProjectWindow(bool raise) {
    if (sProjectWin) {
        prjRebuildTree(&sProject);
    } else {
        sProjectWin = prjCreateWindow(sAc, &sProject, onPrjFileDblClick, updateProjectMenuState);

        if (!sProjectWin) {
            return;
        }

        sProjectWin->y          = toolbarBottom() + IDE_PROJECT_WIN_OFFSET;
        sProjectWin->onClose    = onProjectWinClose;
        sProjectWin->onMenu     = onMenu;
        sProjectWin->accelTable = sWin ? sWin->accelTable : NULL;
    }

    if (raise) {
        dvxRaiseWindow(sAc, sProjectWin);
    }
}


// evaluateImmediate -- compile and execute a single line from the
// Immediate window.  If the line doesn't start with a statement keyword,
// wrap it in PRINT so expressions produce visible output.

static void evaluateImmediate(const char *expr) {
    if (!expr || *expr == '\0') {
        return;
    }

    // Try assignment first when paused
    if (immTryAssign(expr)) {
        return;
    }

    char wrapped[IDE_IMM_WRAP_BUF];

    // If it already starts with a statement keyword, use as-is
    if (kwMatch(expr, KW_PRINT) || kwMatch(expr, KW_DIM) || kwMatch(expr, KW_LET)) {
        snprintf(wrapped, sizeof(wrapped), "%s", expr);
    } else {
        snprintf(wrapped, sizeof(wrapped), "%s %s", KW_PRINT, expr);
    }

    BasModuleT *mod = NULL;
    char        err[IDE_VALUE_BUF];
    BasVmT     *vm  = buildSnippetVm(wrapped, &mod, err, sizeof(err));

    if (!vm) {
        immPrintCallback(NULL, "Error: ", false);
        immPrintCallback(NULL, err, true);
        return;
    }

    basVmSetPrintCallback(vm, immPrintCallback, NULL);

    BasVmResultE result = basVmRun(vm);

    if (result != BAS_VM_HALTED && result != BAS_VM_OK && result != BAS_VM_BREAKPOINT) {
        immPrintCallback(NULL, "Error: ", false);
        immPrintCallback(NULL, basVmGetError(vm), true);
    }

    basVmDestroy(vm);
    basModuleFree(mod);
}


// evalWatchExpr -- compile and evaluate an expression using the paused VM's
// state.  Used as a fallback when lookupWatchVar can't handle the
// expression.  Returns the printed result in outBuf.

static bool evalWatchExpr(const char *expr, char *outBuf, int32_t outBufSize) {
    if (sDbgState != DBG_PAUSED || !sVm || !sDbgModule || !sDbgModule->debugVars) {
        return false;
    }

    char wrapped[IDE_IMM_WRAP_BUF];
    snprintf(wrapped, sizeof(wrapped), "%s %s", KW_PRINT, expr);

    BasModuleT *mod = NULL;
    char        err[IDE_VALUE_BUF];
    BasVmT     *tvm = buildSnippetVm(wrapped, &mod, err, sizeof(err));

    if (!tvm) {
        return false;
    }

    // Set up capture callback
    sWatchPrintBuf[0] = '\0';
    sWatchPrintLen    = 0;
    basVmSetPrintCallback(tvm, watchPrintCallback, NULL);

    // Run
    basVmRun(tvm);

    // Strip trailing newline
    if (sWatchPrintLen > 0 && sWatchPrintBuf[sWatchPrintLen - 1] == '\n') {
        sWatchPrintBuf[--sWatchPrintLen] = '\0';
    }

    snprintf(outBuf, outBufSize, "%s", sWatchPrintBuf);

    basVmDestroy(tvm);
    basModuleFree(mod);
    return sWatchPrintLen > 0;
}


// evtLabelMatches -- compare an Event dropdown label against an event
// name, ignoring the [] brackets that mark unimplemented events.

static bool evtLabelMatches(const char *label, const char *evtName) {
    if (label[0] == '[') {
        size_t nameLen = strlen(evtName);

        return strncasecmp(label + 1, evtName, nameLen) == 0 && label[nameLen + 1] == ']' && label[nameLen + 2] == '\0';
    }

    return strcasecmp(label, evtName) == 0;
}


// extractNewProcs -- scan a buffer for Sub/Function declarations that
// don't belong (e.g. user typed a new Sub in the General section).
// Extracts them into new sProcBufs entries and removes them from the
// source buffer. Returns a new buffer (caller frees) or NULL if no
// extraction was needed.

static char *extractNewProcs(const char *buf) {
    if (!buf || !buf[0]) {
        return NULL;
    }

    // Scan for Sub/Function at the start of a line
    bool found = false;
    const char *pos = buf;

    while (*pos) {
        bool isSub;

        if (procDeclAt(dvxSkipWs(pos), &isSub)) {
            found = true;
            break;
        }

        pos = skipLine(pos);
    }

    if (!found) {
        return NULL;
    }

    // Build the remaining text (before the first proc) and extract procs
    int32_t bufLen    = (int32_t)strlen(buf);
    char *remaining   = (char *)malloc(bufLen + 1);

    if (!remaining) {
        return NULL;
    }

    int32_t remPos = 0;
    pos = buf;

    while (*pos) {
        const char *lineStart = pos;
        bool        isSub     = false;
        const char *afterKw   = procDeclAt(dvxSkipWs(pos), &isSub);

        if (afterKw) {
            // Extract procedure name for duplicate check
            char newName[IDE_FULL_NAME_BUF];
            procNameFromDecl(afterKw, newName, sizeof(newName));

            // Check for duplicate against existing proc buffers
            bool isDuplicate = false;

            for (int32_t p = 0; p < (int32_t)arrlen(sProcBufs); p++) {
                bool        existIsSub = false;
                const char *existDecl  = procDeclAt(dvxSkipWs(sProcBufs[p]), &existIsSub);
                char        existName[IDE_FULL_NAME_BUF];

                procNameFromDecl(existDecl ? existDecl : sProcBufs[p], existName, sizeof(existName));

                if (strcasecmp(newName, existName) == 0) {
                    isDuplicate = true;
                    break;
                }
            }

            // Find End Sub / End Function
            int32_t     endOffset = 0;
            const char *scan      = skipToEndProc(pos, isSub, &endOffset);

            if (isDuplicate) {
                // Leave it in the General section -- compiler will report the error
                const char *next = skipLine(pos);
                memcpy(remaining + remPos, pos, next - pos);
                remPos += (int32_t)(next - pos);
                pos = next;
            } else {
                // Extract this procedure into a new buffer
                int32_t procLen = (int32_t)(scan - lineStart);
                char *procBuf   = (char *)malloc(procLen + 1);

                if (procBuf) {
                    memcpy(procBuf, lineStart, procLen);
                    procBuf[procLen] = '\0';
                    arrput(sProcBufs, procBuf);
                }

                pos = scan;
            }

            continue;
        }

        // Copy non-proc lines to remaining
        const char *next = skipLine(pos);
        memcpy(remaining + remPos, pos, next - pos);
        remPos += (int32_t)(next - pos);
        pos = next;
    }

    remaining[remPos] = '\0';
    return remaining;
}


// findDebugVar -- find a debug variable by name, respecting scope
static const BasDebugVarT *findDebugVar(const char *name) {
    if (!sVm || !sDbgModule || !sDbgModule->debugVars) {
        return NULL;
    }

    int32_t curProcIdx = vmProcIndexForPc(sVm->pc);

    for (int32_t i = 0; i < sDbgModule->debugVarCount; i++) {
        const BasDebugVarT *dv = &sDbgModule->debugVars[i];

        if (strcasecmp(dv->name, name) != 0) {
            continue;
        }

        if (dv->scope == SCOPE_LOCAL && dv->procIndex != curProcIdx) {
            continue;
        }

        if (dv->scope == SCOPE_FORM && !sVm->currentFormVars) {
            continue;
        }

        return dv;
    }

    return NULL;
}


// findInProject -- search all project files for a text match.
// Starts from the current editor position in the current file,
// then continues through subsequent files, wrapping around.
// Only the file containing the match is activated; other files are
// searched in their project buffers.

static bool findInProject(const char *needle, bool caseSensitive, bool forward) {
    if (!needle || !needle[0] || sProject.fileCount == 0) {
        return false;
    }

    int32_t needleLen = (int32_t)strlen(needle);
    int32_t step      = forward ? 1 : -1;

    // Stash current editor state so all buffers are up-to-date
    stashCurrentFile();

    // Start from the active file, searching from the current selection in
    // the chosen direction.
    int32_t startFile          = sProject.activeFileIdx >= 0 ? sProject.activeFileIdx : 0;
    bool    searchedEditorFile = false;

    // If the editor is open on the current file, try the current proc
    // first (no wrap -- returns false if no more matches that way).
    if (sEditor && sEditorFileIdx == startFile) {
        searchedEditorFile = true;

        if (wgtTextAreaFindNext(sEditor, needle, caseSensitive, forward)) {
            return true;
        }

        int32_t procCount = (int32_t)arrlen(sProcBufs);

        // Procs on the far side of the current one, in scan order.
        // Forward: procs after the current one, then General, then procs
        // before it (wrap within file).  Backward mirrors this exactly:
        // procs before the current one (descending), then General, then
        // procs after it (descending).
        if (forward) {
            // sCurProcIdx is -2 when stashCurrentFile discarded an empty
            // skeleton proc, so clamp the scan start to the first proc.
            int32_t firstProc = sCurProcIdx + 1;

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

            for (int32_t p = firstProc; p < procCount; p++) {
                if (procBufContains(sProcBufs[p], needle, needleLen, caseSensitive)) {
                    showProcAndFind(p, needle, caseSensitive, forward);
                    return true;
                }
            }

            if (sCurProcIdx != -1 && procBufContains(sGeneralBuf, needle, needleLen, caseSensitive)) {
                showProcAndFind(-1, needle, caseSensitive, forward);
                return true;
            }

            for (int32_t p = 0; p < sCurProcIdx && p < procCount; p++) {
                if (procBufContains(sProcBufs[p], needle, needleLen, caseSensitive)) {
                    showProcAndFind(p, needle, caseSensitive, forward);
                    return true;
                }
            }
        } else {
            for (int32_t p = sCurProcIdx - 1; p >= 0; p--) {
                if (procBufContains(sProcBufs[p], needle, needleLen, caseSensitive)) {
                    showProcAndFind(p, needle, caseSensitive, forward);
                    return true;
                }
            }

            if (sCurProcIdx != -1 && procBufContains(sGeneralBuf, needle, needleLen, caseSensitive)) {
                showProcAndFind(-1, needle, caseSensitive, forward);
                return true;
            }

            for (int32_t p = procCount - 1; p > sCurProcIdx; p--) {
                if (p < 0) {
                    break;
                }

                if (procBufContains(sProcBufs[p], needle, needleLen, caseSensitive)) {
                    showProcAndFind(p, needle, caseSensitive, forward);
                    return true;
                }
            }
        }

        // Move to the adjacent file in scan order.
        startFile = (startFile + step + sProject.fileCount) % sProject.fileCount;
    }

    // Search remaining files, proc by proc, in scan order.
    // startFile was advanced past the current file if we already searched it.
    int32_t filesToSearch = sProject.fileCount;

    if (searchedEditorFile) {
        filesToSearch--; // skip the file we already searched above
    }

    for (int32_t attempt = 0; attempt < filesToSearch; attempt++) {
        int32_t fileIdx = (startFile + step * attempt + sProject.fileCount * sProject.fileCount) % sProject.fileCount;

        // Search the file's buffer without activating it.  The code of a
        // .frm is a subset of its buffer, so a match in the .frm chrome
        // just costs an activation that finds nothing.
        const char *text = sProject.files[fileIdx].buffer;

        if (!procBufContains(text, needle, needleLen, caseSensitive)) {
            continue;
        }

        // Activate the file to load its proc buffers
        activateFile(fileIdx, sProject.files[fileIdx].isForm ? ViewCodeE : ViewAutoE);

        int32_t procCount = (int32_t)arrlen(sProcBufs);

        // Within each file, General precedes the numbered procs forward;
        // backward visits the numbered procs (descending) then General.
        if (forward) {
            if (procBufContains(sGeneralBuf, needle, needleLen, caseSensitive)) {
                showProcAndFind(-1, needle, caseSensitive, forward);
                return true;
            }

            for (int32_t p = 0; p < procCount; p++) {
                if (procBufContains(sProcBufs[p], needle, needleLen, caseSensitive)) {
                    showProcAndFind(p, needle, caseSensitive, forward);
                    return true;
                }
            }
        } else {
            for (int32_t p = procCount - 1; p >= 0; p--) {
                if (procBufContains(sProcBufs[p], needle, needleLen, caseSensitive)) {
                    showProcAndFind(p, needle, caseSensitive, forward);
                    return true;
                }
            }

            if (procBufContains(sGeneralBuf, needle, needleLen, caseSensitive)) {
                showProcAndFind(-1, needle, caseSensitive, forward);
                return true;
            }
        }
    }

    return false;
}


// Case-insensitive strstr replacement (strcasestr is a GNU extension)
static const char *findSubstrNoCase(const char *haystack, const char *needle, int32_t needleLen) {
    for (; *haystack; haystack++) {
        if (strncasecmp(haystack, needle, needleLen) == 0) {
            return haystack;
        }
    }

    return NULL;
}


// findUdtFieldIdx -- field index within a UDT from the debug type table
static int32_t findUdtFieldIdx(int32_t typeId, const char *fieldName) {
    for (int32_t t = 0; t < sDbgModule->debugUdtDefCount; t++) {
        if (sDbgModule->debugUdtDefs[t].typeId != typeId) {
            continue;
        }

        for (int32_t f = 0; f < sDbgModule->debugUdtDefs[t].fieldCount; f++) {
            if (strcasecmp(sDbgModule->debugUdtDefs[t].fields[f].name, fieldName) == 0) {
                return f;
            }
        }

        return -1;
    }

    return -1;
}


static void formatValue(const BasValueT *v, char *buf, int32_t bufSize) {
    switch (v->type) {
        case BAS_TYPE_INTEGER:
            snprintf(buf, bufSize, "%d", (int)v->intVal);
            break;

        case BAS_TYPE_LONG:
            snprintf(buf, bufSize, "%ld", (long)v->longVal);
            break;

        case BAS_TYPE_SINGLE:
            snprintf(buf, bufSize, "%.6g", (double)v->sngVal);
            break;

        case BAS_TYPE_DOUBLE:
            snprintf(buf, bufSize, "%.10g", v->dblVal);
            break;

        case BAS_TYPE_BOOLEAN:
            snprintf(buf, bufSize, "%s", v->boolVal ? "True" : "False");
            break;

        case BAS_TYPE_STRING:
            if (v->strVal) {
                snprintf(buf, bufSize, "\"%.*s\"", (int)(bufSize - IDE_QUOTE_OVERHEAD), v->strVal->data);
            } else {
                snprintf(buf, bufSize, "\"\"");
            }
            break;

        case BAS_TYPE_ARRAY: {
            BasArrayT *arr = v->arrVal;

            if (!arr) {
                snprintf(buf, bufSize, "(uninitialized)");
                break;
            }

            // emitClamped clamps pos to bufSize-1 on truncation, so a
            // multi-dim array can't push pos past bufSize and make a later
            // 'bufSize - pos' go negative (huge size_t -> OOB write).
            int32_t pos = emitClamped(buf, bufSize, 0, "%s(", typeNameStr(arr->elementType));

            for (int32_t d = 0; d < arr->dims && pos < bufSize - IDE_ARRAY_DIM_RESERVE; d++) {
                if (d > 0) {
                    pos = emitClamped(buf, bufSize, pos, ", ");
                }

                pos = emitClamped(buf, bufSize, pos, "%d To %d", (int)arr->lbound[d], (int)arr->ubound[d]);
            }

            emitClamped(buf, bufSize, pos, ") [%d]", (int)arr->totalElements);
            break;
        }

        default:
            snprintf(buf, bufSize, "...");
            break;
    }
}


// freeProcBufs -- release all procedure buffers

static void freeProcBufs(void) {
    free(sGeneralBuf);
    sGeneralBuf = NULL;

    for (int32_t i = 0; i < (int32_t)arrlen(sProcBufs); i++) {
        free(sProcBufs[i]);
    }

    arrfree(sProcBufs);
    sProcBufs      = NULL;
    sCurProcIdx    = -2;
    sEditorFileIdx = -1;
    arrsetlen(sProcTable, 0);
}


// getDebugVarSlot -- return a pointer to the actual BasValueT in the running VM
static BasValueT *getDebugVarSlot(const BasDebugVarT *dv) {
    if (!sVm) {
        return NULL;
    }

    if (dv->scope == SCOPE_LOCAL && sVm->callDepth > 0) {
        BasCallFrameT *frame = &sVm->callStack[sVm->callDepth - 1];

        if (dv->index >= 0 && dv->index < BAS_VM_MAX_LOCALS) {
            return &frame->locals[dv->index];
        }
    } else if (dv->scope == SCOPE_GLOBAL) {
        if (dv->index >= 0 && dv->index < BAS_VM_MAX_GLOBALS) {
            return &sVm->globals[dv->index];
        }
    } else if (dv->scope == SCOPE_FORM && sVm->currentFormVars) {
        if (dv->index >= 0 && dv->index < sVm->currentFormVarCount) {
            return &sVm->currentFormVars[dv->index];
        }
    }

    return NULL;
}


// Parameter list the runtime passes to a handler of evtName ("" when
// none, or for events not in the master table).
static const char *getEventParams(const char *evtName) {
    for (int32_t i = 0; i < IDE_EVENT_COUNT; i++) {
        if (strcasecmp(sEventTable[i].suffix, evtName) == 0) {
            return sEventTable[i].params;
        }
    }

    return "";
}


static bool getFindForward(void) {
    if (!sDirGroup) {
        return true;
    }

    return wgtRadioGetIndex(sDirGroup) == 0;
}


static bool getFindMatchCase(void) {
    return sCaseCheck && wgtCheckboxIsChecked(sCaseCheck);
}


static FindScopeE getFindScope(void) {
    if (!sScopeGroup) {
        return ScopeProjE;
    }

    int32_t idx = wgtRadioGetIndex(sScopeGroup);

    switch (idx) {
        case 0:
            return ScopeFuncE;

        case 1:
            return ScopeObjE;

        case 2:
            return ScopeFileE;

        default:
            return ScopeProjE;
    }
}


// getFullSource -- save the editor and reassemble all buffers into one
// source string.  Returns a pointer into an internal cache that is owned
// by joinProcBufs and freed on the next call.  Callers must NOT free it;
// strdup it if the result must outlive the next call.

static const char *getFullSource(void) {
    saveCurProc();
    return joinProcBufs();
}


static WindowT *getLastFocusWin(void) {
    if (sLastFocusWin == sCodeWin ||
        sLastFocusWin == sOutWin ||
        sLastFocusWin == sImmWin) {
        return sLastFocusWin;
    }

    sLastFocusWin = NULL;
    return NULL;
}


static void handleEditCmd(int32_t cmd) {
    switch (cmd) {
        case CMD_CUT:
        case CMD_COPY:
        case CMD_PASTE:
        case CMD_SELECT_ALL: {
            static const int32_t keys[] = { KEY_CTRL_X, KEY_CTRL_C, KEY_CTRL_V, IDE_KEY_CTRL_A };
            int32_t key = keys[cmd - CMD_CUT];
            WindowT *target = getLastFocusWin();

            if (target && target->onKey) {
                target->onKey(target, key, ACCEL_CTRL);
            }

            break;
        }

        case CMD_DELETE:
            if (sFormWin) {
                deleteSelectedControl();
            }
            break;

        case CMD_FIND:
            openFindDialog(false);
            break;

        case CMD_FIND_NEXT:
            if (sFindText[0]) {
                onFindNext(NULL);
            } else {
                openFindDialog(false);
            }
            break;

        case CMD_REPLACE:
            openFindDialog(true);
            break;

        default:
            break;
    }
}


// Accelerators bypass the menu enabled state, so anything that changes
// the project or the compiled module is gated on the program being idle.
static void handleFileCmd(int32_t cmd) {
    bool isIdle = (sDbgState == DBG_IDLE);

    switch (cmd) {
        case CMD_OPEN:
            if (isIdle) {
                loadFile();
            }
            break;

        case CMD_SAVE:
            saveFile();
            break;

        case CMD_SAVE_ALL:
            if (saveAllModified()) {
                setStatus("All files saved.");
            }
            break;

        case CMD_MAKE_EXE:
            if (isIdle) {
                makeExecutable();
            }
            break;

        case CMD_EXIT:
            if (sWin) {
                onClose(sWin);
            }
            break;

        default:
            // Recent files
            if (isIdle && cmd >= CMD_RECENT_BASE && cmd < CMD_RECENT_BASE + CMD_RECENT_MAX) {
                recentOpen(cmd - CMD_RECENT_BASE);
            }
            break;
    }
}


static void handleProjectCmd(int32_t cmd) {
    bool isIdle = (sDbgState == DBG_IDLE);

    switch (cmd) {
        case CMD_PRJ_NEW:
            if (isIdle) {
                newProject();
            }
            break;

        case CMD_PRJ_OPEN:
            if (isIdle) {
                openProject();
            }
            break;

        case CMD_PRJ_SAVE:
            if (hasProject() && saveProjectFile()) {
                setStatus("Project saved.");
            }
            break;

        case CMD_PRJ_CLOSE:
            if (isIdle && promptAndSave()) {
                closeProject();
            }
            break;

        case CMD_PRJ_PROPS:
            if (hasProject()) {
                if (prjPropertiesDialog(sAc, &sProject, sCtx->appPath)) {
                    updateMainTitle();

                    if (sProjectWin) {
                        prjRebuildTree(&sProject);
                    }
                }
            }
            break;

        case CMD_PRJ_REMOVE: {
            int32_t rmIdx = prjGetSelectedFileIdx();

            if (!isIdle || rmIdx < 0 || rmIdx >= sProject.fileCount) {
                break;
            }

            PrjFileT *rmFile = &sProject.files[rmIdx];
            char rmMsg[IDE_MSG_BUF];
            snprintf(rmMsg, sizeof(rmMsg), "Remove %s from the project?", rmFile->path);

            if (dvxMessageBox(sAc, "Remove File", rmMsg, MB_YESNO | MB_ICONQUESTION) != ID_YES) {
                break;
            }

            if (rmIdx == sProject.activeFileIdx) {
                stashCurrentFile();
            }

            if (rmFile->modified) {
                int32_t result = dvxPromptSave(sAc, IDE_MAIN_TITLE);

                if (result == DVX_SAVE_CANCEL) {
                    break;
                }

                if (result == DVX_SAVE_YES && !writeProjectFile(rmIdx)) {
                    break;
                }
            }

            // Detach the editor/designer from the file before it goes away
            if (rmIdx == sEditorFileIdx) {
                freeProcBufs();

                if (sEditor) {
                    void (*savedOnChange)(WidgetT *) = sEditor->onChange;
                    sEditor->onChange = NULL;
                    wgtSetText(sEditor, "");
                    sEditor->onChange = savedOnChange;
                }
            } else if (sEditorFileIdx > rmIdx) {
                sEditorFileIdx--;
            }

            if (rmIdx == sProject.activeFileIdx && rmFile->isForm) {
                teardownFormWin();
                dsgnFree(&sDesigner);
            }

            removeBreakpointsForFile(rmIdx);
            prjRemoveFile(&sProject, rmIdx); // adjusts activeFileIdx
            updateDropdowns();
            prjRebuildTree(&sProject);
            updateProjectMenuState();
            updateDirtyIndicators();
            break;
        }

        default:
            break;
    }
}


static void handleRunCmd(int32_t cmd) {
    switch (cmd) {
        case CMD_RUN:
            if (sDbgState == DBG_PAUSED) {
                // Resume from breakpoint -- clear debug mode so it runs free
                sDbgEnabled = false;
                clearVmStepState();

                if (sVm) {
                    basVmSetBreakpoints(sVm, NULL, 0);
                }

                debugResume("Running...");
            } else if (sDbgState == DBG_IDLE) {
                sDbgEnabled = false;
                compileAndRun();
            }
            break;

        case CMD_DEBUG:
            if (sDbgState == DBG_PAUSED) {
                // Already debugging -- resume, run to next breakpoint
                clearVmStepState();
                debugResume("Debugging...");
            } else if (sDbgState == DBG_IDLE) {
                // Start in debug mode with breakpoints
                sDbgEnabled = true;
                compileAndRun();
            }
            break;

        case CMD_RUN_NOCMP:
            if (sDbgState == DBG_IDLE) {
                runCached();
            }
            break;

        case CMD_STOP:
            if (sDbgState != DBG_IDLE) {
                debugStop();
                setStatus("Program stopped.");
            }
            break;

        case CMD_OUTPUT_TO_LOG:
            if (sWin && sWin->menuBar) {
                sOutputToLog = wmMenuItemIsChecked(sWin->menuBar, CMD_OUTPUT_TO_LOG);
                prefsSetBool(sPrefs, PREF_SEC_RUN, PREF_KEY_OUTPUT_TO_LOG, sOutputToLog);
                prefsSave(sPrefs);
            }
            break;

        case CMD_STEP_INTO:
        case CMD_STEP_OVER:
        case CMD_STEP_OUT:
        case CMD_RUN_TO_CURSOR:
            debugStartOrResume(cmd);
            break;

        case CMD_TOGGLE_BP:
            toggleBreakpoint();
            break;

        case CMD_CLEAR:
            clearOutput();
            break;

        case CMD_SAVE_ON_RUN:
            if (sWin && sWin->menuBar) {
                bool save = wmMenuItemIsChecked(sWin->menuBar, CMD_SAVE_ON_RUN);
                prefsSetBool(sPrefs, PREF_SEC_RUN, PREF_KEY_SAVE_ON_RUN, save);
                prefsSave(sPrefs);
            }
            break;

        default:
            break;
    }
}


static void handleViewCmd(int32_t cmd) {
    switch (cmd) {
        case CMD_VIEW_CODE: {
            int32_t selFileIdx = prjGetSelectedFileIdx();

            if (selFileIdx >= 0 && selFileIdx != sProject.activeFileIdx) {
                activateFile(selFileIdx, ViewCodeE);
            } else if (sProject.activeFileIdx >= 0) {
                stashDesignerState();
            }

            break;
        }

        case CMD_VIEW_DESIGN: {
            int32_t selFileIdx = prjGetSelectedFileIdx();

            if (selFileIdx >= 0 && selFileIdx != sProject.activeFileIdx &&
                selFileIdx < sProject.fileCount && sProject.files[selFileIdx].isForm) {
                activateFile(selFileIdx, ViewDesignE);
            } else if (sProject.activeFileIdx >= 0) {
                switchToDesign();
            }

            break;
        }

        case CMD_VIEW_TOOLBAR:
            if (sToolbar && sWin->menuBar) {
                bool show = wmMenuItemIsChecked(sWin->menuBar, CMD_VIEW_TOOLBAR);
                sToolbar->visible = show;
                dvxFitWindowH(sAc, sWin);
                prefsSetBool(sPrefs, PREF_SEC_VIEW, PREF_KEY_TOOLBAR, show);
                prefsSave(sPrefs);
            }
            break;

        case CMD_VIEW_STATUS:
            if (sStatusBar && sWin->menuBar) {
                bool show = wmMenuItemIsChecked(sWin->menuBar, CMD_VIEW_STATUS);
                sStatusBar->visible = show;
                dvxFitWindowH(sAc, sWin);
                prefsSetBool(sPrefs, PREF_SEC_VIEW, PREF_KEY_STATUSBAR, show);
                prefsSave(sPrefs);
            }
            break;

        case CMD_MENU_EDITOR: {
            // Activate selected form if not already active
            int32_t selMenuIdx = prjGetSelectedFileIdx();

            if (selMenuIdx >= 0 && selMenuIdx != sProject.activeFileIdx &&
                selMenuIdx < sProject.fileCount && sProject.files[selMenuIdx].isForm) {
                activateFile(selMenuIdx, ViewDesignE);
            }

            if (sDesigner.form) {
                // Snapshot old menu names for rename detection
                char **oldNames = NULL;
                int32_t oldCount = (int32_t)arrlen(sDesigner.form->menuItems);

                for (int32_t mi = 0; mi < oldCount; mi++) {
                    arrput(oldNames, strdup(sDesigner.form->menuItems[mi].name));
                }

                if (mnuEditorDialog(sAc, sDesigner.form)) {
                    sDesigner.form->dirty = true;

                    // Detect renames: match by position (items may have been
                    // reordered, but renamed items keep their index)
                    int32_t newCount = (int32_t)arrlen(sDesigner.form->menuItems);
                    int32_t minCount = oldCount < newCount ? oldCount : newCount;

                    for (int32_t mi = 0; mi < minCount; mi++) {
                        if (oldNames[mi] && oldNames[mi][0] && sDesigner.form->menuItems[mi].name[0] &&
                            strcasecmp(oldNames[mi], sDesigner.form->menuItems[mi].name) != 0) {
                            ideRenameInCode(oldNames[mi], sDesigner.form->menuItems[mi].name);
                        }
                    }

                    // Rebuild menu bar preview
                    if (sFormWin) {
                        wmDestroyMenuBar(sFormWin, &sAc->display);
                        dsgnBuildPreviewMenuBar(sFormWin, sDesigner.form);
                        dvxInvalidateWindow(sAc, sFormWin);
                    }

                    // Rebuild Object dropdown to reflect added/removed/renamed items
                    updateDropdowns();
                }

                for (int32_t mi = 0; mi < oldCount; mi++) {
                    free(oldNames[mi]);
                }

                arrfree(oldNames);
            }
            break;
        }

        default:
            break;
    }
}


static void handleWindowCmd(int32_t cmd) {
    switch (cmd) {
        case CMD_WIN_CODE:
            showCodeWindow();
            break;

        case CMD_WIN_OUTPUT:
            showOutputWindow();
            break;

        case CMD_WIN_IMM:
            showImmediateWindow();
            break;

        case CMD_WIN_LOCALS:
            showLocalsWindow();
            break;

        case CMD_WIN_CALLSTACK:
            showCallStackWindow();
            break;

        case CMD_WIN_WATCH:
            showWatchWindow();
            break;

        case CMD_WIN_BREAKPOINTS:
            showBreakpointWindow();
            break;

        case CMD_WIN_TOOLBOX:
            if (!sToolboxWin) {
                sToolboxWin = tbxCreate(sAc, &sDesigner, toolbarBottom());

                if (sToolboxWin) {
                    sToolboxWin->onMenu     = onMenu;
                    sToolboxWin->accelTable = sWin ? sWin->accelTable : NULL;
                }
            }
            break;

        case CMD_WIN_PROPS:
            if (!sPropsWin) {
                sPropsWin = prpCreate(sAc, &sDesigner);

                if (sPropsWin) {
                    sPropsWin->y          = toolbarBottom();
                    sPropsWin->onMenu     = onMenu;
                    sPropsWin->accelTable = sWin ? sWin->accelTable : NULL;
                }
            }
            break;

        case CMD_WIN_PROJECT:
            ensureProjectWindow(true);
            break;

        default:
            break;
    }
}


static bool hasProject(void) {
    return sProject.projectPath[0] != '\0';
}


static bool hasUnsavedData(void) {
    // Check the active editor/designer
    if (sDesigner.form && sDesigner.form->dirty) {
        return true;
    }

    // Check all project files
    for (int32_t i = 0; i < sProject.fileCount; i++) {
        if (sProject.files[i].modified) {
            return true;
        }
    }

    return sProject.dirty;
}


// Build control help topic from type name.
// Topic IDs in .bhs files follow the pattern ctrl.<lowercase(basName)>.
// Generated dynamically so third-party widgets get help automatically.
static void helpBuildCtrlTopic(const char *typeName, char *buf, int32_t bufSize) {
    int32_t off = snprintf(buf, bufSize, "ctrl.");

    for (int32_t i = 0; typeName[i] && off < bufSize - 1; i++) {
        buf[off++] = tolower((unsigned char)typeName[i]);
    }

    buf[off] = '\0';
}


static const char *helpLookupKeyword(const char *word) {
    for (int32_t i = 0; i < (int32_t)HELP_MAP_COUNT; i++) {
        if (strcasecmp(sHelpMap[i].keyword, word) == 0) {
            return sHelpMap[i].topic;
        }
    }

    return NULL;
}


static void helpQueryHandler(void *ctx) {
    (void)ctx;

    sCtx->helpTopic[0] = '\0';

    // Restore IDE help file (may have been swapped for a BASIC program's)
    snprintf(sCtx->helpFile, sizeof(sCtx->helpFile), "%s", sIdeHelpFile);

    // Determine which window is focused
    WindowT *focusWin = NULL;

    if (sAc->stack.focusedIdx >= 0 && sAc->stack.focusedIdx < sAc->stack.count) {
        focusWin = sAc->stack.windows[sAc->stack.focusedIdx];
    }

    // Running BASIC program: check if focused window belongs to a form
    if (sDbgFormRt && sDbgFormRt->helpFile[0] && focusWin) {
        for (int32_t i = 0; i < (int32_t)arrlen(sDbgFormRt->forms); i++) {
            BasFormT *form = sDbgFormRt->forms[i];

            if (form->window == focusWin) {
                // Swap to the project's help file
                snprintf(sCtx->helpFile, sizeof(sCtx->helpFile), "%s", sDbgFormRt->helpFile);

                // Find the focused widget's HelpTopic
                WidgetT *focused = wgtGetFocused();

                if (focused && focused->userData) {
                    BasControlT *ctrl = (BasControlT *)focused->userData;

                    if (ctrl->helpTopic[0]) {
                        snprintf(sCtx->helpTopic, sizeof(sCtx->helpTopic), "%s", ctrl->helpTopic);
                        return;
                    }
                }

                // Fall back to form-level HelpTopic
                if (form->helpTopic[0]) {
                    snprintf(sCtx->helpTopic, sizeof(sCtx->helpTopic), "%s", form->helpTopic);
                    return;
                }

                // No topic set -- open help at default
                return;
            }
        }
    }

    // Code editor / Immediate window: look up the word under the cursor
    if ((focusWin == sCodeWin && sEditor) || (focusWin == sImmWin && sImmediate)) {
        WidgetT *area = (focusWin == sCodeWin) ? sEditor : sImmediate;
        char word[IDE_FULL_NAME_BUF];

        if (wgtTextAreaGetWordAtCursor(area, word, sizeof(word)) > 0) {
            const char *topic = helpLookupKeyword(word);

            if (topic) {
                snprintf(sCtx->helpTopic, sizeof(sCtx->helpTopic), "%s", topic);
                return;
            }
        }

        // No keyword match -- open the window's own topic
        snprintf(sCtx->helpTopic, sizeof(sCtx->helpTopic), "%s", (focusWin == sCodeWin) ? "ide.editor" : "ide.immediate");
        return;
    }

    // Form designer: help for the selected control type
    if (focusWin == sFormWin && sDesigner.form) {
        if (sDesigner.selectedIdx >= 0 && sDesigner.selectedIdx < arrlen(sDesigner.form->controls)) {
            helpSetCtrlTopic(sDesigner.form->controls[sDesigner.selectedIdx]->typeName);
        } else {
            snprintf(sCtx->helpTopic, sizeof(sCtx->helpTopic), "ctrl.form");
        }
        return;
    }

    // Toolbox: help for the active tool
    if (focusWin == sToolboxWin) {
        if (sDesigner.activeTool[0]) {
            helpSetCtrlTopic(sDesigner.activeTool);
        } else {
            snprintf(sCtx->helpTopic, sizeof(sCtx->helpTopic), "ide.toolbox");
        }
        return;
    }

    // Properties panel
    if (focusWin == sPropsWin) {
        if (sDesigner.form && sDesigner.selectedIdx >= 0 && sDesigner.selectedIdx < arrlen(sDesigner.form->controls)) {
            helpSetCtrlTopic(sDesigner.form->controls[sDesigner.selectedIdx]->typeName);
        } else {
            snprintf(sCtx->helpTopic, sizeof(sCtx->helpTopic), "ctrl.common.props");
        }
        return;
    }

    // Remaining windows map straight to a topic
    const char *topic = "ide.overview";

    if (focusWin == sOutWin) {
        topic = "ide.output";
    } else if (focusWin == sProjectWin) {
        topic = "ide.project";
    } else if (focusWin == sLocalsWin) {
        topic = "ide.debug.locals";
    } else if (focusWin == sCallStackWin) {
        topic = "ide.debug.callstack";
    } else if (focusWin == sWatchWin) {
        topic = "ide.debug.watch";
    } else if (focusWin == sBreakpointWin) {
        topic = "ide.debug.breakpoints";
    }

    snprintf(sCtx->helpTopic, sizeof(sCtx->helpTopic), "%s", topic);
}


static void helpSetCtrlTopic(const char *typeName) {
    helpBuildCtrlTopic(typeName, sCtx->helpTopic, sizeof(sCtx->helpTopic));
}


// ============================================================
// ideRenameInCode -- rename form/control references in all .bas files
// ============================================================
//
// Case-insensitive replacement of OldName followed by '.' or '_' with
// NewName followed by the same delimiter. This handles:
//   ControlName.Property    -> NewName.Property
//   ControlName_Click       -> NewName_Click  (event handlers)
//   FormName.ControlName.X  -> NewFormName.ControlName.X
//   Sub FormName_Load       -> Sub NewFormName_Load

void ideRenameInCode(const char *oldName, const char *newName) {
    if (!oldName || !newName || strcasecmp(oldName, newName) == 0) {
        return;
    }

    // The editor may hold edits that are not in the proc buffers yet
    saveCurProc();

    // Rename in the per-procedure buffers (form code currently being edited)
    if (sGeneralBuf) {
        char *replaced = renameInBuffer(sGeneralBuf, oldName, newName);

        if (replaced) {
            free(sGeneralBuf);
            sGeneralBuf = replaced;
        }
    }

    for (int32_t i = 0; i < (int32_t)arrlen(sProcBufs); i++) {
        if (sProcBufs[i]) {
            char *replaced = renameInBuffer(sProcBufs[i], oldName, newName);

            if (replaced) {
                free(sProcBufs[i]);
                sProcBufs[i] = replaced;
            }
        }
    }

    // Update the editor if it's showing a procedure
    if (sEditor && sCurProcIdx >= -1) {
        if (sCurProcIdx == -1 && sGeneralBuf) {
            wgtSetText(sEditor, sGeneralBuf);
        } else if (sCurProcIdx >= 0 && sCurProcIdx < (int32_t)arrlen(sProcBufs)) {
            wgtSetText(sEditor, sProcBufs[sCurProcIdx]);
        }
    }

    // Update form->code from the renamed buffers (only if editor has this form's code)
    if (sDesigner.form && sEditorFileIdx >= 0 && sEditorFileIdx < sProject.fileCount &&
        sProject.files[sEditorFileIdx].isForm &&
        strcasecmp(sProject.files[sEditorFileIdx].formName, sDesigner.form->name) == 0) {
        char *code = strdup(getFullSource());

        if (code) {
            free(sDesigner.form->code);
            sDesigner.form->code = code;
        }

        sDesigner.form->dirty = true;
    }

    // Update cached formName if the active file is a form being renamed
    if (sProject.activeFileIdx >= 0 && sProject.activeFileIdx < sProject.fileCount) {
        PrjFileT *cur = &sProject.files[sProject.activeFileIdx];

        if (cur->isForm && strcasecmp(cur->formName, oldName) == 0) {
            snprintf(cur->formName, sizeof(cur->formName), "%s", newName);
        }
    }

    // Rename in all project .bas file buffers (and non-active .frm code)
    for (int32_t i = 0; i < sProject.fileCount; i++) {
        // Skip the active file (already handled above)
        if (i == sProject.activeFileIdx) {
            continue;
        }

        char *buf = sProject.files[i].buffer;

        if (!buf) {
            // Load from disk
            char fullPath[DVX_MAX_PATH];
            prjFullPath(&sProject, i, fullPath, sizeof(fullPath));

            int32_t br = 0;
            buf = platformReadFile(fullPath, &br);

            if (!buf) {
                continue;
            }

            if (br <= 0 || br >= IDE_MAX_SOURCE) {
                free(buf);
                continue;
            }

            sProject.files[i].buffer = buf;
        }

        char *replaced = renameInBuffer(buf, oldName, newName);

        if (replaced) {
            free(sProject.files[i].buffer);
            sProject.files[i].buffer   = replaced;
            sProject.files[i].modified = true;
        }
    }

    // Refresh dropdowns to reflect renamed procedures
    updateDropdowns();
}


// immParseScalarFromStr -- parse a string into a typed BasValueT based on the
// target slot's current type.  Returns true on success.

static bool immParseScalarFromStr(const char *rhs, const BasValueT *target, BasValueT *outVal) {
    memset(outVal, 0, sizeof(*outVal));

    switch (target->type) {
        case BAS_TYPE_INTEGER:
            outVal->type   = BAS_TYPE_INTEGER;
            outVal->intVal = (int16_t)atoi(rhs);
            return true;

        case BAS_TYPE_LONG:
            outVal->type    = BAS_TYPE_LONG;
            outVal->longVal = (int32_t)atol(rhs);
            return true;

        case BAS_TYPE_SINGLE:
            outVal->type   = BAS_TYPE_SINGLE;
            outVal->sngVal = (float)atof(rhs);
            return true;

        case BAS_TYPE_DOUBLE:
            outVal->type   = BAS_TYPE_DOUBLE;
            outVal->dblVal = atof(rhs);
            return true;

        case BAS_TYPE_BOOLEAN:
            outVal->type = BAS_TYPE_BOOLEAN;

            if (strcasecmp(rhs, "TRUE") == 0 || strcasecmp(rhs, "-1") == 0) {
                outVal->intVal = -1;
            } else {
                outVal->intVal = (atoi(rhs) != 0) ? -1 : 0;
            }

            return true;

        case BAS_TYPE_STRING: {
            const char *s = rhs;
            int32_t sLen  = (int32_t)strlen(s);

            if (sLen >= 2 && s[0] == '"' && s[sLen - 1] == '"') {
                s++;
                sLen -= 2;
            }

            outVal->type   = BAS_TYPE_STRING;
            outVal->strVal = basStringNew(s, sLen);
            return true;
        }

        default:
            return false;
    }
}


// immPrintCallback -- append program output to the Immediate window.
// The window has a fixed capacity, so the oldest lines scroll off the
// top once it fills.

static void immPrintCallback(void *ctx, const char *text, bool newline) {
    (void)ctx;

    if (!sImmediate) {
        return;
    }

    static char immBuf[IDE_MAX_IMM];

    const char *cur     = wgtGetText(sImmediate);
    const char *keep    = cur ? cur : "";
    int32_t     curLen  = (int32_t)strlen(keep);
    int32_t     textLen = text ? (int32_t)strlen(text) : 0;
    int32_t     extra   = newline ? 1 : 0;

    // A single oversize chunk is truncated to what can ever fit
    if (textLen + extra + 1 >= IDE_MAX_IMM) {
        textLen = IDE_MAX_IMM - 1 - extra;
    }

    // Drop whole leading lines until the new text fits
    while (curLen + textLen + extra + 1 >= IDE_MAX_IMM) {
        const char *nl = strchr(keep, '\n');

        if (!nl) {
            keep  += curLen;
            curLen = 0;
            break;
        }

        curLen -= (int32_t)(nl + 1 - keep);
        keep    = nl + 1;
    }

    memmove(immBuf, keep, curLen);

    if (textLen > 0) {
        memcpy(immBuf + curLen, text, textLen);
        curLen += textLen;
    }

    if (newline) {
        immBuf[curLen++] = '\n';
    }

    immBuf[curLen] = '\0';
    wgtSetText(sImmediate, immBuf);

    // Move cursor to end so user can keep typing
    wgtTextAreaGoToLine(sImmediate, countLines(immBuf));
}


// immTryAssign -- handle "varName = expr" when paused at a breakpoint.
// Evaluates the RHS, looks up the variable, and writes the value back
// to the running VM.  Returns true if handled as an assignment.

static bool immTryAssign(const char *expr) {
    if (sDbgState != DBG_PAUSED || !sVm || !sDbgModule) {
        return false;
    }

    // Skip leading whitespace
    const char *p = dvxSkipWs(expr);

    // Skip optional LET keyword
    if (kwMatch(p, KW_LET)) {
        p = dvxSkipWs(p + KW_LEN(KW_LET));
    }

    // Resolve the LHS to a live slot in the VM
    const char *afterLhs = NULL;
    BasValueT *slot = resolveVarPath(p, &afterLhs);

    if (!slot || !afterLhs) {
        return false;
    }

    // Build display name from LHS
    char lhsName[IDE_VALUE_BUF];
    int32_t lhsLen = (int32_t)(afterLhs - p);

    if (lhsLen >= (int32_t)sizeof(lhsName)) {
        lhsLen = (int32_t)sizeof(lhsName) - 1;
    }

    memcpy(lhsName, p, lhsLen);
    lhsName[lhsLen] = '\0';

    // Skip whitespace after LHS
    p = dvxSkipWs(afterLhs);

    // Must have '=' (but not '==')
    if (*p != '=' || p[1] == '=') {
        return false;
    }

    p = dvxSkipWs(p + 1); // skip '='

    if (*p == '\0') {
        return false;
    }

    // Parse the RHS into a value matching the target slot's type
    BasValueT newVal;

    if (!immParseScalarFromStr(p, slot, &newVal)) {
        immPrintCallback(NULL, "Cannot assign to this variable type", true);
        return true;
    }

    // Write the value directly to the slot
    basValRelease(slot);
    *slot = newVal; // transfer ownership -- don't release newVal

    // Show confirmation
    char confirm[IDE_VALUE_BUF];
    snprintf(confirm, sizeof(confirm), "%s = ", lhsName);
    immPrintCallback(NULL, confirm, false);
    formatValue(slot, confirm, sizeof(confirm));
    immPrintCallback(NULL, confirm, true);

    // Update debug windows to reflect the change
    updateLocalsWindow();
    updateWatchWindow();
    return true;
}


// Hash-based keyword/type lookup using stb_ds.
// Key = uppercase word, value = SYNTAX_KEYWORD or SYNTAX_TYPE.
// Built once on first use, then O(1) per lookup.

static void initSyntaxMap(void) {
    if (sSyntaxMap) {
        return;
    }

    sh_new_arena(sSyntaxMap);

    // Pull the canonical keyword list from the lexer -- one source of truth.
    int32_t n = basLexerKeywordCount();

    for (int32_t i = 0; i < n; i++) {
        const char *kw = basLexerKeywordAt(i);

        // Skip dollar-suffixed aliases (DIR$, CURDIR$, etc.) -- the
        // unsuffixed form is already in the list and the colorizer
        // strips the $ before lookup.
        if (kw[strlen(kw) - 1] == '$') {
            continue;
        }

        BasKeywordClassE cls = basLexerKeywordClass(i);

        if (cls == BAS_KW_CLASS_TYPE || cls == BAS_KW_CLASS_LITERAL) {
            shput(sSyntaxMap, kw, SYNTAX_TYPE);
        } else {
            shput(sSyntaxMap, kw, SYNTAX_KEYWORD);
        }
    }

    // LIBRARY is a contextual keyword in DECLARE LIBRARY and not a
    // real token, but the colorizer still treats it as a keyword.
    shput(sSyntaxMap, "LIBRARY", SYNTAX_KEYWORD);
    shput(sSyntaxMap, "STOP",    SYNTAX_KEYWORD);
}


static bool inputCallback(void *ctx, const char *prompt, char *buf, int32_t bufSize) {
    (void)ctx;

    // Append prompt to output
    if (prompt && sOutputLen < IDE_MAX_OUTPUT - 1) {
        sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, sOutputLen, "%s", prompt);
        setOutputText(sOutputBuf);
    }

    return dvxInputBox(sAc, IDE_MAIN_TITLE, prompt ? prompt : "Enter value:", NULL, buf, bufSize);
}


static bool isCtrlArrayInDesigner(const char *ctrlName) {
    if (!sDesigner.form) {
        return false;
    }

    int32_t count = (int32_t)arrlen(sDesigner.form->controls);

    for (int32_t i = 0; i < count; i++) {
        if (strcasecmp(sDesigner.form->controls[i]->name, ctrlName) == 0 && sDesigner.form->controls[i]->index >= 0) {
            return true;
        }
    }

    return false;
}


// Check if position i is inside a string literal or comment.
// Scans from the start of the line containing i.
static bool isInStringOrComment(const char *src, int32_t i) {
    // Find start of line
    int32_t lineStart = i;

    while (lineStart > 0 && src[lineStart - 1] != '\n') {
        lineStart--;
    }

    bool inString = false;

    for (int32_t j = lineStart; j < i; j++) {
        if (src[j] == '"') {
            inString = !inString;
        } else if (src[j] == '\'' && !inString) {
            // Rest of line is a comment
            return true;
        }
    }

    return inString;
}


// Internal mangled names (e.g. "DoCount$Count" for Static vars) have a $
// in the middle; string variable names end with $ and are fine.
static bool isMangledDebugName(const char *name) {
    const char *dollar = strchr(name, '$');

    return dollar && dollar[1] != '\0';
}


static bool isReplaceEnabled(void) {
    return sReplCheck && wgtCheckboxIsChecked(sReplCheck);
}


// joinProcArrays -- build the canonical file text from a (General)
// buffer and a proc array: General, one blank line, then each proc
// separated by one blank line.  Returns a malloc'd string or NULL.
static char *joinProcArrays(const char *general, char **procs) {
    int32_t totalLen  = 0;
    int32_t procCount = (int32_t)arrlen(procs);

    if (general && general[0]) {
        totalLen += (int32_t)strlen(general);
        totalLen += 2; // newline + blank line separator
    }

    for (int32_t i = 0; i < procCount; i++) {
        if (procs[i]) {
            totalLen += (int32_t)strlen(procs[i]);
            totalLen += 2; // newline + blank line between procedures
        }
    }

    char *out = (char *)malloc(totalLen + 1);

    if (!out) {
        return NULL;
    }

    int32_t pos = 0;

    if (general && general[0]) {
        int32_t len = (int32_t)strlen(general);
        memcpy(out + pos, general, len);
        pos += len;

        if (out[pos - 1] != '\n') {
            out[pos++] = '\n';
        }

        out[pos++] = '\n';
    }

    for (int32_t i = 0; i < procCount; i++) {
        if (procs[i]) {
            int32_t len = (int32_t)strlen(procs[i]);
            memcpy(out + pos, procs[i], len);
            pos += len;

            if (pos > 0 && out[pos - 1] != '\n') {
                out[pos++] = '\n';
            }

            // Blank line between procedures
            if (i < procCount - 1) {
                out[pos++] = '\n';
            }
        }
    }

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


// joinProcBufs -- reassemble the editor's buffers into sFullSourceCache
// and rebuild sProcTable from the result so both always describe the
// same text.  Does not save the editor first (see getFullSource).
static const char *joinProcBufs(void) {
    free(sFullSourceCache);
    sFullSourceCache = joinProcArrays(sGeneralBuf, sProcBufs);

    const char *src = sFullSourceCache ? sFullSourceCache : "";
    rebuildProcTable(src);
    return src;
}


// kwMatch -- case-insensitive prefix test against a keyword tag
static bool kwMatch(const char *p, const char *kw) {
    return strncasecmp(p, kw, strlen(kw)) == 0;
}


static void listRowsAdd(IdeListRowsT *rows, const char *text) {
    char *copy = strdup(text);

    if (copy) {
        arrput(rows->strs, copy);
    }

    arrput(rows->cells, copy ? copy : "");
}


static void listRowsCommit(const IdeListRowsT *rows, WidgetT *list, int32_t rowCount) {
    wgtListViewSetData(list, rowCount > 0 ? rows->cells : NULL, rowCount);
}


static void listRowsFree(IdeListRowsT *rows) {
    listRowsReset(rows);
    arrfree(rows->strs);
    arrfree(rows->cells);
    rows->strs  = NULL;
    rows->cells = NULL;
}


static void listRowsReset(IdeListRowsT *rows) {
    for (int32_t i = 0; i < (int32_t)arrlen(rows->strs); i++) {
        free(rows->strs[i]);
    }

    arrsetlen(rows->strs, 0);
    arrsetlen(rows->cells, 0);
}


// loadCompileSource -- the code text of project file fileIdx, normalized
// to the canonical proc layout (malloc'd).  Loaded buffers are
// authoritative; disk is only read for files never loaded.  For a .frm
// the result is just its code section.  Returns NULL when the file
// cannot be read.
static char *loadCompileSource(int32_t fileIdx) {
    PrjFileT   *file    = &sProject.files[fileIdx];
    char       *raw     = NULL;  // whole-file text read from disk (owned)
    char       *frmCode = NULL;  // code section extracted from a .frm (owned)
    const char *code    = NULL;

    if (file->isForm && sDesigner.form && fileIdx == sProject.activeFileIdx) {
        // Active form: the designer holds the current code
        code = sDesigner.form->code ? sDesigner.form->code : "";
    } else {
        const char *text = file->buffer;

        if (!text) {
            char    fullPath[DVX_MAX_PATH];
            int32_t len = 0;

            prjFullPath(&sProject, fileIdx, fullPath, sizeof(fullPath));
            raw = platformReadFile(fullPath, &len);

            if (!raw) {
                return NULL;
            }

            if (len >= IDE_MAX_SOURCE) {
                free(raw);
                return NULL;
            }

            text = raw;
        }

        if (file->isForm) {
            // Parse the .frm to extract the code section using the same
            // parser as the designer (handles nested containers).
            DsgnStateT tmpDs;
            memset(&tmpDs, 0, sizeof(tmpDs));
            dsgnLoadFrm(&tmpDs, text, (int32_t)strlen(text));

            if (tmpDs.form && tmpDs.form->code) {
                frmCode          = tmpDs.form->code;
                tmpDs.form->code = NULL;
            }

            dsgnFree(&tmpDs);
            code = frmCode ? frmCode : "";
        } else {
            code = text;
        }
    }

    char *packed = packProcLayout(code);
    free(frmCode);
    free(raw);
    return packed;
}


static void loadFile(void) {
    FileFilterT filters[] = {
        { "BASIC Files (*.bas)" },
        { "Form Files (*.frm)" },
        { "All Files (*.*)" }
    };

    char path[DVX_MAX_PATH];

    if (!dvxFileDialog(sAc, "Add File", FD_OPEN, NULL, filters, (int32_t)(sizeof(filters) / sizeof(filters[0])), path, sizeof(path))) {
        return;
    }

    openSinglePath(path);
}


static void loadFormCodeIntoEditor(void) {
    if (!sDesigner.form) {
        return;
    }

    stashFormCode();
    parseProcs(sDesigner.form->code ? sDesigner.form->code : "");
    sEditorFileIdx = sProject.activeFileIdx;

    if (!sCodeWin) {
        showCodeWindow();
    }

    bool saved = sDropdownNavSuppressed;
    sDropdownNavSuppressed = true;
    updateDropdowns();
    sDropdownNavSuppressed = saved;

    showProc(-1);
}


// loadFrmFiles -- register every .frm listed in the current project with
// the form runtime.  Registration only caches the text; the caller's
// basFormRtLoadAllForms then instantiates them, so a Form_Load that does
// "Load Form2" finds Form2's .frm regardless of project file order.
static void loadFrmFiles(BasFormRtT *rt) {
    for (int32_t i = 0; i < sProject.fileCount; i++) {
        if (!sProject.files[i].isForm) {
            continue;
        }

        char fullPath[DVX_MAX_PATH];
        prjFullPath(&sProject, i, fullPath, sizeof(fullPath));

        int32_t bytesRead = 0;
        char   *frmBuf    = platformReadFile(fullPath, &bytesRead);

        if (!frmBuf) {
            continue;
        }

        if (bytesRead > 0 && bytesRead < IDE_MAX_SOURCE) {
            // Cache the form object name in the project file entry
            char *formName = sProject.files[i].formName;

            if (basExtractFormName(frmBuf, formName, sizeof(sProject.files[i].formName))) {
                basFormRtRegisterFrm(rt, formName, frmBuf, bytesRead);
            }
        }

        free(frmBuf);
    }
}


static WidgetT *loadTbIcon(WidgetT *parent, const char *resName, const char *fallbackText) {
    int32_t iconW     = 0;
    int32_t iconH     = 0;
    int32_t iconPitch = 0;
    uint8_t *data = dvxResLoadIcon(sAc, sCtx->appPath, resName, &iconW, &iconH, &iconPitch);

    if (data) {
        return wgtImageButton(parent, data, iconW, iconH, iconPitch);
    }

    // Fallback to text button if icon not found
    return wgtButton(parent, fallbackText);
}


// Convert an editor-local line number to a full-source line number.
// The editor shows one procedure at a time (sCurProcIdx), so we need
// to add the procedure's starting line offset within the file's code.
// For multi-file projects, we also add the file's offset in the
// concatenated source. The source map's startLine already accounts for
// the injected BEGINFORM directive, so no extra adjustment is needed.
static int32_t localToConcatLine(int32_t editorLine) {
    // Add the current procedure's start offset within the file's code.
    // sCurProcIdx == -1 means (General) section which starts at line 1
    // of the file's code (not the concatenated source).
    int32_t fileLine = editorLineToCodeLine(editorLine);

    // For projects, add the file's offset in the concatenated source.
    // startLine is recorded AFTER the injected BEGINFORM line, so it
    // already points at the first real code line (matching prjMapLine).
    if (sProject.sourceMapCount > 0 && sEditorFileIdx >= 0) {
        for (int32_t i = 0; i < sProject.sourceMapCount; i++) {
            if (sProject.sourceMap[i].fileIdx == sEditorFileIdx) {
                return sProject.sourceMap[i].startLine + fileLine - 1;
            }
        }
    }

    return fileLine;
}


// lookupWatchVar -- evaluate a simple watch expression by reading the
// paused VM directly.  Supports varName, varName(idx, ...), varName.field
// and varName(idx).field; anything else falls back to evalWatchExpr.
static bool lookupWatchVar(const char *expr, BasValueT *outVal) {
    const char *end  = NULL;
    BasValueT  *slot = resolveVarPath(dvxSkipWs(expr), &end);

    if (!slot || !end || *dvxSkipWs(end) != '\0') {
        return false;
    }

    *outVal = *slot;
    return true;
}


static void makeExecutable(void) {
    if (!hasProject()) {
        setStatus("Save the project first.");
        return;
    }

    // Compile (always recompile to ensure latest code)
    if (!compileProject()) {
        return;
    }

    // Ask for output path
    FileFilterT filters[] = {
        { "DVX Applications (*.app)" },
        { "All Files (*.*)" }
    };
    char outPath[DVX_MAX_PATH];
    outPath[0] = '\0';

    if (!dvxFileDialog(sAc, "Make Executable", FD_SAVE, NULL, filters, (int32_t)(sizeof(filters) / sizeof(filters[0])), outPath, sizeof(outPath))) {
        return;
    }

    // Ask debug or release
    const char *modeItems[] = {
        "Debug (include error info)",
        "Release (smaller, no debug info)"
    };
    int32_t modeChoice = 0;

    if (!dvxChoiceDialog(sAc, "Build Mode", "Select build mode:", modeItems, (int32_t)(sizeof(modeItems) / sizeof(modeItems[0])), 0, &modeChoice)) {
        return;
    }

    bool release = (modeChoice == 1);

    setStatus(release ? "Building release executable..." : "Building debug executable...");
    dvxSetBusy(sAc, true);
    dvxUpdate(sAc);

    // Gather every .frm text (unsaved editor buffers win over disk).
    char **frmSources = NULL; // stb_ds: owned form text

    for (int32_t i = 0; i < sProject.fileCount; i++) {
        if (!sProject.files[i].isForm) {
            continue;
        }

        char *frmSrc = NULL;

        if (sProject.files[i].buffer) {
            frmSrc = strdup(sProject.files[i].buffer);
        } else {
            char fp[DVX_MAX_PATH];
            prjFullPath(&sProject, i, fp, sizeof(fp));
            frmSrc = platformReadFile(fp, NULL);
        }

        if (frmSrc) {
            arrput(frmSources, frmSrc);
        }
    }

    BasBuildSpecT spec;
    memset(&spec, 0, sizeof(spec));
    spec.projName    = sProject.name;
    spec.author      = sProject.author;
    spec.publisher   = sProject.publisher;
    spec.version     = sProject.version;
    spec.copyright   = sProject.copyright;
    spec.description = sProject.description;
    spec.projectDir  = sProject.projectDir;
    spec.iconPath    = sProject.iconPath;
    spec.helpFile    = sProject.helpFile;
    spec.selfPath    = sCtx->appPath;
    spec.module      = sCachedModule;
    spec.frmSources  = (const char *const *)frmSources;
    spec.frmCount    = (int32_t)arrlen(frmSources);
    spec.release     = release;

    const char *failure = basBuildApp(outPath, &spec);

    for (int32_t i = 0; i < (int32_t)arrlen(frmSources); i++) {
        free(frmSources[i]);
    }

    arrfree(frmSources);
    dvxSetBusy(sAc, false);

    if (failure) {
        setStatus(failure);
        return;
    }

    char msg[IDE_MSG_BUF];
    snprintf(msg, sizeof(msg), "Created %s (%s)", outPath, release ? "release" : "debug");
    setStatus(msg);
}


static void navigateToBreakpoint(int32_t bpIdx) {
    if (bpIdx < 0 || bpIdx >= (int32_t)arrlen(sBreakpoints)) {
        return;
    }

    IdeBreakpointT *bp = &sBreakpoints[bpIdx];
    const char *procName = (strcmp(bp->procName, IDE_GENERAL_SECTION) != 0) ? bp->procName : NULL;
    navigateToCodeLine(bp->fileIdx, bp->codeLine, procName, false);
}


// ============================================================
// navigateToCodeLine -- show a specific file/line/proc in the code editor
// ============================================================
//
// fileIdx:   project file index (-1 = current)
// codeLine:  1-based line within the file's code section
// procName:  "Obj.Evt" (dot-separated) to match editor proc table, or NULL for General
// setDbgLine: if true, update sDbgCurrentLine for the debug decorator

static void navigateToCodeLine(int32_t fileIdx, int32_t codeLine, const char *procName, bool setDbgLine) {
    // Track whether the file changed so we can force showProc
    bool fileChanged = (fileIdx >= 0 && fileIdx != sProject.activeFileIdx);

    // Switch to the correct file if needed
    if (fileChanged) {
        activateFile(fileIdx, ViewCodeE);
    }

    // Ensure code window exists
    if (!sCodeWin) {
        showCodeWindow();
        updateDropdowns();
        sCurProcIdx = -2;
    }

    if (sCodeWin && !sCodeWin->visible) {
        dvxShowWindow(sAc, sCodeWin);
    }

    if (sCodeWin) {
        dvxRaiseWindow(sAc, sCodeWin);
    }

    // Find the target procedure in the editor's proc table
    int32_t targetProcIdx = -1;
    int32_t procCount     = (int32_t)arrlen(sProcTable);

    if (procName) {
        for (int32_t i = 0; i < procCount; i++) {
            char fullName[IDE_FULL_NAME_BUF];
            snprintf(fullName, sizeof(fullName), "%s.%s", sProcTable[i].objName, sProcTable[i].evtName);

            if (strcasecmp(fullName, procName) == 0) {
                targetProcIdx = i;
                break;
            }
        }
    }

    // Switch to the target procedure (always force after a file change)
    if (targetProcIdx != sCurProcIdx || fileChanged) {
        showProc(targetProcIdx);
    }

    // Update dropdowns to match
    if (targetProcIdx >= 0 && targetProcIdx < procCount) {
        selectDropdowns(sProcTable[targetProcIdx].objName, sProcTable[targetProcIdx].evtName);
    } else if (sObjDropdown) {
        wgtDropdownSetSelected(sObjDropdown, 0);
    }

    // Compute editor-local line within the current proc
    int32_t editorLine = codeLine;

    if (sCurProcIdx >= 0 && sCurProcIdx < procCount) {
        editorLine = codeLine - sProcTable[sCurProcIdx].lineNum + 1;
    }

    if (setDbgLine) {
        sDbgCurrentLine = editorLine;
    }

    if (sEditor) {
        wgtTextAreaGoToLine(sEditor, editorLine);
        wgtInvalidatePaint(sEditor);
    }
}


// navigateToEventSub -- open code editor at the default event sub for the
// selected control (or form).

static void navigateToEventSub(void) {
    if (!sDesigner.form) {
        return;
    }

    // Determine control name and default event
    const char *ctrlName  = NULL;
    const char *eventName = NULL;

    if (sDesigner.selectedIdx >= 0 &&
        sDesigner.selectedIdx < (int32_t)arrlen(sDesigner.form->controls)) {
        DsgnControlT *ctrl = sDesigner.form->controls[sDesigner.selectedIdx];
        ctrlName  = ctrl->name;
        eventName = dsgnDefaultEvent(ctrl->typeName);
    } else {
        ctrlName  = sDesigner.form->name;
        eventName = dsgnDefaultEvent("Form");
    }

    navigateToNamedEventSub(ctrlName, eventName);
}


// navigateToNamedEventSub -- open the code editor at Ctrl_Event, creating
// the sub skeleton if it doesn't exist.  Code is stored in the .frm
// file's code section (sDesigner.form->code).

static void navigateToNamedEventSub(const char *ctrlName, const char *eventName) {
    if (!sDesigner.form || !ctrlName || !eventName) {
        return;
    }

    // Load form code into editor (stashes existing code, parses procs,
    // populates dropdowns without triggering navigation)
    loadFormCodeIntoEditor();

    if (!sEditor) {
        return;
    }

    // Search for existing procedure
    int32_t procCount = (int32_t)arrlen(sProcTable);
    int32_t procIdx   = -1;

    for (int32_t i = 0; i < procCount; i++) {
        if (strcasecmp(sProcTable[i].objName, ctrlName) == 0 && strcasecmp(sProcTable[i].evtName, eventName) == 0) {
            procIdx = i;
            break;
        }
    }

    if (procIdx >= 0) {
        showProc(procIdx);
    } else {
        createEventSubSkeleton(ctrlName, eventName);
    }

    if (sCodeWin) {
        dvxRaiseWindow(sAc, sCodeWin);
    }

    setStatus("Code view.");
    selectDropdowns(ctrlName, eventName);
}


static void newProject(void) {
    if (!promptAndSave()) {
        return;
    }

    char name[PRJ_MAX_NAME];

    if (!dvxInputBox(sAc, "New Project", "Project name:", "", name, sizeof(name))) {
        return;
    }

    if (name[0] == '\0') {
        return;
    }

    // Ask for directory via save dialog (file = name.dbp)
    FileFilterT filters[] = {
        { "Project Files (*.dbp)" }
    };

    char dbpPath[DVX_MAX_PATH];
    snprintf(dbpPath, sizeof(dbpPath), "%s.dbp", name);

    if (!dvxFileDialog(sAc, "Save New Project", FD_SAVE, NULL, filters, 1, dbpPath, sizeof(dbpPath))) {
        return;
    }

    closeProject();

    // Derive directory from chosen path
    char dir[DVX_MAX_PATH];
    snprintf(dir, sizeof(dir), "%s", dbpPath);
    char *sep = platformPathDirEnd(dir);

    if (sep) {
        *sep = '\0';
    } else {
        dir[0] = '.';
        dir[1] = '\0';
    }

    prjNew(&sProject, name, dir, sPrefs);
    snprintf(sProject.projectPath, sizeof(sProject.projectPath), "%s", dbpPath);
    saveProjectFile();

    ensureProjectWindow(false);
    updateMainTitle();
    setStatus("New project created.");
    updateProjectMenuState();
}


static void onBreakpointHit(void *ctx, int32_t line) {
    (void)ctx;

    // The VM fires this with line == -1 at end-of-sub after a breakpointed
    // handler finishes; that is not a real pause point (every genuine pause --
    // breakpoint or Step Into/Over/Out -- arrives with a positive line), so
    // ignore it.  Otherwise Continue would re-pause and require a second press.
    if (line < 0) {
        return;
    }

    sDbgState = DBG_PAUSED;

    if (sVm) {
        sVm->debugPaused = true;
    }

    if (line > 0) {
        sDbgCurrentLine = line;
        debugNavigateToLine(line);
    }

    debugSetBreakTitles(true);
    debugUpdateWindows();
    updateProjectMenuState();
    setStatus("Paused.");
}


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

    if (!sBreakpointList) {
        return;
    }

    int32_t sel = wgtListViewGetSelected(sBreakpointList);
    navigateToBreakpoint(sel);
}


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

    // Delete key
    if (keyCode != KEY_DELETE && keyCode != KEY_ASCII_DEL) {
        return;
    }

    if (!sBreakpointList || arrlen(sBreakpoints) == 0) {
        return;
    }

    // Remove selected breakpoints in reverse order to preserve indices
    for (int32_t i = (int32_t)arrlen(sBreakpoints) - 1; i >= 0; i--) {
        if (wgtListViewIsItemSelected(sBreakpointList, i)) {
            arrdel(sBreakpoints, i);
        }
    }

    syncVmBreakpoints();
    updateBreakpointWindow();

    // Repaint editor to remove breakpoint dots
    if (sEditor) {
        wgtInvalidatePaint(sEditor);
    }
}


static void onBreakpointWinClose(WindowT *win) {
    dvxHideWindow(sAc, win);
}


static void onCallStackClose(WindowT *win) {
    dvxHideWindow(sAc, win);
}


static void onClose(WindowT *win) {
    if (!promptAndSave()) {
        return;
    }

    // Stop any running program
    debugStop();

    // Prevent stale focus tracking during shutdown
    sLastFocusWin = NULL;

    // Null widget pointers first so nothing references destroyed widgets
    sEditor      = NULL;
    sOutput      = NULL;
    sImmediate   = NULL;
    sObjDropdown = NULL;
    sEvtDropdown = NULL;
    sStatus      = NULL;
    sToolbar     = NULL;
    sStatusBar   = NULL;

    // Close the project (designer, code, project windows and buffers),
    // then whatever can exist without one (toolbox/properties).
    closeProject();
    closeProjectUi();

    if (sOutWin) {
        dvxDestroyWindow(sAc, sOutWin);
        sOutWin = NULL;
    }

    if (sImmWin) {
        dvxDestroyWindow(sAc, sImmWin);
        sImmWin = NULL;
    }

    if (sLocalsWin) {
        dvxDestroyWindow(sAc, sLocalsWin);
        sLocalsWin  = NULL;
        sLocalsList = NULL;
    }

    if (sCallStackWin) {
        dvxDestroyWindow(sAc, sCallStackWin);
        sCallStackWin  = NULL;
        sCallStackList = NULL;
    }

    if (sWatchWin) {
        dvxDestroyWindow(sAc, sWatchWin);
        sWatchWin   = NULL;
        sWatchList  = NULL;
        sWatchInput = NULL;
    }

    if (sBreakpointWin) {
        dvxDestroyWindow(sAc, sBreakpointWin);
        sBreakpointWin  = NULL;
        sBreakpointList = NULL;
    }

    // Don't destroy win here -- the shell manages it. Destroying
    // it from inside onClose crashes because the calling code in
    // dvxApp.c still references the window after the callback returns.
    sWin = NULL;

    // A running program is still executing sCachedModule (this close
    // arrived through its event pump); runModule frees it on exit.
    if (sCachedModule && !sVm) {
        basModuleFree(sCachedModule);
        sCachedModule = NULL;
    }

    arrfree(sProcTable);
    sProcTable = NULL;

    for (int32_t i = 0; i < (int32_t)arrlen(sObjItems); i++) {
        free((void *)sObjItems[i]);
    }

    arrfree(sObjItems);
    arrfree(sEvtItems);
    sObjItems = NULL;
    sEvtItems = NULL;

    listRowsFree(&sBpRows);
    listRowsFree(&sLocalsRows);
    listRowsFree(&sCallRows);
    listRowsFree(&sWatchRows);

    for (int32_t i = 0; i < (int32_t)arrlen(sWatchExprs); i++) {
        free(sWatchExprs[i]);
    }

    arrfree(sWatchExprs);
    sWatchExprs = NULL;

    for (int32_t i = 0; i < (int32_t)arrlen(sRecentFiles); i++) {
        free(sRecentFiles[i]);
    }

    arrfree(sRecentFiles);
    sRecentFiles = NULL;

    arrfree(sBreakpoints);
    sBreakpoints = NULL;
    arrfree(sVmBreakpoints);
    sVmBreakpoints = NULL;

    free(sFullSourceCache);
    sFullSourceCache = NULL;

    shfree(sSyntaxMap);
    sSyntaxMap = NULL;

    prefsClose(sPrefs);
    sPrefs = NULL;

    dvxDestroyWindow(sAc, win);
}


static void onCodeWinClose(WindowT *win) {
    // Stash code back before the window is destroyed.  Use stashCurrentFile()
    // (not stashFormCode()) so a plain .bas file's editor contents are written
    // back to its project buffer too -- stashFormCode() is a no-op for
    // non-form files and would discard unsaved .bas edits on close/reopen.
    stashCurrentFile();

    dvxDestroyWindow(sAc, win);
    sCodeWin     = NULL;
    sEditor      = NULL;
    sObjDropdown = NULL;
    sEvtDropdown = NULL;

    if (sLastFocusWin == win) {
        sLastFocusWin = NULL;
    }

    updateProjectMenuState();
}


static void onColorListChange(WidgetT *w) {
    (void)w;
    prefsUpdateColorSliders();
}


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

    int32_t idx = wgtListBoxGetSelected(sPrefsDlg.colorList);

    if (idx < 0 || idx >= SYNTAX_COLOR_COUNT) {
        return;
    }

    uint8_t r = (uint8_t)wgtSliderGetValue(sPrefsDlg.sliderR);
    uint8_t g = (uint8_t)wgtSliderGetValue(sPrefsDlg.sliderG);
    uint8_t b = (uint8_t)wgtSliderGetValue(sPrefsDlg.sliderB);

    prefsSetRgbLabels(r, g, b);

    sPrefsDlg.syntaxColors[idx] = ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b;
    prefsUpdateSwatch();
}


static void onContentFocus(WindowT *win) {
    sLastFocusWin = win;
}


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

    if (!sEditor) {
        return;
    }

    // Track the line count on every edit so the delta applied to
    // breakpoints below never accumulates across edits.
    const char *text         = wgtGetText(sEditor);
    int32_t     newLineCount = countLines(text);
    int32_t     delta        = newLineCount - sEditorLineCount;

    sEditorLineCount = newLineCount;

    // Adjust breakpoints when lines are added or removed
    if (delta != 0 && arrlen(sBreakpoints) > 0) {
        int32_t cursorLine = wgtTextAreaGetCursorLine(sEditor);

        // Convert editor cursor line to file code line
        int32_t editCodeLine = editorLineToCodeLine(cursorLine);

        bool changed = false;

        for (int32_t i = (int32_t)arrlen(sBreakpoints) - 1; i >= 0; i--) {
            if (sBreakpoints[i].fileIdx != sEditorFileIdx) {
                continue;
            }

            if (sBreakpoints[i].codeLine >= editCodeLine) {
                sBreakpoints[i].codeLine += delta;

                // Remove if shifted to invalid line
                if (sBreakpoints[i].codeLine < 1) {
                    arrdel(sBreakpoints, i);
                }

                changed = true;
            }
        }

        if (changed) {
            syncVmBreakpoints();
            updateBreakpointWindow();
        }
    }

    // Mark the file the editor is bound to as modified
    if (sEditorFileIdx >= 0 && sEditorFileIdx < sProject.fileCount) {
        sProject.files[sEditorFileIdx].modified = true;

        // Only mark form dirty when editing the form's code, not a .bas file
        if (sProject.files[sEditorFileIdx].isForm && sDesigner.form) {
            sDesigner.form->dirty = true;
        }
    }

    updateDirtyIndicators();
}


// onEvtDropdownChange -- navigate to the selected procedure when the
// event dropdown changes.

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

    if (sDropdownNavSuppressed) {
        return;
    }

    if (!sObjDropdown || !sEvtDropdown || !sEditor) {
        return;
    }

    int32_t objIdx = wgtDropdownGetSelected(sObjDropdown);
    int32_t evtIdx = wgtDropdownGetSelected(sEvtDropdown);

    if (objIdx < 0 || objIdx >= (int32_t)arrlen(sObjItems) ||
        evtIdx < 0 || evtIdx >= (int32_t)arrlen(sEvtItems)) {
        return;
    }

    const char *selObj = sObjItems[objIdx];
    const char *selEvt = sEvtItems[evtIdx];

    // (Global) shows the General module-level section
    if (strcasecmp(selEvt, IDE_GLOBAL_SECTION) == 0) {
        showProc(-1);
        return;
    }

    // Strip brackets if present (unimplemented event)
    char evtName[BAS_MAX_IDENT];

    if (selEvt[0] == '[') {
        snprintf(evtName, sizeof(evtName), "%s", selEvt + 1);
        int32_t len = (int32_t)strlen(evtName);

        if (len > 0 && evtName[len - 1] == ']') {
            evtName[len - 1] = '\0';
        }
    } else {
        snprintf(evtName, sizeof(evtName), "%s", selEvt);
    }

    // Search for an existing proc matching object + event
    int32_t procCount = (int32_t)arrlen(sProcTable);

    for (int32_t i = 0; i < procCount; i++) {
        if (strcasecmp(sProcTable[i].objName, selObj) == 0 &&
            strcasecmp(sProcTable[i].evtName, evtName) == 0) {
            showProc(i);
            return;
        }
    }

    createEventSubSkeleton(selObj, evtName);
}


static void onFindClose(WindowT *win) {
    (void)win;
    closeFindDialog();
}


static void onFindCloseBtn(WidgetT *w) {
    (void)w;
    closeFindDialog();
}


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

    if (!sFindInput) {
        return;
    }

    const char *needle = wgtGetText(sFindInput);

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

    snprintf(sFindText, sizeof(sFindText), "%s", needle);

    FindScopeE scope = getFindScope();
    bool caseSens    = getFindMatchCase();
    bool forward     = getFindForward();

    if (scope == ScopeFuncE && sEditor) {
        // Search current procedure only
        if (!wgtTextAreaFindNext(sEditor, sFindText, caseSens, forward)) {
            setStatus("Not found.");
        }
    } else if (scope == ScopeObjE || scope == ScopeFileE || scope == ScopeProjE) {
        if (!findInProject(sFindText, caseSens, forward)) {
            setStatus("Not found.");
        }
    }
}


// onFormWinClose -- shell callback when user clicks X on the form window.
static void onFormWinClose(WindowT *win) {
    dvxDestroyWindow(sAc, win);
    cleanupFormWin();
}


static int32_t onFormWinCursorQuery(WindowT *win, int32_t x, int32_t y) {
    (void)win;

    if (!sDesigner.form) {
        return 0;
    }

    // Crosshair when placing a new control
    if (sDesigner.activeTool[0] != '\0') {
        return CURSOR_CROSSHAIR;
    }

    if (!sDesigner.form->controls) {
        return 0;
    }

    int32_t count = (int32_t)arrlen(sDesigner.form->controls);

    if (sDesigner.selectedIdx < 0 || sDesigner.selectedIdx >= count) {
        return 0;
    }

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

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

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

    // SE handle (check first -- overlaps with E and S)
    if (x >= cx + cw - hs/2 && x < cx + cw + hs/2 && y >= cy + ch - hs/2 && y < cy + ch + hs/2) {
        return CURSOR_RESIZE_DIAG_NWSE;
    }

    // E handle (right edge center)
    if (x >= cx + cw - hs/2 && x < cx + cw + hs/2 && y >= cy + ch/2 - hs/2 && y < cy + ch/2 + hs/2) {
        return CURSOR_RESIZE_H;
    }

    // S handle (bottom center)
    if (x >= cx + cw/2 - hs/2 && x < cx + cw/2 + hs/2 && y >= cy + ch - hs/2 && y < cy + ch + hs/2) {
        return CURSOR_RESIZE_V;
    }

    return 0;
}


static void onFormWinKey(WindowT *win, int32_t key, int32_t mod) {
    // Ctrl+C: copy selected control
    if (key == KEY_CTRL_C && (mod & ACCEL_CTRL)) {
        dsgnCopySelected();
        return;
    }

    // Ctrl+X: cut selected control
    if (key == KEY_CTRL_X && (mod & ACCEL_CTRL)) {
        dsgnCopySelected();
        deleteSelectedControl();
        return;
    }

    // Ctrl+V: paste control
    if (key == KEY_CTRL_V && (mod & ACCEL_CTRL)) {
        dsgnPasteControl();
        return;
    }

    if (key == KEY_DELETE && sDesigner.selectedIdx >= 0) {
        deleteSelectedControl();
        return;
    }

    // Forward unhandled keys to the widget system
    widgetOnKey(win, key, mod);
}


// onFormWinMenu -- menu click on the designer form preview.
// Designer menu items use IDs starting at DSGN_MENU_ID_BASE.
// Clicking one navigates to the menu item's Click event code.
// Other IDs (from shared accel table) fall through to onMenu.
static void onFormWinMenu(WindowT *win, int32_t menuId) {
    if (sDesigner.form && menuId >= DSGN_MENU_ID_BASE) {
        int32_t idx = menuId - DSGN_MENU_ID_BASE;
        int32_t menuCount = (int32_t)arrlen(sDesigner.form->menuItems);

        if (idx >= 0 && idx < menuCount) {
            const char *name = sDesigner.form->menuItems[idx].name;

            if (name[0]) {
                navigateToNamedEventSub(name, "Click");
                return;
            }
        }
    }

    onMenu(win, menuId);
}


// onFormWinMouse -- handle mouse events on the form designer window.
// Coordinates are relative to the window's client area (content box origin).

static void onFormWinMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) {
    (void)win;
    static int32_t lastButtons = 0;

    bool wasDown = (lastButtons & MOUSE_LEFT) != 0;
    bool isDown  = (buttons & MOUSE_LEFT) != 0;

    if (!sDesigner.form || !sFormWin) {
        lastButtons = buttons;
        return;
    }

    if (isDown && !wasDown) {
        // Detect double-click using the system-wide setting
        int32_t clicks = multiClickDetect(x, y);

        int32_t prevCount = (int32_t)arrlen(sDesigner.form->controls);
        bool wasDirty     = sDesigner.form->dirty;
        dsgnOnMouse(&sDesigner, x, y, false);
        int32_t newCount = (int32_t)arrlen(sDesigner.form->controls);
        bool nowDirty    = sDesigner.form->dirty;

        if (newCount != prevCount || (nowDirty && !wasDirty)) {
            prpRebuildTree(&sDesigner);
        }

        prpRefresh(&sDesigner);
        dvxInvalidateWindow(sAc, sFormWin);

        if (clicks >= 2 && sDesigner.activeTool[0] == '\0') {
            navigateToEventSub();
        }
    } else if (isDown && wasDown) {
        // Drag
        dsgnOnMouse(&sDesigner, x, y, true);
        prpRefresh(&sDesigner);
        dvxInvalidateWindow(sAc, sFormWin);
    } else if (!isDown && wasDown) {
        // Release
        dsgnOnMouse(&sDesigner, x, y, false);
        prpRefresh(&sDesigner);
        dvxInvalidateWindow(sAc, sFormWin);
    }

    lastButtons = buttons;

    updateDirtyIndicators();
}


// onFormWinPaint -- draw selection handles after widgets have painted.

static void onFormWinPaint(WindowT *win, RectT *dirtyArea) {
    if (!win) {
        return;
    }

    // Force measure + relayout only on structural changes (control
    // add/remove/resize). Selection clicks just need repaint + overlay.
    if (win->paintNeeded >= PAINT_FULL && win->widgetRoot) {
        widgetCalcMinSizeTree(win->widgetRoot, &sAc->font);
        win->widgetRoot->w = 0; // force layout pass
    }

    // Designer always needs full repaint (handles must be erased/redrawn)
    win->paintNeeded = PAINT_FULL;
    widgetOnPaint(win, dirtyArea);

    // Then draw selection handles on top
    dsgnPaintOverlay(&sDesigner);
}


// onFormWinResize -- update form dimensions when the design window is resized
static void onFormWinResize(WindowT *win, int32_t newW, int32_t newH) {
    // Let the widget system handle the layout recalculation
    widgetOnResize(win, newW, newH);

    if (sDesigner.form) {
        // Store the outer window dimensions (win->w/h), not the content
        // dimensions (newW/newH), since dvxResizeWindow takes outer dims.
        sDesigner.form->width  = win->w;
        sDesigner.form->height = win->h;
        sDesigner.form->dirty  = true;
        prpRefresh(&sDesigner);
    }
}


static void onGutterClick(WidgetT *w, int32_t lineNum) {
    (void)w;
    toggleBreakpointLine(lineNum);
}


// onImmediateChange -- detect Enter in the Immediate window and evaluate
// the last line.

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

    if (!sImmediate) {
        return;
    }

    const char *text = wgtGetText(sImmediate);

    if (!text) {
        return;
    }

    int32_t len = (int32_t)strlen(text);

    if (len < 2 || text[len - 1] != '\n') {
        return;
    }

    // Find the start of the line before the trailing newline
    int32_t lineEnd = len - 1;
    int32_t lineStart = lineEnd - 1;

    while (lineStart > 0 && text[lineStart - 1] != '\n') {
        lineStart--;
    }

    if (lineStart >= lineEnd) {
        return;
    }

    // Extract the line
    char expr[IDE_IMM_LINE_BUF];
    int32_t lineLen = lineEnd - lineStart;

    if (lineLen >= (int32_t)sizeof(expr)) {
        lineLen = (int32_t)sizeof(expr) - 1;
    }

    memcpy(expr, text + lineStart, lineLen);
    expr[lineLen] = '\0';

    evaluateImmediate(expr);
}


static void onLocalsClose(WindowT *win) {
    dvxHideWindow(sAc, win);
}


static void onMenu(WindowT *win, int32_t menuId) {
    (void)win;

    handleFileCmd(menuId);
    handleEditCmd(menuId);
    handleRunCmd(menuId);
    handleViewCmd(menuId);
    handleWindowCmd(menuId);
    handleProjectCmd(menuId);

    if (menuId == CMD_PREFERENCES) {
        showPreferencesDialog();
    }

    if (menuId == CMD_DEBUG_LAYOUT && sWin && sWin->menuBar) {
        wgtSetDebugLayout(sAc, wmMenuItemIsChecked(sWin->menuBar, CMD_DEBUG_LAYOUT));
    }

    if (menuId == CMD_HELP_CONTENTS) {
        shellLoadAppWithArgs(sAc, DVX_HELP_VIEWER_APP_LITERAL, sIdeHelpFile);
    }

    if (menuId == CMD_HELP_API) {
        char sysHlp[DVX_MAX_PATH];
        snprintf(sysHlp, sizeof(sysHlp), "APPS" DVX_PATH_SEP "KPUNCH" DVX_PATH_SEP "PROGMAN" DVX_PATH_SEP "DVXHELP.HLP");
        shellLoadAppWithArgs(sAc, DVX_HELP_VIEWER_APP_LITERAL, sysHlp);
    }

    if (menuId == CMD_HELP_ABOUT) {
        dvxMessageBox(sAc, "About DVX BASIC",
            "DVX BASIC 1.0\n"
            "Visual BASIC Development Environment\n"
            "for the DVX GUI System\n"
            "\n"
            "Copyright 2026 Scott Duensing",
            MB_OK | MB_ICONINFO);
    }
}


// onObjDropdownChange -- update the Event dropdown when the Object
// selection changes.

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

    if (!sObjDropdown || !sEvtDropdown) {
        return;
    }

    int32_t objIdx = wgtDropdownGetSelected(sObjDropdown);

    if (objIdx < 0 || objIdx >= (int32_t)arrlen(sObjItems)) {
        return;
    }

    const char *selObj = sObjItems[objIdx];

    // Collect which events already have code
    arrsetlen(sEvtItems, 0);

    int32_t procCount = (int32_t)arrlen(sProcTable);
    const char **existingEvts = NULL; // stb_ds temp array

    for (int32_t i = 0; i < procCount; i++) {
        if (strcasecmp(sProcTable[i].objName, selObj) == 0) {
            arrput(existingEvts, sProcTable[i].evtName);
        }
    }

    if (strcasecmp(selObj, IDE_GENERAL_SECTION) == 0) {
        // Always include (Global) to access module-level code
        arrput(sEvtItems, IDE_GLOBAL_SECTION);

        for (int32_t i = 0; i < (int32_t)arrlen(existingEvts); i++) {
            arrput(sEvtItems, existingEvts[i]);
        }

        arrfree(existingEvts);

        int32_t evtCount = (int32_t)arrlen(sEvtItems);

        // Sort procs after (Global)
        if (evtCount > 2) {
            qsort(sEvtItems + 1, evtCount - 1, sizeof(const char *), cmpStrPtrs);
        }

        wgtDropdownSetItems(sEvtDropdown, sEvtItems, evtCount);
        wgtDropdownSetSelected(sEvtDropdown, 0);
        onEvtDropdownChange(sEvtDropdown);

        return;
    }

    // Check if this is a form name
    bool isForm = false;

    if (sDesigner.form && strcasecmp(selObj, sDesigner.form->name) == 0) {
        isForm = true;
    }

    // Check if this is a menu item (only event is Click)
    bool isMenuItem = false;

    if (!isForm && sDesigner.form) {
        for (int32_t i = 0; i < (int32_t)arrlen(sDesigner.form->menuItems); i++) {
            if (strcasecmp(sDesigner.form->menuItems[i].name, selObj) == 0) {
                isMenuItem = true;
                break;
            }
        }
    }

    // Standard events for this kind of object, from the master table
    // (NULL-terminated stb_ds temp array)
    const char **availEvents = NULL;

    if (isMenuItem) {
        arrput(availEvents, IDE_MENU_EVENT);
    } else {
        uint8_t scope = isForm ? BAS_EVT_SCOPE_FORM : BAS_EVT_SCOPE_CTRL;

        for (int32_t i = 0; i < IDE_EVENT_COUNT; i++) {
            if (sEventTable[i].scope & scope) {
                arrput(availEvents, sEventTable[i].suffix);
            }
        }
    }

    arrput(availEvents, NULL);

    // Get widget-specific events from the interface
    const WgtIfaceT *iface = NULL;

    if (!isForm && !isMenuItem && sDesigner.form) {
        for (int32_t i = 0; i < (int32_t)arrlen(sDesigner.form->controls); i++) {
            if (strcasecmp(sDesigner.form->controls[i]->name, selObj) == 0) {
                const char *wgtName = wgtFindByBasName(sDesigner.form->controls[i]->typeName);

                if (wgtName) {
                    iface = wgtGetIface(wgtName);
                }

                break;
            }
        }
    }

    // Build the event list: standard events + widget-specific events
    int32_t labelIdx = 0;

    // Add standard events (common or form)
    for (int32_t i = 0; availEvents[i]; i++) {
        bool hasCode = false;

        for (int32_t j = 0; j < (int32_t)arrlen(existingEvts); j++) {
            if (strcasecmp(existingEvts[j], availEvents[i]) == 0) {
                hasCode = true;
                break;
            }
        }

        if (labelIdx < IDE_MAX_EVT_LABELS) {
            if (hasCode) {
                snprintf(sEvtLabelBufs[labelIdx], IDE_EVT_LABEL_LEN, "%s", availEvents[i]);
            } else {
                snprintf(sEvtLabelBufs[labelIdx], IDE_EVT_LABEL_LEN, "[%s]", availEvents[i]);
            }

            arrput(sEvtItems, sEvtLabelBufs[labelIdx]);
            labelIdx++;
        }
    }

    // Add widget-specific events
    if (iface) {
        for (int32_t i = 0; i < iface->eventCount; i++) {
            const char *evtName = iface->events[i].name;

            // Skip if already in the standard list
            bool alreadyListed = false;

            for (int32_t j = 0; availEvents[j]; j++) {
                if (strcasecmp(availEvents[j], evtName) == 0) {
                    alreadyListed = true;
                    break;
                }
            }

            if (alreadyListed) {
                continue;
            }

            bool hasCode = false;

            for (int32_t j = 0; j < (int32_t)arrlen(existingEvts); j++) {
                if (strcasecmp(existingEvts[j], evtName) == 0) {
                    hasCode = true;
                    break;
                }
            }

            if (labelIdx < IDE_MAX_EVT_LABELS) {
                if (hasCode) {
                    snprintf(sEvtLabelBufs[labelIdx], IDE_EVT_LABEL_LEN, "%s", evtName);
                } else {
                    snprintf(sEvtLabelBufs[labelIdx], IDE_EVT_LABEL_LEN, "[%s]", evtName);
                }

                arrput(sEvtItems, sEvtLabelBufs[labelIdx]);
                labelIdx++;
            }
        }
    }

    // Add any existing events not in the standard/widget list (custom subs)
    for (int32_t i = 0; i < (int32_t)arrlen(existingEvts); i++) {
        bool alreadyListed = false;
        int32_t evtCount = (int32_t)arrlen(sEvtItems);

        for (int32_t j = 0; j < evtCount; j++) {
            if (evtLabelMatches(sEvtItems[j], existingEvts[i])) {
                alreadyListed = true;
                break;
            }
        }

        if (!alreadyListed && labelIdx < IDE_MAX_EVT_LABELS) {
            snprintf(sEvtLabelBufs[labelIdx], IDE_EVT_LABEL_LEN, "%s", existingEvts[i]);
            arrput(sEvtItems, sEvtLabelBufs[labelIdx]);
            labelIdx++;
        }
    }

    arrfree(existingEvts);
    arrfree(availEvents);

    int32_t evtCount = (int32_t)arrlen(sEvtItems);

    // Sort: implemented events first, then unimplemented, alphabetical within each
    if (evtCount > 1) {
        qsort(sEvtItems, evtCount, sizeof(const char *), cmpEvtPtrs);
    }

    wgtDropdownSetItems(sEvtDropdown, sEvtItems, evtCount);

    if (evtCount > 0) {
        // Find the first implemented event (no brackets)
        int32_t selectIdx = -1;

        for (int32_t i = 0; i < evtCount; i++) {
            if (sEvtItems[i][0] != '[') {
                selectIdx = i;
                break;
            }
        }

        if (selectIdx >= 0) {
            // Navigate to the first implemented event
            wgtDropdownSetSelected(sEvtDropdown, selectIdx);
            onEvtDropdownChange(sEvtDropdown);
        } else {
            // No implemented events -- find and select the default event
            // for this widget type, which will create its skeleton
            const char *defEvt = NULL;

            if (isForm) {
                defEvt = dsgnDefaultEvent("Form");
            } else if (sDesigner.form) {
                for (int32_t i = 0; i < (int32_t)arrlen(sDesigner.form->controls); i++) {
                    if (strcasecmp(sDesigner.form->controls[i]->name, selObj) == 0) {
                        defEvt = dsgnDefaultEvent(sDesigner.form->controls[i]->typeName);
                        break;
                    }
                }
            }

            if (!defEvt) {
                defEvt = "Click";
            }

            // Find the default event in the list (it will be bracketed)
            for (int32_t i = 0; i < evtCount; i++) {
                if (evtLabelMatches(sEvtItems[i], defEvt)) {
                    wgtDropdownSetSelected(sEvtDropdown, i);
                    onEvtDropdownChange(sEvtDropdown);
                    break;
                }
            }
        }
    }
}


static void onPrefsCancel(WidgetT *w) {
    (void)w;
    sPrefsDlg.done = true;
}


static void onPrefsOk(WidgetT *w) {
    (void)w;
    sPrefsDlg.accepted = true;
    sPrefsDlg.done     = true;
}


static void onPrjFileDblClick(int32_t fileIdx, bool isForm) {
    (void)isForm;
    activateFile(fileIdx, ViewAutoE);
}


static void onProjectWinClose(WindowT *win) {
    prjDestroyWindow(sAc, win);
    sProjectWin = NULL;
}


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

    if (!sEditor || !sFindInput || !sReplInput || !isReplaceEnabled()) {
        return;
    }

    const char *needle = wgtGetText(sFindInput);
    const char *repl   = wgtGetText(sReplInput);

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

    snprintf(sFindText, sizeof(sFindText), "%s", needle);
    snprintf(sReplaceText, sizeof(sReplaceText), "%s", repl ? repl : "");

    bool caseSens = getFindMatchCase();
    bool forward  = getFindForward();

    // Single Replace always acts on the editor currently shown: if its
    // selection matches the search term, splice in the replacement and
    // advance to the next match; otherwise just find next.  Find Next's
    // own scope/direction handling (which may switch procs or files) is
    // only reached when there is no matching selection to replace, so a
    // cross-file single replace stays unambiguous -- consistent with how
    // onReplaceAll treats each scope as a sequence of in-editor passes.
    if (wgtTextAreaReplaceSelection(sEditor, sFindText, sReplaceText, caseSens, forward)) {
        return;
    }

    onFindNext(w);
}


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

    if (!sFindInput || !sReplInput || !isReplaceEnabled()) {
        return;
    }

    const char *needle = wgtGetText(sFindInput);
    const char *repl   = wgtGetText(sReplInput);

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

    snprintf(sFindText, sizeof(sFindText), "%s", needle);
    snprintf(sReplaceText, sizeof(sReplaceText), "%s", repl ? repl : "");

    bool caseSens    = getFindMatchCase();
    FindScopeE scope = getFindScope();
    int32_t totalCount = 0;

    if (scope == ScopeFuncE && sEditor) {
        totalCount = wgtTextAreaReplaceAll(sEditor, sFindText, sReplaceText, caseSens);
    } else if (scope == ScopeProjE) {
        int32_t needleLen = (int32_t)strlen(sFindText);

        stashCurrentFile();

        for (int32_t i = 0; i < sProject.fileCount; i++) {
            // Skip files whose buffer has no match at all
            if (!procBufContains(sProject.files[i].buffer, sFindText, needleLen, caseSens)) {
                continue;
            }

            activateFile(i, sProject.files[i].isForm ? ViewCodeE : ViewAutoE);

            // wgtTextAreaReplaceAll only touches the proc currently shown
            // in the editor, so visit every proc of this file (General +
            // each Sub/Function), exactly as the ScopeFileE path does --
            // otherwise only the (General) section gets replaced.  The
            // buffers are in sync after stashCurrentFile/activateFile, so
            // procs without a match are skipped instead of paying for a
            // full editor reload on every proc.  The bound is re-read each
            // pass because showProc can discard an empty skeleton proc.
            for (int32_t p = -1; p < (int32_t)arrlen(sProcBufs); p++) {
                const char *procBuf = (p == -1) ? sGeneralBuf : sProcBufs[p];

                if (!procBufContains(procBuf, sFindText, needleLen, caseSens)) {
                    continue;
                }

                showProc(p);

                if (sEditor) {
                    totalCount += wgtTextAreaReplaceAll(sEditor, sFindText, sReplaceText, caseSens);
                }
            }
        }
    } else if (scope == ScopeFileE && sEditor) {
        // Replace in all procs of current file
        int32_t procCount = (int32_t)arrlen(sProcBufs);

        for (int32_t p = -1; p < procCount; p++) {
            showProc(p);

            if (sEditor) {
                totalCount += wgtTextAreaReplaceAll(sEditor, sFindText, sReplaceText, caseSens);
            }
        }
    } else if (scope == ScopeObjE && sEditor && sObjDropdown) {
        // Replace in all procs belonging to current object
        int32_t objIdx = wgtDropdownGetSelected(sObjDropdown);

        if (objIdx >= 0 && objIdx < (int32_t)arrlen(sObjItems)) {
            const char *objName = sObjItems[objIdx];
            int32_t procCount = (int32_t)arrlen(sProcTable);

            for (int32_t p = 0; p < procCount; p++) {
                if (strcasecmp(sProcTable[p].objName, objName) == 0) {
                    showProc(p);

                    if (sEditor) {
                        totalCount += wgtTextAreaReplaceAll(sEditor, sFindText, sReplaceText, caseSens);
                    }
                }
            }
        }
    }

    char statusBuf[BAS_MAX_IDENT];
    snprintf(statusBuf, sizeof(statusBuf), "%d replacement(s) made.", (int)totalCount);
    setStatus(statusBuf);
}


static void onReplCheckChange(WidgetT *w) {
    (void)w;
    bool show = isReplaceEnabled();

    if (sReplInput) {
        sReplInput->enabled = show;
    }

    if (sBtnReplace) {
        sBtnReplace->enabled = show;
    }

    if (sBtnReplAll) {
        sBtnReplAll->enabled = show;
    }

    if (sFindWin) {
        dvxInvalidateWindow(sAc, sFindWin);
    }
}


static void onTbCode(WidgetT *w) {
    (void)w;
    handleViewCmd(CMD_VIEW_CODE);
}


static void onTbDebug(WidgetT *w) {
    (void)w;
    handleRunCmd(CMD_DEBUG);
}


static void onTbDesign(WidgetT *w) {
    (void)w;
    handleViewCmd(CMD_VIEW_DESIGN);
}


static void onTbOpen(WidgetT *w) {
    (void)w;
    handleProjectCmd(CMD_PRJ_OPEN);
}


static void onTbRun(WidgetT *w) {
    (void)w;
    handleRunCmd(CMD_RUN);
}


static void onTbRunToCur(WidgetT *w) {
    (void)w;
    handleRunCmd(CMD_RUN_TO_CURSOR);
}


static void onTbSave(WidgetT *w) {
    (void)w;
    handleFileCmd(CMD_SAVE);
}


static void onTbStepInto(WidgetT *w) {
    (void)w;
    handleRunCmd(CMD_STEP_INTO);
}


static void onTbStepOut(WidgetT *w) {
    (void)w;
    handleRunCmd(CMD_STEP_OUT);
}


static void onTbStepOver(WidgetT *w) {
    (void)w;
    handleRunCmd(CMD_STEP_OVER);
}


static void onTbStop(WidgetT *w) {
    (void)w;
    handleRunCmd(CMD_STOP);
}


static void onWatchClose(WindowT *win) {
    dvxHideWindow(sAc, win);
}


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

    if (keyCode != '\r' && keyCode != '\n') {
        return;
    }

    if (!sWatchInput) {
        return;
    }

    const char *text = wgtGetText(sWatchInput);

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

    // Add to watch list
    char *copy = strdup(text);

    if (!copy) {
        return;
    }

    arrput(sWatchExprs, copy);
    updateWatchWindow();

    // Clear input
    wgtSetText(sWatchInput, "");
}


static void onWatchListDblClick(WidgetT *w) {
    (void)w;
    watchEditSelected();
}


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

    // Enter -- edit selected item
    if (keyCode == '\r' || keyCode == '\n') {
        watchEditSelected();
        return;
    }

    // Delete key (scancode 0x53 with extended flag)
    if (keyCode != KEY_DELETE && keyCode != KEY_ASCII_DEL) {
        return;
    }

    if (!sWatchList) {
        return;
    }

    int32_t sel = wgtListViewGetSelected(sWatchList);

    if (sel < 0 || sel >= (int32_t)arrlen(sWatchExprs)) {
        return;
    }

    free(sWatchExprs[sel]);
    arrdel(sWatchExprs, sel);

    updateWatchWindow();
}


static void openFindDialog(bool showReplace) {
    if (sFindWin) {
        // Already open -- just toggle replace mode and raise
        if (sReplCheck) {
            wgtCheckboxSetChecked(sReplCheck, showReplace);
            onReplCheckChange(sReplCheck);
        }

        dvxRaiseWindow(sAc, sFindWin);
        return;
    }

    sFindWin = dvxCreateWindowCentered(sAc, "Find / Replace", IDE_FIND_WIN_W, IDE_FIND_WIN_H, false);

    if (!sFindWin) {
        return;
    }

    sFindWin->onClose    = onFindClose;
    sFindWin->onMenu     = onMenu;
    sFindWin->accelTable = sWin ? sWin->accelTable : NULL;

    WidgetT *root = wgtInitWindow(sAc, sFindWin);
    root->spacing = wgtPixels(IDE_FIND_ROOT_SPACING);

    // Find row
    WidgetT *findRow = wgtHBox(root);
    findRow->spacing = wgtPixels(IDE_WIDGET_SPACING);
    wgtLabel(findRow, "Find:");
    sFindInput = wgtTextInput(findRow, IDE_FIND_TEXT_BUF);
    sFindInput->weight = WGT_WEIGHT_FILL;
    wgtSetText(sFindInput, sFindText);

    // Replace checkbox + input
    WidgetT *replRow = wgtHBox(root);
    replRow->spacing = wgtPixels(IDE_WIDGET_SPACING);
    sReplCheck = wgtCheckbox(replRow, "Replace:");
    wgtCheckboxSetChecked(sReplCheck, showReplace);
    sReplCheck->onChange = onReplCheckChange;
    sReplInput = wgtTextInput(replRow, IDE_FIND_TEXT_BUF);
    sReplInput->weight = WGT_WEIGHT_FILL;
    wgtSetText(sReplInput, sReplaceText);

    // Options row: scope + direction + case
    WidgetT *optRow = wgtHBox(root);
    optRow->spacing = wgtPixels(IDE_WIDGET_SPACING_LG);

    // Scope
    WidgetT *scopeFrame = wgtFrame(optRow, "Scope");
    WidgetT *scopeBox   = wgtVBox(scopeFrame);
    sScopeGroup = wgtRadioGroup(scopeBox);
    wgtRadio(sScopeGroup, "Function");
    wgtRadio(sScopeGroup, "Object");
    wgtRadio(sScopeGroup, "File");
    wgtRadio(sScopeGroup, "Project");
    wgtRadioGroupSetSelected(sScopeGroup, ScopeProjE);

    // Direction
    WidgetT *dirFrame = wgtFrame(optRow, "Direction");
    WidgetT *dirBox   = wgtVBox(dirFrame);
    sDirGroup = wgtRadioGroup(dirBox);
    wgtRadio(sDirGroup, "Forward");
    wgtRadio(sDirGroup, "Backward");
    wgtRadioGroupSetSelected(sDirGroup, 0); // Forward

    // Match Case
    WidgetT *caseBox = wgtVBox(optRow);
    sCaseCheck = wgtCheckbox(caseBox, "Match Case");

    // Buttons
    WidgetT *btnRow = wgtHBox(root);
    btnRow->spacing = wgtPixels(IDE_WIDGET_SPACING);
    btnRow->align   = AlignEndE;

    WidgetT *btnFind = wgtButton(btnRow, "Find Next");
    btnFind->onClick = onFindNext;

    sBtnReplace = wgtButton(btnRow, "Replace");
    sBtnReplace->onClick = onReplace;

    sBtnReplAll = wgtButton(btnRow, "Replace All");
    sBtnReplAll->onClick = onReplaceAll;

    WidgetT *btnClose = wgtButton(btnRow, "Close");
    btnClose->onClick = onFindCloseBtn;

    // Set initial replace enable state
    onReplCheckChange(sReplCheck);

    dvxFitWindow(sAc, sFindWin);
}


static void openProject(void) {
    if (!promptAndSave()) {
        return;
    }

    FileFilterT filters[] = {
        { "Project Files (*.dbp)" },
        { "All Files (*.*)" }
    };

    char path[DVX_MAX_PATH];

    if (!dvxFileDialog(sAc, "Open Project", FD_OPEN, NULL, filters, (int32_t)(sizeof(filters) / sizeof(filters[0])), path, sizeof(path))) {
        return;
    }

    closeProject();
    openProjectPath(path);
}


// openProjectPath -- load a .dbp (the previous project is already closed)
static void openProjectPath(const char *path) {
    if (!prjLoad(&sProject, path)) {
        dvxErrorBox(sAc, NULL, "Could not open project file.");
        return;
    }

    prjLoadAllFiles(&sProject, sAc);
    ensureProjectWindow(false);
    updateMainTitle();
    setStatus("Project loaded.");
    updateProjectMenuState();
    recentAdd(path);
}


// openSinglePath -- open a .bas/.frm: added to the current project, or
// used to create an implicit project when none is open.
static void openSinglePath(const char *path) {
    if (hasProject()) {
        if (addFileToProject(path)) {
            recentAdd(path);
        }

        return;
    }

    if (!promptAndSave()) {
        return;
    }

    ensureProject(path);
    ensureProjectWindow(true);
    updateProjectMenuState();
    recentAdd(path);
}


// packProcLayout -- normalize source text to the canonical proc layout
// (see joinProcArrays).  Returns a malloc'd string or NULL.
static char *packProcLayout(const char *source) {
    char  *general = NULL;
    char **procs   = NULL;

    splitProcs(source, &general, &procs);

    char *packed = joinProcArrays(general, procs);

    free(general);

    for (int32_t i = 0; i < (int32_t)arrlen(procs); i++) {
        free(procs[i]);
    }

    arrfree(procs);
    return packed;
}


// parseIndexList -- parse "i1, i2, ...)" starting just after the '('.
// Returns the position after the closing ')' (or where parsing stopped).
static const char *parseIndexList(const char *p, int32_t *indices, int32_t *outCount) {
    int32_t n = 0;

    while (*p && *p != ')' && n < BAS_ARRAY_MAX_DIMS) {
        const char *start = p;

        p = dvxSkipWs(p);
        indices[n++] = atoi(p);

        // Skip past the number
        if (*p == '-') {
            p++;
        }

        while (*p >= '0' && *p <= '9') {
            p++;
        }

        p = dvxSkipWs(p);

        if (*p == ',') {
            p++;
        }

        // Anything unparseable would otherwise spin forever
        if (p == start) {
            break;
        }
    }

    if (*p == ')') {
        p++;
    }

    *outCount = n;
    return p;
}


// parseProcs -- split source into (General) + per-procedure buffers

static void parseProcs(const char *source) {
    freeProcBufs();
    splitProcs(source ? source : "", &sGeneralBuf, &sProcBufs);
}


// prefsSetRgbLabels -- show the slider values next to the sliders
static void prefsSetRgbLabels(uint8_t r, uint8_t g, uint8_t b) {
    char rBuf[IDE_RGB_LABEL_BUF];
    char gBuf[IDE_RGB_LABEL_BUF];
    char bBuf[IDE_RGB_LABEL_BUF];

    snprintf(rBuf, sizeof(rBuf), "%d", (int)r);
    snprintf(gBuf, sizeof(gBuf), "%d", (int)g);
    snprintf(bBuf, sizeof(bBuf), "%d", (int)b);
    wgtSetText(sPrefsDlg.lblR, rBuf);
    wgtSetText(sPrefsDlg.lblG, gBuf);
    wgtSetText(sPrefsDlg.lblB, bBuf);
}


static void prefsUpdateColorSliders(void) {
    int32_t idx = wgtListBoxGetSelected(sPrefsDlg.colorList);

    if (idx < 0 || idx >= SYNTAX_COLOR_COUNT) {
        return;
    }

    uint32_t c = sPrefsDlg.syntaxColors[idx];
    uint8_t  r = (c >> 16) & 0xFF;
    uint8_t  g = (c >> 8) & 0xFF;
    uint8_t  b = c & 0xFF;

    wgtSliderSetValue(sPrefsDlg.sliderR, r);
    wgtSliderSetValue(sPrefsDlg.sliderG, g);
    wgtSliderSetValue(sPrefsDlg.sliderB, b);
    prefsSetRgbLabels(r, g, b);
    prefsUpdateSwatch();
}


static void prefsUpdateSwatch(void) {
    if (!sPrefsDlg.colorSwatch) {
        return;
    }

    uint8_t r = (uint8_t)wgtSliderGetValue(sPrefsDlg.sliderR);
    uint8_t g = (uint8_t)wgtSliderGetValue(sPrefsDlg.sliderG);
    uint8_t b = (uint8_t)wgtSliderGetValue(sPrefsDlg.sliderB);

    uint32_t color = packColor(&sAc->display, r, g, b);
    wgtCanvasClear(sPrefsDlg.colorSwatch, color);
}


static void printCallback(void *ctx, const char *text, bool newline) {
    (void)ctx;

    if (!text) {
        return;
    }

    int32_t textLen = (int32_t)strlen(text);

    // Append to output buffer
    if (sOutputLen + textLen < IDE_MAX_OUTPUT - 2) {
        memcpy(sOutputBuf + sOutputLen, text, textLen);
        sOutputLen += textLen;
    }

    if (newline && sOutputLen < IDE_MAX_OUTPUT - 2) {
        sOutputBuf[sOutputLen++] = '\n';
    }

    // Defend against a caller that accumulated past the cap: never index the
    // terminator out of bounds.
    if (sOutputLen >= IDE_MAX_OUTPUT) {
        sOutputLen = IDE_MAX_OUTPUT - 1;
    }

    sOutputBuf[sOutputLen] = '\0';

    // Update the output textarea immediately so PRINT is visible
    if (sOutput) {
        setOutputText(sOutputBuf);
    }
}


// procBufContains -- true if needle occurs anywhere in a proc buffer.
static bool procBufContains(const char *hay, const char *needle, int32_t needleLen, bool caseSensitive) {
    if (!hay) {
        return false;
    }

    if (caseSensitive) {
        return strstr(hay, needle) != NULL;
    }

    return findSubstrNoCase(hay, needle, needleLen) != NULL;
}


// procDeclAt -- if p (already whitespace-skipped) starts a SUB or
// FUNCTION declaration, returns the text after the keyword; else NULL.
static const char *procDeclAt(const char *p, bool *outIsSub) {
    if (kwMatch(p, KW_SUB)) {
        *outIsSub = true;
        return p + KW_LEN(KW_SUB);
    }

    if (kwMatch(p, KW_FUNCTION)) {
        *outIsSub = false;
        return p + KW_LEN(KW_FUNCTION);
    }

    return NULL;
}


// procNameFromDecl -- copy the procedure name that follows a SUB/FUNCTION
// keyword (stops at '(' or whitespace).
static void procNameFromDecl(const char *afterKw, char *out, int32_t outSize) {
    const char *p = dvxSkipWs(afterKw);
    int32_t     n = 0;

    while (*p && *p != '(' && *p != ' ' && *p != '\t' && *p != '\r' && *p != '\n' && n < outSize - 1) {
        out[n++] = *p++;
    }

    out[n] = '\0';
}


// promptAndSave -- ask user to save, discard, or cancel.
// Returns true if the caller should proceed (user saved or discarded).
// Returns false if the user cancelled or a save failed.

static bool promptAndSave(void) {
    if (!hasUnsavedData()) {
        return true;
    }

    int32_t result = dvxPromptSave(sAc, IDE_MAIN_TITLE);

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

    return result == DVX_SAVE_NO;
}


// rebuildProcTable -- scan canonical-layout source for SUB/FUNCTION
// declarations and record each proc's object/event split plus its line
// range.  Procedure names are split on '_' into ObjectName and EventName
// (e.g. "Command1_Click") using the designer's known object names.

static void rebuildProcTable(const char *src) {
    arrsetlen(sProcTable, 0);

    // Collect all known object names once; they only depend on sDesigner.form
    // and never change during the proc scan below.
    const char **objNames = NULL; // stb_ds temp array

    if (sDesigner.form) {
        arrput(objNames, sDesigner.form->name);

        for (int32_t ci = 0; ci < (int32_t)arrlen(sDesigner.form->controls); ci++) {
            arrput(objNames, sDesigner.form->controls[ci]->name);
        }

        for (int32_t mi = 0; mi < (int32_t)arrlen(sDesigner.form->menuItems); mi++) {
            arrput(objNames, sDesigner.form->menuItems[mi].name);
        }
    }

    // Scan line by line for SUB / FUNCTION
    const char *pos     = src;
    int32_t     lineNum = 1;

    while (*pos) {
        bool        isSub   = false;
        const char *afterKw = procDeclAt(dvxSkipWs(pos), &isSub);

        if (!afterKw) {
            pos = skipLine(pos);
            lineNum++;
            continue;
        }

        char procName[BAS_MAX_IDENT];
        procNameFromDecl(afterKw, procName, sizeof(procName));

        // Find End Sub / End Function and record its line.
        int32_t     endOffset = 0;
        const char *scan      = skipToEndProc(pos, isSub, &endOffset);

        IdeProcEntryT entry;
        memset(&entry, 0, sizeof(entry));
        entry.lineNum    = lineNum;
        entry.endLineNum = lineNum + endOffset;

        // Match proc name against known objects: form name, controls,
        // menu items. Try each as a prefix followed by "_". This handles
        // names with multiple underscores correctly (e.g., "cmdOK_Click"
        // matches "cmdOK", not "This_Is_A_Dumb_Name" matching a control
        // named "This").
        bool isEvent = false;

        for (int32_t oi = 0; oi < (int32_t)arrlen(objNames); oi++) {
            int32_t objLen = (int32_t)strlen(objNames[oi]);

            if (objLen > 0 && strncasecmp(procName, objNames[oi], objLen) == 0 && procName[objLen] == '_') {
                snprintf(entry.objName, sizeof(entry.objName), "%s", objNames[oi]);
                snprintf(entry.evtName, sizeof(entry.evtName), "%s", procName + objLen + 1);
                isEvent = true;
                break;
            }
        }

        if (!isEvent) {
            snprintf(entry.objName, sizeof(entry.objName), "%s", IDE_GENERAL_SECTION);
            snprintf(entry.evtName, sizeof(entry.evtName), "%s", procName);
        }

        arrput(sProcTable, entry);

        // Skip to end of this proc, counting the lines we passed
        for (const char *c = pos; c < scan; c++) {
            if (*c == '\n') {
                lineNum++;
            }
        }

        pos = scan;
    }

    arrfree(objNames);
}


static void recentAdd(const char *path) {
    if (!path || !path[0]) {
        return;
    }

    // If already in the list, move it to the top
    for (int32_t i = 0; i < (int32_t)arrlen(sRecentFiles); i++) {
        if (strcasecmp(sRecentFiles[i], path) == 0) {
            char *existing = sRecentFiles[i];
            arrdel(sRecentFiles, i);
            arrins(sRecentFiles, 0, existing);
            recentSave();
            recentRebuildMenu();
            return;
        }
    }

    // If at cap, drop the oldest entry (last)
    while ((int32_t)arrlen(sRecentFiles) >= CMD_RECENT_MAX) {
        int32_t last = (int32_t)arrlen(sRecentFiles) - 1;
        free(sRecentFiles[last]);
        arrdel(sRecentFiles, last);
    }

    // Insert new entry at the top
    char *copy = strdup(path);

    if (!copy) {
        return;
    }

    arrins(sRecentFiles, 0, copy);
    recentSave();
    recentRebuildMenu();
}


static void recentLoad(void) {
    // Clear any prior entries
    for (int32_t i = 0; i < (int32_t)arrlen(sRecentFiles); i++) {
        free(sRecentFiles[i]);
    }

    arrsetlen(sRecentFiles, 0);

    if (!sPrefs) {
        return;
    }

    for (int32_t i = 0; i < CMD_RECENT_MAX; i++) {
        char key[IDE_PREF_KEY_BUF];
        snprintf(key, sizeof(key), PREF_KEY_RECENT_FMT, (long)i);
        const char *val = prefsGetString(sPrefs, PREF_SEC_RECENT, key, "");

        if (val[0]) {
            char *copy = strdup(val);

            if (copy) {
                arrput(sRecentFiles, copy);
            }
        }
    }
}


static void recentOpen(int32_t index) {
    if (index < 0 || index >= (int32_t)arrlen(sRecentFiles)) {
        return;
    }

    // recentAdd reorders the list, so work from a private copy of the path
    char path[DVX_MAX_PATH];
    snprintf(path, sizeof(path), "%s", sRecentFiles[index]);

    const char *ext = strrchr(path, '.');

    if (ext && strcasecmp(ext, ".dbp") == 0) {
        // Project file
        if (!promptAndSave()) {
            return;
        }

        closeProject();
        openProjectPath(path);
    } else {
        openSinglePath(path);
    }
}


static void recentRebuildMenu(void) {
    if (!sFileMenu) {
        return;
    }

    // Truncate menu back to the base items (everything through Exit)
    sFileMenu->itemCount = sFileMenuBase;

    int32_t count = (int32_t)arrlen(sRecentFiles);

    if (count == 0) {
        return;
    }

    // Append separator + recent file items after Exit
    wmAddMenuSeparator(sFileMenu);

    for (int32_t i = 0; i < count; i++) {
        // Show just the filename for shorter labels
        const char *name = platformPathBaseName(sRecentFiles[i]);

        char label[MAX_MENU_LABEL];
        snprintf(label, sizeof(label), "&%ld %s", (long)(i + 1), name);
        wmAddMenuItem(sFileMenu, label, CMD_RECENT_BASE + i);
    }
}


static void recentSave(void) {
    if (!sPrefs) {
        return;
    }

    int32_t count = (int32_t)arrlen(sRecentFiles);

    for (int32_t i = 0; i < CMD_RECENT_MAX; i++) {
        char key[IDE_PREF_KEY_BUF];
        snprintf(key, sizeof(key), PREF_KEY_RECENT_FMT, (long)i);
        prefsSetString(sPrefs, PREF_SEC_RECENT, key, i < count ? sRecentFiles[i] : "");
    }

    prefsSave(sPrefs);
}


static void removeBreakpointsForFile(int32_t fileIdx) {
    // Remove breakpoints for the given file and adjust indices for
    // files above the removed one (since file indices shift down).
    for (int32_t i = (int32_t)arrlen(sBreakpoints) - 1; i >= 0; i--) {
        if (sBreakpoints[i].fileIdx == fileIdx) {
            arrdel(sBreakpoints, i);
        } else if (sBreakpoints[i].fileIdx > fileIdx) {
            sBreakpoints[i].fileIdx--;
        }
    }

    syncVmBreakpoints();
    updateBreakpointWindow();
}


// renameInBuffer -- word-boundary rename of OldName followed by '.' or
// '_'.  The character before the match must be a non-identifier
// character to avoid replacing "Command1" inside "MyCommand1".
static char *renameInBuffer(const char *src, const char *oldName, const char *newName) {
    if (!src || !oldName || !newName || !oldName[0]) {
        return NULL;
    }

    int32_t oldLen = (int32_t)strlen(oldName);
    int32_t newLen = (int32_t)strlen(newName);
    int32_t srcLen = (int32_t)strlen(src);
    bool    skipComments = prefsGetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_RENAME_SKIP, true);

    // First pass: count replacements to compute output size
    int32_t count = 0;

    for (int32_t i = 0; i <= srcLen - oldLen; i++) {
        if (strncasecmp(&src[i], oldName, oldLen) != 0) {
            continue;
        }

        char after = src[i + oldLen];

        if (after != '.' && after != '_') {
            continue;
        }

        if (i > 0 && basIsIdentChar(src[i - 1])) {
            continue;
        }

        if (skipComments && isInStringOrComment(src, i)) {
            continue;
        }

        count++;
    }

    if (count == 0) {
        return NULL;
    }

    // Allocate output
    int32_t outLen = srcLen + count * (newLen - oldLen);
    char *out = (char *)malloc(outLen + 1);

    if (!out) {
        return NULL;
    }

    // Second pass: build output
    int32_t op = 0;

    for (int32_t i = 0; i < srcLen; ) {
        if (i <= srcLen - oldLen &&
            strncasecmp(&src[i], oldName, oldLen) == 0) {
            char after = src[i + oldLen];

            if ((after == '.' || after == '_') &&
                (i == 0 || !basIsIdentChar(src[i - 1])) &&
                (!skipComments || !isInStringOrComment(src, i))) {
                memcpy(out + op, newName, newLen);
                op += newLen;
                i  += oldLen;
                continue;
            }
        }

        out[op++] = src[i++];
    }

    out[op] = '\0';
    return out;
}


// resolveVarPath -- parse a variable reference and resolve it to a
// pointer into the running VM's live data.  Handles:
//   varName            -- scalar variable
//   varName(i)         -- array element
//   varName.field      -- UDT field
//   varName(i).field   -- array element UDT field
// Returns NULL if it can't be resolved.  *endPtr is set past the path.

static BasValueT *resolveVarPath(const char *path, const char **endPtr) {
    const char *p = path;

    // Extract variable name (identifier plus optional type suffix)
    const char *nameStart = p;

    while (basIsIdentChar(*p)) {
        p++;
    }

    if (p == nameStart) {
        return NULL;
    }

    if (basIsTypeSuffixChar(*p)) {
        p++;
    }

    char varName[IDE_FULL_NAME_BUF];
    int32_t nameLen = (int32_t)(p - nameStart);

    if (nameLen >= (int32_t)sizeof(varName)) {
        return NULL;
    }

    memcpy(varName, nameStart, nameLen);
    varName[nameLen] = '\0';

    // Look up the base variable
    const BasDebugVarT *dv = findDebugVar(varName);

    if (!dv) {
        return NULL;
    }

    BasValueT *slot = getDebugVarSlot(dv);

    if (!slot) {
        return NULL;
    }

    // Parse optional array subscript: (idx1, idx2, ...)
    if (*p == '(') {
        if (slot->type != BAS_TYPE_ARRAY || !slot->arrVal) {
            return NULL;
        }

        int32_t indices[BAS_ARRAY_MAX_DIMS];
        int32_t numIndices = 0;

        p = parseIndexList(p + 1, indices, &numIndices);

        int32_t flatIdx = basArrayIndex(slot->arrVal, indices, numIndices);

        if (flatIdx < 0 || flatIdx >= slot->arrVal->totalElements) {
            return NULL;
        }

        slot = &slot->arrVal->elements[flatIdx];
    }

    // Parse optional UDT field: .fieldName
    if (*p == '.') {
        p++; // skip '.'

        if (slot->type != BAS_TYPE_UDT || !slot->udtVal) {
            return NULL;
        }

        const char *fieldStart = p;

        while (basIsIdentChar(*p)) {
            p++;
        }

        char fieldName[IDE_FULL_NAME_BUF];
        int32_t fieldLen = (int32_t)(p - fieldStart);

        if (fieldLen <= 0 || fieldLen >= (int32_t)sizeof(fieldName)) {
            return NULL;
        }

        memcpy(fieldName, fieldStart, fieldLen);
        fieldName[fieldLen] = '\0';

        int32_t fieldIdx = findUdtFieldIdx(slot->udtVal->typeId, fieldName);

        if (fieldIdx < 0 || fieldIdx >= slot->udtVal->fieldCount) {
            return NULL;
        }

        slot = &slot->udtVal->fields[fieldIdx];
    }

    if (endPtr) {
        *endPtr = p;
    }

    return slot;
}


static void runCached(void) {
    if (!sCachedModule) {
        setStatus("No compiled program. Press F5 to compile first.");
        return;
    }

    clearOutput();
    runModule(sCachedModule);
}


static void runModule(BasModuleT *mod) {
    // Create VM
    BasVmT *vm = basVmCreate();

    if (!vm) {
        setStatus("Out of memory creating VM.");
        return;
    }

    // Create form runtime (bridges UI opcodes to DVX widgets)
    BasFormRtT *formRt = basFormRtCreate(sAc, vm, mod);

    if (!formRt) {
        basVmDestroy(vm);
        setStatus("Out of memory creating form runtime.");
        return;
    }

    setStatus("Running...");
    closeFindDialog();

    // Hide the designer, code and project windows while the program runs;
    // they are restored (where they were visible) when it ends.
    bool hadFormWin   = sFormWin    && sFormWin->visible;
    bool hadToolbox   = sToolboxWin && sToolboxWin->visible;
    bool hadProps     = sPropsWin   && sPropsWin->visible;
    bool hadCodeWin   = sCodeWin    && sCodeWin->visible;
    bool hadPrjWin    = sProjectWin && sProjectWin->visible;

    if (sFormWin) {
        dvxHideWindow(sAc, sFormWin);
    }

    if (sToolboxWin) {
        dvxHideWindow(sAc, sToolboxWin);
    }

    if (sPropsWin) {
        dvxHideWindow(sAc, sPropsWin);
    }

    if (sCodeWin) {
        dvxHideWindow(sAc, sCodeWin);
    }

    if (sProjectWin) {
        dvxHideWindow(sAc, sProjectWin);
    }

    basVmLoadModule(vm, mod);

    // Set App.Path/Config/Data. In the IDE, config and data live under
    // the project directory so everything stays together during development.
    // Standalone apps use the DVX root-level CONFIG/ and DATA/ directories
    // since the app directory (on CD-ROM) is read-only.
    snprintf(vm->appPath, sizeof(vm->appPath), "%s", sProject.projectDir);
    snprintf(vm->appConfig, sizeof(vm->appConfig), "%s" DVX_PATH_SEP "CONFIG", sProject.projectDir);
    snprintf(vm->appData, sizeof(vm->appData), "%s" DVX_PATH_SEP "DATA", sProject.projectDir);

    platformMkdirRecursive(vm->appConfig);
    platformMkdirRecursive(vm->appData);

    // Set up implicit main frame
    vm->callStack[0].localCount = mod->globalCount > BAS_VM_MAX_LOCALS ? BAS_VM_MAX_LOCALS : mod->globalCount;
    vm->callDepth = 1;

    // Set I/O callbacks
    basVmSetPrintCallback(vm, printCallback, NULL);
    basVmSetInputCallback(vm, inputCallback, NULL);
    basVmSetDoEventsCallback(vm, doEventsCallback, NULL);
    vm->breakpointFn  = onBreakpointHit;
    vm->breakpointCtx = NULL;

    // Set SQL callbacks
    BasSqlCallbacksT sqlCb;
    memset(&sqlCb, 0, sizeof(sqlCb));
    sqlCb.sqlOpen         = dvxSqlOpen;
    sqlCb.sqlClose        = dvxSqlClose;
    sqlCb.sqlExec         = dvxSqlExec;
    sqlCb.sqlError        = dvxSqlError;
    sqlCb.sqlQuery        = dvxSqlQuery;
    sqlCb.sqlNext         = dvxSqlNext;
    sqlCb.sqlEof          = dvxSqlEof;
    sqlCb.sqlFieldCount   = dvxSqlFieldCount;
    sqlCb.sqlFieldName    = dvxSqlFieldName;
    sqlCb.sqlFieldText    = dvxSqlFieldText;
    sqlCb.sqlFieldByName  = dvxSqlFieldByName;
    sqlCb.sqlFieldInt     = dvxSqlFieldInt;
    sqlCb.sqlFieldDbl     = dvxSqlFieldDbl;
    sqlCb.sqlFreeResult   = dvxSqlFreeResult;
    sqlCb.sqlAffectedRows = dvxSqlAffectedRows;
    basVmSetSqlCallbacks(vm, &sqlCb);

    // Set extern library callbacks (DECLARE LIBRARY support)
    BasExternCallbacksT extCb;
    extCb.resolveExtern = basExternResolve;
    extCb.callExtern    = basExternCall;
    extCb.ctx           = NULL;
    basVmSetExternCallbacks(vm, &extCb);

    // The IDE surfaces runtime errors via the Output pane and jumps
    // the editor to the faulting line, so suppress the form runtime's
    // modal error dialog.  Keeping the dialog on top of an already-
    // modal-heavy IDE session was producing phantom "press OK twice"
    // UX as the dialog's nested event pump raced with other queued
    // events coming out of the user's program.
    formRt->suppressErrorDialog = true;

    sVm            = vm;
    sDbgFormRt     = formRt;
    sDbgModule     = mod;
    sDbgState      = DBG_RUNNING;
    sStopRequested = false;

    // Set project help file on form runtime for F1 context help
    if (sProject.helpFile[0]) {
        snprintf(formRt->helpFile, sizeof(formRt->helpFile), "%s" DVX_PATH_SEP "%s",
                 sProject.projectDir, sProject.helpFile);
    }

    updateProjectMenuState();

    // Set breakpoints BEFORE loading forms so breakpoints in form
    // init code (module-level statements inside BEGINFORM) fire.
    if (sDbgEnabled) {
        syncVmBreakpoints();

        if (sDbgBreakOnStart) {
            basVmStepInto(vm);
        }
    }

    // Load forms from project files (AFTER breakpoints are set so
    // init code breakpoints fire), then show the startup form.
    loadFrmFiles(formRt);
    basFormRtLoadAllForms(formRt, sProject.startupForm);

    // Run in slices of 10000 steps, yielding to DVX between slices
    basVmSetStepLimit(vm, BAS_VM_DEFAULT_STEP_SLICE);

    BasVmResultE result = BAS_VM_HALTED;

    for (;;) {
        if (!sWin || !sAc->running || sStopRequested) {
            break;
        }

        if (sDbgState == DBG_PAUSED) {
            // Paused at breakpoint/step -- spin on GUI events until user acts
            dvxUpdate(sAc);

            // User may have pressed F5 (continue), F8 (step), or Esc (stop)
            if (sDbgState == DBG_RUNNING) {
                vm->running = true;
            }

            continue;
        }

        result = basVmRun(vm);

        if (result == BAS_VM_BREAKPOINT) {
            sDbgState       = DBG_PAUSED;
            sDbgCurrentLine = vm->currentLine;
            debugNavigateToLine(vm->currentLine);
            debugUpdateWindows();
            setStatus("Paused.");
            continue;
        }

        if (result == BAS_VM_STEP_LIMIT) {
            dvxUpdate(sAc);
            continue;
        }

        if (result == BAS_VM_HALTED) {
            break;
        }

        // Module-level runtime error.  For VM-internal errors (e.g.
        // division by zero) there's no vm->errorLine set, so fall
        // back to vm->currentLine -- but copy it into errorLine so
        // the shared post-loop handler below can format and navigate
        // uniformly with the event-handler error path.
        if (vm->errorMsg[0] == '\0') {
            const char *msg = basVmGetError(vm);
            snprintf(vm->errorMsg, sizeof(vm->errorMsg), "%s", msg ? msg : "Runtime error");
        }

        if (vm->errorLine == 0 && vm->currentLine > 0) {
            vm->errorLine = vm->currentLine;
        }

        break;
    }

    // VB-style event loop: after module-level code finishes,
    // keep processing events as long as any form is loaded.
    // The program ends when all forms are unloaded (closed).
    // Breakpoints inside event handlers pause inside the VM's own
    // spin (basVmCallSub -> doEventsCallback), so nothing here needs
    // to watch for DBG_PAUSED.
    if (result == BAS_VM_HALTED && (int32_t)arrlen(formRt->forms) > 0) {
        setStatus("Running (event loop)...");

        while (sWin && sAc->running && (int32_t)arrlen(formRt->forms) > 0 && !sStopRequested && !vm->ended) {
            dvxUpdate(sAc);
        }
    }

    // A runtime error fired from a host callback (e.g. setting a
    // property on an unknown control) halts execution by writing
    // vm->errorMsg and setting vm->ended.  basVmRun surfaces that as
    // BAS_VM_ERROR for module-level code, but errors raised inside an
    // event handler just break the loop above.  Either way, report
    // the message and navigate the editor to the offending line so
    // the user can fix it.  debugNavigateToLine depends on sVm and
    // sDbgModule, so it has to run BEFORE the cleanup block below.
    // Use vm->errorLine (snapshot at error time) rather than
    // vm->currentLine: the dialog/event pump can fire another handler
    // whose OP_LINE overwrites currentLine before we read it here.
    bool    hadRuntimeError = (vm->errorMsg[0] != '\0') && sWin;
    int32_t errorLine       = hadRuntimeError ? vm->errorLine : 0;

    if (hadRuntimeError) {
        // Map the concatenated line to a file / proc-local line the
        // same way compile errors do, so the Output message matches
        // what the editor is actually showing.
        int32_t errFileIdx   = -1;
        int32_t errLocalLine = errorLine;
        const char *errFile  = "";

        if (errorLine > 0 && sProject.fileCount > 0 && sProject.sourceMapCount > 0) {
            prjMapLine(&sProject, errorLine, &errFileIdx, &errLocalLine);

            if (errFileIdx >= 0 && errFileIdx < sProject.fileCount) {
                errFile = sProject.files[errFileIdx].path;
            }
        }

        // Activate the file (populates sProcTable for that file), then
        // find the proc whose body actually contains errLocalLine.
        // A simple "last proc with lineNum <= errLocalLine" match is
        // wrong when the error is module-level code between procs:
        // it lands on whichever proc just finished instead of
        // correctly reporting that the error is outside any sub.
        // Use the proc's endLineNum to gate membership.
        char    procName[IDE_FULL_NAME_BUF] = {0};
        int32_t procLine                    = errLocalLine;

        if (errFileIdx >= 0) {
            activateFile(errFileIdx, ViewCodeE);

            int32_t procCount = (int32_t)arrlen(sProcTable);

            for (int32_t i = 0; i < procCount; i++) {
                if (errLocalLine >= sProcTable[i].lineNum &&
                    errLocalLine <= sProcTable[i].endLineNum) {
                    snprintf(procName, sizeof(procName), "%s.%s",
                             sProcTable[i].objName, sProcTable[i].evtName);
                    procLine = errLocalLine - sProcTable[i].lineNum + 1;
                    break;
                }
            }

            navigateToCodeLine(errFileIdx, errLocalLine, procName[0] ? procName : NULL, false);
        }

        // emitClamped clamps the running length to IDE_MAX_OUTPUT-1 on
        // truncation; a bare snprintf would return the would-be length and
        // push sOutputLen past the cap, later OOB-writing the NUL terminator.
        if (procName[0]) {
            sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, sOutputLen,
                                     "\nRUNTIME ERROR:\n%s line %d: %s\n",
                                     procName, (int)procLine, vm->errorMsg);
        } else if (errFile[0]) {
            sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, sOutputLen,
                                     "\nRUNTIME ERROR:\n%s line %d: %s\n",
                                     errFile, (int)errLocalLine, vm->errorMsg);
        } else {
            sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, sOutputLen,
                                     "\nRUNTIME ERROR:\nline %d: %s\n",
                                     (int)errLocalLine, vm->errorMsg);
        }
    }

    sVm             = NULL;
    sDbgFormRt      = NULL;
    sDbgModule      = NULL;
    sDbgState       = DBG_IDLE;
    sDbgCurrentLine = -1;
    sDbgEnabled     = false;

    basFormRtDestroy(formRt);
    basVmDestroy(vm);

    // If the IDE was closed while the program was running, skip all UI
    // updates -- the windows are already destroyed -- and release the
    // module onClose left for us.
    if (!sWin) {
        if (sCachedModule) {
            basModuleFree(sCachedModule);
            sCachedModule = NULL;
        }

        return;
    }

    updateProjectMenuState();
    setOutputText(sOutputBuf);

    setStatus(hadRuntimeError ? "Runtime error." : "Done.");

    // Restore IDE windows.  On a runtime error we want the code
    // window on top so the user lands on the offending line; don't
    // pop the form designer / toolbox / project back up, since that
    // would cover the editor (dvxShowWindow raises).  Output window
    // stays up too so the error text is visible.
    if (hadRuntimeError) {
        if (sCodeWin && !sCodeWin->visible) {
            dvxShowWindow(sAc, sCodeWin);
        }

        if (sCodeWin) {
            dvxRaiseWindow(sAc, sCodeWin);
        }
    } else {
        if (hadFormWin && sFormWin) {
            dvxShowWindow(sAc, sFormWin);
        }

        if (hadToolbox && sToolboxWin) {
            dvxShowWindow(sAc, sToolboxWin);
        }

        if (hadProps && sPropsWin) {
            dvxShowWindow(sAc, sPropsWin);
        }

        if (hadCodeWin && sCodeWin) {
            dvxShowWindow(sAc, sCodeWin);
        }

        if (hadPrjWin && sProjectWin) {
            dvxShowWindow(sAc, sProjectWin);
        }
    }

    // Repaint to clear destroyed runtime forms and restore designer
    dvxUpdate(sAc);
}


// saveActiveFile -- write the active file (editor/designer state first
// stashed into its buffer).  Returns false when nothing was written.
static bool saveActiveFile(void) {
    if (!hasProject()) {
        return false;
    }

    int32_t idx = sProject.activeFileIdx;

    if (idx < 0 || idx >= sProject.fileCount) {
        return false;
    }

    // Ensure buffer is up-to-date with editor/designer state
    stashCurrentFile();

    if (!writeProjectFile(idx)) {
        return false;
    }

    setStatus("Saved.");
    updateDirtyIndicators();
    return true;
}


// saveAllModified -- write every modified file plus the .dbp when dirty.
// Returns false if any write failed (the user has already been told).
static bool saveAllModified(void) {
    if (!hasProject()) {
        return true;
    }

    stashCurrentFile();
    syncFormDirty();

    bool ok = true;

    for (int32_t i = 0; i < sProject.fileCount; i++) {
        if (sProject.files[i].modified && !writeProjectFile(i)) {
            ok = false;
        }
    }

    if (sProject.dirty && !saveProjectFile()) {
        ok = false;
    }

    updateDirtyIndicators();
    return ok;
}


// saveCurProc -- save editor contents back to the current buffer.
// Returns true if the proc list was modified (skeleton discarded or
// new procs extracted), meaning sProcBufs indices may have shifted.

static bool saveCurProc(void) {
    if (!sEditor) {
        return false;
    }

    const char *edText = wgtGetText(sEditor);

    if (!edText) {
        return false;
    }

    if (sCurProcIdx == -1) {
        // General section -- check for embedded proc declarations
        char *cleaned = extractNewProcs(edText);
        char *updated = cleaned ? cleaned : strdup(edText);

        if (!updated) {
            return false;
        }

        free(sGeneralBuf);
        sGeneralBuf = updated;

        if (cleaned) {
            wgtSetText(sEditor, sGeneralBuf);
            return true;
        }

        return false;
    }

    if (sCurProcIdx < 0 || sCurProcIdx >= (int32_t)arrlen(sProcBufs)) {
        return false;
    }

    // Get the name of the current proc so we can identify its block
    // regardless of position in the editor.
    char ownName[IDE_FULL_NAME_BUF] = "";

    if (sCurProcIdx < (int32_t)arrlen(sProcTable) && strcmp(sProcTable[sCurProcIdx].objName, IDE_GENERAL_SECTION) != 0) {
        snprintf(ownName, sizeof(ownName), "%s_%s", sProcTable[sCurProcIdx].objName, sProcTable[sCurProcIdx].evtName);
    }

    // General procs (no object prefix) take their name from the
    // declaration line of the existing buffer.
    if (ownName[0] == '\0') {
        bool        isSub   = false;
        const char *afterKw = procDeclAt(dvxSkipWs(sProcBufs[sCurProcIdx]), &isSub);

        procNameFromDecl(afterKw ? afterKw : sProcBufs[sCurProcIdx], ownName, sizeof(ownName));
    }

    // Count Sub/Function blocks in the editor text
    int32_t blockCount = 0;

    for (const char *scan = edText; *scan; scan = skipLine(scan)) {
        bool isSub;

        if (procDeclAt(dvxSkipWs(scan), &isSub)) {
            blockCount++;
        }
    }

    if (blockCount <= 1) {
        // Single proc (or none) -- check for empty skeleton
        bool        isSub   = false;
        const char *p       = dvxSkipWs(edText);
        const char *afterKw = procDeclAt(p, &isSub);
        const char *endTag  = (afterKw && !isSub) ? KW_END_FUNCTION : KW_END_SUB;

        // Skip declaration line
        p = skipLine(p);

        bool bodyEmpty = true;

        while (*p) {
            if (kwMatch(dvxSkipWs(p), endTag)) {
                break;
            }

            while (*p && *p != '\n') {
                if (*p != ' ' && *p != '\t' && *p != '\r') {
                    bodyEmpty = false;
                }

                p++;
            }

            if (*p == '\n') {
                p++;
            }
        }

        if (bodyEmpty) {
            free(sProcBufs[sCurProcIdx]);
            arrdel(sProcBufs, sCurProcIdx);
            sCurProcIdx = -2;
            return true;
        }

        char *copy = strdup(edText);

        if (copy) {
            free(sProcBufs[sCurProcIdx]);
            sProcBufs[sCurProcIdx] = copy;
        }

        return false;
    }

    // Multiple proc blocks in the editor.  Find the one matching
    // ownName, keep it in this buffer, extract the rest.
    const char *pos      = edText;
    const char *ownStart = NULL;
    const char *ownEnd   = NULL;
    char       *extras   = (char *)malloc(strlen(edText) + 1);
    int32_t     extPos   = 0;

    if (!extras) {
        char *copy = strdup(edText);

        if (copy) {
            free(sProcBufs[sCurProcIdx]);
            sProcBufs[sCurProcIdx] = copy;
        }

        return false;
    }

    while (*pos) {
        bool        isSub   = false;
        const char *afterKw = procDeclAt(dvxSkipWs(pos), &isSub);

        if (!afterKw) {
            // Non-proc line (blank lines between blocks etc.) -- skip
            pos = skipLine(pos);
            continue;
        }

        char name[IDE_FULL_NAME_BUF];
        procNameFromDecl(afterKw, name, sizeof(name));

        int32_t     endOffset = 0;
        const char *blockEnd  = skipToEndProc(pos, isSub, &endOffset);

        if (strcasecmp(name, ownName) == 0) {
            ownStart = pos;
            ownEnd   = blockEnd;
        } else {
            // Copy this block to extras
            int32_t blen = (int32_t)(blockEnd - pos);
            memcpy(extras + extPos, pos, blen);
            extPos += blen;
        }

        pos = blockEnd;
    }

    extras[extPos] = '\0';

    // Update this buffer with just the owned proc
    if (ownStart && ownEnd) {
        int32_t keepLen = (int32_t)(ownEnd - ownStart);
        char *kept = (char *)malloc(keepLen + 1);

        if (kept) {
            memcpy(kept, ownStart, keepLen);
            kept[keepLen] = '\0';
            free(sProcBufs[sCurProcIdx]);
            sProcBufs[sCurProcIdx] = kept;
        }
    }

    // Extract extra procs into their own buffers.  extractNewProcs
    // returns leftover (duplicate-named) blocks in a malloc'd
    // buffer; append them to the General section so the compiler
    // reports the duplicate rather than silently losing the code.
    if (extPos > 0) {
        char *remaining = extractNewProcs(extras);

        if (remaining && remaining[0]) {
            int32_t genLen = sGeneralBuf ? (int32_t)strlen(sGeneralBuf) : 0;
            int32_t remLen = (int32_t)strlen(remaining);
            char   *merged = (char *)malloc((size_t)(genLen + remLen + 2));

            if (merged) {
                int32_t mpos = 0;

                if (genLen > 0) {
                    memcpy(merged, sGeneralBuf, (size_t)genLen);
                    mpos = genLen;
                    merged[mpos++] = '\n';
                }

                memcpy(merged + mpos, remaining, (size_t)remLen);
                mpos += remLen;
                merged[mpos] = '\0';
                free(sGeneralBuf);
                sGeneralBuf = merged;
            }
        }

        free(remaining);
    }

    free(extras);

    // Update editor to show only this proc.  Extraction keeps the
    // owned proc at sProcBufs[sCurProcIdx] and only appends new
    // procs to the end, so no earlier index shifts -- report false.
    wgtSetText(sEditor, sProcBufs[sCurProcIdx]);
    return false;
}


// saveFile -- Ctrl+S: the active file plus any other modified forms
static void saveFile(void) {
    if (!hasProject() || sProject.activeFileIdx < 0) {
        return;
    }

    if (!saveActiveFile()) {
        return;
    }

    syncFormDirty();

    for (int32_t i = 0; i < sProject.fileCount; i++) {
        if (i != sProject.activeFileIdx && sProject.files[i].isForm && sProject.files[i].modified) {
            writeProjectFile(i);
        }
    }

    updateDirtyIndicators();
}


// saveProjectFile -- write the .dbp; dirty is only cleared on success
static bool saveProjectFile(void) {
    if (!prjSave(&sProject)) {
        dvxErrorBox(sAc, NULL, "Could not write the project file.");
        return false;
    }

    sProject.dirty = false;
    return true;
}


// selectDropdowns -- set the Object and Event dropdown selections to
// match a given control/event without triggering navigation callbacks.
// The caller is responsible for having already navigated to the proc.

static void selectDropdowns(const char *objName, const char *evtName) {
    if (!sObjDropdown || !sEvtDropdown) {
        return;
    }

    // Select the object
    int32_t objCount = (int32_t)arrlen(sObjItems);

    for (int32_t i = 0; i < objCount; i++) {
        if (strcasecmp(sObjItems[i], objName) == 0) {
            wgtDropdownSetSelected(sObjDropdown, i);
            break;
        }
    }

    // Rebuild the event list for this object but suppress navigation
    bool savedSuppress = sDropdownNavSuppressed;
    sDropdownNavSuppressed = true;
    onObjDropdownChange(sObjDropdown);
    sDropdownNavSuppressed = savedSuppress;

    // Now select the specific event
    int32_t evtCount = (int32_t)arrlen(sEvtItems);

    for (int32_t i = 0; i < evtCount; i++) {
        if (evtLabelMatches(sEvtItems[i], evtName)) {
            wgtDropdownSetSelected(sEvtDropdown, i);
            break;
        }
    }
}


static void setOutputText(const char *text) {
    if (!sOutWin) {
        showOutputWindow();
    }

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

    if (sOutputToLog && text && text[0]) {
        dvxLog("BASIC: %s", text);
    }
}


static void setStatus(const char *text) {
    if (sStatus) {
        wgtSetText(sStatus, text);
    }
}


static void showBreakpointWindow(void) {
    if (sBreakpointWin) {
        dvxShowWindow(sAc, sBreakpointWin);
        dvxRaiseWindow(sAc, sBreakpointWin);
        updateBreakpointWindow();
        return;
    }

    int32_t winW = IDE_BP_WIN_W;
    int32_t winH = IDE_DEBUG_WIN_H;
    int32_t winX = sAc->display.width - winW - IDE_WIN_MARGIN;
    int32_t winY = sAc->display.height - winH - IDE_WIN_MARGIN;

    sBreakpointWin = dvxCreateWindow(sAc, "Breakpoints", winX, winY, winW, winH, true);

    if (sBreakpointWin) {
        sBreakpointWin->onClose    = onBreakpointWinClose;
        sBreakpointWin->onMenu     = onMenu;
        sBreakpointWin->accelTable = sWin ? sWin->accelTable : NULL;
        sBreakpointWin->resizable  = true;

        WidgetT *root = wgtInitWindow(sAc, sBreakpointWin);

        if (root) {
            sBreakpointList = wgtListView(root);

            if (sBreakpointList) {
                sBreakpointList->weight     = WGT_WEIGHT_FILL;
                sBreakpointList->onKeyDown  = onBreakpointListKeyDown;
                sBreakpointList->onDblClick = onBreakpointListDblClick;
                wgtListViewSetMultiSelect(sBreakpointList, true);

                static const ListViewColT cols[] = {
                    { "File",      wgtChars(12), ListViewAlignLeftE },
                    { "Procedure", wgtChars(16), ListViewAlignLeftE },
                    { "Line",      wgtChars(6),  ListViewAlignRightE },
                };

                wgtListViewSetColumns(sBreakpointList, cols, (int32_t)(sizeof(cols) / sizeof(cols[0])));
            }
        }
    }

    updateBreakpointWindow();
}


static void showCallStackWindow(void) {
    if (sCallStackWin) {
        dvxShowWindow(sAc, sCallStackWin);
        dvxRaiseWindow(sAc, sCallStackWin);
        updateCallStackWindow();
        return;
    }

    int32_t winW = IDE_CALLSTACK_WIN_W;
    int32_t winH = IDE_DEBUG_WIN_H;
    int32_t winX = sAc->display.width - winW;
    int32_t winY = toolbarBottom() + IDE_CALLSTACK_WIN_Y_OFF;

    sCallStackWin = dvxCreateWindow(sAc, "Call Stack", winX, winY, winW, winH, true);

    if (sCallStackWin) {
        sCallStackWin->onClose    = onCallStackClose;
        sCallStackWin->onMenu     = onMenu;
        sCallStackWin->accelTable = sWin ? sWin->accelTable : NULL;
        sCallStackWin->resizable  = true;

        WidgetT *root = wgtInitWindow(sAc, sCallStackWin);

        if (root) {
            sCallStackList = wgtListView(root);

            if (sCallStackList) {
                sCallStackList->weight = WGT_WEIGHT_FILL;

                static const ListViewColT cols[] = {
                    { "Procedure", wgtChars(16), ListViewAlignLeftE },
                    { "Line",      wgtChars(6),  ListViewAlignRightE },
                };

                wgtListViewSetColumns(sCallStackList, cols, (int32_t)(sizeof(cols) / sizeof(cols[0])));
            }
        }
    }

    updateCallStackWindow();
}


static void showCodeWindow(void) {
    if (sCodeWin) {
        return; // already open
    }

    int32_t codeY = toolbarBottom();
    int32_t codeH = sAc->display.height - codeY - (IDE_OUTPUT_WIN_H + IDE_OUTPUT_WIN_GAP);

    sCodeWin = dvxCreateWindow(sAc, "Code", 0, codeY, sAc->display.width, codeH, true);

    if (!sCodeWin) {
        return;
    }

    // Ensure position is below the toolbar (dvxCreateWindow may adjust)
    sCodeWin->y          = codeY;
    sCodeWin->onMenu     = onMenu;
    sCodeWin->onFocus    = onContentFocus;
    sCodeWin->onClose    = onCodeWinClose;
    sCodeWin->accelTable = sWin ? sWin->accelTable : NULL;
    sLastFocusWin        = sCodeWin;

    WidgetT *codeRoot = wgtInitWindow(sAc, sCodeWin);

    WidgetT *dropdownRow = wgtHBox(codeRoot);
    dropdownRow->spacing = wgtPixels(IDE_WIDGET_SPACING);

    wgtLabel(dropdownRow, "Object:");

    sObjDropdown = wgtDropdown(dropdownRow);
    sObjDropdown->weight   = WGT_WEIGHT_FILL;
    sObjDropdown->onChange = onObjDropdownChange;
    wgtDropdownSetItems(sObjDropdown, NULL, 0);

    wgtLabel(dropdownRow, "Function:");

    sEvtDropdown = wgtDropdown(dropdownRow);
    sEvtDropdown->weight   = WGT_WEIGHT_FILL;
    sEvtDropdown->onChange = onEvtDropdownChange;
    wgtDropdownSetItems(sEvtDropdown, NULL, 0);

    sEditor = wgtTextArea(codeRoot, IDE_MAX_SOURCE);
    sEditor->weight = WGT_WEIGHT_FILL;
    wgtTextAreaSetColorize(sEditor, basicColorize, NULL);

    // Apply saved syntax colors
    uint32_t initColors[SYNTAX_COLOR_COUNT];

    for (int32_t i = 0; i < SYNTAX_COLOR_COUNT; i++) {
        char key[IDE_PREF_KEY_BUF];
        snprintf(key, sizeof(key), PREF_KEY_COLOR_FMT, (int)i);
        initColors[i] = (uint32_t)prefsGetInt(sPrefs, PREF_SEC_SYNTAX, key, (int32_t)sDefaultSyntaxColors[i]);
    }

    wgtTextAreaSetSyntaxColors(sEditor, initColors, SYNTAX_COLOR_COUNT);

    wgtTextAreaSetLineDecorator(sEditor, debugLineDecorator, sAc);
    wgtTextAreaSetGutterClick(sEditor, onGutterClick);
    wgtTextAreaSetShowLineNumbers(sEditor, true);
    wgtTextAreaSetAutoIndent(sEditor, true);
    wgtTextAreaSetCaptureTabs(sEditor, true);
    wgtTextAreaSetTabWidth(sEditor, prefsGetInt(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_TAB_WIDTH, IDE_DEFAULT_TAB_WIDTH));
    wgtTextAreaSetUseTabChar(sEditor, !prefsGetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_USE_SPACES, true));

    // showProc suppresses onChange while it loads content, so the
    // handler can be attached right away without false dirty marking.
    sEditor->onChange = onEditorChange;

    updateProjectMenuState();
    updateDirtyIndicators();
}


// showCompileError -- present sOutputBuf (already filled) as the
// compile result and leave the busy state.
static void showCompileError(const char *status) {
    setOutputText(sOutputBuf);
    showOutputWindow();

    if (sOutWin) {
        dvxRaiseWindow(sAc, sOutWin);
    }

    setStatus(status);
    dvxSetBusy(sAc, false);
}


static void showImmediateWindow(void) {
    if (sImmWin) {
        return;
    }

    int32_t outH = IDE_OUTPUT_WIN_H;
    int32_t outY = sAc->display.height - outH;

    sImmWin = dvxCreateWindow(sAc, "Immediate", sAc->display.width / 2, outY, sAc->display.width / 2, outH, true);

    if (sImmWin) {
        sImmWin->onFocus     = onContentFocus;
        sImmWin->onMenu      = onMenu;
        sImmWin->accelTable  = sWin ? sWin->accelTable : NULL;
        sLastFocusWin        = sImmWin;

        WidgetT *immRoot = wgtInitWindow(sAc, sImmWin);

        if (immRoot) {
            sImmediate = wgtTextArea(immRoot, IDE_MAX_IMM);

            if (sImmediate) {
                sImmediate->weight    = WGT_WEIGHT_FILL;
                sImmediate->readOnly  = false;
                sImmediate->onChange  = onImmediateChange;
            } else {
                dvxLog("IDE: failed to create immediate TextArea");
            }

        } else {
            dvxLog("IDE: failed to init immediate window root");
        }
    }
}


static void showLocalsWindow(void) {
    if (sLocalsWin) {
        dvxShowWindow(sAc, sLocalsWin);
        dvxRaiseWindow(sAc, sLocalsWin);
        return;
    }

    int32_t winW = IDE_LOCALS_WIN_W;
    int32_t winH = IDE_LOCALS_WIN_H;
    int32_t winX = sAc->display.width - winW;
    int32_t winY = toolbarBottom();

    sLocalsWin = dvxCreateWindow(sAc, "Locals", winX, winY, winW, winH, true);

    if (sLocalsWin) {
        sLocalsWin->onClose    = onLocalsClose;
        sLocalsWin->onMenu     = onMenu;
        sLocalsWin->accelTable = sWin ? sWin->accelTable : NULL;
        sLocalsWin->resizable  = true;

        WidgetT *root = wgtInitWindow(sAc, sLocalsWin);

        if (root) {
            sLocalsList = wgtListView(root);

            if (sLocalsList) {
                sLocalsList->weight = WGT_WEIGHT_FILL;

                static const ListViewColT cols[] = {
                    { "Name",  wgtChars(12),  ListViewAlignLeftE },
                    { "Type",  wgtChars(8),   ListViewAlignLeftE },
                    { "Value", wgtChars(16),  ListViewAlignLeftE },
                };

                wgtListViewSetColumns(sLocalsList, cols, (int32_t)(sizeof(cols) / sizeof(cols[0])));
            }
        }
    }

    updateLocalsWindow();
}


static void showOutputWindow(void) {
    if (sOutWin) {
        return;
    }

    int32_t outH = IDE_OUTPUT_WIN_H;
    int32_t outY = sAc->display.height - outH;

    sOutWin = dvxCreateWindow(sAc, "Output", 0, outY, sAc->display.width / 2, outH, true);

    if (sOutWin) {
        sOutWin->onFocus     = onContentFocus;
        sOutWin->onMenu      = onMenu;
        sOutWin->accelTable  = sWin ? sWin->accelTable : NULL;
        sLastFocusWin        = sOutWin;

        WidgetT *outRoot = wgtInitWindow(sAc, sOutWin);
        sOutput = wgtTextArea(outRoot, IDE_MAX_OUTPUT);
        sOutput->weight   = WGT_WEIGHT_FILL;
        sOutput->readOnly = true;

        if (sOutputLen > 0) {
            setOutputText(sOutputBuf);
        }
    }
}


static void showPreferencesDialog(void) {
    memset(&sPrefsDlg, 0, sizeof(sPrefsDlg));

    WindowT *win = dvxCreateWindowCentered(sAc, "Preferences", IDE_PREFS_WIN_W, IDE_PREFS_WIN_H, false);

    if (!win) {
        return;
    }

    win->maxW = win->w;
    win->maxH = win->h;

    WidgetT *root = wgtInitWindow(sAc, win);
    root->spacing = wgtPixels(IDE_WIDGET_SPACING);

    // ---- Tab control ----
    WidgetT *tabs = wgtTabControl(root);
    tabs->weight = WGT_WEIGHT_FILL;

    // ======== General tab ========
    WidgetT *generalPage = wgtTabPage(tabs, "General");
    generalPage->spacing = wgtPixels(IDE_WIDGET_SPACING);

    // Editor section
    WidgetT *edFrame = wgtFrame(generalPage, "Editor");
    edFrame->spacing = wgtPixels(IDE_WIDGET_SPACING_SM);

    sPrefsDlg.renameSkipComments = wgtCheckbox(edFrame, "Skip comments/strings when renaming");
    wgtCheckboxSetChecked(sPrefsDlg.renameSkipComments, prefsGetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_RENAME_SKIP, true));

    sPrefsDlg.optionExplicit = wgtCheckbox(edFrame, "OPTION EXPLICIT default for new projects");
    wgtCheckboxSetChecked(sPrefsDlg.optionExplicit, prefsGetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_OPTION_EXPLICIT, false));

    WidgetT *tabRow = wgtHBox(edFrame);
    tabRow->spacing = wgtPixels(IDE_WIDGET_SPACING);
    wgtLabel(tabRow, "Tab width:");
    sPrefsDlg.tabWidthInput = wgtTextInput(tabRow, IDE_PREFS_TAB_INPUT_MAX);
    sPrefsDlg.tabWidthInput->maxW = wgtPixels(IDE_PREFS_TAB_INPUT_W);

    char tabBuf[IDE_TAB_WIDTH_BUF];
    snprintf(tabBuf, sizeof(tabBuf), "%d", (int)prefsGetInt(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_TAB_WIDTH, IDE_DEFAULT_TAB_WIDTH));
    wgtSetText(sPrefsDlg.tabWidthInput, tabBuf);

    sPrefsDlg.useSpaces = wgtCheckbox(edFrame, "Insert spaces instead of tabs");
    wgtCheckboxSetChecked(sPrefsDlg.useSpaces, prefsGetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_USE_SPACES, true));

    // Project Defaults section
    WidgetT *prjFrame = wgtFrame(generalPage, "New Project Defaults");
    prjFrame->spacing = wgtPixels(IDE_WIDGET_SPACING_SM);
    prjFrame->weight  = WGT_WEIGHT_FILL;

    WidgetT *r1 = wgtHBox(prjFrame);
    r1->spacing = wgtPixels(IDE_WIDGET_SPACING);
    WidgetT *l1 = wgtLabel(r1, "Author:");
    l1->minW = wgtPixels(IDE_PREFS_LABEL_W);
    sPrefsDlg.defAuthor = wgtTextInput(r1, IDE_PREFS_TEXT_MAX);
    sPrefsDlg.defAuthor->weight = WGT_WEIGHT_FILL;
    wgtSetText(sPrefsDlg.defAuthor, prefsGetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_AUTHOR, ""));

    WidgetT *r2 = wgtHBox(prjFrame);
    r2->spacing = wgtPixels(IDE_WIDGET_SPACING);
    WidgetT *l2 = wgtLabel(r2, "Publisher:");
    l2->minW = wgtPixels(IDE_PREFS_LABEL_W);
    sPrefsDlg.defPublisher = wgtTextInput(r2, IDE_PREFS_TEXT_MAX);
    sPrefsDlg.defPublisher->weight = WGT_WEIGHT_FILL;
    wgtSetText(sPrefsDlg.defPublisher, prefsGetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_PUBLISHER, ""));

    WidgetT *r3 = wgtHBox(prjFrame);
    r3->spacing = wgtPixels(IDE_WIDGET_SPACING);
    WidgetT *l3 = wgtLabel(r3, "Version:");
    l3->minW = wgtPixels(IDE_PREFS_LABEL_W);
    sPrefsDlg.defVersion = wgtTextInput(r3, IDE_PREFS_VERSION_MAX);
    sPrefsDlg.defVersion->weight = WGT_WEIGHT_FILL;
    wgtSetText(sPrefsDlg.defVersion, prefsGetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_VERSION, IDE_PREF_DEFAULT_VERSION));

    WidgetT *r4 = wgtHBox(prjFrame);
    r4->spacing = wgtPixels(IDE_WIDGET_SPACING);
    WidgetT *l4 = wgtLabel(r4, "Copyright:");
    l4->minW = wgtPixels(IDE_PREFS_LABEL_W);
    sPrefsDlg.defCopyright = wgtTextInput(r4, IDE_PREFS_TEXT_MAX);
    sPrefsDlg.defCopyright->weight = WGT_WEIGHT_FILL;
    wgtSetText(sPrefsDlg.defCopyright, prefsGetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_COPYRIGHT, ""));

    wgtLabel(prjFrame, "Description:");
    sPrefsDlg.defDescription = wgtTextArea(prjFrame, IDE_PREFS_DESC_MAX);
    sPrefsDlg.defDescription->weight = WGT_WEIGHT_FILL;
    sPrefsDlg.defDescription->minH  = wgtPixels(IDE_PREFS_DESC_H);
    wgtSetText(sPrefsDlg.defDescription, prefsGetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_DESCRIPTION, ""));

    // ======== Colors tab ========
    WidgetT *colorsPage = wgtTabPage(tabs, "Colors");
    colorsPage->spacing = wgtPixels(IDE_WIDGET_SPACING);

    // Load current colors from prefs (or defaults)
    for (int32_t i = 0; i < SYNTAX_COLOR_COUNT; i++) {
        char key[IDE_PREF_KEY_BUF];
        snprintf(key, sizeof(key), PREF_KEY_COLOR_FMT, (int)i);
        sPrefsDlg.syntaxColors[i] = (uint32_t)prefsGetInt(sPrefs, PREF_SEC_SYNTAX, key, (int32_t)sDefaultSyntaxColors[i]);
    }

    WidgetT *colorsHBox = wgtHBox(colorsPage);
    colorsHBox->spacing = wgtPixels(IDE_WIDGET_SPACING_LG);
    colorsHBox->weight  = WGT_WEIGHT_FILL;

    // Left: color list
    sPrefsDlg.colorList = wgtListBox(colorsHBox);
    sPrefsDlg.colorList->weight   = WGT_WEIGHT_FILL;
    sPrefsDlg.colorList->onChange = onColorListChange;

    wgtListBoxSetItems(sPrefsDlg.colorList, sSyntaxColorNames, SYNTAX_COLOR_COUNT);

    // Right: RGB sliders + value labels + swatch preview
    WidgetT *sliderBox = wgtVBox(colorsHBox);
    sliderBox->spacing = wgtPixels(IDE_WIDGET_SPACING_SM);
    sliderBox->weight  = WGT_WEIGHT_FILL;

    wgtLabel(sliderBox, "Red:");
    sPrefsDlg.sliderR = wgtSlider(sliderBox, 0, BAS_RGB_COMPONENT_MAX);
    sPrefsDlg.sliderR->onChange = onColorSliderChange;
    sPrefsDlg.lblR = wgtLabel(sliderBox, "0");
    wgtLabelSetAlign(sPrefsDlg.lblR, AlignEndE);

    wgtLabel(sliderBox, "Green:");
    sPrefsDlg.sliderG = wgtSlider(sliderBox, 0, BAS_RGB_COMPONENT_MAX);
    sPrefsDlg.sliderG->onChange = onColorSliderChange;
    sPrefsDlg.lblG = wgtLabel(sliderBox, "0");
    wgtLabelSetAlign(sPrefsDlg.lblG, AlignEndE);

    wgtLabel(sliderBox, "Blue:");
    sPrefsDlg.sliderB = wgtSlider(sliderBox, 0, BAS_RGB_COMPONENT_MAX);
    sPrefsDlg.sliderB->onChange = onColorSliderChange;
    sPrefsDlg.lblB = wgtLabel(sliderBox, "0");
    wgtLabelSetAlign(sPrefsDlg.lblB, AlignEndE);

    wgtLabel(sliderBox, "Preview:");
    sPrefsDlg.colorSwatch = wgtCanvas(sliderBox, IDE_SWATCH_W, IDE_SWATCH_H);

    // Start on the Keywords entry (the most commonly customized) and load sliders
    wgtListBoxSetSelected(sPrefsDlg.colorList, SYNTAX_KEYWORD);
    prefsUpdateColorSliders();

    wgtTabControlSetActive(tabs, 0);

    // ---- OK / Cancel ----
    WidgetT *btnRow = wgtHBox(root);
    btnRow->spacing = wgtPixels(IDE_WIDGET_SPACING_LG);
    btnRow->align   = AlignEndE;

    WidgetT *btnOk = wgtButton(btnRow, "OK");
    btnOk->minW    = wgtPixels(IDE_PREFS_BUTTON_W);
    btnOk->onClick = onPrefsOk;

    WidgetT *btnCancel = wgtButton(btnRow, "Cancel");
    btnCancel->minW    = wgtPixels(IDE_PREFS_BUTTON_W);
    btnCancel->onClick = onPrefsCancel;

    dvxFitWindow(sAc, win);

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

    while (!sPrefsDlg.done && sAc->running) {
        dvxUpdate(sAc);
    }

    if (sPrefsDlg.accepted) {
        // General tab
        prefsSetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_RENAME_SKIP, wgtCheckboxIsChecked(sPrefsDlg.renameSkipComments));
        prefsSetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_OPTION_EXPLICIT, wgtCheckboxIsChecked(sPrefsDlg.optionExplicit));
        prefsSetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_USE_SPACES, wgtCheckboxIsChecked(sPrefsDlg.useSpaces));

        const char *tw = wgtGetText(sPrefsDlg.tabWidthInput);
        int32_t tabW   = tw ? atoi(tw) : IDE_DEFAULT_TAB_WIDTH;

        if (tabW < IDE_MIN_TAB_WIDTH) {
            tabW = IDE_MIN_TAB_WIDTH;
        }

        if (tabW > IDE_MAX_TAB_WIDTH) {
            tabW = IDE_MAX_TAB_WIDTH;
        }

        prefsSetInt(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_TAB_WIDTH, tabW);

        if (sEditor) {
            wgtTextAreaSetTabWidth(sEditor, tabW);
            wgtTextAreaSetUseTabChar(sEditor, !wgtCheckboxIsChecked(sPrefsDlg.useSpaces));
        }

        const char *val;

        val = wgtGetText(sPrefsDlg.defAuthor);
        prefsSetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_AUTHOR, val ? val : "");

        val = wgtGetText(sPrefsDlg.defPublisher);
        prefsSetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_PUBLISHER, val ? val : "");

        val = wgtGetText(sPrefsDlg.defVersion);
        prefsSetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_VERSION, val ? val : IDE_PREF_DEFAULT_VERSION);

        val = wgtGetText(sPrefsDlg.defCopyright);
        prefsSetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_COPYRIGHT, val ? val : "");

        val = wgtGetText(sPrefsDlg.defDescription);
        prefsSetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_DESCRIPTION, val ? val : "");

        // Colors tab
        for (int32_t i = 0; i < SYNTAX_COLOR_COUNT; i++) {
            char key[IDE_PREF_KEY_BUF];
            snprintf(key, sizeof(key), PREF_KEY_COLOR_FMT, (int)i);
            prefsSetInt(sPrefs, PREF_SEC_SYNTAX, key, (int32_t)sPrefsDlg.syntaxColors[i]);
        }

        applySyntaxColors();
        prefsSave(sPrefs);
    }

    sAc->modalWindow = prevModal;
    dvxDestroyWindow(sAc, win);
}


// showProc -- display a procedure buffer in the editor

static void showProc(int32_t procIdx) {
    if (!sEditor) {
        return;
    }

    // Save whatever is currently in the editor.
    // If a buffer was deleted (empty skeleton discard), adjust the
    // target index since arrdel shifts everything after it.
    if (sCurProcIdx >= -1) {
        int32_t deletedIdx = sCurProcIdx;

        if (saveCurProc()) {
            // The proc list changed; keep sProcTable in step with it
            joinProcBufs();

            if (deletedIdx >= 0 && procIdx > deletedIdx) {
                procIdx--;
            }
        }
    }

    // Suppress onChange while loading -- setting text is not a user edit
    void (*savedOnChange)(WidgetT *) = sEditor->onChange;
    sEditor->onChange = NULL;

    if (procIdx == -1) {
        wgtSetText(sEditor, sGeneralBuf ? sGeneralBuf : "");
        sCurProcIdx = -1;
    } else if (procIdx >= 0 && procIdx < (int32_t)arrlen(sProcBufs)) {
        wgtSetText(sEditor, sProcBufs[procIdx] ? sProcBufs[procIdx] : "");
        sCurProcIdx = procIdx;
    }

    sEditorLineCount = countLines(wgtGetText(sEditor));
    sEditor->onChange = savedOnChange;
}


// showProcAndFind -- switch to a procedure, sync the dropdowns, and
// select the search match in the editor.

static bool showProcAndFind(int32_t procIdx, const char *needle, bool caseSensitive, bool forward) {
    showProc(procIdx);

    // Sync the Object/Event dropdowns to match
    if (procIdx >= 0 && procIdx < (int32_t)arrlen(sProcTable)) {
        selectDropdowns(sProcTable[procIdx].objName, sProcTable[procIdx].evtName);
    } else if (procIdx == -1 && sObjDropdown) {
        wgtDropdownSetSelected(sObjDropdown, 0); // (General)
    }

    if (sEditor) {
        // showProc leaves the cursor at the top of the proc, which is the
        // wrong anchor for a backward scan (it would find nothing).  When
        // searching backward, move the cursor to the true end of the buffer
        // (offset == len) so the scan walks upward through every match,
        // including any on the final line of a proc with no trailing newline.
        if (!forward) {
            wgtTextAreaCursorToEnd(sEditor);
        }

        return wgtTextAreaFindNext(sEditor, needle, caseSensitive, forward);
    }

    return false;
}


static void showWatchWindow(void) {
    if (sWatchWin) {
        dvxShowWindow(sAc, sWatchWin);
        dvxRaiseWindow(sAc, sWatchWin);
        updateWatchWindow();
        return;
    }

    int32_t winW = IDE_WATCH_WIN_W;
    int32_t winH = IDE_DEBUG_WIN_H;
    int32_t winX = sAc->display.width - winW - IDE_WATCH_WIN_X_GAP;
    int32_t winY = toolbarBottom();

    sWatchWin = dvxCreateWindow(sAc, "Watch", winX, winY, winW, winH, true);

    if (sWatchWin) {
        sWatchWin->onClose    = onWatchClose;
        sWatchWin->onMenu     = onMenu;
        sWatchWin->accelTable = sWin ? sWin->accelTable : NULL;
        sWatchWin->resizable  = true;

        WidgetT *root = wgtInitWindow(sAc, sWatchWin);

        if (root) {
            // Expression input at top
            sWatchInput = wgtTextInput(root, IDE_VALUE_BUF);

            if (sWatchInput) {
                sWatchInput->onKeyDown = onWatchInputKeyDown;
            }

            // Results list below
            sWatchList = wgtListView(root);

            if (sWatchList) {
                sWatchList->weight       = WGT_WEIGHT_FILL;
                sWatchList->onKeyDown    = onWatchListKeyDown;
                sWatchList->onDblClick   = onWatchListDblClick;

                static const ListViewColT cols[] = {
                    { "Expression", wgtChars(14), ListViewAlignLeftE },
                    { "Value",      wgtChars(20), ListViewAlignLeftE },
                };

                wgtListViewSetColumns(sWatchList, cols, (int32_t)(sizeof(cols) / sizeof(cols[0])));
            }
        }
    }

    updateWatchWindow();
}


// skipLine -- advance past the end of the current line (or to the NUL)
static const char *skipLine(const char *p) {
    while (*p && *p != '\n') {
        p++;
    }

    if (*p == '\n') {
        p++;
    }

    return p;
}


// skipToEndProc -- from the start of a SUB/FUNCTION declaration line,
// return the position just past the matching END SUB/END FUNCTION line
// (or the end of the text).  *outEndLineOffset receives the END line's
// distance in lines from the declaration (the last line's when missing).
static const char *skipToEndProc(const char *declLine, bool isSub, int32_t *outEndLineOffset) {
    const char *endTag = isSub ? KW_END_SUB : KW_END_FUNCTION;
    const char *scan   = skipLine(declLine);
    int32_t     line   = 1;
    bool        found  = false;

    while (*scan) {
        if (kwMatch(dvxSkipWs(scan), endTag)) {
            found = true;
            scan  = skipLine(scan);
            break;
        }

        scan = skipLine(scan);
        line++;
    }

    *outEndLineOffset = found ? line : line - 1;
    return scan;
}


// splitProcs -- split source into a (General) buffer plus one buffer per
// SUB/FUNCTION block.  Every line outside a procedure (including any
// between or after procedures) belongs to General, so nothing is lost
// when the file is reassembled.  Caller frees both results.
static void splitProcs(const char *source, char **outGeneral, char ***outProcs) {
    int32_t  srcLen  = (int32_t)strlen(source);
    char    *general = (char *)malloc(srcLen + 2); // room for '\n' + NUL
    int32_t  genLen  = 0;
    char   **procs   = NULL;
    const char *pos  = source;

    while (*pos) {
        bool isSub = false;

        if (procDeclAt(dvxSkipWs(pos), &isSub)) {
            int32_t     endOffset = 0;
            const char *end       = skipToEndProc(pos, isSub, &endOffset);
            int32_t     procLen   = (int32_t)(end - pos);
            char       *procBuf   = (char *)malloc(procLen + 1);

            if (procBuf) {
                memcpy(procBuf, pos, procLen);
                procBuf[procLen] = '\0';
                arrput(procs, procBuf);
            }

            pos = end;
            continue;
        }

        const char *next = skipLine(pos);

        if (general) {
            memcpy(general + genLen, pos, next - pos);
            genLen += (int32_t)(next - pos);
        }

        pos = next;
    }

    if (general) {
        // Trim trailing blank lines; a non-empty section ends in one newline
        while (genLen > 0 && (general[genLen - 1] == '\n' || general[genLen - 1] == '\r' ||
                              general[genLen - 1] == ' '  || general[genLen - 1] == '\t')) {
            genLen--;
        }

        if (genLen > 0) {
            general[genLen++] = '\n';
        }

        general[genLen] = '\0';
    }

    *outGeneral = general;
    *outProcs   = procs;
}


// stashCurrentFile -- stash the currently active file's editor/designer
// state back into its project buffer.  This is caching only -- does not
// mark modified.

static void stashCurrentFile(void) {
    if (sProject.activeFileIdx < 0 || sProject.activeFileIdx >= sProject.fileCount) {
        return;
    }

    PrjFileT *cur = &sProject.files[sProject.activeFileIdx];

    if (cur->isForm && sDesigner.form) {
        // Save editor code back to form->code before serializing
        stashFormCode();

        // Serialize form designer state to .frm text
        char *frmBuf = (char *)malloc(IDE_MAX_SOURCE);

        if (!frmBuf) {
            return;
        }

        int32_t frmLen = dsgnSaveFrm(&sDesigner, frmBuf, IDE_MAX_SOURCE);

        // dsgnSaveFrm returns -1 when the form does not fit; keep the
        // previous buffer rather than a truncated one.
        if (frmLen < 0) {
            free(frmBuf);
            dvxErrorBox(sAc, NULL, "Form is too large to save; its code section does not fit.");
            return;
        }

        frmBuf[frmLen] = '\0';
        free(cur->buffer);
        cur->buffer = frmBuf;
    } else if (!cur->isForm && sEditorFileIdx == sProject.activeFileIdx) {
        // Stash full source (only if editor has this file's code)
        char *copy = strdup(getFullSource());

        if (copy) {
            free(cur->buffer);
            cur->buffer = copy;
        }
    }
}


static void stashDesignerState(void) {
    // If a form is open in the designer, switch to its code view
    if (sDesigner.form && sProject.activeFileIdx >= 0 &&
        sProject.activeFileIdx < sProject.fileCount &&
        sProject.files[sProject.activeFileIdx].isForm) {
        loadFormCodeIntoEditor();

        if (sCodeWin) {
            dvxRaiseWindow(sAc, sCodeWin);
        }

        setStatus("Code view.");
        return;
    }

    stashCurrentFile();

    // Show code window if hidden
    if (sCodeWin && !sCodeWin->visible) {
        dvxShowWindow(sAc, sCodeWin);
    }

    if (sCodeWin) {
        dvxRaiseWindow(sAc, sCodeWin);
    }

    setStatus("Code view.");
}


// stashFormCode -- if the proc buffers belong to the designer's form,
// save them back to form->code.  Uses sEditorFileIdx to know which
// file the proc buffers actually belong to.

static void stashFormCode(void) {
    if (!sDesigner.form || sEditorFileIdx < 0) {
        return;
    }

    if (sEditorFileIdx >= sProject.fileCount || !sProject.files[sEditorFileIdx].isForm) {
        return;
    }

    if (strcasecmp(sProject.files[sEditorFileIdx].formName, sDesigner.form->name) != 0) {
        return;
    }

    char *code = strdup(getFullSource());

    if (code) {
        free(sDesigner.form->code);
        sDesigner.form->code = code;
    }
}


static void switchToDesign(void) {
    stashFormCode();

    // If already open, just bring to front
    if (sFormWin) {
        dvxRaiseWindow(sAc, sFormWin);
        return;
    }

    // If no form is loaded, create a blank one
    if (!sDesigner.form) {
        dsgnNewForm(&sDesigner, IDE_DEFAULT_FORM);
    }

    if (!sDesigner.form) {
        return;
    }

    DsgnFormT *form = sDesigner.form;

    // Create the form designer window using the shared form window builder
    char title[IDE_FORM_TITLE_BUF];
    snprintf(title, sizeof(title), "%s [Design]", form->name);

    WidgetT *root;
    WidgetT *contentBox;

    sFormWin = basFormRtCreateFormWindow(sAc, title, form->layout, form->resizable, false, false, form->width, form->height, 0, 0, &root, &contentBox);

    if (!sFormWin) {
        return;
    }

    sFormWin->visible      = true;
    sFormWin->onClose      = onFormWinClose;
    sFormWin->onMenu       = onFormWinMenu;
    sFormWin->accelTable   = sWin ? sWin->accelTable : NULL;
    sDesigner.formWin      = sFormWin;

    // Build preview menu bar from form's menu items
    dsgnBuildPreviewMenuBar(sFormWin, form);

    // Override paint and mouse AFTER wgtInitWindow (which sets widgetOnPaint)
    sFormWin->onPaint       = onFormWinPaint;
    sFormWin->onMouse       = onFormWinMouse;
    sFormWin->onKey         = onFormWinKey;
    sFormWin->onResize      = onFormWinResize;
    sFormWin->onCursorQuery = onFormWinCursorQuery;

    // Create live widgets for each control
    dsgnCreateWidgets(&sDesigner, contentBox);

    // Set form caption as window title
    if (form->caption[0]) {
        snprintf(title, sizeof(title), "%s [Design]", form->caption);
        dvxSetTitle(sAc, sFormWin, title);
    }

    // Size the form window
    if (form->autoSize) {
        dvxFitWindow(sAc, sFormWin);
        form->width  = sFormWin->w;
        form->height = sFormWin->h;
    } else {
        dvxResizeWindow(sAc, sFormWin, form->width, form->height);
    }

    // Create toolbox and properties windows
    if (!sToolboxWin) {
        sToolboxWin = tbxCreate(sAc, &sDesigner, toolbarBottom());

        if (sToolboxWin) {
            sToolboxWin->onMenu     = onMenu;
            sToolboxWin->accelTable = sWin ? sWin->accelTable : NULL;
        }
    }

    if (!sPropsWin) {
        sPropsWin = prpCreate(sAc, &sDesigner);

        if (sPropsWin) {
            sPropsWin->y          = toolbarBottom();
            sPropsWin->onMenu     = onMenu;
            sPropsWin->accelTable = sWin ? sWin->accelTable : NULL;
        }
    }

    dvxInvalidateWindow(sAc, sFormWin);
    updateProjectMenuState();
    setStatus("Design view open.");
}


// syncFormDirty -- mirror form->dirty onto the project file entry so the
// tree, title bar and save logic all see a single consistent flag.
static void syncFormDirty(void) {
    if (!sDesigner.form || !sDesigner.form->dirty) {
        return;
    }

    int32_t idx = sProject.activeFileIdx;

    if (idx >= 0 && idx < sProject.fileCount && sProject.files[idx].isForm) {
        sProject.files[idx].modified = true;
    }
}


// syncVmBreakpoints -- push the IDE breakpoint list to the live VM (no-op
// when nothing is running or the program was started without debugging).
static void syncVmBreakpoints(void) {
    if (!sVm || !sDbgEnabled) {
        return;
    }

    buildVmBreakpoints();
    basVmSetBreakpoints(sVm, sVmBreakpoints, (int32_t)arrlen(sVmBreakpoints));
}


static void teardownFormWin(void) {
    if (sFormWin) {
        dvxDestroyWindow(sAc, sFormWin);
        cleanupFormWin();
    }
}


static void toggleBreakpoint(void) {
    if (!sEditor) {
        return;
    }

    toggleBreakpointLine(wgtTextAreaGetCursorLine(sEditor));
}


static void toggleBreakpointLine(int32_t editorLine) {
    int32_t fileIdx = sEditorFileIdx;

    if (fileIdx < 0) {
        return;
    }

    // Convert editor line to file code line by adding proc offset
    int32_t codeLine = editorLineToCodeLine(editorLine);

    // Check if this breakpoint already exists -- remove it
    for (int32_t i = 0; i < (int32_t)arrlen(sBreakpoints); i++) {
        if (sBreakpoints[i].fileIdx == fileIdx && sBreakpoints[i].codeLine == codeLine) {
            arrdel(sBreakpoints, i);
            syncVmBreakpoints();

            if (sEditor) {
                wgtInvalidatePaint(sEditor);
            }

            updateBreakpointWindow();
            return;
        }
    }

    // Validate that this line is breakable (not blank, comment, or SUB/FUNCTION decl/end)
    if (sEditor) {
        const char *text = wgtGetText(sEditor);

        if (text) {
            // Find the start of editorLine (1-based)
            const char *p = text;

            for (int32_t ln = 1; *p && ln < editorLine; ln++) {
                p = skipLine(p);
            }

            // Skip leading whitespace
            p = dvxSkipWs(p);

            // Blank line
            if (*p == '\0' || *p == '\n' || *p == '\r') {
                return;
            }

            // Comment (single quote or REM)
            if (*p == '\'') {
                return;
            }

            if (kwMatch(p, KW_REM) && !basIsIdentChar(p[KW_LEN(KW_REM)])) {
                return;
            }

            // SUB/FUNCTION declaration or END SUB/FUNCTION
            bool isSub;

            if (procDeclAt(p, &isSub) || kwMatch(p, KW_END_SUB) || kwMatch(p, KW_END_FUNCTION)) {
                return;
            }
        }
    }

    // Add new breakpoint
    IdeBreakpointT bp;
    memset(&bp, 0, sizeof(bp));
    bp.fileIdx  = fileIdx;
    bp.codeLine = codeLine;

    if (sCurProcIdx >= 0 && sCurProcIdx < (int32_t)arrlen(sProcTable)) {
        snprintf(bp.procName, sizeof(bp.procName), "%s.%s", sProcTable[sCurProcIdx].objName, sProcTable[sCurProcIdx].evtName);
    } else {
        snprintf(bp.procName, sizeof(bp.procName), "%s", IDE_GENERAL_SECTION);
    }

    arrput(sBreakpoints, bp);
    syncVmBreakpoints();

    if (sEditor) {
        wgtInvalidatePaint(sEditor);
    }

    updateBreakpointWindow();
}


static int32_t toolbarBottom(void) {
    return sWin ? sWin->y + sWin->h + IDE_TOOLBAR_GAP : IDE_TOOLBAR_FALLBACK_Y;
}


static const char *typeNameStr(uint8_t dt) {
    switch (dt) {
        case BAS_TYPE_INTEGER:
            return "Integer";

        case BAS_TYPE_LONG:
            return "Long";

        case BAS_TYPE_SINGLE:
            return "Single";

        case BAS_TYPE_DOUBLE:
            return "Double";

        case BAS_TYPE_STRING:
            return "String";

        case BAS_TYPE_BOOLEAN:
            return "Boolean";

        case BAS_TYPE_ARRAY:
            return "Array";

        case BAS_TYPE_UDT:
            return "UDT";

        default:
            return "?";
    }
}


static void updateBreakpointWindow(void) {
    if (!sBreakpointList || !sBreakpointWin || !sBreakpointWin->visible) {
        return;
    }

    listRowsReset(&sBpRows);

    int32_t count = (int32_t)arrlen(sBreakpoints);

    for (int32_t i = 0; i < count; i++) {
        char lineBuf[IDE_LINE_NUM_BUF];

        // File name
        if (sBreakpoints[i].fileIdx >= 0 && sBreakpoints[i].fileIdx < sProject.fileCount) {
            listRowsAdd(&sBpRows, sProject.files[sBreakpoints[i].fileIdx].path);
        } else {
            listRowsAdd(&sBpRows, "?");
        }

        // Procedure name
        listRowsAdd(&sBpRows, sBreakpoints[i].procName);

        // Line number
        snprintf(lineBuf, sizeof(lineBuf), "%d", (int)sBreakpoints[i].codeLine);
        listRowsAdd(&sBpRows, lineBuf);
    }

    listRowsCommit(&sBpRows, sBreakpointList, count);
}


static void updateCallStackWindow(void) {
    if (!sCallStackList || !sCallStackWin || !sCallStackWin->visible) {
        return;
    }

    listRowsReset(&sCallRows);

    if (sDbgState != DBG_PAUSED || !sVm || !sDbgModule) {
        listRowsCommit(&sCallRows, sCallStackList, 0);
        return;
    }

    int32_t rowCount = 0;

    // Current location first
    if (sDbgCurrentLine > 0) {
        char lineBuf[IDE_LINE_NUM_BUF];

        snprintf(lineBuf, sizeof(lineBuf), "%d", (int)sDbgCurrentLine);
        listRowsAdd(&sCallRows, vmProcNameForPc(sVm->pc));
        listRowsAdd(&sCallRows, lineBuf);
        rowCount++;
    }

    // Walk call stack (skip frame 0 which is the implicit module frame)
    for (int32_t d = sVm->callDepth - 2; d >= 0; d--) {
        listRowsAdd(&sCallRows, vmProcNameForPc(sVm->callStack[d + 1].returnPc));
        listRowsAdd(&sCallRows, "");
        rowCount++;
    }

    listRowsCommit(&sCallRows, sCallStackList, rowCount);
}


static void updateDirtyIndicators(void) {
    syncFormDirty();
    updateMainTitle();

    // Code window title -- only shows * when code has been edited
    if (sCodeWin) {
        bool codeDirty = false;

        const char *codeFile = "";

        if (sEditorFileIdx >= 0 && sEditorFileIdx < sProject.fileCount) {
            codeDirty = sProject.files[sEditorFileIdx].modified;
            codeFile  = sProject.files[sEditorFileIdx].path;
        } else if (sProject.activeFileIdx >= 0) {
            codeDirty = sProject.files[sProject.activeFileIdx].modified;
            codeFile  = sProject.files[sProject.activeFileIdx].path;
        }

        char codeTitle[IDE_MSG_BUF];
        snprintf(codeTitle, sizeof(codeTitle), "Code - %s%s", codeFile, codeDirty ? " *" : "");
        dvxSetTitle(sAc, sCodeWin, codeTitle);
    }

    // Design window title
    if (sFormWin && sDesigner.form) {
        char title[IDE_FORM_TITLE_BUF];
        snprintf(title, sizeof(title), "%s [Design]%s",
                 sDesigner.form->caption[0] ? sDesigner.form->caption : sDesigner.form->name,
                 sDesigner.form->dirty ? " *" : "");
        dvxSetTitle(sAc, sFormWin, title);
    }

    // Project tree: rebuild with "*" on dirty files
    if (sProjectWin) {
        prjRebuildTree(&sProject);
    }
}


// updateDropdowns -- refresh sProcTable from the editor's buffers and
// repopulate the Object dropdown (which cascades into the Event dropdown).

static void updateDropdowns(void) {
    // Rebuilds sProcTable as a side effect
    getFullSource();

    // Reset dynamic arrays.  sObjItems owns strdup'd copies -- free them
    // first so no entry survives a subsequent controls/menuItems mutation
    // that could invalidate the source pointer.
    for (int32_t i = 0; i < (int32_t)arrlen(sObjItems); i++) {
        free((void *)sObjItems[i]);
    }

    arrsetlen(sObjItems, 0);
    arrsetlen(sEvtItems, 0);

    if (!sObjDropdown || !sEvtDropdown) {
        return;
    }

    // Build object names for the Object dropdown.  All entries are
    // strdup'd so sObjItems owns its strings -- the designer's menuItems
    // array in particular is an stb_ds struct array whose backing
    // storage can move on the next arrput, which would dangle any name
    // pointer we borrowed from &menuItems[i].name.
    arrput(sObjItems, strdup(IDE_GENERAL_SECTION));

    if (sDesigner.form) {
        arrput(sObjItems, strdup(sDesigner.form->name));

        for (int32_t i = 0; i < (int32_t)arrlen(sDesigner.form->controls); i++) {
            arrput(sObjItems, strdup(sDesigner.form->controls[i]->name));
        }

        // Add menu item names (non-separator, non-top-level-header-only)
        for (int32_t i = 0; i < (int32_t)arrlen(sDesigner.form->menuItems); i++) {
            DsgnMenuItemT *mi = &sDesigner.form->menuItems[i];

            if (mi->name[0] && mi->caption[0] != '-') {
                arrput(sObjItems, strdup(mi->name));
            }
        }
    }

    // A failed strdup would put a NULL name in the dropdown
    for (int32_t i = (int32_t)arrlen(sObjItems) - 1; i >= 0; i--) {
        if (!sObjItems[i]) {
            arrdel(sObjItems, i);
        }
    }

    // Sort object items alphabetically, keeping (General) first
    int32_t objCount = (int32_t)arrlen(sObjItems);

    if (objCount > 1) {
        qsort(sObjItems + 1, objCount - 1, sizeof(const char *), cmpStrPtrs);
    }

    wgtDropdownSetItems(sObjDropdown, sObjItems, objCount);

    if (objCount > 0) {
        wgtDropdownSetSelected(sObjDropdown, 0);
        onObjDropdownChange(sObjDropdown);
    } else {
        wgtDropdownSetItems(sEvtDropdown, NULL, 0);
    }
}


static void updateLocalsWindow(void) {
    if (!sLocalsList || !sLocalsWin || !sLocalsWin->visible) {
        return;
    }

    listRowsReset(&sLocalsRows);

    if (sDbgState != DBG_PAUSED || !sVm || !sDbgModule) {
        listRowsCommit(&sLocalsRows, sLocalsList, 0);
        return;
    }

    // Find which procedure we're in by matching PC to proc table
    int32_t curProcIdx = vmProcIndexForPc(sVm->pc);

    // Collect matching debug vars
    int32_t rowCount = 0;

    if (sDbgModule->debugVars) {
        for (int32_t i = 0; i < sDbgModule->debugVarCount; i++) {
            BasDebugVarT *dv = &sDbgModule->debugVars[i];

            // Show locals for current proc, and globals/form vars
            if (dv->scope == SCOPE_LOCAL && dv->procIndex != curProcIdx) {
                continue;
            }

            if (isMangledDebugName(dv->name)) {
                continue;
            }

            // For form-scope vars, only show if we're in that form's context
            // and the form name matches the current form.
            if (dv->scope == SCOPE_FORM) {
                if (!sVm->currentFormVars) {
                    continue;
                }

                // Match against current form name
                if (dv->formName[0] && sVm->currentForm) {
                    BasFormT *curForm = (BasFormT *)sVm->currentForm;

                    if (strcasecmp(dv->formName, curForm->name) != 0) {
                        continue;
                    }
                }
            }

            char typeBuf[IDE_TYPE_NAME_BUF];
            char valueBuf[BAS_MAX_IDENT];

            // Read the value first so we can use it for the type column
            BasValueT  val;
            BasValueT *slot = getDebugVarSlot(dv);

            if (slot) {
                val = *slot;
            } else {
                memset(&val, 0, sizeof(val));
            }

            // Type column -- arrays show "Array(type)" with element type
            if (dv->dataType == BAS_TYPE_ARRAY && val.arrVal) {
                snprintf(typeBuf, sizeof(typeBuf), "%s()", typeNameStr(val.arrVal->elementType));
            } else {
                snprintf(typeBuf, sizeof(typeBuf), "%s", typeNameStr(dv->dataType));
            }

            formatValue(&val, valueBuf, sizeof(valueBuf));

            listRowsAdd(&sLocalsRows, dv->name);
            listRowsAdd(&sLocalsRows, typeBuf);
            listRowsAdd(&sLocalsRows, valueBuf);
            rowCount++;
        }
    }

    listRowsCommit(&sLocalsRows, sLocalsList, rowCount);
}


// updateMainTitle -- "DVX BASIC - [ProjectName] *" (the * when anything
// is unsaved), or just "DVX BASIC" with no project open.
static void updateMainTitle(void) {
    if (!sWin) {
        return;
    }

    char title[IDE_TITLE_BUF];

    if (hasProject()) {
        bool anyDirty = sProject.dirty;

        for (int32_t i = 0; i < sProject.fileCount && !anyDirty; i++) {
            anyDirty = sProject.files[i].modified;
        }

        snprintf(title, sizeof(title), "%s - [%s]%s", IDE_MAIN_TITLE, sProject.name, anyDirty ? " *" : "");
    } else {
        snprintf(title, sizeof(title), "%s", IDE_MAIN_TITLE);
    }

    dvxSetTitle(sAc, sWin, title);
}


static void updateProjectMenuState(void) {
    if (!sWin || !sWin->menuBar) {
        return;
    }

    bool project    = hasProject();
    bool hasFile    = (project && sProject.activeFileIdx >= 0);
    bool hasForm    = (hasFile && sProject.files[sProject.activeFileIdx].isForm);
    bool isIdle     = (sDbgState == DBG_IDLE);
    bool isPaused   = (sDbgState == DBG_PAUSED);
    bool isRunning  = (sDbgState == DBG_RUNNING);
    bool canRun     = project && (isIdle || isPaused);
    bool canStop    = isRunning || isPaused;

    // Project menu
    wmMenuItemSetEnabled(sWin->menuBar, CMD_PRJ_SAVE,   project && sProject.dirty);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_PRJ_CLOSE,  project && isIdle);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_PRJ_NEW,    isIdle);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_PRJ_OPEN,   isIdle);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_OPEN,       isIdle);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_PRJ_PROPS,  project);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_PRJ_REMOVE, isIdle && prjGetSelectedFileIdx() >= 0);

    // Save: only when active file is dirty
    bool fileDirty = hasFile && sProject.files[sProject.activeFileIdx].modified;
    bool formDirty = hasFile && sDesigner.form && sDesigner.form->dirty;

    wmMenuItemSetEnabled(sWin->menuBar, CMD_SAVE, fileDirty || formDirty);

    // Save All: only when any file is dirty
    bool anyDirty = false;

    for (int32_t i = 0; i < sProject.fileCount && !anyDirty; i++) {
        anyDirty = sProject.files[i].modified;
    }

    if (sDesigner.form && sDesigner.form->dirty) {
        anyDirty = true;
    }

    wmMenuItemSetEnabled(sWin->menuBar, CMD_SAVE_ALL, anyDirty);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_MAKE_EXE, project && isIdle);

    // Edit menu
    wmMenuItemSetEnabled(sWin->menuBar, CMD_FIND,       project);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_FIND_NEXT,  project);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_REPLACE,    project);

    // View menu -- consider both active file and project tree selection
    int32_t selIdx     = prjGetSelectedFileIdx();
    bool    selIsFile  = (project && selIdx >= 0 && selIdx < sProject.fileCount);
    bool    selIsForm  = (selIsFile && sProject.files[selIdx].isForm);
    bool    canCode    = hasFile || selIsFile;
    bool    canDesign  = hasForm || selIsForm;

    wmMenuItemSetEnabled(sWin->menuBar, CMD_VIEW_CODE,    canCode);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_VIEW_DESIGN,  canDesign);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_MENU_EDITOR,  canDesign);

    // Run menu
    wmMenuItemSetEnabled(sWin->menuBar, CMD_RUN,           canRun);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_RUN_NOCMP,    project && isIdle && sCachedModule != NULL);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_DEBUG,         canRun);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_STOP,          canStop);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_STEP_INTO,     canRun);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_STEP_OVER,     isPaused);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_STEP_OUT,      isPaused);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_RUN_TO_CURSOR, isPaused);
    wmMenuItemSetEnabled(sWin->menuBar, CMD_TOGGLE_BP,     hasFile);

    // Toolbar buttons
    if (sTbRun) {
        wgtSetEnabled(sTbRun, canRun);
    }

    if (sTbStop) {
        wgtSetEnabled(sTbStop, canStop);
    }

    if (sTbDebug) {
        wgtSetEnabled(sTbDebug, canRun);
    }

    if (sTbStepInto) {
        wgtSetEnabled(sTbStepInto, canRun);
    }

    if (sTbStepOver) {
        wgtSetEnabled(sTbStepOver, isPaused);
    }

    if (sTbStepOut) {
        wgtSetEnabled(sTbStepOut, isPaused);
    }

    if (sTbRunToCur) {
        wgtSetEnabled(sTbRunToCur, isPaused);
    }

    if (sTbCode) {
        wgtSetEnabled(sTbCode, canCode);
    }

    if (sTbDesign) {
        wgtSetEnabled(sTbDesign, canDesign);
    }
}


// updateWatchWindow -- evaluate watch expressions

static void updateWatchWindow(void) {
    if (!sWatchList || !sWatchWin || !sWatchWin->visible) {
        return;
    }

    listRowsReset(&sWatchRows);

    int32_t count = (int32_t)arrlen(sWatchExprs);

    for (int32_t i = 0; i < count; i++) {
        char valBuf[IDE_VALUE_BUF];

        valBuf[0] = '\0';

        if (sDbgState == DBG_PAUSED && sVm && sDbgModule) {
            BasValueT val;
            memset(&val, 0, sizeof(val));

            if (lookupWatchVar(sWatchExprs[i], &val)) {
                // Simple variable/field/subscript lookup succeeded
                formatValue(&val, valBuf, sizeof(valBuf));
            } else if (!evalWatchExpr(sWatchExprs[i], valBuf, sizeof(valBuf))) {
                snprintf(valBuf, sizeof(valBuf), "<error>");
            }
        }

        listRowsAdd(&sWatchRows, sWatchExprs[i]);
        listRowsAdd(&sWatchRows, valBuf);
    }

    listRowsCommit(&sWatchRows, sWatchList, count);
}


// ============================================================
// Compile-time CtrlName.Member validator
// ============================================================
//
// The IDE (unlike bascomp) has widget DXEs loaded, so wgtGetIface
// returns live interface metadata.  Combined with a static scan of
// the project's .frm files (control name -> widget type), we can
// reject typos like GfxCanvas.Boggle or LblStatus.Caphtion at
// compile time instead of letting them surface as runtime errors
// at event-click time.  Dynamically-created controls (via
// CreateControl at runtime) aren't in the map; lookupCtrlType
// returns NULL for them and validation is skipped.

// Walk every .frm in the project and populate a (name -> wgtType) map
// the parser can consult.  Caller must arrfree(ctx->entries) when done.
static void validatorBuildCtrlMap(IdeValidatorCtxT *ctx) {
    ctx->entries = NULL;

    FrmParserCbsT cbs;
    memset(&cbs, 0, sizeof(cbs));
    cbs.userData     = ctx;
    cbs.onFormBegin  = validatorOnFormBegin;
    cbs.onCtrlBegin  = validatorOnCtrlBegin;
    cbs.onMenuBegin  = validatorOnMenuBegin;

    for (int32_t i = 0; i < sProject.fileCount; i++) {
        if (!sProject.files[i].isForm) {
            continue;
        }

        // Use the buffered source if the file is open in an editor,
        // otherwise load from disk.  Mirrors how the designer loads.
        char *diskBuf   = NULL;
        const char *src = sProject.files[i].buffer;
        int32_t len     = src ? (int32_t)strlen(src) : 0;

        if (!src) {
            int32_t dlen = 0;
            char    fullPath[DVX_MAX_PATH];

            // PrjFileT.path is project-relative; read via the full path so
            // the control map isn't silently empty when the project dir
            // differs from the process CWD.
            prjFullPath(&sProject, i, fullPath, sizeof(fullPath));
            diskBuf = platformReadFile(fullPath, &dlen);
            src = diskBuf;
            len = dlen;
        }

        if (src && len > 0) {
            frmParse(src, len, &cbs);
        }

        free(diskBuf);
    }
}


static bool validatorIsMethodValid(void *ctx, const char *wgtType, const char *methodName) {
    (void)ctx;

    if (!wgtType || !methodName) {
        return true; // be permissive on malformed input
    }

    if (basFormRtCommonMethodId(methodName) != BAS_CM_NONE) {
        return true;
    }

    // Form-level methods
    if (strcasecmp(wgtType, "Form") == 0) {
        return strcasecmp(methodName, "Show") == 0 ||
               strcasecmp(methodName, "Hide") == 0;
    }

    // Menu items have no methods beyond common
    if (strcasecmp(wgtType, "Menu") == 0) {
        return false;
    }

    // Widget-specific methods from the live iface
    const char *wgtName = wgtFindByBasName(wgtType);

    if (!wgtName) {
        return true; // unknown type -- skip validation
    }

    const WgtIfaceT *iface = wgtGetIface(wgtName);

    if (!iface || !iface->methods) {
        return true;
    }

    for (int32_t i = 0; i < iface->methodCount; i++) {
        if (strcasecmp(iface->methods[i].name, methodName) == 0) {
            return true;
        }
    }

    return false;
}


static bool validatorIsPropValid(void *ctx, const char *wgtType, const char *propName) {
    (void)ctx;

    if (!wgtType || !propName) {
        return true;
    }

    // Form-level properties (the runtime's own table)
    if (strcasecmp(wgtType, "Form") == 0) {
        return basFormRtFindFormProp(propName) != NULL;
    }

    // Menu items
    if (strcasecmp(wgtType, "Menu") == 0) {
        return strcasecmp(propName, "Name")       == 0 ||
               strcasecmp(propName, "Checked")    == 0 ||
               strcasecmp(propName, "Enabled")    == 0 ||
               strcasecmp(propName, "Visible")    == 0 ||  // top-level: False = popup
               strcasecmp(propName, "RadioCheck") == 0 ||
               strcasecmp(propName, "Caption")    == 0;
    }

    // Properties every non-menu control accepts (the runtime's own table)
    if (basFormRtFindCommonProp(propName)) {
        return true;
    }

    // Widget-specific props from the live iface
    const char *wgtName = wgtFindByBasName(wgtType);

    if (!wgtName) {
        return true;
    }

    const WgtIfaceT *iface = wgtGetIface(wgtName);

    if (!iface || !iface->props) {
        return true;
    }

    return wgtIfaceFindProp(iface, propName) != NULL;
}


static const char *validatorLookupCtrlType(void *ctx, const char *ctrlName) {
    IdeValidatorCtxT *v = (IdeValidatorCtxT *)ctx;

    if (!v || !ctrlName) {
        return NULL;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(v->entries); i++) {
        if (strcasecmp(v->entries[i].name, ctrlName) == 0) {
            return v->entries[i].wgtType;
        }
    }

    return NULL;
}


static void validatorOnCtrlBegin(void *ud, const char *typeName, const char *name) {
    IdeValidatorCtxT *v = (IdeValidatorCtxT *)ud;
    IdeCtrlMapEntryT e;
    memset(&e, 0, sizeof(e));
    snprintf(e.name,    BAS_MAX_IDENT, "%s", name ? name : "");
    snprintf(e.wgtType, BAS_MAX_IDENT, "%s", typeName ? typeName : "");
    arrput(v->entries, e);

    // Also flag unknown widget types.  wgtFindByBasName returns NULL
    // for anything not registered by a .wgt DXE loaded by the IDE.
    // Skip internal structural types (they're handled by layout code,
    // not widget DXEs).
    if (typeName && typeName[0]) {
        bool structural = (strcasecmp(typeName, "Form")  == 0 ||
                           strcasecmp(typeName, "Menu")  == 0);

        if (!structural && !wgtFindByBasName(typeName)) {
            IdeBadTypeT bad;
            memset(&bad, 0, sizeof(bad));
            snprintf(bad.ctrlName, BAS_MAX_IDENT, "%s", name ? name : "?");
            snprintf(bad.typeName, BAS_MAX_IDENT, "%s", typeName);
            snprintf(bad.formName, BAS_MAX_IDENT, "%s", v->currentForm);
            arrput(v->badTypes, bad);
        }
    }
}


static bool validatorOnFormBegin(void *ud, const char *name) {
    IdeValidatorCtxT *v = (IdeValidatorCtxT *)ud;
    IdeCtrlMapEntryT e;
    memset(&e, 0, sizeof(e));
    snprintf(e.name,    BAS_MAX_IDENT, "%s", name ? name : "");
    snprintf(e.wgtType, BAS_MAX_IDENT, "%s", "Form");
    arrput(v->entries, e);
    snprintf(v->currentForm, BAS_MAX_IDENT, "%s", name ? name : "");
    return true;
}


static void validatorOnMenuBegin(void *ud, const char *name, int32_t level) {
    (void)level;
    IdeValidatorCtxT *v = (IdeValidatorCtxT *)ud;
    IdeCtrlMapEntryT e;
    memset(&e, 0, sizeof(e));
    snprintf(e.name,    BAS_MAX_IDENT, "%s", name);
    snprintf(e.wgtType, BAS_MAX_IDENT, "%s", "Menu");
    arrput(v->entries, e);
}


// vmProcIndexForPc -- index of the compiled proc whose codeAddr is the
// greatest one <= pc, or -1 for module-level code.  Requires sDbgModule.
static int32_t vmProcIndexForPc(int32_t pc) {
    int32_t best     = -1;
    int32_t bestAddr = -1;

    for (int32_t i = 0; i < sDbgModule->procCount; i++) {
        int32_t addr = sDbgModule->procs[i].codeAddr;

        if (addr <= pc && addr > bestAddr) {
            bestAddr = addr;
            best     = i;
        }
    }

    return best;
}


static const char *vmProcNameForPc(int32_t pc) {
    int32_t idx = vmProcIndexForPc(pc);

    return idx >= 0 ? sDbgModule->procs[idx].name : IDE_MODULE_FRAME;
}


static void watchEditSelected(void) {
    if (!sWatchList || !sWatchInput) {
        return;
    }

    int32_t sel = wgtListViewGetSelected(sWatchList);

    if (sel < 0 || sel >= (int32_t)arrlen(sWatchExprs)) {
        return;
    }

    // Put expression text into the input box
    wgtSetText(sWatchInput, sWatchExprs[sel]);

    // Remove from list
    free(sWatchExprs[sel]);
    arrdel(sWatchExprs, sel);

    updateWatchWindow();

    // Focus the input box
    wgtSetFocused(sWatchInput);
}


static void watchPrintCallback(void *ctx, const char *text, bool newline) {
    (void)ctx;

    if (text) {
        int32_t tl = (int32_t)strlen(text);

        if (sWatchPrintLen + tl < (int32_t)sizeof(sWatchPrintBuf) - 1) {
            memcpy(sWatchPrintBuf + sWatchPrintLen, text, tl);
            sWatchPrintLen += tl;
        }
    }

    if (newline && sWatchPrintLen < (int32_t)sizeof(sWatchPrintBuf) - 1) {
        sWatchPrintBuf[sWatchPrintLen++] = '\n';
    }

    sWatchPrintBuf[sWatchPrintLen] = '\0';
}


// writeProjectFile -- write files[idx].buffer to disk (a NULL buffer is
// an empty file).  Clears the modified flags only when every write call
// succeeded; reports the failure to the user otherwise.
static bool writeProjectFile(int32_t idx) {
    PrjFileT *file = &sProject.files[idx];
    char      fullPath[DVX_MAX_PATH];

    prjFullPath(&sProject, idx, fullPath, sizeof(fullPath));

    FILE *f  = fopen(fullPath, "w");
    bool  ok = (f != NULL);

    if (f) {
        ok = (fputs(file->buffer ? file->buffer : "", f) != EOF);
        ok = (fclose(f) == 0) && ok;
    }

    if (!ok) {
        char msg[IDE_MSG_BUF];
        snprintf(msg, sizeof(msg), "Could not write %s.", file->path);
        dvxErrorBox(sAc, NULL, msg);
        return false;
    }

    file->modified = false;

    if (file->isForm && sDesigner.form && idx == sProject.activeFileIdx) {
        sDesigner.form->dirty = false;
    }

    return true;
}


int32_t appMain(DxeAppContextT *ctx) {
    sCtx = ctx;
    sAc  = ctx->shellCtx;

    // Set help file and context-sensitive F1 handler
    snprintf(ctx->helpFile, sizeof(ctx->helpFile), "%s" DVX_PATH_SEP "%s", ctx->appDir, IDE_HELP_FILE);
    snprintf(sIdeHelpFile, sizeof(sIdeHelpFile), "%s", ctx->helpFile);
    ctx->onHelpQuery  = helpQueryHandler;
    ctx->helpQueryCtx = NULL;

    prjInit(&sProject);
    buildWindow();

    // Load persisted settings
    shellEnsureConfigDir(sCtx);
    char prefsPath[DVX_MAX_PATH];
    shellConfigPath(sCtx, IDE_PREFS_FILE, prefsPath, sizeof(prefsPath));
    sPrefs = prefsLoad(prefsPath);

    if (!sPrefs) {
        sPrefs = prefsCreate();
        prefsSaveAs(sPrefs, prefsPath);
    }

    if (sWin && sWin->menuBar) {
        if (sToolbar) {
            bool showTb = prefsGetBool(sPrefs, PREF_SEC_VIEW, PREF_KEY_TOOLBAR, true);
            sToolbar->visible = showTb;
            wmMenuItemSetChecked(sWin->menuBar, CMD_VIEW_TOOLBAR, showTb);
        }

        if (sStatusBar) {
            bool showSb = prefsGetBool(sPrefs, PREF_SEC_VIEW, PREF_KEY_STATUSBAR, true);
            sStatusBar->visible = showSb;
            wmMenuItemSetChecked(sWin->menuBar, CMD_VIEW_STATUS, showSb);
        }

        bool saveOnRun = prefsGetBool(sPrefs, PREF_SEC_RUN, PREF_KEY_SAVE_ON_RUN, true);
        wmMenuItemSetChecked(sWin->menuBar, CMD_SAVE_ON_RUN, saveOnRun);

        sOutputToLog = prefsGetBool(sPrefs, PREF_SEC_RUN, PREF_KEY_OUTPUT_TO_LOG, false);
        wmMenuItemSetChecked(sWin->menuBar, CMD_OUTPUT_TO_LOG, sOutputToLog);
    }

    // Load recent files list and populate menu
    recentLoad();
    recentRebuildMenu();

    if (sWin) {
        dvxFitWindowH(sAc, sWin);
    }

    updateProjectMenuState();
    setStatus("Ready.");
    return 0;
}
