#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. // // Supports two numeric modes: // Integer mode (default): int32_t value, step, min, max // Real mode (useReal=true): double value, step, min, max with // configurable decimal places // // 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-numeric characters before // they reach the editor. // // 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 #include #include // ============================================================ // Constants // ============================================================ #define SPINNER_BTN_W 14 #define SPINNER_BORDER 2 #define SPINNER_PAD 3 #define SPINNER_BUF_SIZE 32 #define SPINNER_DEFAULT_DECIMALS 2 // ============================================================ // Per-instance data // ============================================================ typedef struct { // Integer mode int32_t value; int32_t minValue; int32_t maxValue; int32_t step; // Real mode double realValue; double realMin; double realMax; double realStep; int32_t decimals; bool useReal; // Text editing state char buf[SPINNER_BUF_SIZE]; int32_t len; int32_t cursorPos; int32_t scrollOff; int32_t selStart; int32_t selEnd; char undoBuf[SPINNER_BUF_SIZE]; int32_t undoLen; int32_t undoCursor; bool editing; } SpinnerDataT; // ============================================================ // Prototypes // ============================================================ static bool spinnerAllowMinus(const SpinnerDataT *d); static void spinnerClampAndFormat(SpinnerDataT *d); static void spinnerCommitEdit(SpinnerDataT *d); static void spinnerFormat(SpinnerDataT *d); static void spinnerStartEdit(SpinnerDataT *d); static bool spinnerValidateBuffer(const SpinnerDataT *d); static void widgetSpinnerCalcMinSize(WidgetT *w, const BitmapFontT *font); static void widgetSpinnerDestroy(WidgetT *w); static const char *widgetSpinnerGetText(const WidgetT *w); static void widgetSpinnerOnKey(WidgetT *w, int32_t key, int32_t mod); static void widgetSpinnerOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy); static void widgetSpinnerPaint(WidgetT *w, DisplayT *disp, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); static void widgetSpinnerSetText(WidgetT *w, const char *text); // ============================================================ // spinnerAllowMinus -- can negative values be entered? // ============================================================ static bool spinnerAllowMinus(const SpinnerDataT *d) { if (d->useReal) { return d->realMin < 0.0; } return d->minValue < 0; } // ============================================================ // spinnerClampAndFormat // ============================================================ static void spinnerClampAndFormat(SpinnerDataT *d) { if (d->useReal) { if (d->realValue < d->realMin) { d->realValue = d->realMin; } if (d->realValue > d->realMax) { d->realValue = d->realMax; } } else { 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'; if (d->useReal) { d->realValue = strtod(d->buf, NULL); } else { d->value = (int32_t)strtol(d->buf, NULL, 10); } 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) { if (d->useReal) { d->len = snprintf(d->buf, sizeof(d->buf), "%.*f", (int)d->decimals, d->realValue); } else { 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; } } // ============================================================ // spinnerValidateBuffer -- check buffer contains valid number // ============================================================ static bool spinnerValidateBuffer(const SpinnerDataT *d) { bool allowMin = spinnerAllowMinus(d); bool hadDot = false; for (int32_t i = 0; i < d->len; i++) { char c = d->buf[i]; if (c == '-' && i == 0 && allowMin) { continue; } if (c == '.' && d->useReal && !hadDot) { hadDot = true; continue; } if (c < '0' || c > '9') { return false; } } return true; } // ============================================================ // spinnerStep -- apply step in given direction (+1 or -1) // ============================================================ static void spinnerApplyStep(SpinnerDataT *d, int32_t direction, int32_t multiplier) { if (d->useReal) { d->realValue += d->realStep * direction * multiplier; } else { d->value += d->step * direction * multiplier; } spinnerClampAndFormat(d); } // ============================================================ // widgetSpinnerCalcMinSize // ============================================================ static 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; } // ============================================================ // widgetSpinnerDestroy // ============================================================ static void widgetSpinnerDestroy(WidgetT *w) { free(w->data); } // ============================================================ // widgetSpinnerGetText // ============================================================ static 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. static void widgetSpinnerOnKey(WidgetT *w, int32_t key, int32_t mod) { SpinnerDataT *d = (SpinnerDataT *)w->data; // Up arrow -- increment if (key == (0x48 | 0x100)) { spinnerCommitEdit(d); spinnerApplyStep(d, 1, 1); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); return; } // Down arrow -- decrement if (key == (0x50 | 0x100)) { spinnerCommitEdit(d); spinnerApplyStep(d, -1, 1); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); return; } // Page Up -- increment by step * 10 if (key == (0x49 | 0x100)) { spinnerCommitEdit(d); spinnerApplyStep(d, 1, 10); if (w->onChange) { w->onChange(w); } wgtInvalidatePaint(w); return; } // Page Down -- decrement by step * 10 if (key == (0x51 | 0x100)) { spinnerCommitEdit(d); spinnerApplyStep(d, -1, 10); 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, dot (real mode), and control keys bool isDigit = (key >= '0' && key <= '9'); bool isMinus = (key == '-'); bool isDot = (key == '.' && d->useReal); bool isControl = (key < 0x20) || (key & 0x100); if (!isDigit && !isMinus && !isDot && !isControl) { return; } // Minus only at position 0 (and only if min is negative) if (isMinus && (d->cursorPos != 0 || !spinnerAllowMinus(d))) { return; } // Dot only once (check if buffer already has one) if (isDot) { for (int32_t i = 0; i < d->len; i++) { if (d->buf[i] == '.') { return; } } } // Enter edit mode on first text-modifying key if (isDigit || isMinus || isDot || 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, w->w - SPINNER_BORDER * 2 - SPINNER_BTN_W); // Validate buffer after paste -- reject non-numeric content. if (!spinnerValidateBuffer(d)) { // 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. static void widgetSpinnerOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) { sFocusedWidget = hit; 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); spinnerApplyStep(d, (vy < midY) ? 1 : -1, 1); 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. static 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 == sFocusedWidget, 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 == sFocusedWidget) { drawFocusRect(disp, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg); } } // ============================================================ // widgetSpinnerSetText // ============================================================ static void widgetSpinnerSetText(WidgetT *w, const char *text) { SpinnerDataT *d = (SpinnerDataT *)w->data; if (d->useReal) { d->realValue = strtod(text, NULL); } else { d->value = (int32_t)strtol(text, NULL, 10); } spinnerClampAndFormat(d); } // ============================================================ // DXE registration // ============================================================ static const WidgetClassT sClassSpinner = { .version = WGT_CLASS_VERSION, .flags = WCLASS_FOCUSABLE | WCLASS_SCROLLABLE, .handlers = { [WGT_METHOD_PAINT] = (void *)widgetSpinnerPaint, [WGT_METHOD_CALC_MIN_SIZE] = (void *)widgetSpinnerCalcMinSize, [WGT_METHOD_ON_MOUSE] = (void *)widgetSpinnerOnMouse, [WGT_METHOD_ON_KEY] = (void *)widgetSpinnerOnKey, [WGT_METHOD_DESTROY] = (void *)widgetSpinnerDestroy, [WGT_METHOD_GET_TEXT] = (void *)widgetSpinnerGetText, [WGT_METHOD_SET_TEXT] = (void *)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->decimals = SPINNER_DEFAULT_DECIMALS; d->realStep = 1.0; d->selStart = -1; d->selEnd = -1; spinnerFormat(d); } return w; } // ============================================================ // Public API -- integer mode // ============================================================ int32_t wgtSpinnerGetValue(const WidgetT *w) { VALIDATE_WIDGET(w, sTypeId, 0); SpinnerDataT *d = (SpinnerDataT *)w->data; // Commit any in-progress edit so the returned value is current if (d->editing) { spinnerCommitEdit(d); } 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); } // ============================================================ // Public API -- real mode // ============================================================ void wgtSpinnerSetRealMode(WidgetT *w, bool enable) { VALIDATE_WIDGET_VOID(w, sTypeId); SpinnerDataT *d = (SpinnerDataT *)w->data; d->useReal = enable; spinnerClampAndFormat(d); wgtInvalidate(w); } double wgtSpinnerGetRealValue(const WidgetT *w) { VALIDATE_WIDGET(w, sTypeId, 0.0); SpinnerDataT *d = (SpinnerDataT *)w->data; if (d->editing) { spinnerCommitEdit(d); } return d->realValue; } void wgtSpinnerSetRealValue(WidgetT *w, double value) { VALIDATE_WIDGET_VOID(w, sTypeId); SpinnerDataT *d = (SpinnerDataT *)w->data; d->realValue = value; spinnerClampAndFormat(d); wgtInvalidate(w); } void wgtSpinnerSetRealRange(WidgetT *w, double minVal, double maxVal) { VALIDATE_WIDGET_VOID(w, sTypeId); SpinnerDataT *d = (SpinnerDataT *)w->data; d->realMin = minVal; d->realMax = maxVal; spinnerClampAndFormat(d); wgtInvalidate(w); } void wgtSpinnerSetRealStep(WidgetT *w, double step) { VALIDATE_WIDGET_VOID(w, sTypeId); SpinnerDataT *d = (SpinnerDataT *)w->data; d->realStep = step > 0.0 ? step : 0.01; } void wgtSpinnerSetDecimals(WidgetT *w, int32_t decimals) { VALIDATE_WIDGET_VOID(w, sTypeId); SpinnerDataT *d = (SpinnerDataT *)w->data; d->decimals = (decimals >= 0 && decimals <= 10) ? decimals : SPINNER_DEFAULT_DECIMALS; spinnerFormat(d); wgtInvalidate(w); } // ============================================================ // Property getters/setters for WgtIfaceT // ============================================================ static int32_t ifaceGetValue(const WidgetT *w) { return wgtSpinnerGetValue(w); } static void ifaceSetValue(WidgetT *w, int32_t v) { wgtSpinnerSetValue(w, v); } static bool ifaceGetRealMode(const WidgetT *w) { return ((SpinnerDataT *)w->data)->useReal; } static void ifaceSetRealMode(WidgetT *w, bool v) { wgtSpinnerSetRealMode(w, v); } static int32_t ifaceGetDecimals(const WidgetT *w) { return ((SpinnerDataT *)w->data)->decimals; } static void ifaceSetDecimals(WidgetT *w, int32_t v) { wgtSpinnerSetDecimals(w, v); } // ============================================================ // DXE API and interface 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); void (*setRealMode)(WidgetT *w, bool enable); double (*getRealValue)(const WidgetT *w); void (*setRealValue)(WidgetT *w, double value); void (*setRealRange)(WidgetT *w, double minVal, double maxVal); void (*setRealStep)(WidgetT *w, double step); void (*setDecimals)(WidgetT *w, int32_t decimals); } sApi = { .create = wgtSpinner, .setValue = wgtSpinnerSetValue, .getValue = wgtSpinnerGetValue, .setRange = wgtSpinnerSetRange, .setStep = wgtSpinnerSetStep, .setRealMode = wgtSpinnerSetRealMode, .getRealValue = wgtSpinnerGetRealValue, .setRealValue = wgtSpinnerSetRealValue, .setRealRange = wgtSpinnerSetRealRange, .setRealStep = wgtSpinnerSetRealStep, .setDecimals = wgtSpinnerSetDecimals, }; static const WgtPropDescT sProps[] = { { "Value", WGT_IFACE_INT, (void *)ifaceGetValue, (void *)ifaceSetValue, NULL }, { "RealMode", WGT_IFACE_BOOL, (void *)ifaceGetRealMode, (void *)ifaceSetRealMode, NULL }, { "Decimals", WGT_IFACE_INT, (void *)ifaceGetDecimals, (void *)ifaceSetDecimals, NULL }, }; static const WgtMethodDescT sMethods[] = { { "SetRange", WGT_SIG_INT_INT, (void *)wgtSpinnerSetRange }, { "SetStep", WGT_SIG_INT, (void *)wgtSpinnerSetStep } }; static const WgtIfaceT sIface = { .basName = "SpinButton", .props = sProps, .propCount = 3, .methods = sMethods, .methodCount = 2, .events = NULL, .eventCount = 0, .createSig = WGT_CREATE_PARENT_INT_INT_INT, .createArgs = { 0, 100, 1 }, .defaultEvent = "Change", .namePrefix = "Spin", }; void wgtRegister(void) { sTypeId = wgtRegisterClass(&sClassSpinner); wgtRegisterApi("spinner", &sApi); wgtRegisterIface("spinner", &sIface); }