DVX_GUI/dvx/dvxDialog.c

1293 lines
42 KiB
C

// 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 <ctype.h>
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
// ============================================================
// 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;
}