// dvxDialog.c -- Modal dialogs for DVX GUI // // Provides two standard dialog types: message boxes and file dialogs. // Both use the nested-event-loop modal pattern: the dialog creates a // window, sets it as the AppContext's modalWindow, then runs dvxUpdate // in a tight loop until the user dismisses the dialog. This blocks the // caller's code flow, which is the simplest possible modal API -- the // caller gets the result as a return value, no callbacks needed. // // The nested loop approach works because dvxUpdate is re-entrant: it // polls input, dispatches events, and composites. The modalWindow field // causes handleMouseButton to reject clicks on non-modal windows, so // only the dialog receives interaction. This is exactly how Windows // MessageBox and GetOpenFileName work internally. // // State for each dialog type is stored in a single static struct (sMsgBox, // sFd) rather than on the heap. This means only one dialog of each type // can be active at a time, but that's fine -- you never need two file // dialogs simultaneously. The static approach avoids malloc/free and // keeps the state accessible to callback functions without threading // context pointers through every widget callback. #include "dvxDialog.h" #include "platform/dvxPlatform.h" #include "dvxWidget.h" #include "widgets/widgetInternal.h" #include #include #include #include #include #include #include // ============================================================ // Constants // ============================================================ // Message box layout constants. MSG_MAX_WIDTH caps dialog width so long // messages wrap rather than producing absurdly wide dialogs. #define MSG_MAX_WIDTH 320 #define MSG_PADDING 8 #define ICON_AREA_WIDTH 40 #define BUTTON_WIDTH 80 #define BUTTON_HEIGHT 24 #define BUTTON_GAP 8 // Icon glyph constants. Icons are drawn procedurally (pixel by pixel) // rather than using bitmap resources. This avoids needing to ship and // load icon files, and the glyphs scale-by-design since they're // defined in terms of geometric shapes. The circle shapes use the // distance-squared test (d2 between INNER_R2 and OUTER_R2) to draw // a ring without needing floating-point sqrt. #define ICON_GLYPH_SIZE 24 // icon glyph drawing area (pixels) #define ICON_GLYPH_CENTER 12 // center of icon glyph (ICON_GLYPH_SIZE / 2) #define ICON_OUTER_R2 144 // outer circle radius squared (12*12) #define ICON_INNER_R2 100 // inner circle radius squared (10*10) #define FD_DIALOG_WIDTH 360 // file dialog window width #define FD_DIALOG_HEIGHT 340 // file dialog window height // ============================================================ // Prototypes // ============================================================ static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color); static bool fdAcceptFile(const char *name); static int fdEntryCompare(const void *a, const void *b); static bool fdFilterMatch(const char *name, const char *pattern); static void fdFreeEntries(void); static void fdLoadDir(void); static void fdNavigate(const char *path); static void fdOnCancel(WidgetT *w); static void fdOnClose(WindowT *win); static void fdOnFilterChange(WidgetT *w); static void fdOnListClick(WidgetT *w); static void fdOnListDblClick(WidgetT *w); static void fdOnOk(WidgetT *w); static void fdOnPathSubmit(WidgetT *w); static bool fdValidateFilename(const char *name); static void onButtonClick(WidgetT *w); static void onMsgBoxClose(WindowT *win); static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea); static int32_t wordWrapHeight(const BitmapFontT *font, const char *text, int32_t maxW); static void wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t maxW, uint32_t fg, uint32_t bg); // ============================================================ // Message box state (one active at a time) // ============================================================ // // Static because only one message box can be active at a time. // The 'done' flag is set by button clicks or window close, which // breaks the nested dvxUpdate loop in dvxMessageBox. Layout values // (textX, textY, textMaxW, msgAreaH) are computed once when the // dialog opens and reused by the paint callback. typedef struct { AppContextT *ctx; int32_t result; // ID_OK, ID_CANCEL, ID_YES, ID_NO, etc. bool done; // set true to break the modal loop const char *message; int32_t iconType; int32_t textX; // pre-computed text origin (accounts for icon) int32_t textY; int32_t textMaxW; // max text width for word wrapping int32_t msgAreaH; // height of the message+icon area above the buttons } MsgBoxStateT; static MsgBoxStateT sMsgBox; // ============================================================ // drawIconGlyph -- draw a simple icon shape // ============================================================ // // Procedurally draws message box icons using only integer math: // MB_ICONINFO: circle with 'i' (information) // MB_ICONWARNING: triangle with '!' (warning) // MB_ICONERROR: circle with 'X' (error) // MB_ICONQUESTION: circle with '?' (question) // // Circles use the integer distance-squared test: for each pixel, compute // dx*dx + dy*dy and check if it falls between INNER_R2 and OUTER_R2 // to draw a 2-pixel-wide ring. This is a brute-force O(n^2) approach // but n is only 24 pixels, so it's 576 iterations -- trivial even on a 486. // // The inner symbols (i, !, X, ?) are drawn with hardcoded rectFill calls // at specific pixel offsets. This is less elegant than using the font // renderer, but it gives precise control over the glyph appearance at // this small size where the 8x16 bitmap font would look too blocky. // // Drawing 1x1 rects for individual pixels is intentional: it goes // through the clip rect check in rectFill, so we don't need separate // bounds checking here. static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color) { if (iconType == MB_ICONINFO) { // Circle outline with 'i' for (int32_t row = 0; row < ICON_GLYPH_SIZE; row++) { for (int32_t col = 0; col < ICON_GLYPH_SIZE; col++) { int32_t dx = col - ICON_GLYPH_CENTER; int32_t dy = row - ICON_GLYPH_CENTER; int32_t d2 = dx * dx + dy * dy; if (d2 <= ICON_OUTER_R2 && d2 >= ICON_INNER_R2) { rectFill(d, ops, x + col, y + row, 1, 1, color); } } } rectFill(d, ops, x + 11, y + 6, 2, 2, color); rectFill(d, ops, x + 11, y + 10, 2, 8, color); } else if (iconType == MB_ICONWARNING) { // Triangle outline with '!' for (int32_t row = 0; row < ICON_GLYPH_SIZE; row++) { int32_t halfW = row / 2; int32_t lx = ICON_GLYPH_CENTER - halfW; int32_t rx = ICON_GLYPH_CENTER + halfW; rectFill(d, ops, x + lx, y + row, 1, 1, color); rectFill(d, ops, x + rx, y + row, 1, 1, color); if (row == ICON_GLYPH_SIZE - 1) { drawHLine(d, ops, x + lx, y + row, rx - lx + 1, color); } } rectFill(d, ops, x + 11, y + 8, 2, 9, color); rectFill(d, ops, x + 11, y + 19, 2, 2, color); } else if (iconType == MB_ICONERROR) { // Circle outline with X for (int32_t row = 0; row < ICON_GLYPH_SIZE; row++) { for (int32_t col = 0; col < ICON_GLYPH_SIZE; col++) { int32_t dx = col - ICON_GLYPH_CENTER; int32_t dy = row - ICON_GLYPH_CENTER; int32_t d2 = dx * dx + dy * dy; if (d2 <= ICON_OUTER_R2 && d2 >= ICON_INNER_R2) { rectFill(d, ops, x + col, y + row, 1, 1, color); } } } for (int32_t i = 0; i < ICON_GLYPH_CENTER; i++) { rectFill(d, ops, x + 6 + i, y + 6 + i, 2, 2, color); rectFill(d, ops, x + 18 - i, y + 6 + i, 2, 2, color); } } else if (iconType == MB_ICONQUESTION) { // Circle outline with '?' for (int32_t row = 0; row < ICON_GLYPH_SIZE; row++) { for (int32_t col = 0; col < ICON_GLYPH_SIZE; col++) { int32_t dx = col - ICON_GLYPH_CENTER; int32_t dy = row - ICON_GLYPH_CENTER; int32_t d2 = dx * dx + dy * dy; if (d2 <= ICON_OUTER_R2 && d2 >= ICON_INNER_R2) { rectFill(d, ops, x + col, y + row, 1, 1, color); } } } rectFill(d, ops, x + 9, y + 6, 6, 2, color); rectFill(d, ops, x + 13, y + 8, 2, 4, color); rectFill(d, ops, x + 11, y + 12, 2, 3, color); rectFill(d, ops, x + 11, y + 17, 2, 2, color); } } // ============================================================ // dvxMessageBox // ============================================================ // // Creates and runs a modal message box. The flags parameter is a bitmask: // low nibble selects button set (MB_OK, MB_YESNO, etc.), next nibble // selects icon type (MB_ICONINFO, MB_ICONERROR, etc.). This is the same // flag encoding Windows MessageBox uses, which makes porting code easier. // // The dialog is auto-sized to fit the word-wrapped message text plus the // button row. Non-resizable (maxW/maxH clamped to initial size) because // resizing a message box serves no purpose. // // Button labels use '&' to mark accelerator keys (e.g., "&OK" makes // Alt+O activate the button). Button IDs are stored in widget->userData // via intptr_t cast -- a common pattern when you need to associate a // small integer with a widget without allocating a separate struct. int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags) { int32_t btnFlags = flags & 0x000F; int32_t iconFlags = flags & 0x00F0; // Determine button labels and IDs const char *btnLabels[3]; int32_t btnIds[3]; int32_t btnCount = 0; switch (btnFlags) { case MB_OK: btnLabels[0] = "&OK"; btnIds[0] = ID_OK; btnCount = 1; break; case MB_OKCANCEL: btnLabels[0] = "&OK"; btnIds[0] = ID_OK; btnLabels[1] = "&Cancel"; btnIds[1] = ID_CANCEL; btnCount = 2; break; case MB_YESNO: btnLabels[0] = "&Yes"; btnIds[0] = ID_YES; btnLabels[1] = "&No"; btnIds[1] = ID_NO; btnCount = 2; break; case MB_YESNOCANCEL: btnLabels[0] = "&Yes"; btnIds[0] = ID_YES; btnLabels[1] = "&No"; btnIds[1] = ID_NO; btnLabels[2] = "&Cancel"; btnIds[2] = ID_CANCEL; btnCount = 3; break; case MB_RETRYCANCEL: btnLabels[0] = "&Retry"; btnIds[0] = ID_RETRY; btnLabels[1] = "&Cancel"; btnIds[1] = ID_CANCEL; btnCount = 2; break; default: btnLabels[0] = "&OK"; btnIds[0] = ID_OK; btnCount = 1; break; } // Calculate message text dimensions bool hasIcon = (iconFlags != 0); int32_t textMaxW = MSG_MAX_WIDTH - MSG_PADDING * 2 - (hasIcon ? ICON_AREA_WIDTH : 0); int32_t textH = wordWrapHeight(&ctx->font, message, textMaxW); // Calculate content area sizes int32_t msgAreaH = textH + MSG_PADDING * 2; int32_t iconAreaH = hasIcon ? (ICON_GLYPH_SIZE + MSG_PADDING * 2) : 0; if (msgAreaH < iconAreaH) { msgAreaH = iconAreaH; } int32_t buttonsW = btnCount * BUTTON_WIDTH + (btnCount - 1) * BUTTON_GAP; int32_t contentW = MSG_MAX_WIDTH; if (buttonsW + MSG_PADDING * 2 > contentW) { contentW = buttonsW + MSG_PADDING * 2; } int32_t contentH = msgAreaH + BUTTON_HEIGHT + MSG_PADDING * 3; // Create the dialog window (non-resizable) int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE; int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP; WindowT *win = dvxCreateWindow(ctx, title, winX, winY, contentW + CHROME_TOTAL_SIDE * 2, contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM, false); if (!win) { return ID_CANCEL; } win->modal = true; // Set up state sMsgBox.ctx = ctx; sMsgBox.result = ID_CANCEL; sMsgBox.done = false; sMsgBox.message = message; sMsgBox.iconType = iconFlags; sMsgBox.textX = MSG_PADDING + (hasIcon ? ICON_AREA_WIDTH : 0); sMsgBox.textY = MSG_PADDING; sMsgBox.textMaxW = textMaxW; sMsgBox.msgAreaH = msgAreaH; // Create button widgets using wgtInitWindow for proper root setup // (sets onPaint, onMouse, onKey, onResize, userData on root) WidgetT *root = wgtInitWindow(ctx, win); // Override onPaint with our custom handler, set window-level state win->userData = &sMsgBox; win->onPaint = onMsgBoxPaint; win->onClose = onMsgBoxClose; win->maxW = win->w; win->maxH = win->h; if (root) { // Spacer for message area (text/icon drawn by onPaint) WidgetT *msgSpacer = wgtSpacer(root); if (msgSpacer) { msgSpacer->minH = wgtPixels(msgAreaH); } // Button row centered WidgetT *btnRow = wgtHBox(root); if (btnRow) { btnRow->align = AlignCenterE; for (int32_t i = 0; i < btnCount; i++) { WidgetT *btn = wgtButton(btnRow, btnLabels[i]); if (btn) { btn->minW = wgtPixels(BUTTON_WIDTH); btn->minH = wgtPixels(BUTTON_HEIGHT); btn->userData = (void *)(intptr_t)btnIds[i]; btn->onClick = onButtonClick; } } } // Bottom padding WidgetT *bottomSpacer = wgtSpacer(root); if (bottomSpacer) { bottomSpacer->minH = wgtPixels(MSG_PADDING); } } // Initial paint (window is already correctly sized, don't call dvxFitWindow) RectT fullRect = { 0, 0, win->contentW, win->contentH }; win->onPaint(win, &fullRect); // Save previous modal so stacked modals work correctly. This happens // when a message box opens from within a file dialog (e.g., overwrite // confirmation) -- the file dialog's modal is pushed and restored // when the message box closes. WindowT *prevModal = ctx->modalWindow; ctx->modalWindow = win; // Nested event loop -- blocks here until user clicks a button or closes. // dvxUpdate handles all input/rendering; sMsgBox.done is set by the // button onClick callback or the window close callback. while (!sMsgBox.done && ctx->running) { dvxUpdate(ctx); } ctx->modalWindow = prevModal; dvxDestroyWindow(ctx, win); return sMsgBox.result; } // ============================================================ // onButtonClick // ============================================================ static void onButtonClick(WidgetT *w) { sMsgBox.result = (int32_t)(intptr_t)w->userData; sMsgBox.done = true; } // ============================================================ // onMsgBoxClose // ============================================================ static void onMsgBoxClose(WindowT *win) { (void)win; sMsgBox.result = ID_CANCEL; sMsgBox.done = true; } // ============================================================ // onMsgBoxPaint -- custom paint: background + text/icon + widgets // ============================================================ // // The message box uses a custom onPaint callback rather than pure widgets // because the message text area with optional icon doesn't map cleanly to // the widget model. The paint callback creates a temporary display context // that points at the window's content buffer (not the screen backbuffer), // draws the background/text/icon directly, then runs the widget layout // and paint for the button row. The separator line between the message // area and buttons uses a highlight-over-shadow pair to create a Motif // etched-line effect. static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea) { (void)dirtyArea; MsgBoxStateT *state = (MsgBoxStateT *)win->userData; AppContextT *ctx = state->ctx; // Create a temporary display context targeting the window's content // buffer. This is the standard pattern for drawing into a window's // private buffer rather than the screen backbuffer. DisplayT cd = ctx->display; cd.lfb = win->contentBuf; cd.backBuf = win->contentBuf; cd.width = win->contentW; cd.height = win->contentH; cd.pitch = win->contentPitch; cd.clipX = 0; cd.clipY = 0; cd.clipW = win->contentW; cd.clipH = win->contentH; // Fill background with window face color (not content bg -- dialog style) rectFill(&cd, &ctx->blitOps, 0, 0, win->contentW, win->contentH, ctx->colors.windowFace); // Draw word-wrapped message text wordWrapDraw(&cd, &ctx->blitOps, &ctx->font, state->textX, state->textY, state->message, state->textMaxW, ctx->colors.contentFg, ctx->colors.windowFace); // Draw icon if (state->iconType != 0) { drawIconGlyph(&cd, &ctx->blitOps, MSG_PADDING, MSG_PADDING, state->iconType, ctx->colors.contentFg); } // Draw separator line above buttons drawHLine(&cd, &ctx->blitOps, 0, state->msgAreaH, win->contentW, ctx->colors.windowShadow); drawHLine(&cd, &ctx->blitOps, 0, state->msgAreaH + 1, win->contentW, ctx->colors.windowHighlight); // Paint widget tree (buttons) on top if (win->widgetRoot) { WidgetT *root = win->widgetRoot; // Layout widgets widgetCalcMinSizeTree(root, &ctx->font); root->x = 0; root->y = 0; root->w = win->contentW; root->h = win->contentH; widgetLayoutChildren(root, &ctx->font); // Paint widgets wgtPaint(root, &cd, &ctx->blitOps, &ctx->font, &ctx->colors); } } // ============================================================ // wordWrapDraw -- draw word-wrapped text // ============================================================ // // Simple greedy word-wrap: fill each line with as many characters as fit // within maxW, breaking at the last space if the line overflows. If a // single word is longer than maxW, it gets its own line (may be clipped). // Explicit newlines are honored. This is a fixed-width font, so "max // chars per line" is just maxW / charWidth -- no per-character width // accumulation needed. static void wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t maxW, uint32_t fg, uint32_t bg) { int32_t charW = font->charWidth; int32_t lineH = font->charHeight; int32_t maxChars = maxW / charW; int32_t curY = y; if (maxChars < 1) { maxChars = 1; } while (*text) { // Skip leading spaces while (*text == ' ') { text++; } if (*text == '\0') { break; } // Handle explicit newlines if (*text == '\n') { curY += lineH; text++; continue; } // Find how many characters fit on this line int32_t lineLen = 0; int32_t lastSpace = -1; while (text[lineLen] && text[lineLen] != '\n' && lineLen < maxChars) { if (text[lineLen] == ' ') { lastSpace = lineLen; } lineLen++; } // If we didn't reach end and didn't hit newline, wrap at word boundary if (text[lineLen] && text[lineLen] != '\n' && lastSpace > 0) { lineLen = lastSpace; } drawTextN(d, ops, font, x, curY, text, lineLen, fg, bg, true); curY += lineH; text += lineLen; } } // ============================================================ // wordWrapHeight -- compute height of word-wrapped text // ============================================================ // // Duplicates the word-wrap logic from wordWrapDraw but only counts lines // instead of drawing. This is needed to compute the dialog height before // creating the window. The duplication is intentional -- combining them // into a single function with a "just measure" flag would add branching // to the draw path and make both harder to read. static int32_t wordWrapHeight(const BitmapFontT *font, const char *text, int32_t maxW) { int32_t charW = font->charWidth; int32_t lineH = font->charHeight; int32_t maxChars = maxW / charW; int32_t lines = 0; if (maxChars < 1) { maxChars = 1; } while (*text) { while (*text == ' ') { text++; } if (*text == '\0') { break; } if (*text == '\n') { lines++; text++; continue; } int32_t lineLen = 0; int32_t lastSpace = -1; while (text[lineLen] && text[lineLen] != '\n' && lineLen < maxChars) { if (text[lineLen] == ' ') { lastSpace = lineLen; } lineLen++; } if (text[lineLen] && text[lineLen] != '\n' && lastSpace > 0) { lineLen = lastSpace; } lines++; text += lineLen; } if (lines == 0) { lines = 1; } return lines * lineH; } // ============================================================ // File dialog // ============================================================ // // File open/save dialog with directory navigation, file type filtering, // and overwrite/create confirmation. The dialog is built from standard // widgets (listbox, text inputs, dropdown, buttons) composed in the // widget system. Directory entries are prefixed with "[brackets]" in the // listbox to distinguish them from files, following the DOS convention. // FD_MAX_PATH is 260 to match DOS MAX_PATH (including null terminator) #define FD_MAX_ENTRIES 512 #define FD_MAX_PATH 260 #define FD_NAME_LEN 64 typedef struct { AppContextT *ctx; bool done; // set true to break modal loop bool accepted; // true if user clicked OK/Open/Save int32_t flags; // FD_SAVE, etc. char curDir[FD_MAX_PATH]; const FileFilterT *filters; // caller-provided filter list int32_t filterCount; int32_t activeFilter; // index into filters[] char *entryNames[FD_MAX_ENTRIES]; // heap-allocated, freed by fdFreeEntries bool entryIsDir[FD_MAX_ENTRIES]; int32_t entryCount; const char *listItems[FD_MAX_ENTRIES]; // pointers into entryNames, for listbox API WidgetT *fileList; WidgetT *pathInput; WidgetT *nameInput; WidgetT *filterDd; char outPath[FD_MAX_PATH]; } FileDialogStateT; static FileDialogStateT sFd; // ============================================================ // fdFilterMatch -- check if filename matches a glob pattern // ============================================================ // // Supports only the most common DOS file filter patterns: "*.*", "*", // and "*.ext". Full glob matching isn't needed because file dialogs // historically only use extension-based filters. The case-insensitive // extension compare handles DOS's case-insensitive filesystem behavior. // Match a filename against a single *.ext pattern (case-insensitive). static bool fdMatchSingle(const char *name, const char *pat) { if (strcmp(pat, "*.*") == 0 || strcmp(pat, "*") == 0) { return true; } if (pat[0] == '*' && pat[1] == '.') { const char *ext = strrchr(name, '.'); if (!ext) { return false; } ext++; const char *patExt = pat + 2; while (*patExt && *ext) { if (tolower((unsigned char)*patExt) != tolower((unsigned char)*ext)) { return false; } patExt++; ext++; } return (*patExt == '\0' && *ext == '\0'); } return true; } // Match a filename against a pattern string that may contain multiple // semicolon-delimited patterns (e.g. "*.bmp;*.jpg;*.png"). static bool fdFilterMatch(const char *name, const char *pattern) { if (!pattern || pattern[0] == '\0') { return true; } // Work on a copy so we can tokenize with NUL char buf[128]; strncpy(buf, pattern, sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; char *p = buf; while (*p) { // Find the end of this token char *semi = strchr(p, ';'); if (semi) { *semi = '\0'; } // Trim leading whitespace while (*p == ' ') { p++; } if (fdMatchSingle(name, p)) { return true; } if (!semi) { break; } p = semi + 1; } return false; } // ============================================================ // fdFreeEntries -- free allocated entry name strings // ============================================================ static void fdFreeEntries(void) { for (int32_t i = 0; i < sFd.entryCount; i++) { free(sFd.entryNames[i]); sFd.entryNames[i] = NULL; } sFd.entryCount = 0; } // ============================================================ // fdEntryCompare -- sort: dirs first, then alphabetical // ============================================================ // // Sort comparator for the indirect sort in fdLoadDir. Uses an index // array rather than sorting the entryNames/entryIsDir arrays directly // because qsort would need a struct or the comparator would need to // move both arrays in sync. Indirect sort with an index array is // simpler and avoids that coordination problem. static int fdEntryCompare(const void *a, const void *b) { int32_t ia = *(const int32_t *)a; int32_t ib = *(const int32_t *)b; // Dirs before files if (sFd.entryIsDir[ia] != sFd.entryIsDir[ib]) { return sFd.entryIsDir[ia] ? -1 : 1; } return stricmp(sFd.entryNames[ia], sFd.entryNames[ib]); } // ============================================================ // fdLoadDir -- read directory contents into state // ============================================================ // // Reads the current directory, applies the active file filter, sorts // (directories first, then alphabetical), and updates the listbox widget. // Entry names are strdup'd because dirent buffers are reused by readdir. // The sort is done via an index array to avoid shuffling two parallel // arrays; after sorting the index array, the actual arrays are rebuilt // in sorted order with a single memcpy pass. static void fdLoadDir(void) { fdFreeEntries(); const char *pattern = NULL; if (sFd.filters && sFd.activeFilter >= 0 && sFd.activeFilter < sFd.filterCount) { pattern = sFd.filters[sFd.activeFilter].pattern; } DIR *dir = opendir(sFd.curDir); if (!dir) { return; } struct dirent *ent; while ((ent = readdir(dir)) != NULL && sFd.entryCount < FD_MAX_ENTRIES) { // Skip "." if (strcmp(ent->d_name, ".") == 0) { continue; } // Build full path to stat char fullPath[FD_MAX_PATH * 2]; snprintf(fullPath, sizeof(fullPath), "%s/%s", sFd.curDir, ent->d_name); struct stat st; if (stat(fullPath, &st) != 0) { continue; } bool isDir = S_ISDIR(st.st_mode); // Apply filter to files only if (!isDir && !fdFilterMatch(ent->d_name, pattern)) { continue; } int32_t idx = sFd.entryCount; sFd.entryIsDir[idx] = isDir; if (isDir) { // Prefix dirs with brackets char buf[FD_MAX_PATH]; snprintf(buf, sizeof(buf), "[%s]", ent->d_name); sFd.entryNames[idx] = strdup(buf); } else { sFd.entryNames[idx] = strdup(ent->d_name); } sFd.entryCount++; } closedir(dir); // Sort: build index array, sort, reorder int32_t sortIdx[FD_MAX_ENTRIES]; for (int32_t i = 0; i < sFd.entryCount; i++) { sortIdx[i] = i; } qsort(sortIdx, sFd.entryCount, sizeof(int32_t), fdEntryCompare); // Rebuild arrays in sorted order char *tmpNames[FD_MAX_ENTRIES]; bool tmpIsDir[FD_MAX_ENTRIES]; for (int32_t i = 0; i < sFd.entryCount; i++) { tmpNames[i] = sFd.entryNames[sortIdx[i]]; tmpIsDir[i] = sFd.entryIsDir[sortIdx[i]]; } memcpy(sFd.entryNames, tmpNames, sizeof(char *) * sFd.entryCount); memcpy(sFd.entryIsDir, tmpIsDir, sizeof(bool) * sFd.entryCount); // Build listItems pointer array for the listbox for (int32_t i = 0; i < sFd.entryCount; i++) { sFd.listItems[i] = sFd.entryNames[i]; } wgtListBoxSetItems(sFd.fileList, sFd.listItems, sFd.entryCount); wgtListBoxSetSelected(sFd.fileList, 0); sFd.fileList->as.listBox.scrollPos = 0; // Update path display wgtSetText(sFd.pathInput, sFd.curDir); } // ============================================================ // fdNavigate -- change to a new directory // ============================================================ // // Handles both absolute and relative paths. Relative paths are resolved // against curDir. realpath is used to canonicalize the result (resolve // ".." components, symlinks) so the path display always shows a clean // absolute path. The stat check ensures we don't try to navigate into // a file or nonexistent path. static void fdNavigate(const char *path) { char resolved[FD_MAX_PATH]; if (path[0] == '/' || path[0] == '\\' || (path[1] == ':' && (path[2] == '/' || path[2] == '\\'))) { strncpy(resolved, path, FD_MAX_PATH - 1); resolved[FD_MAX_PATH - 1] = '\0'; } else { char tmp[FD_MAX_PATH * 2]; snprintf(tmp, sizeof(tmp), "%s/%s", sFd.curDir, path); strncpy(resolved, tmp, FD_MAX_PATH - 1); resolved[FD_MAX_PATH - 1] = '\0'; } // Verify it's a directory struct stat st; if (stat(resolved, &st) != 0 || !S_ISDIR(st.st_mode)) { return; } // Canonicalize the path char canon[FD_MAX_PATH]; if (realpath(resolved, canon) != NULL) { strncpy(sFd.curDir, canon, FD_MAX_PATH - 1); sFd.curDir[FD_MAX_PATH - 1] = '\0'; } else { strncpy(sFd.curDir, resolved, FD_MAX_PATH - 1); sFd.curDir[FD_MAX_PATH - 1] = '\0'; } fdLoadDir(); } // ============================================================ // fdValidateFilename -- check filename is valid for target OS // ============================================================ static bool fdValidateFilename(const char *name) { const char *error = platformValidateFilename(name); if (error) { dvxMessageBox(sFd.ctx, "Invalid Filename", error, MB_OK | MB_ICONERROR); return false; } return true; } // ============================================================ // fdAcceptFile -- confirm and accept the selected filename // ============================================================ // // Validates the filename (platform-specific rules), then checks for // confirmation scenarios: save dialog + existing file = "overwrite?", // open dialog + missing file = "create?". The nested dvxMessageBox calls // work because the modal system supports stacking (prevModal is saved and // restored). Returns false if the user cancelled at any confirmation step. static bool fdAcceptFile(const char *name) { if (!fdValidateFilename(name)) { return false; } char fullPath[FD_MAX_PATH * 2]; snprintf(fullPath, sizeof(fullPath), "%s/%s", sFd.curDir, name); struct stat st; bool exists = (stat(fullPath, &st) == 0 && S_ISREG(st.st_mode)); if ((sFd.flags & FD_SAVE) && exists) { // Save dialog: confirm overwrite of existing file char msg[FD_MAX_PATH * 2 + 64]; snprintf(msg, sizeof(msg), "%s already exists.\nDo you want to replace it?", name); if (dvxMessageBox(sFd.ctx, "Confirm Save", msg, MB_YESNO | MB_ICONQUESTION) != ID_YES) { return false; } } else if (!(sFd.flags & FD_SAVE) && !exists) { // Open dialog: confirm creation of non-existent file char msg[FD_MAX_PATH * 2 + 64]; snprintf(msg, sizeof(msg), "%s does not exist.\nDo you want to create it?", name); if (dvxMessageBox(sFd.ctx, "Confirm Create", msg, MB_YESNO | MB_ICONQUESTION) != ID_YES) { return false; } } sFd.accepted = true; sFd.done = true; return true; } // ============================================================ // fdOnListClick -- file list selection changed // ============================================================ static void fdOnListClick(WidgetT *w) { int32_t sel = wgtListBoxGetSelected(w); if (sel < 0 || sel >= sFd.entryCount) { return; } if (!sFd.entryIsDir[sel]) { wgtSetText(sFd.nameInput, sFd.entryNames[sel]); } } // ============================================================ // fdOnListDblClick -- file list double-click // ============================================================ // // Double-click on a directory navigates into it. Double-click on a file // accepts it immediately (equivalent to selecting + clicking OK). The // bracket stripping (removing "[" and "]") is needed because directory // names in the list are displayed as "[dirname]" for visual distinction. static void fdOnListDblClick(WidgetT *w) { int32_t sel = wgtListBoxGetSelected(w); if (sel < 0 || sel >= sFd.entryCount) { return; } if (sFd.entryIsDir[sel]) { const char *display = sFd.entryNames[sel]; char dirName[FD_NAME_LEN]; if (display[0] == '[') { strncpy(dirName, display + 1, sizeof(dirName) - 1); dirName[sizeof(dirName) - 1] = '\0'; char *bracket = strchr(dirName, ']'); if (bracket) { *bracket = '\0'; } } else { strncpy(dirName, display, sizeof(dirName) - 1); dirName[sizeof(dirName) - 1] = '\0'; } fdNavigate(dirName); wgtSetText(sFd.nameInput, ""); } else { // Double-click on file -- accept it (with confirmation if needed) wgtSetText(sFd.nameInput, sFd.entryNames[sel]); fdAcceptFile(sFd.entryNames[sel]); } } // ============================================================ // fdOnOk -- OK button clicked // ============================================================ // // OK has three behaviors depending on context: // 1. If a directory is selected in the list, navigate into it // 2. If the typed filename resolves to a directory, navigate there // 3. Otherwise, accept the filename (with overwrite/create confirmation) // This matches Windows file dialog behavior where Enter/OK on a directory // navigates rather than accepting. static void fdOnOk(WidgetT *w) { (void)w; const char *name = wgtGetText(sFd.nameInput); // If the filename input is empty and a directory is selected in the // list, navigate into it. But if the user has typed a filename, // always accept it -- don't let the listbox selection override. if (!name || name[0] == '\0') { int32_t sel = wgtListBoxGetSelected(sFd.fileList); if (sel >= 0 && sel < sFd.entryCount && sFd.entryIsDir[sel]) { const char *display = sFd.entryNames[sel]; char dirName[FD_NAME_LEN]; if (display[0] == '[') { strncpy(dirName, display + 1, sizeof(dirName) - 1); dirName[sizeof(dirName) - 1] = '\0'; char *bracket = strchr(dirName, ']'); if (bracket) { *bracket = '\0'; } } else { strncpy(dirName, display, sizeof(dirName) - 1); dirName[sizeof(dirName) - 1] = '\0'; } fdNavigate(dirName); return; } return; } if (!name || name[0] == '\0') { return; } // If user typed a directory path, navigate there char testPath[FD_MAX_PATH * 2]; snprintf(testPath, sizeof(testPath), "%s/%s", sFd.curDir, name); struct stat st; if (stat(testPath, &st) == 0 && S_ISDIR(st.st_mode)) { fdNavigate(testPath); wgtSetText(sFd.nameInput, ""); return; } // Accept the file (with confirmation if needed) fdAcceptFile(name); } // ============================================================ // fdOnCancel -- Cancel button clicked // ============================================================ static void fdOnCancel(WidgetT *w) { (void)w; sFd.accepted = false; sFd.done = true; } // ============================================================ // fdOnClose -- window close button // ============================================================ static void fdOnClose(WindowT *win) { (void)win; sFd.accepted = false; sFd.done = true; } // ============================================================ // fdOnFilterChange -- filter dropdown changed // ============================================================ static void fdOnFilterChange(WidgetT *w) { sFd.activeFilter = wgtDropdownGetSelected(w); fdLoadDir(); } // ============================================================ // fdOnPathSubmit -- enter pressed in path input // ============================================================ static void fdOnPathSubmit(WidgetT *w) { const char *path = wgtGetText(w); if (path && path[0]) { fdNavigate(path); } } // ============================================================ // dvxFileDialog // ============================================================ // // Creates a modal file dialog using the widget system. The layout is: // - Path input (with Enter-to-navigate) // - File listbox (single-click selects, double-click opens/accepts) // - Optional filter dropdown (only shown if filters are provided) // - Filename input // - OK/Cancel button row // // The filter dropdown uses a static label array because the dropdown // widget takes const char** and the filter labels need to persist for // the dialog's lifetime. Static is safe since only one file dialog can // be active at a time. bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const char *initialDir, const FileFilterT *filters, int32_t filterCount, char *outPath, int32_t outPathSize) { memset(&sFd, 0, sizeof(sFd)); sFd.ctx = ctx; sFd.flags = flags; sFd.filters = filters; sFd.filterCount = filterCount; sFd.activeFilter = 0; // Set initial directory if (initialDir && initialDir[0]) { strncpy(sFd.curDir, initialDir, FD_MAX_PATH - 1); } else { getcwd(sFd.curDir, FD_MAX_PATH); } sFd.curDir[FD_MAX_PATH - 1] = '\0'; // Create dialog window int32_t dlgW = FD_DIALOG_WIDTH; int32_t dlgH = FD_DIALOG_HEIGHT; int32_t winX = (ctx->display.width - dlgW) / 2; int32_t winY = (ctx->display.height - dlgH) / 2; WindowT *win = dvxCreateWindow(ctx, title ? title : ((flags & FD_SAVE) ? "Save As" : "Open"), winX, winY, dlgW, dlgH, false); if (!win) { return false; } win->modal = true; win->onClose = fdOnClose; win->userData = ctx; WidgetT *root = wgtInitWindow(ctx, win); if (!root) { dvxDestroyWindow(ctx, win); return false; } // Path row WidgetT *pathRow = wgtHBox(root); wgtLabel(pathRow, "&Path:"); sFd.pathInput = wgtTextInput(pathRow, FD_MAX_PATH); sFd.pathInput->onChange = fdOnPathSubmit; // File list sFd.fileList = wgtListBox(root); sFd.fileList->weight = 100; sFd.fileList->onChange = fdOnListClick; sFd.fileList->onDblClick = fdOnListDblClick; // Filter row (if filters provided) if (filters && filterCount > 0) { WidgetT *filterRow = wgtHBox(root); wgtLabel(filterRow, "F&ilter:"); sFd.filterDd = wgtDropdown(filterRow); // Build filter label array (static -- lives for dialog lifetime) static const char *filterLabels[16]; int32_t fc = filterCount < 16 ? filterCount : 16; for (int32_t i = 0; i < fc; i++) { filterLabels[i] = filters[i].label; } wgtDropdownSetItems(sFd.filterDd, filterLabels, fc); wgtDropdownSetSelected(sFd.filterDd, 0); sFd.filterDd->onChange = fdOnFilterChange; } // Filename row WidgetT *nameRow = wgtHBox(root); wgtLabel(nameRow, "File &name:"); sFd.nameInput = wgtTextInput(nameRow, FD_MAX_PATH); // Button row WidgetT *btnRow = wgtHBox(root); btnRow->align = AlignEndE; WidgetT *okBtn = wgtButton(btnRow, (flags & FD_SAVE) ? "&Save" : "&Open"); okBtn->onClick = fdOnOk; okBtn->minW = wgtPixels(80); WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel"); cancelBtn->onClick = fdOnCancel; cancelBtn->minW = wgtPixels(80); // Load initial directory fdLoadDir(); wgtInvalidate(root); // Modal loop ctx->modalWindow = win; while (!sFd.done && ctx->running) { dvxUpdate(ctx); } ctx->modalWindow = NULL; // Build result path bool result = false; if (sFd.accepted) { const char *name = wgtGetText(sFd.nameInput); if (name && name[0]) { char tmp[FD_MAX_PATH * 2]; snprintf(tmp, sizeof(tmp), "%s/%s", sFd.curDir, name); strncpy(sFd.outPath, tmp, FD_MAX_PATH - 1); sFd.outPath[FD_MAX_PATH - 1] = '\0'; if (outPath && outPathSize > 0) { strncpy(outPath, sFd.outPath, outPathSize - 1); outPath[outPathSize - 1] = '\0'; } result = true; } } // Cleanup dvxDestroyWindow(ctx, win); fdFreeEntries(); return result; }