1092 lines
31 KiB
C
1092 lines
31 KiB
C
// dvxDialog.c — Modal dialogs for DVX GUI
|
|
|
|
#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
|
|
// ============================================================
|
|
|
|
#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
|
|
|
|
// ============================================================
|
|
// 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)
|
|
// ============================================================
|
|
|
|
typedef struct {
|
|
AppContextT *ctx;
|
|
int32_t result;
|
|
bool done;
|
|
const char *message;
|
|
int32_t iconType;
|
|
int32_t textX;
|
|
int32_t textY;
|
|
int32_t textMaxW;
|
|
int32_t msgAreaH;
|
|
} MsgBoxStateT;
|
|
|
|
static MsgBoxStateT sMsgBox;
|
|
|
|
|
|
// ============================================================
|
|
// drawIconGlyph — draw a simple icon shape
|
|
// ============================================================
|
|
|
|
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 < 24; row++) {
|
|
for (int32_t col = 0; col < 24; col++) {
|
|
int32_t dx = col - 12;
|
|
int32_t dy = row - 12;
|
|
int32_t d2 = dx * dx + dy * dy;
|
|
|
|
if (d2 <= 144 && d2 >= 100) {
|
|
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 < 24; row++) {
|
|
int32_t halfW = row / 2;
|
|
int32_t lx = 12 - halfW;
|
|
int32_t rx = 12 + halfW;
|
|
|
|
rectFill(d, ops, x + lx, y + row, 1, 1, color);
|
|
rectFill(d, ops, x + rx, y + row, 1, 1, color);
|
|
|
|
if (row == 23) {
|
|
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 < 24; row++) {
|
|
for (int32_t col = 0; col < 24; col++) {
|
|
int32_t dx = col - 12;
|
|
int32_t dy = row - 12;
|
|
int32_t d2 = dx * dx + dy * dy;
|
|
|
|
if (d2 <= 144 && d2 >= 100) {
|
|
rectFill(d, ops, x + col, y + row, 1, 1, color);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int32_t i = 0; i < 12; 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 < 24; row++) {
|
|
for (int32_t col = 0; col < 24; col++) {
|
|
int32_t dx = col - 12;
|
|
int32_t dy = row - 12;
|
|
int32_t d2 = dx * dx + dy * dy;
|
|
|
|
if (d2 <= 144 && d2 >= 100) {
|
|
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
|
|
// ============================================================
|
|
|
|
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 ? (24 + 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);
|
|
|
|
// Set as modal (save previous in case we're stacking modals)
|
|
WindowT *prevModal = ctx->modalWindow;
|
|
ctx->modalWindow = win;
|
|
|
|
// Nested event loop
|
|
while (!sMsgBox.done && ctx->running) {
|
|
dvxUpdate(ctx);
|
|
}
|
|
|
|
// Clean up — restore previous modal
|
|
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
|
|
// ============================================================
|
|
|
|
static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea) {
|
|
(void)dirtyArea;
|
|
|
|
MsgBoxStateT *state = (MsgBoxStateT *)win->userData;
|
|
AppContextT *ctx = state->ctx;
|
|
|
|
// Set up display context pointing at content buffer
|
|
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
|
|
// ============================================================
|
|
|
|
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
|
|
// ============================================================
|
|
|
|
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
|
|
// ============================================================
|
|
|
|
#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);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 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) {
|
|
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
|
|
// ============================================================
|
|
|
|
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
|
|
// ============================================================
|
|
|
|
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, "");
|
|
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, "");
|
|
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;
|
|
}
|