Basic file open/save dialogs added.

This commit is contained in:
Scott Duensing 2026-03-16 14:25:45 -05:00
parent b8eb83d63f
commit 705fa1e99a
6 changed files with 745 additions and 4 deletions

View file

@ -4,7 +4,13 @@
#include "dvxWidget.h" #include "dvxWidget.h"
#include "widgets/widgetInternal.h" #include "widgets/widgetInternal.h"
#include <ctype.h>
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h>
#include <unistd.h>
// ============================================================ // ============================================================
// Constants // Constants
@ -22,6 +28,20 @@
// ============================================================ // ============================================================
static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color); 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 onButtonClick(WidgetT *w);
static void onMsgBoxClose(WindowT *win); static void onMsgBoxClose(WindowT *win);
static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea); static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea);
@ -283,16 +303,17 @@ int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message,
RectT fullRect = { 0, 0, win->contentW, win->contentH }; RectT fullRect = { 0, 0, win->contentW, win->contentH };
win->onPaint(win, &fullRect); win->onPaint(win, &fullRect);
// Set as modal // Set as modal (save previous in case we're stacking modals)
ctx->modalWindow = win; WindowT *prevModal = ctx->modalWindow;
ctx->modalWindow = win;
// Nested event loop // Nested event loop
while (!sMsgBox.done && ctx->running) { while (!sMsgBox.done && ctx->running) {
dvxUpdate(ctx); dvxUpdate(ctx);
} }
// Clean up // Clean up — restore previous modal
ctx->modalWindow = NULL; ctx->modalWindow = prevModal;
dvxDestroyWindow(ctx, win); dvxDestroyWindow(ctx, win);
return sMsgBox.result; return sMsgBox.result;
@ -484,3 +505,659 @@ static int32_t wordWrapHeight(const BitmapFontT *font, const char *text, int32_t
return lines * lineH; return lines * lineH;
} }
// ============================================================
// File dialog
// ============================================================
#define FD_MAX_ENTRIES 512
#define FD_MAX_PATH 260
#define FD_NAME_LEN 64
typedef struct {
AppContextT *ctx;
bool done;
bool accepted;
int32_t flags;
char curDir[FD_MAX_PATH];
const FileFilterT *filters;
int32_t filterCount;
int32_t activeFilter;
char *entryNames[FD_MAX_ENTRIES];
bool entryIsDir[FD_MAX_ENTRIES];
int32_t entryCount;
const char *listItems[FD_MAX_ENTRIES];
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
// ============================================================
static bool fdFilterMatch(const char *name, const char *pattern) {
if (!pattern || pattern[0] == '\0') {
return true;
}
if (strcmp(pattern, "*.*") == 0 || strcmp(pattern, "*") == 0) {
return true;
}
// Simple *.ext matching
if (pattern[0] == '*' && pattern[1] == '.') {
const char *ext = strrchr(name, '.');
if (!ext) {
return false;
}
ext++;
const char *patExt = pattern + 2;
// Case-insensitive extension compare
while (*patExt && *ext) {
if (tolower((unsigned char)*patExt) != tolower((unsigned char)*ext)) {
return false;
}
patExt++;
ext++;
}
return (*patExt == '\0' && *ext == '\0');
}
return true;
}
// ============================================================
// 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
// ============================================================
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
// ============================================================
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);
wgtInvalidate(sFd.fileList);
}
// ============================================================
// fdNavigate — change to a new directory
// ============================================================
static void fdNavigate(const char *path) {
// Resolve relative paths
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) {
// DOS 8.3 validation
static const char *reserved[] = {
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
NULL
};
if (!name || name[0] == '\0') {
return false;
}
// Split into base and extension
const char *dot = strrchr(name, '.');
int32_t baseLen;
int32_t extLen;
if (dot) {
baseLen = (int32_t)(dot - name);
extLen = (int32_t)strlen(dot + 1);
} else {
baseLen = (int32_t)strlen(name);
extLen = 0;
}
if (baseLen < 1 || baseLen > 8) {
dvxMessageBox(sFd.ctx, "Invalid Filename", "Filename must be 1-8 characters before the extension.", MB_OK | MB_ICONERROR);
return false;
}
if (extLen > 3) {
dvxMessageBox(sFd.ctx, "Invalid Filename", "Extension must be 3 characters or fewer.", MB_OK | MB_ICONERROR);
return false;
}
// Check for invalid characters
for (const char *p = name; *p; p++) {
if (*p == '.') {
continue;
}
if (*p < '!' || *p > '~') {
dvxMessageBox(sFd.ctx, "Invalid Filename", "Filename contains invalid characters.", MB_OK | MB_ICONERROR);
return false;
}
if (strchr(" \"*+,/:;<=>?[\\]|", *p)) {
dvxMessageBox(sFd.ctx, "Invalid Filename", "Filename contains invalid characters.", MB_OK | MB_ICONERROR);
return false;
}
}
// Check for multiple dots
if (dot && strchr(name, '.') != dot) {
dvxMessageBox(sFd.ctx, "Invalid Filename", "Filename may contain only one dot.", MB_OK | MB_ICONERROR);
return false;
}
// Check reserved device names (compare base only, case-insensitive)
char base[9];
int32_t copyLen = baseLen < 8 ? baseLen : 8;
for (int32_t i = 0; i < copyLen; i++) {
base[i] = toupper((unsigned char)name[i]);
}
base[copyLen] = '\0';
for (const char **r = reserved; *r; r++) {
if (strcmp(base, *r) == 0) {
dvxMessageBox(sFd.ctx, "Invalid Filename", "That name is a reserved device name.", MB_OK | MB_ICONERROR);
return false;
}
}
return true;
}
// ============================================================
// fdAcceptFile — confirm and accept the selected filename
// ============================================================
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]);
wgtInvalidatePaint(sFd.nameInput);
}
}
// ============================================================
// fdOnListDblClick — file list double-click
// ============================================================
static void fdOnListDblClick(WidgetT *w) {
int32_t sel = wgtListBoxGetSelected(w);
if (sel < 0 || sel >= sFd.entryCount) {
return;
}
if (sFd.entryIsDir[sel]) {
// Double-click on directory — navigate into it
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
// ============================================================
static void fdOnOk(WidgetT *w) {
(void)w;
// Check if a directory is selected in the list
int32_t sel = wgtListBoxGetSelected(sFd.fileList);
if (sel >= 0 && sel < sFd.entryCount && sFd.entryIsDir[sel]) {
// Extract directory name from "[name]"
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, "");
wgtInvalidatePaint(sFd.nameInput);
return;
}
const char *name = wgtGetText(sFd.nameInput);
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, "");
wgtInvalidatePaint(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
// ============================================================
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 = 360;
int32_t dlgH = 340;
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;
}

View file

@ -37,4 +37,27 @@
// flags = MB_xxx button flag | MB_ICONxxx icon flag // flags = MB_xxx button flag | MB_ICONxxx icon flag
int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags); int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags);
// ============================================================
// File dialog flags
// ============================================================
#define FD_OPEN 0x0000 // Open file (default)
#define FD_SAVE 0x0001 // Save file
// ============================================================
// File dialog filter
// ============================================================
typedef struct {
const char *label; // e.g. "Text Files (*.txt)"
const char *pattern; // e.g. "*.txt" (case-insensitive, single pattern)
} FileFilterT;
// Show a modal file open/save dialog. Returns true if the user selected
// a file, false if cancelled. The selected path is written to outPath
// (buffer must be at least outPathSize bytes).
// initialDir may be NULL (defaults to current directory).
// filters/filterCount may be NULL/0 for no filter dropdown.
bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const char *initialDir, const FileFilterT *filters, int32_t filterCount, char *outPath, int32_t outPathSize);
#endif // DVX_DIALOG_H #endif // DVX_DIALOG_H

