// 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 "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 // ============================================================ // 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(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 // ============================================================ // 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(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 // ============================================================ // 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) { 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); // Validate buffer after paste -- reject non-numeric content. // Allow optional leading minus and digits only. bool valid = true; for (int32_t i = 0; i < w->as.spinner.len; i++) { char c = w->as.spinner.buf[i]; if (c == '-' && i == 0 && w->as.spinner.minValue < 0) { continue; } if (c < '0' || c > '9') { valid = false; break; } } if (!valid) { // Revert to the undo buffer (pre-paste state) memcpy(w->as.spinner.buf, w->as.spinner.undoBuf, sizeof(w->as.spinner.buf)); w->as.spinner.len = w->as.spinner.undoLen; w->as.spinner.cursorPos = w->as.spinner.undoCursor; w->as.spinner.selStart = 0; w->as.spinner.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; 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; widgetTextEditMouseClick(hit, vx, vy, hit->x + SPINNER_BORDER + SPINNER_PAD, &ctx->font, hit->as.spinner.buf, hit->as.spinner.len, hit->as.spinner.scrollOff, &hit->as.spinner.cursorPos, &hit->as.spinner.selStart, &hit->as.spinner.selEnd, false, false); spinnerStartEdit(hit); } 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 *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) { 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(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; } widgetTextEditPaintLine(d, ops, font, colors, textX, textY, &w->as.spinner.buf[off], len, off, w->as.spinner.cursorPos, w->as.spinner.selStart, w->as.spinner.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(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) { VALIDATE_WIDGET(w, WidgetSpinnerE, 0); return w->as.spinner.value; } // ============================================================ // wgtSpinnerSetRange // ============================================================ void wgtSpinnerSetRange(WidgetT *w, int32_t minVal, int32_t maxVal) { VALIDATE_WIDGET_VOID(w, WidgetSpinnerE); w->as.spinner.minValue = minVal; w->as.spinner.maxValue = maxVal; spinnerClampAndFormat(w); wgtInvalidate(w); } // ============================================================ // wgtSpinnerSetStep // ============================================================ void wgtSpinnerSetStep(WidgetT *w, int32_t step) { VALIDATE_WIDGET_VOID(w, WidgetSpinnerE); w->as.spinner.step = step > 0 ? step : 1; } // ============================================================ // wgtSpinnerSetValue // ============================================================ void wgtSpinnerSetValue(WidgetT *w, int32_t value) { VALIDATE_WIDGET_VOID(w, WidgetSpinnerE); w->as.spinner.value = value; spinnerClampAndFormat(w); wgtInvalidate(w); }