#define DVX_WIDGET_IMPL // widgetSpinner.c -- Spinner (numeric up/down) widget // // A hybrid widget combining a single-line text editor with up/down // arrow buttons for numeric value entry. The user can either click // the arrows, use Up/Down keys, or type a number directly. // // Design: the widget has two modes -- display mode (showing the // formatted value) and edit mode (allowing free-form text input). // Edit mode is entered on the first text-modifying keystroke and // committed on Enter or when arrows are clicked. Escape cancels // the edit and reverts to the pre-edit value. This two-mode design // keeps the display clean (always showing a properly formatted // number) while still allowing direct keyboard entry. // // The text editing delegates to widgetTextEditOnKey() -- the same // shared single-line editing logic used by TextInput. This gives // the spinner cursor movement, selection, cut/copy/paste, and undo // for free. Input validation filters non-digit characters before // they reach the editor, and only allows minus at position 0. // // Undo uses a single-level swap buffer (same as TextInput): the // current state is copied to undoBuf before each mutation, and // Ctrl+Z swaps current<->undo. This is simpler and cheaper than // a multi-level undo stack for the small buffers involved. // // Rendering: sunken border enclosing the text area + two stacked // raised-bevel arrow buttons on the right. The buttons extend to // the widget's right edge (including the border width) so they // look like they're part of the border chrome. The up/down buttons // split the widget height evenly. #include "dvxWidgetPlugin.h" #include "../texthelp/textHelp.h" static int32_t sTypeId = -1; #include #include typedef struct { int32_t value; int32_t minValue; int32_t maxValue; int32_t step; char buf[16]; int32_t len; int32_t cursorPos; int32_t scrollOff; int32_t selStart; int32_t selEnd; char undoBuf[16]; int32_t undoLen; int32_t undoCursor; bool editing; } SpinnerDataT; #define SPINNER_BTN_W 14 #define SPINNER_BORDER 2 #define SPINNER_PAD 3 // ============================================================ // Prototypes // ============================================================ static void spinnerClampAndFormat(SpinnerDataT *d); static void spinnerCommitEdit(SpinnerDataT *d); static void spinnerFormat(SpinnerDataT *d); static void spinnerStartEdit(SpinnerDataT *d); // ============================================================ // spinnerClampAndFormat // ============================================================ static void spinnerClampAndFormat(SpinnerDataT *d) { if (d->value < d->minValue) { d->value = d->minValue; } if (d->value > d->maxValue) { d->value = d->maxValue; } spinnerFormat(d); } // ============================================================ // spinnerCommitEdit // ============================================================ static void spinnerCommitEdit(SpinnerDataT *d) { if (!d->editing) { return; } d->editing = false; d->buf[d->len] = '\0'; int32_t val = (int32_t)strtol(d->buf, NULL, 10); d->value = val; spinnerClampAndFormat(d); } // ============================================================ // spinnerFormat // ============================================================ // Format always places the cursor at the end and resets scroll/selection. // This is called after any value change to synchronize the text buffer // with the numeric value. The cursor-at-end position matches user // expectation after arrow-key increment/decrement. static void spinnerFormat(SpinnerDataT *d) { d->len = snprintf(d->buf, sizeof(d->buf), "%d", (int)d->value); d->cursorPos = d->len; d->scrollOff = 0; d->selStart = -1; d->selEnd = -1; } // ============================================================ // spinnerStartEdit // ============================================================ // Entering edit mode snapshots the buffer for undo so the user can // revert to the pre-edit formatted value. The snapshot is only taken // on the transition to editing, not on every keystroke, so repeated // typing within one edit session can be undone all at once. static void spinnerStartEdit(SpinnerDataT *d) { if (!d->editing) { d->editing = true; // Snapshot for undo memcpy(d->undoBuf, d->buf, sizeof(d->buf)); d->undoLen = d->len; d->undoCursor = d->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) { const SpinnerDataT *d = (const SpinnerDataT *)w->data; return d->buf; } // ============================================================ // widgetSpinnerOnKey // ============================================================ // Key handling has two distinct paths: navigation keys (Up/Down/PgUp/ // PgDn) always commit any pending edit first, then adjust the numeric // value directly. Text keys enter edit mode and are forwarded to the // shared text editor. This split ensures arrow-key nudging always // operates on the committed value, not on partially typed text. // // Page Up/Down use step*10 for coarser adjustment, matching the // convention used by Windows spin controls. void widgetSpinnerOnKey(WidgetT *w, int32_t key, int32_t mod) { SpinnerDataT *d = (SpinnerDataT *)w->data; int32_t step = d->step; // Up arrow -- increment if (key == (0x48 | 0x100)) { spinnerCommitEdit(d); d->value += step; spinnerClampAndFormat(d); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); return; } // Down arrow -- decrement if (key == (0x50 | 0x100)) { spinnerCommitEdit(d); d->value -= step; spinnerClampAndFormat(d); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); return; } // Page Up -- increment by step * 10 if (key == (0x49 | 0x100)) { spinnerCommitEdit(d); d->value += step * 10; spinnerClampAndFormat(d); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); return; } // Page Down -- decrement by step * 10 if (key == (0x51 | 0x100)) { spinnerCommitEdit(d); d->value -= step * 10; spinnerClampAndFormat(d); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); return; } // Enter -- commit edit if (key == '\r' || key == '\n') { if (d->editing) { spinnerCommitEdit(d); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); } return; } // Escape -- cancel edit, revert to current value if (key == 27) { if (d->editing) { d->editing = false; spinnerFormat(d); 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 && (d->cursorPos != 0 || d->minValue >= 0)) { return; } // Enter edit mode on first text-modifying key if (isDigit || isMinus || key == 8 || key == 127 || key == (0x53 | 0x100)) { spinnerStartEdit(d); } // Delegate to shared text editing logic (handles cursor movement, // selection, cut/copy/paste, undo/redo, backspace, delete, etc.) widgetTextEditOnKey(w, key, mod, d->buf, (int32_t)sizeof(d->buf), &d->len, &d->cursorPos, &d->scrollOff, &d->selStart, &d->selEnd, d->undoBuf, &d->undoLen, &d->undoCursor); // Validate buffer after paste -- reject non-numeric content. // Allow optional leading minus and digits only. bool valid = true; for (int32_t i = 0; i < d->len; i++) { char c = d->buf[i]; if (c == '-' && i == 0 && d->minValue < 0) { continue; } if (c < '0' || c > '9') { valid = false; break; } } if (!valid) { // Revert to the undo buffer (pre-paste state) memcpy(d->buf, d->undoBuf, sizeof(d->buf)); d->len = d->undoLen; d->cursorPos = d->undoCursor; d->selStart = 0; d->selEnd = 0; } wgtInvalidatePaint(w); } // ============================================================ // widgetSpinnerOnMouse // ============================================================ // Mouse click regions: button area (right side) vs text area (left side). // Button area is split vertically at the midpoint -- top half increments, // bottom half decrements. Clicking a button commits any pending edit // before adjusting the value, same as arrow keys. // // Text area clicks compute cursor position from pixel offset using the // fixed-width font. Double-click selects all text (select-word doesn't // make sense for numbers), entering edit mode to allow replacement. void widgetSpinnerOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) { hit->focused = true; SpinnerDataT *d = (SpinnerDataT *)hit->data; 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(d); if (vy < midY) { d->value += d->step; } else { d->value -= d->step; } spinnerClampAndFormat(d); if (hit->onChange) { hit->onChange(hit); } } else { // Click on text area AppContextT *ctx = (AppContextT *)root->userData; widgetTextEditMouseClick(hit, vx, vy, hit->x + SPINNER_BORDER + SPINNER_PAD, &ctx->font, d->buf, d->len, d->scrollOff, &d->cursorPos, &d->selStart, &d->selEnd, false, false); spinnerStartEdit(d); } wgtInvalidatePaint(hit); } // ============================================================ // widgetSpinnerPaint // ============================================================ // Paint uses the same 3-run text rendering approach as TextInput: // before-selection, selection (highlighted), after-selection. This // avoids overdraw and gives correct selection highlighting with only // one pass over the visible text. The scroll offset ensures the // cursor is always visible even when the number is wider than the // text area. // // The two buttons (up/down) extend SPINNER_BORDER pixels past the // button area into the widget's right border so they visually merge // with the outer bevel -- this is why btnW is btnW + SPINNER_BORDER // in the drawBevel calls. void widgetSpinnerPaint(WidgetT *w, DisplayT *disp, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) { SpinnerDataT *d = (SpinnerDataT *)w->data; uint32_t fg = w->enabled ? (w->fgColor ? w->fgColor : colors->contentFg) : colors->windowShadow; 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(disp, 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 (d->cursorPos < d->scrollOff) { d->scrollOff = d->cursorPos; } else if (d->cursorPos > d->scrollOff + maxChars) { d->scrollOff = d->cursorPos - maxChars; } int32_t off = d->scrollOff; int32_t len = d->len - off; if (len > maxChars) { len = maxChars; } if (len < 0) { len = 0; } widgetTextEditPaintLine(disp, ops, font, colors, textX, textY, &d->buf[off], len, off, d->cursorPos, d->selStart, d->selEnd, fg, bg, w->focused, w->x + SPINNER_BORDER, btnX - SPINNER_PAD); // Up button (top half) int32_t btnTopH = w->h / 2; int32_t btnBotH = w->h - btnTopH; BevelStyleT btnBevel = BEVEL_RAISED(colors, 1); drawBevel(disp, 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(disp, ops, cx - i, cy - 1 + i, 1 + i * 2, fg); } } // Down button (bottom half) drawBevel(disp, 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(disp, ops, cx - i, cy + 1 - i, 1 + i * 2, fg); } } // Focus rect around entire widget if (w->focused) { drawFocusRect(disp, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg); } } // ============================================================ // widgetSpinnerSetText // ============================================================ void widgetSpinnerSetText(WidgetT *w, const char *text) { SpinnerDataT *d = (SpinnerDataT *)w->data; int32_t val = (int32_t)strtol(text, NULL, 10); d->value = val; spinnerClampAndFormat(d); } // ============================================================ // widgetSpinnerDestroy // ============================================================ void widgetSpinnerDestroy(WidgetT *w) { free(w->data); } // ============================================================ // DXE registration // ============================================================ static const WidgetClassT sClassSpinner = { .flags = WCLASS_FOCUSABLE | WCLASS_SCROLLABLE, .paint = widgetSpinnerPaint, .paintOverlay = NULL, .calcMinSize = widgetSpinnerCalcMinSize, .layout = NULL, .onMouse = widgetSpinnerOnMouse, .onKey = widgetSpinnerOnKey, .destroy = widgetSpinnerDestroy, .getText = widgetSpinnerGetText, .setText = widgetSpinnerSetText }; // ============================================================ // Widget creation functions // ============================================================ WidgetT *wgtSpinner(WidgetT *parent, int32_t minVal, int32_t maxVal, int32_t step) { WidgetT *w = widgetAlloc(parent, sTypeId); if (w) { SpinnerDataT *d = (SpinnerDataT *)calloc(1, sizeof(SpinnerDataT)); if (!d) { return w; } w->data = d; d->minValue = minVal; d->maxValue = maxVal; d->step = step > 0 ? step : 1; d->value = minVal; d->selStart = -1; d->selEnd = -1; spinnerFormat(d); } return w; } int32_t wgtSpinnerGetValue(const WidgetT *w) { VALIDATE_WIDGET(w, sTypeId, 0); const SpinnerDataT *d = (const SpinnerDataT *)w->data; return d->value; } void wgtSpinnerSetRange(WidgetT *w, int32_t minVal, int32_t maxVal) { VALIDATE_WIDGET_VOID(w, sTypeId); SpinnerDataT *d = (SpinnerDataT *)w->data; d->minValue = minVal; d->maxValue = maxVal; spinnerClampAndFormat(d); wgtInvalidate(w); } void wgtSpinnerSetStep(WidgetT *w, int32_t step) { VALIDATE_WIDGET_VOID(w, sTypeId); SpinnerDataT *d = (SpinnerDataT *)w->data; d->step = step > 0 ? step : 1; } void wgtSpinnerSetValue(WidgetT *w, int32_t value) { VALIDATE_WIDGET_VOID(w, sTypeId); SpinnerDataT *d = (SpinnerDataT *)w->data; d->value = value; spinnerClampAndFormat(d); wgtInvalidate(w); } // ============================================================ // DXE registration // ============================================================ static const struct { WidgetT *(*create)(WidgetT *parent, int32_t minVal, int32_t maxVal, int32_t step); void (*setValue)(WidgetT *w, int32_t value); int32_t (*getValue)(const WidgetT *w); void (*setRange)(WidgetT *w, int32_t minVal, int32_t maxVal); void (*setStep)(WidgetT *w, int32_t step); } sApi = { .create = wgtSpinner, .setValue = wgtSpinnerSetValue, .getValue = wgtSpinnerGetValue, .setRange = wgtSpinnerSetRange, .setStep = wgtSpinnerSetStep }; void wgtRegister(void) { sTypeId = wgtRegisterClass(&sClassSpinner); wgtRegisterApi("spinner", &sApi); }