DVX_GUI/src/apps/kpunch/dvxbasic/ide/ideMenuEditor.c
2026-04-22 20:33:49 -05:00

739 lines
20 KiB
C

// The MIT License (MIT)
//
// Copyright (C) 2026 Scott Duensing
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
// ideMenuEditor.c -- DVX BASIC menu editor dialog
//
// VB3-style modal dialog for designing form menu bars. Works on
// a cloned copy of the menu item array; changes are only applied
// when the user clicks OK.
#include "ideMenuEditor.h"
#include "dvxDlg.h"
#include "dvxWm.h"
#include "box/box.h"
#include "button/button.h"
#include "checkbox/checkbox.h"
#include "label/label.h"
#include "listBox/listBox.h"
#include "textInput/textInpt.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
// ============================================================
// Constants
// ============================================================
#define MAX_MENU_LEVEL 5
#define ARROW_STR "-> "
// ============================================================
// Dialog state
// ============================================================
typedef struct {
bool done;
bool accepted;
AppContextT *ctx;
DsgnFormT *form;
// Working copy of menu items
DsgnMenuItemT *items; // stb_ds array
int32_t selectedIdx;
bool nameAutoGen; // true = name was auto-generated, update on caption change
// Widgets
WidgetT *captionInput;
WidgetT *nameInput;
WidgetT *checkedCb;
WidgetT *radioCheckCb;
WidgetT *enabledCb;
WidgetT *popupCb; // Top-level popup (hidden from menu bar)
WidgetT *listBox;
} MnuEdStateT;
static MnuEdStateT sMed;
// Dynamic per-refresh display buffers (stb_ds). The listBox stores the pointer
// array, so these must outlive each SetItems call. Freed on dialog teardown.
static char **sLabelBufs = NULL;
static const char **sLabels = NULL;
// ============================================================
// Prototypes
// ============================================================
static void applyFields(void);
static int32_t findSubtreeEnd(int32_t idx);
static void loadFields(void);
static void onCancel(WidgetT *w);
static void onCaptionChange(WidgetT *w);
static void onDelete(WidgetT *w);
static void onIndent(WidgetT *w);
static void onInsert(WidgetT *w);
static void onListClick(WidgetT *w);
static void onMoveDown(WidgetT *w);
static void onMoveUp(WidgetT *w);
static void onNameChange(WidgetT *w);
static void onNext(WidgetT *w);
static void onOk(WidgetT *w);
static void onOutdent(WidgetT *w);
static void rebuildList(void);
static void applyFields(void) {
int32_t count = (int32_t)arrlen(sMed.items);
if (sMed.selectedIdx < 0 || sMed.selectedIdx >= count) {
return;
}
DsgnMenuItemT *mi = &sMed.items[sMed.selectedIdx];
const char *cap = wgtGetText(sMed.captionInput);
const char *nam = wgtGetText(sMed.nameInput);
if (cap) {
snprintf(mi->caption, DSGN_MAX_TEXT, "%s", cap);
}
if (nam) {
snprintf(mi->name, DSGN_MAX_NAME, "%s", nam);
}
// Auto-generate name from caption if name is empty or was auto-generated
if ((mi->name[0] == '\0' || sMed.nameAutoGen) && mi->caption[0] != '\0') {
char autoName[DSGN_MAX_NAME];
if (mi->caption[0] == '-') {
// Separator: generate mnuSep1, mnuSep2, etc.
int32_t sepNum = 1;
int32_t itemCount = (int32_t)arrlen(sMed.items);
for (int32_t i = 0; i < itemCount; i++) {
if (i != sMed.selectedIdx && strncasecmp(sMed.items[i].name, "mnuSep", 6) == 0) {
int32_t n = atoi(sMed.items[i].name + 6);
if (n >= sepNum) {
sepNum = n + 1;
}
}
}
snprintf(autoName, DSGN_MAX_NAME, "mnuSep%d", (int)sepNum);
} else {
// Normal item: strip & and non-alphanumeric, prefix "mnu"
int32_t p = 0;
autoName[p++] = 'm';
autoName[p++] = 'n';
autoName[p++] = 'u';
for (const char *c = mi->caption; *c && p < DSGN_MAX_NAME - 1; c++) {
if (*c == '&') {
continue;
}
if ((*c >= 'A' && *c <= 'Z') || (*c >= 'a' && *c <= 'z') || (*c >= '0' && *c <= '9')) {
autoName[p++] = *c;
}
}
autoName[p] = '\0';
}
snprintf(mi->name, DSGN_MAX_NAME, "%s", autoName);
wgtSetText(sMed.nameInput, mi->name);
sMed.nameAutoGen = true;
}
mi->checked = wgtCheckboxIsChecked(sMed.checkedCb);
mi->radioCheck = wgtCheckboxIsChecked(sMed.radioCheckCb);
mi->enabled = wgtCheckboxIsChecked(sMed.enabledCb);
// Popup checkbox only meaningful on top-level items; for nested
// items `visible` stays true (the field is ignored there).
if (mi->level == 0 && sMed.popupCb) {
mi->visible = !wgtCheckboxIsChecked(sMed.popupCb);
} else {
mi->visible = true;
}
}
static int32_t findSubtreeEnd(int32_t idx) {
int32_t count = (int32_t)arrlen(sMed.items);
int32_t level = sMed.items[idx].level;
int32_t end = idx + 1;
while (end < count && sMed.items[end].level > level) {
end++;
}
return end;
}
static void loadFields(void) {
int32_t count = (int32_t)arrlen(sMed.items);
// Reset auto-gen flag -- if the loaded item has an empty name, it's
// eligible for auto-generation; if it has a name, the user set it.
sMed.nameAutoGen = false;
if (sMed.selectedIdx < 0 || sMed.selectedIdx >= count) {
wgtSetText(sMed.captionInput, "");
wgtSetText(sMed.nameInput, "");
wgtCheckboxSetChecked(sMed.checkedCb, false);
wgtCheckboxSetChecked(sMed.radioCheckCb, false);
wgtCheckboxSetChecked(sMed.enabledCb, true);
if (sMed.popupCb) {
wgtCheckboxSetChecked(sMed.popupCb, false);
wgtSetEnabled(sMed.popupCb, false);
}
sMed.nameAutoGen = true; // new blank item -- auto-gen eligible
return;
}
DsgnMenuItemT *mi = &sMed.items[sMed.selectedIdx];
sMed.nameAutoGen = (mi->name[0] == '\0');
wgtSetText(sMed.captionInput, mi->caption);
wgtSetText(sMed.nameInput, mi->name);
wgtCheckboxSetChecked(sMed.checkedCb, mi->checked);
wgtCheckboxSetChecked(sMed.radioCheckCb, mi->radioCheck);
wgtCheckboxSetChecked(sMed.enabledCb, mi->enabled);
if (sMed.popupCb) {
// Popup (Visible=False) only applies to top-level menus.
wgtCheckboxSetChecked(sMed.popupCb, mi->level == 0 && !mi->visible);
wgtSetEnabled(sMed.popupCb, mi->level == 0);
}
}
bool mnuEditorDialog(AppContextT *ctx, DsgnFormT *form) {
memset(&sMed, 0, sizeof(sMed));
sMed.ctx = ctx;
sMed.form = form;
sMed.selectedIdx = 0;
// Clone menu items as working copy
int32_t count = (int32_t)arrlen(form->menuItems);
for (int32_t i = 0; i < count; i++) {
arrput(sMed.items, form->menuItems[i]);
}
// If empty, start with one blank item so the user can type immediately
if (arrlen(sMed.items) == 0) {
DsgnMenuItemT mi;
memset(&mi, 0, sizeof(mi));
mi.enabled = true;
mi.visible = true;
arrput(sMed.items, mi);
}
// Create modal dialog
WindowT *win = dvxCreateWindowCentered(ctx, "Menu Editor", 360, 420, false);
if (!win) {
arrfree(sMed.items);
return false;
}
win->maxW = win->w;
win->maxH = win->h;
WidgetT *root = wgtInitWindow(ctx, win);
root->spacing = wgtPixels(4);
// Caption row
WidgetT *capRow = wgtHBox(root);
capRow->spacing = wgtPixels(4);
WidgetT *capLbl = wgtLabel(capRow, "Caption:");
capLbl->minW = wgtPixels(60);
sMed.captionInput = wgtTextInput(capRow, DSGN_MAX_TEXT);
sMed.captionInput->weight = WGT_WEIGHT_FILL;
sMed.captionInput->onChange = onCaptionChange;
// Name row
WidgetT *namRow = wgtHBox(root);
namRow->spacing = wgtPixels(4);
WidgetT *namLbl = wgtLabel(namRow, "Name:");
namLbl->minW = wgtPixels(60);
sMed.nameInput = wgtTextInput(namRow, DSGN_MAX_NAME);
sMed.nameInput->weight = WGT_WEIGHT_FILL;
sMed.nameInput->onChange = onNameChange;
// Check row
WidgetT *chkRow = wgtHBox(root);
chkRow->spacing = wgtPixels(12);
sMed.checkedCb = wgtCheckbox(chkRow, "Checked");
sMed.radioCheckCb = wgtCheckbox(chkRow, "RadioCheck");
sMed.enabledCb = wgtCheckbox(chkRow, "Enabled");
sMed.popupCb = wgtCheckbox(chkRow, "Popup");
wgtCheckboxSetChecked(sMed.enabledCb, true);
// Listbox
sMed.listBox = wgtListBox(root);
sMed.listBox->weight = WGT_WEIGHT_FILL;
sMed.listBox->onChange = onListClick;
// Arrow buttons
WidgetT *arrowRow = wgtHBox(root);
arrowRow->spacing = wgtPixels(4);
WidgetT *btnOut = wgtButton(arrowRow, "<-");
btnOut->onClick = onOutdent;
btnOut->minW = wgtPixels(32);
WidgetT *btnIn = wgtButton(arrowRow, "->");
btnIn->onClick = onIndent;
btnIn->minW = wgtPixels(32);
WidgetT *btnUp = wgtButton(arrowRow, "Up");
btnUp->onClick = onMoveUp;
btnUp->minW = wgtPixels(32);
WidgetT *btnDn = wgtButton(arrowRow, "Dn");
btnDn->onClick = onMoveDown;
btnDn->minW = wgtPixels(32);
// Action buttons
WidgetT *actRow = wgtHBox(root);
actRow->spacing = wgtPixels(4);
WidgetT *btnNext = wgtButton(actRow, "&Next");
btnNext->onClick = onNext;
WidgetT *btnIns = wgtButton(actRow, "&Insert");
btnIns->onClick = onInsert;
WidgetT *btnDel = wgtButton(actRow, "&Delete");
btnDel->onClick = onDelete;
// OK / Cancel
WidgetT *okRow = wgtHBox(root);
okRow->spacing = wgtPixels(8);
WidgetT *btnOk = wgtButton(okRow, "OK");
btnOk->onClick = onOk;
btnOk->minW = wgtPixels(60);
WidgetT *btnCancel = wgtButton(okRow, "Cancel");
btnCancel->onClick = onCancel;
btnCancel->minW = wgtPixels(60);
// Populate
rebuildList();
loadFields();
wgtSetFocused(sMed.captionInput);
dvxFitWindow(ctx, win);
// Modal loop
WindowT *prevModal = ctx->modalWindow;
ctx->modalWindow = win;
while (!sMed.done && ctx->running) {
dvxUpdate(ctx);
}
ctx->modalWindow = prevModal;
dvxDestroyWindow(ctx, win);
// Cleanup working copy
arrfree(sMed.items);
sMed.items = NULL;
// Cleanup display buffers
for (int32_t i = 0; i < (int32_t)arrlen(sLabelBufs); i++) {
free(sLabelBufs[i]);
}
arrfree(sLabelBufs);
arrfree(sLabels);
sLabelBufs = NULL;
sLabels = NULL;
return sMed.accepted;
}
static void onCancel(WidgetT *w) {
(void)w;
sMed.accepted = false;
sMed.done = true;
}
static void onCaptionChange(WidgetT *w) {
(void)w;
applyFields();
rebuildList();
}
static void onDelete(WidgetT *w) {
(void)w;
int32_t count = (int32_t)arrlen(sMed.items);
if (sMed.selectedIdx < 0 || sMed.selectedIdx >= count) {
return;
}
applyFields();
// Delete selected item and its children
int32_t subEnd = findSubtreeEnd(sMed.selectedIdx);
for (int32_t i = subEnd - 1; i >= sMed.selectedIdx; i--) {
arrdel(sMed.items, i);
}
count = (int32_t)arrlen(sMed.items);
if (sMed.selectedIdx >= count) {
sMed.selectedIdx = count - 1;
}
rebuildList();
loadFields();
wgtSetFocused(sMed.captionInput);
}
static void onIndent(WidgetT *w) {
(void)w;
int32_t count = (int32_t)arrlen(sMed.items);
int32_t idx = sMed.selectedIdx;
if (idx <= 0 || idx >= count) {
return;
}
applyFields();
// Can only indent if previous item is at same or higher level
int32_t prevLevel = sMed.items[idx - 1].level;
if (sMed.items[idx].level > prevLevel) {
return; // already deeper than prev
}
if (sMed.items[idx].level >= MAX_MENU_LEVEL) {
return;
}
// Indent this item and all its children
int32_t subEnd = findSubtreeEnd(idx);
for (int32_t i = idx; i < subEnd; i++) {
sMed.items[i].level++;
}
rebuildList();
}
static void onInsert(WidgetT *w) {
(void)w;
int32_t count = (int32_t)arrlen(sMed.items);
applyFields();
DsgnMenuItemT mi;
memset(&mi, 0, sizeof(mi));
mi.enabled = true;
mi.visible = true;
// Insert after the current item's subtree, at the same level
int32_t insertAt;
if (sMed.selectedIdx >= 0 && sMed.selectedIdx < count) {
mi.level = sMed.items[sMed.selectedIdx].level;
insertAt = findSubtreeEnd(sMed.selectedIdx);
} else {
insertAt = count;
}
arrins(sMed.items, insertAt, mi);
sMed.selectedIdx = insertAt;
rebuildList();
loadFields();
wgtSetFocused(sMed.captionInput);
}
static void onListClick(WidgetT *w) {
int32_t prev = sMed.selectedIdx;
sMed.selectedIdx = wgtListBoxGetSelected(w);
if (prev != sMed.selectedIdx && prev >= 0 && prev < (int32_t)arrlen(sMed.items)) {
// Save fields from previously selected item
int32_t saveSel = sMed.selectedIdx;
sMed.selectedIdx = prev;
applyFields();
sMed.selectedIdx = saveSel;
}
loadFields();
}
static void onMoveDown(WidgetT *w) {
(void)w;
int32_t count = (int32_t)arrlen(sMed.items);
int32_t idx = sMed.selectedIdx;
if (idx < 0 || idx >= count) {
return;
}
applyFields();
int32_t subEnd = findSubtreeEnd(idx);
if (subEnd >= count) {
return; // already at bottom
}
// The item/subtree after us
int32_t nextEnd = findSubtreeEnd(subEnd);
// Rotate: move the next subtree before our subtree
// Simple approach: extract our subtree, delete it, insert after next subtree
int32_t subSize = subEnd - idx;
DsgnMenuItemT *tmp = (DsgnMenuItemT *)malloc(subSize * sizeof(DsgnMenuItemT));
memcpy(tmp, &sMed.items[idx], subSize * sizeof(DsgnMenuItemT));
// Delete our subtree
for (int32_t i = subEnd - 1; i >= idx; i--) {
arrdel(sMed.items, i);
}
// Insert after what was the next subtree (now shifted)
int32_t insertAt = nextEnd - subSize;
for (int32_t i = 0; i < subSize; i++) {
arrins(sMed.items, insertAt + i, tmp[i]);
}
free(tmp);
sMed.selectedIdx = insertAt;
rebuildList();
}
static void onMoveUp(WidgetT *w) {
(void)w;
int32_t idx = sMed.selectedIdx;
if (idx <= 0) {
return;
}
applyFields();
// Find the start of the previous subtree at the same or lower level
int32_t prevIdx = idx - 1;
while (prevIdx > 0 && sMed.items[prevIdx].level > sMed.items[idx].level) {
prevIdx--;
}
// Move our subtree before the previous item's subtree
int32_t subEnd = findSubtreeEnd(idx);
int32_t subSize = subEnd - idx;
DsgnMenuItemT *tmp = (DsgnMenuItemT *)malloc(subSize * sizeof(DsgnMenuItemT));
memcpy(tmp, &sMed.items[idx], subSize * sizeof(DsgnMenuItemT));
// Delete our subtree
for (int32_t i = subEnd - 1; i >= idx; i--) {
arrdel(sMed.items, i);
}
// Insert at prevIdx
for (int32_t i = 0; i < subSize; i++) {
arrins(sMed.items, prevIdx + i, tmp[i]);
}
free(tmp);
sMed.selectedIdx = prevIdx;
rebuildList();
}
static void onNameChange(WidgetT *w) {
(void)w;
sMed.nameAutoGen = false; // user took over
applyFields();
rebuildList();
}
static void onNext(WidgetT *w) {
(void)w;
int32_t count = (int32_t)arrlen(sMed.items);
applyFields();
if (sMed.selectedIdx < count - 1) {
sMed.selectedIdx++;
} else {
// Append new item
DsgnMenuItemT mi;
memset(&mi, 0, sizeof(mi));
mi.enabled = true;
mi.visible = true;
if (count > 0) {
mi.level = sMed.items[count - 1].level;
}
arrput(sMed.items, mi);
sMed.selectedIdx = (int32_t)arrlen(sMed.items) - 1;
}
rebuildList();
loadFields();
wgtSetFocused(sMed.captionInput);
}
static void onOk(WidgetT *w) {
(void)w;
applyFields();
// Strip blank items (no caption and no name)
for (int32_t i = (int32_t)arrlen(sMed.items) - 1; i >= 0; i--) {
if (sMed.items[i].caption[0] == '\0' && sMed.items[i].name[0] == '\0') {
arrdel(sMed.items, i);
}
}
// Validate: check names are non-empty and unique
int32_t count = (int32_t)arrlen(sMed.items);
for (int32_t i = 0; i < count; i++) {
if (sMed.items[i].name[0] == '\0') {
dvxErrorBox(sMed.ctx, "Menu Editor", "All menu items must have a Name.");
sMed.selectedIdx = i;
rebuildList();
loadFields();
wgtSetFocused(sMed.captionInput);
return;
}
for (int32_t j = i + 1; j < count; j++) {
if (strcasecmp(sMed.items[i].name, sMed.items[j].name) == 0) {
char msg[128];
snprintf(msg, sizeof(msg), "Duplicate menu name: %s", sMed.items[i].name);
dvxErrorBox(sMed.ctx, "Menu Editor", msg);
sMed.selectedIdx = j;
rebuildList();
loadFields();
wgtSetFocused(sMed.captionInput);
return;
}
}
}
// Copy working items back to form
arrfree(sMed.form->menuItems);
sMed.form->menuItems = NULL;
for (int32_t i = 0; i < count; i++) {
arrput(sMed.form->menuItems, sMed.items[i]);
}
sMed.accepted = true;
sMed.done = true;
}
static void onOutdent(WidgetT *w) {
(void)w;
int32_t count = (int32_t)arrlen(sMed.items);
int32_t idx = sMed.selectedIdx;
if (idx < 0 || idx >= count) {
return;
}
if (sMed.items[idx].level <= 0) {
return;
}
applyFields();
int32_t subEnd = findSubtreeEnd(idx);
for (int32_t i = idx; i < subEnd; i++) {
sMed.items[i].level--;
}
rebuildList();
}
static void rebuildList(void) {
int32_t count = (int32_t)arrlen(sMed.items);
// The listBox stores pointers, not copies -- so we must keep these buffers
// alive until the next rebuild or dialog destroy. Free prior pass first.
for (int32_t i = 0; i < (int32_t)arrlen(sLabelBufs); i++) {
free(sLabelBufs[i]);
}
arrsetlen(sLabelBufs, 0);
arrsetlen(sLabels, 0);
for (int32_t i = 0; i < count; i++) {
char buf[DSGN_MAX_TEXT + 32];
int32_t pos = 0;
buf[0] = '\0';
for (int32_t lv = 0; lv < sMed.items[i].level; lv++) {
pos += snprintf(buf + pos, sizeof(buf) - pos, "%s", ARROW_STR);
}
const char *cap = sMed.items[i].caption;
if (cap[0] == '-') {
snprintf(buf + pos, sizeof(buf) - pos, "--------");
} else if (cap[0]) {
snprintf(buf + pos, sizeof(buf) - pos, "%s", cap);
} else {
snprintf(buf + pos, sizeof(buf) - pos, "(%s)", sMed.items[i].name[0] ? sMed.items[i].name : "untitled");
}
arrput(sLabelBufs, strdup(buf));
}
for (int32_t i = 0; i < count; i++) {
arrput(sLabels, (const char *)sLabelBufs[i]);
}
wgtListBoxSetItems(sMed.listBox, sLabels, count);
wgtListBoxSetSelected(sMed.listBox, sMed.selectedIdx);
}