View file

@ -187,6 +187,7 @@ typedef struct WidgetT {
void *userData; void *userData;
void (*onClick)(struct WidgetT *w); void (*onClick)(struct WidgetT *w);
void (*onChange)(struct WidgetT *w); void (*onChange)(struct WidgetT *w);
void (*onDblClick)(struct WidgetT *w);
// Type-specific data // Type-specific data
union { union {

View file

@ -522,6 +522,10 @@ void widgetListBoxOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
if (hit->onChange) { if (hit->onChange) {
hit->onChange(hit); hit->onChange(hit);
} }
if (hit->onDblClick && multiClickDetect(vx, vy) >= 2) {
hit->onDblClick(hit);
}
} }

View file

@ -1020,6 +1020,10 @@ void widgetListViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy)
if (hit->onChange) { if (hit->onChange) {
hit->onChange(hit); hit->onChange(hit);
} }
if (hit->onDblClick && multiClickDetect(vx, vy) >= 2) {
hit->onDblClick(hit);
}
} }
wgtInvalidatePaint(hit); wgtInvalidatePaint(hit);

View file

@ -143,10 +143,42 @@ static void onCloseMainCb(WindowT *win) {
// onMenuCb // onMenuCb
// ============================================================ // ============================================================
static const FileFilterT sFileFilters[] = {
{"All Files (*.*)", "*.*"},
{"Text Files (*.txt)", "*.txt"},
{"Batch Files (*.bat)", "*.bat"},
{"Executables (*.exe)", "*.exe"},
{"Bitmap Files (*.bmp)", "*.bmp"}
};
static void onMenuCb(WindowT *win, int32_t menuId) { static void onMenuCb(WindowT *win, int32_t menuId) {
AppContextT *ctx = (AppContextT *)win->userData; AppContextT *ctx = (AppContextT *)win->userData;
switch (menuId) { switch (menuId) {
case CMD_FILE_OPEN: {
char path[260];
if (dvxFileDialog(ctx, "Open File", FD_OPEN, NULL, sFileFilters, 5, path, sizeof(path))) {
char msg[300];
snprintf(msg, sizeof(msg), "Selected: %s", path);
dvxMessageBox(ctx, "Open", msg, MB_OK | MB_ICONINFO);
}
break;
}
case CMD_FILE_SAVE: {
char path[260];
if (dvxFileDialog(ctx, "Save As", FD_SAVE, NULL, sFileFilters, 5, path, sizeof(path))) {
char msg[300];
snprintf(msg, sizeof(msg), "Save to: %s", path);
dvxMessageBox(ctx, "Save", msg, MB_OK | MB_ICONINFO);
}
break;
}
case CMD_FILE_EXIT: case CMD_FILE_EXIT:
if (ctx) { if (ctx) {
int32_t result = dvxMessageBox(ctx, "Exit", int32_t result = dvxMessageBox(ctx, "Exit",