1293 lines
42 KiB
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;
|
|
}
|