diff --git a/dvx/Makefile b/dvx/Makefile index df6cfa0..be167e9 100644 --- a/dvx/Makefile +++ b/dvx/Makefile @@ -35,6 +35,7 @@ WSRCS = widgets/widgetAnsiTerm.c \ widgets/widgetSeparator.c \ widgets/widgetSlider.c \ widgets/widgetSpacer.c \ + widgets/widgetSpinner.c \ widgets/widgetStatusBar.c \ widgets/widgetTabControl.c \ widgets/widgetTextInput.c \ @@ -102,6 +103,7 @@ $(WOBJDIR)/widgetRadio.o: widgets/widgetRadio.c $(WIDGET_DEPS) $(WOBJDIR)/widgetSeparator.o: widgets/widgetSeparator.c $(WIDGET_DEPS) $(WOBJDIR)/widgetSlider.o: widgets/widgetSlider.c $(WIDGET_DEPS) $(WOBJDIR)/widgetSpacer.o: widgets/widgetSpacer.c $(WIDGET_DEPS) +$(WOBJDIR)/widgetSpinner.o: widgets/widgetSpinner.c $(WIDGET_DEPS) $(WOBJDIR)/widgetStatusBar.o: widgets/widgetStatusBar.c $(WIDGET_DEPS) $(WOBJDIR)/widgetTabControl.o: widgets/widgetTabControl.c $(WIDGET_DEPS) $(WOBJDIR)/widgetTextInput.o: widgets/widgetTextInput.c $(WIDGET_DEPS) diff --git a/dvx/dvxWidget.h b/dvx/dvxWidget.h index b239df8..8c34d9f 100644 --- a/dvx/dvxWidget.h +++ b/dvx/dvxWidget.h @@ -68,7 +68,8 @@ typedef enum { WidgetImageButtonE, WidgetCanvasE, WidgetAnsiTermE, - WidgetListViewE + WidgetListViewE, + WidgetSpinnerE } WidgetTypeE; // ============================================================ @@ -423,6 +424,23 @@ typedef struct WidgetT { uint8_t *selBits; void (*onHeaderClick)(struct WidgetT *w, int32_t col, ListViewSortE dir); } listView; + + struct { + int32_t value; + int32_t minValue; + int32_t maxValue; + int32_t step; + char buf[16]; // formatted value text + int32_t len; + int32_t cursorPos; + int32_t scrollOff; + int32_t selStart; // selection anchor (-1 = none) + int32_t selEnd; // selection end (-1 = none) + char undoBuf[16]; // undo snapshot + int32_t undoLen; + int32_t undoCursor; + bool editing; // true when user is typing + } spinner; } as; } WidgetT; @@ -505,6 +523,16 @@ WidgetT *wgtSlider(WidgetT *parent, int32_t minVal, int32_t maxVal); void wgtSliderSetValue(WidgetT *w, int32_t value); int32_t wgtSliderGetValue(const WidgetT *w); +// ============================================================ +// Spinner (numeric input) +// ============================================================ + +WidgetT *wgtSpinner(WidgetT *parent, int32_t minVal, int32_t maxVal, int32_t step); +void wgtSpinnerSetValue(WidgetT *w, int32_t value); +int32_t wgtSpinnerGetValue(const WidgetT *w); +void wgtSpinnerSetRange(WidgetT *w, int32_t minVal, int32_t maxVal); +void wgtSpinnerSetStep(WidgetT *w, int32_t step); + // ============================================================ // TabControl // ============================================================ diff --git a/dvx/widgets/widgetClass.c b/dvx/widgets/widgetClass.c index f3315e0..d6ed76d 100644 --- a/dvx/widgets/widgetClass.c +++ b/dvx/widgets/widgetClass.c @@ -370,6 +370,19 @@ static const WidgetClassT sClassAnsiTerm = { .setText = NULL }; +static const WidgetClassT sClassSpinner = { + .flags = WCLASS_FOCUSABLE, + .paint = widgetSpinnerPaint, + .paintOverlay = NULL, + .calcMinSize = widgetSpinnerCalcMinSize, + .layout = NULL, + .onMouse = widgetSpinnerOnMouse, + .onKey = widgetSpinnerOnKey, + .destroy = NULL, + .getText = widgetSpinnerGetText, + .setText = widgetSpinnerSetText +}; + // ============================================================ // Class table — indexed by WidgetTypeE // ============================================================ @@ -402,5 +415,6 @@ const WidgetClassT *widgetClassTable[] = { [WidgetImageButtonE] = &sClassImageButton, [WidgetCanvasE] = &sClassCanvas, [WidgetAnsiTermE] = &sClassAnsiTerm, - [WidgetListViewE] = &sClassListView + [WidgetListViewE] = &sClassListView, + [WidgetSpinnerE] = &sClassSpinner }; diff --git a/dvx/widgets/widgetInternal.h b/dvx/widgets/widgetInternal.h index 89ddbbc..7cb52a5 100644 --- a/dvx/widgets/widgetInternal.h +++ b/dvx/widgets/widgetInternal.h @@ -171,6 +171,7 @@ void widgetProgressBarPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const void widgetRadioPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetSeparatorPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetSliderPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); +void widgetSpinnerPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetStatusBarPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetTabControlPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetTextAreaPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); @@ -197,6 +198,7 @@ void widgetProgressBarCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetRadioCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetSeparatorCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetSliderCalcMinSize(WidgetT *w, const BitmapFontT *font); +void widgetSpinnerCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetSpacerCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetTabControlCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetTextAreaCalcMinSize(WidgetT *w, const BitmapFontT *font); @@ -277,6 +279,10 @@ void widgetRadioOnKey(WidgetT *w, int32_t key, int32_t mod); void widgetRadioOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); void widgetSliderOnKey(WidgetT *w, int32_t key, int32_t mod); void widgetSliderOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); +void widgetSpinnerOnKey(WidgetT *w, int32_t key, int32_t mod); +void widgetSpinnerOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); +const char *widgetSpinnerGetText(const WidgetT *w); +void widgetSpinnerSetText(WidgetT *w, const char *text); void widgetTabControlOnKey(WidgetT *w, int32_t key, int32_t mod); void widgetTabControlOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); void widgetTextAreaOnKey(WidgetT *w, int32_t key, int32_t mod); diff --git a/dvx/widgets/widgetSpinner.c b/dvx/widgets/widgetSpinner.c new file mode 100644 index 0000000..80dccb1 --- /dev/null +++ b/dvx/widgets/widgetSpinner.c @@ -0,0 +1,508 @@ +// widgetSpinner.c — Spinner (numeric up/down) widget + +#include "widgetInternal.h" + +#include +#include + +#define SPINNER_BTN_W 14 +#define SPINNER_BORDER 2 +#define SPINNER_PAD 3 + + +// ============================================================ +// Prototypes +// ============================================================ + +static void spinnerClampAndFormat(WidgetT *w); +static void spinnerCommitEdit(WidgetT *w); +static void spinnerFormat(WidgetT *w); +static void spinnerStartEdit(WidgetT *w); + + +// ============================================================ +// spinnerClampAndFormat +// ============================================================ + +static void spinnerClampAndFormat(WidgetT *w) { + if (w->as.spinner.value < w->as.spinner.minValue) { + w->as.spinner.value = w->as.spinner.minValue; + } + + if (w->as.spinner.value > w->as.spinner.maxValue) { + w->as.spinner.value = w->as.spinner.maxValue; + } + + spinnerFormat(w); +} + + +// ============================================================ +// spinnerCommitEdit +// ============================================================ + +static void spinnerCommitEdit(WidgetT *w) { + if (!w->as.spinner.editing) { + return; + } + + w->as.spinner.editing = false; + w->as.spinner.buf[w->as.spinner.len] = '\0'; + + int32_t val = (int32_t)strtol(w->as.spinner.buf, NULL, 10); + w->as.spinner.value = val; + spinnerClampAndFormat(w); +} + + +// ============================================================ +// spinnerFormat +// ============================================================ + +static void spinnerFormat(WidgetT *w) { + w->as.spinner.len = snprintf(w->as.spinner.buf, sizeof(w->as.spinner.buf), "%d", (int)w->as.spinner.value); + w->as.spinner.cursorPos = w->as.spinner.len; + w->as.spinner.scrollOff = 0; + w->as.spinner.selStart = -1; + w->as.spinner.selEnd = -1; +} + + +// ============================================================ +// spinnerStartEdit +// ============================================================ + +static void spinnerStartEdit(WidgetT *w) { + if (!w->as.spinner.editing) { + w->as.spinner.editing = true; + + // Snapshot for undo + memcpy(w->as.spinner.undoBuf, w->as.spinner.buf, sizeof(w->as.spinner.buf)); + w->as.spinner.undoLen = w->as.spinner.len; + w->as.spinner.undoCursor = w->as.spinner.cursorPos; + } +} + + +// ============================================================ +// widgetSpinnerCalcMinSize +// ============================================================ + +void widgetSpinnerCalcMinSize(WidgetT *w, const BitmapFontT *font) { + w->calcMinW = font->charWidth * 6 + SPINNER_PAD * 2 + SPINNER_BORDER * 2 + SPINNER_BTN_W; + w->calcMinH = font->charHeight + SPINNER_PAD * 2 + SPINNER_BORDER * 2; +} + + +// ============================================================ +// widgetSpinnerGetText +// ============================================================ + +const char *widgetSpinnerGetText(const WidgetT *w) { + return w->as.spinner.buf; +} + + +// ============================================================ +// widgetSpinnerOnKey +// ============================================================ + +void widgetSpinnerOnKey(WidgetT *w, int32_t key, int32_t mod) { + int32_t step = w->as.spinner.step; + + // Up arrow — increment + if (key == (0x48 | 0x100)) { + spinnerCommitEdit(w); + w->as.spinner.value += step; + spinnerClampAndFormat(w); + + if (w->onChange) { + w->onChange(w); + } + + wgtInvalidatePaint(w); + return; + } + + // Down arrow — decrement + if (key == (0x50 | 0x100)) { + spinnerCommitEdit(w); + w->as.spinner.value -= step; + spinnerClampAndFormat(w); + + if (w->onChange) { + w->onChange(w); + } + + wgtInvalidatePaint(w); + return; + } + + // Page Up — increment by step * 10 + if (key == (0x49 | 0x100)) { + spinnerCommitEdit(w); + w->as.spinner.value += step * 10; + spinnerClampAndFormat(w); + + if (w->onChange) { + w->onChange(w); + } + + wgtInvalidatePaint(w); + return; + } + + // Page Down — decrement by step * 10 + if (key == (0x51 | 0x100)) { + spinnerCommitEdit(w); + w->as.spinner.value -= step * 10; + spinnerClampAndFormat(w); + + if (w->onChange) { + w->onChange(w); + } + + wgtInvalidatePaint(w); + return; + } + + // Enter — commit edit + if (key == '\r' || key == '\n') { + if (w->as.spinner.editing) { + spinnerCommitEdit(w); + + if (w->onChange) { + w->onChange(w); + } + + wgtInvalidatePaint(w); + } + + return; + } + + // Escape — cancel edit, revert to current value + if (key == 27) { + if (w->as.spinner.editing) { + w->as.spinner.editing = false; + spinnerFormat(w); + wgtInvalidatePaint(w); + } + + return; + } + + // Filter: only allow digits, minus, and control keys through to text editor + bool isDigit = (key >= '0' && key <= '9'); + bool isMinus = (key == '-'); + bool isControl = (key < 0x20) || (key & 0x100); + + if (!isDigit && !isMinus && !isControl) { + return; + } + + // Minus only at position 0 (and only if min is negative) + if (isMinus && (w->as.spinner.cursorPos != 0 || w->as.spinner.minValue >= 0)) { + return; + } + + // Enter edit mode on first text-modifying key + if (isDigit || isMinus || key == 8 || key == 127 || key == (0x53 | 0x100)) { + spinnerStartEdit(w); + } + + // Delegate to shared text editing logic (handles cursor movement, + // selection, cut/copy/paste, undo/redo, backspace, delete, etc.) + widgetTextEditOnKey(w, key, mod, + w->as.spinner.buf, (int32_t)sizeof(w->as.spinner.buf), + &w->as.spinner.len, &w->as.spinner.cursorPos, + &w->as.spinner.scrollOff, + &w->as.spinner.selStart, &w->as.spinner.selEnd, + w->as.spinner.undoBuf, &w->as.spinner.undoLen, + &w->as.spinner.undoCursor); + + wgtInvalidatePaint(w); +} + + +// ============================================================ +// widgetSpinnerOnMouse +// ============================================================ + +void widgetSpinnerOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) { + hit->focused = true; + + int32_t btnX = hit->x + hit->w - SPINNER_BORDER - SPINNER_BTN_W; + int32_t midY = hit->y + hit->h / 2; + + if (vx >= btnX) { + // Click on button area + spinnerCommitEdit(hit); + + if (vy < midY) { + hit->as.spinner.value += hit->as.spinner.step; + } else { + hit->as.spinner.value -= hit->as.spinner.step; + } + + spinnerClampAndFormat(hit); + + if (hit->onChange) { + hit->onChange(hit); + } + } else { + // Click on text area + AppContextT *ctx = (AppContextT *)root->userData; + const BitmapFontT *font = &ctx->font; + int32_t textX = hit->x + SPINNER_BORDER + SPINNER_PAD; + int32_t relX = vx - textX + hit->as.spinner.scrollOff * font->charWidth; + int32_t pos = relX / font->charWidth; + + if (pos < 0) { + pos = 0; + } + + if (pos > hit->as.spinner.len) { + pos = hit->as.spinner.len; + } + + int32_t clicks = multiClickDetect(vx, vy); + + if (clicks >= 2) { + // Double/triple-click: select all text + hit->as.spinner.selStart = 0; + hit->as.spinner.selEnd = hit->as.spinner.len; + hit->as.spinner.cursorPos = hit->as.spinner.len; + } else { + // Single click: place cursor + hit->as.spinner.cursorPos = pos; + hit->as.spinner.selStart = -1; + hit->as.spinner.selEnd = -1; + } + + spinnerStartEdit(hit); + } + + wgtInvalidatePaint(hit); +} + + +// ============================================================ +// widgetSpinnerPaint +// ============================================================ + +void widgetSpinnerPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) { + uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg; + uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg; + + int32_t btnW = SPINNER_BTN_W; + int32_t btnX = w->x + w->w - SPINNER_BORDER - btnW; + + // Sunken border around entire widget + BevelStyleT bevel; + bevel.highlight = colors->windowShadow; + bevel.shadow = colors->windowHighlight; + bevel.face = bg; + bevel.width = SPINNER_BORDER; + drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel); + + // Text area + int32_t textX = w->x + SPINNER_BORDER + SPINNER_PAD; + int32_t textY = w->y + (w->h - font->charHeight) / 2; + int32_t textW = btnX - (w->x + SPINNER_BORDER) - SPINNER_PAD * 2; + int32_t maxChars = textW / font->charWidth; + + if (maxChars < 0) { + maxChars = 0; + } + + // Scroll to keep cursor visible + if (w->as.spinner.cursorPos < w->as.spinner.scrollOff) { + w->as.spinner.scrollOff = w->as.spinner.cursorPos; + } else if (w->as.spinner.cursorPos > w->as.spinner.scrollOff + maxChars) { + w->as.spinner.scrollOff = w->as.spinner.cursorPos - maxChars; + } + + int32_t off = w->as.spinner.scrollOff; + int32_t len = w->as.spinner.len - off; + + if (len > maxChars) { + len = maxChars; + } + + if (len < 0) { + len = 0; + } + + // Selection range + int32_t selLo = -1; + int32_t selHi = -1; + + if (w->as.spinner.selStart >= 0 && w->as.spinner.selEnd >= 0 && w->as.spinner.selStart != w->as.spinner.selEnd) { + selLo = w->as.spinner.selStart < w->as.spinner.selEnd ? w->as.spinner.selStart : w->as.spinner.selEnd; + selHi = w->as.spinner.selStart > w->as.spinner.selEnd ? w->as.spinner.selStart : w->as.spinner.selEnd; + } + + // Draw text with selection highlighting (3-run approach like textInput) + int32_t visSelLo = selLo - off; + int32_t visSelHi = selHi - off; + + if (visSelLo < 0) { + visSelLo = 0; + } + + if (visSelHi > len) { + visSelHi = len; + } + + if (selLo >= 0 && visSelLo < visSelHi) { + // Before selection + if (visSelLo > 0) { + drawTextN(d, ops, font, textX, textY, &w->as.spinner.buf[off], visSelLo, fg, bg, true); + } + + // Selection + drawTextN(d, ops, font, textX + visSelLo * font->charWidth, textY, &w->as.spinner.buf[off + visSelLo], visSelHi - visSelLo, colors->menuHighlightFg, colors->menuHighlightBg, true); + + // After selection + if (visSelHi < len) { + drawTextN(d, ops, font, textX + visSelHi * font->charWidth, textY, &w->as.spinner.buf[off + visSelHi], len - visSelHi, fg, bg, true); + } + } else if (len > 0) { + drawTextN(d, ops, font, textX, textY, &w->as.spinner.buf[off], len, fg, bg, true); + } + + // Cursor + if (w->focused) { + int32_t curX = textX + (w->as.spinner.cursorPos - off) * font->charWidth; + + if (curX >= w->x + SPINNER_BORDER && curX < btnX - SPINNER_PAD) { + drawVLine(d, ops, curX, textY, font->charHeight, fg); + } + } + + // Up button (top half) + int32_t btnTopH = w->h / 2; + int32_t btnBotH = w->h - btnTopH; + + BevelStyleT btnBevel = BEVEL_RAISED(colors, 1); + drawBevel(d, ops, btnX, w->y, btnW + SPINNER_BORDER, btnTopH, &btnBevel); + + // Up arrow triangle + { + int32_t cx = btnX + btnW / 2; + int32_t cy = w->y + btnTopH / 2; + + for (int32_t i = 0; i < 3; i++) { + drawHLine(d, ops, cx - i, cy - 1 + i, 1 + i * 2, fg); + } + } + + // Down button (bottom half) + drawBevel(d, ops, btnX, w->y + btnTopH, btnW + SPINNER_BORDER, btnBotH, &btnBevel); + + // Down arrow triangle + { + int32_t cx = btnX + btnW / 2; + int32_t cy = w->y + btnTopH + btnBotH / 2; + + for (int32_t i = 0; i < 3; i++) { + drawHLine(d, ops, cx - i, cy + 1 - i, 1 + i * 2, fg); + } + } + + // Focus rect around entire widget + if (w->focused) { + drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg); + } +} + + +// ============================================================ +// widgetSpinnerSetText +// ============================================================ + +void widgetSpinnerSetText(WidgetT *w, const char *text) { + int32_t val = (int32_t)strtol(text, NULL, 10); + w->as.spinner.value = val; + spinnerClampAndFormat(w); +} + + +// ============================================================ +// wgtSpinner +// ============================================================ + +WidgetT *wgtSpinner(WidgetT *parent, int32_t minVal, int32_t maxVal, int32_t step) { + WidgetT *w = widgetAlloc(parent, WidgetSpinnerE); + + if (w) { + w->as.spinner.minValue = minVal; + w->as.spinner.maxValue = maxVal; + w->as.spinner.step = step > 0 ? step : 1; + w->as.spinner.value = minVal; + w->as.spinner.editing = false; + w->as.spinner.selStart = -1; + w->as.spinner.selEnd = -1; + w->as.spinner.undoLen = 0; + w->as.spinner.undoCursor = 0; + spinnerFormat(w); + } + + return w; +} + + +// ============================================================ +// wgtSpinnerGetValue +// ============================================================ + +int32_t wgtSpinnerGetValue(const WidgetT *w) { + if (!w || w->type != WidgetSpinnerE) { + return 0; + } + + return w->as.spinner.value; +} + + +// ============================================================ +// wgtSpinnerSetRange +// ============================================================ + +void wgtSpinnerSetRange(WidgetT *w, int32_t minVal, int32_t maxVal) { + if (!w || w->type != WidgetSpinnerE) { + return; + } + + w->as.spinner.minValue = minVal; + w->as.spinner.maxValue = maxVal; + spinnerClampAndFormat(w); +} + + +// ============================================================ +// wgtSpinnerSetStep +// ============================================================ + +void wgtSpinnerSetStep(WidgetT *w, int32_t step) { + if (!w || w->type != WidgetSpinnerE) { + return; + } + + w->as.spinner.step = step > 0 ? step : 1; +} + + +// ============================================================ +// wgtSpinnerSetValue +// ============================================================ + +void wgtSpinnerSetValue(WidgetT *w, int32_t value) { + if (!w || w->type != WidgetSpinnerE) { + return; + } + + w->as.spinner.value = value; + spinnerClampAndFormat(w); +} diff --git a/dvxdemo/demo.c b/dvxdemo/demo.c index 2780d93..be522bf 100644 --- a/dvxdemo/demo.c +++ b/dvxdemo/demo.c @@ -424,6 +424,13 @@ static void setupControlsWindow(AppContextT *ctx) { wgtLabel(page1, "&Volume:"); wgtSlider(page1, 0, 100); + WidgetT *spinRow = wgtHBox(page1); + spinRow->maxH = wgtPixels(30); + wgtLabel(spinRow, "&Quantity:"); + WidgetT *spin = wgtSpinner(spinRow, 0, 999, 1); + wgtSpinnerSetValue(spin, 42); + spin->weight = 50; + // --- Tab 2: Tree --- WidgetT *page2 = wgtTabPage(tabs, "&Tree");