// 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. #define DVX_WIDGET_IMPL // widgetRadio.c -- RadioGroup and Radio button widgets // // Two-level architecture: RadioGroupE is an invisible container that // holds the selection state (selectedIdx), while RadioE children are // the visible buttons. This separation means the group tracks which // index is selected while each radio button only knows its own index, // keeping the per-button state minimal. // // Selection state is stored as an integer index on the parent group // rather than a pointer, because indices survive widget reordering // and are trivially serializable. The index is auto-assigned at // construction time based on sibling order. // // Rendering: diamond-shaped indicator (Motif-style) using scanline // horizontal lines rather than a circle algorithm. This avoids any // need for anti-aliasing or trigonometry -- purely integer H/V lines. // The selection dot inside uses a hardcoded width table for a 6-row // filled diamond, sized to look crisp at the fixed 12px box size. // // Keyboard: Space/Enter select the current radio. Arrow keys move // focus AND selection together (standard radio group behavior -- // focus and selection are coupled, unlike checkboxes). #include "dvxWgtP.h" #define CHECKBOX_BOX_SIZE 12 #define CHECKBOX_GAP 4 static int32_t sRadioGroupTypeId = -1; static int32_t sRadioTypeId = -1; typedef struct { const char *text; int32_t index; } RadioDataT; typedef struct { int32_t selectedIdx; } RadioGroupDataT; // ============================================================ // Prototypes // ============================================================ static void invalidateOldSelection(WidgetT *group, int32_t oldIdx); WidgetT *wgtRadio(WidgetT *parent, const char *text); int32_t wgtRadioGetIndex(const WidgetT *w); WidgetT *wgtRadioGroup(WidgetT *parent); void wgtRadioGroupSetSelected(WidgetT *w, int32_t idx); void wgtRegister(void); void widgetRadioAccelActivate(WidgetT *w, WidgetT *root); void widgetRadioCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetRadioOnKey(WidgetT *w, int32_t key, int32_t mod); void widgetRadioOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); void widgetRadioPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); // Find the currently selected radio in the group and invalidate it static void invalidateOldSelection(WidgetT *group, int32_t oldIdx) { for (WidgetT *c = group->firstChild; c; c = c->nextSibling) { if (c->type == sRadioTypeId) { RadioDataT *rd = (RadioDataT *)c->data; if (rd->index == oldIdx) { wgtInvalidatePaint(c); return; } } } } WidgetT *wgtRadio(WidgetT *parent, const char *text) { WidgetT *w = widgetAllocWithText(parent, sRadioTypeId, sizeof(RadioDataT), text); if (w && w->data) { RadioDataT *d = (RadioDataT *)w->data; // Auto-assign index based on position in parent int32_t idx = 0; for (WidgetT *c = parent->firstChild; c != w; c = c->nextSibling) { if (c->type == sRadioTypeId) { idx++; } } d->index = idx; } return w; } int32_t wgtRadioGetIndex(const WidgetT *w) { VALIDATE_WIDGET(w, sRadioTypeId, -1); RadioDataT *d = (RadioDataT *)w->data; return d->index; } WidgetT *wgtRadioGroup(WidgetT *parent) { WidgetT *w = widgetAlloc(parent, sRadioGroupTypeId); if (w) { RadioGroupDataT *d = calloc(1, sizeof(RadioGroupDataT)); w->data = d; } return w; } void wgtRadioGroupSetSelected(WidgetT *w, int32_t idx) { VALIDATE_WIDGET_VOID(w, sRadioGroupTypeId); RadioGroupDataT *d = (RadioGroupDataT *)w->data; d->selectedIdx = idx; wgtInvalidatePaint(w); } // ============================================================ // DXE registration // ============================================================ static const WidgetClassT sClassRadioGroup = { .version = WGT_CLASS_VERSION, .flags = 0, .handlers = { [WGT_METHOD_CALC_MIN_SIZE] = (void *)widgetCalcMinSizeBox, [WGT_METHOD_LAYOUT] = (void *)widgetLayoutBox, } }; static const WidgetClassT sClassRadio = { .version = WGT_CLASS_VERSION, .flags = WCLASS_FOCUSABLE | WCLASS_HAS_TEXT, .handlers = { [WGT_METHOD_PAINT] = (void *)widgetRadioPaint, [WGT_METHOD_CALC_MIN_SIZE] = (void *)widgetRadioCalcMinSize, [WGT_METHOD_ON_MOUSE] = (void *)widgetRadioOnMouse, [WGT_METHOD_ON_KEY] = (void *)widgetRadioOnKey, [WGT_METHOD_ON_ACCEL_ACTIVATE] = (void *)widgetRadioAccelActivate, [WGT_METHOD_GET_TEXT] = (void *)widgetTextGet, [WGT_METHOD_SET_TEXT] = (void *)widgetTextSet, } }; static const struct { WidgetT *(*group)(WidgetT *parent); WidgetT *(*create)(WidgetT *parent, const char *text); void (*groupSetSelected)(WidgetT *group, int32_t index); int32_t (*getIndex)(const WidgetT *w); } sApi = { .group = wgtRadioGroup, .create = wgtRadio, .groupSetSelected = wgtRadioGroupSetSelected, .getIndex = wgtRadioGetIndex }; static const WgtPropDescT sProps[] = { { "Value", WGT_IFACE_INT, (void *)wgtRadioGetIndex, NULL, NULL } }; static const WgtMethodDescT sMethods[] = { { "SetSelected", WGT_SIG_INT, (void *)wgtRadioGroupSetSelected } }; static const WgtIfaceT sIface = { .basName = "OptionButton", .props = sProps, .propCount = 1, .methods = sMethods, .methodCount = 1, .events = NULL, .eventCount = 0, .createSig = WGT_CREATE_PARENT_TEXT, .defaultEvent = "Click", .namePrefix = "Option" }; void wgtRegister(void) { sRadioGroupTypeId = wgtRegisterClass(&sClassRadioGroup); sRadioTypeId = wgtRegisterClass(&sClassRadio); wgtRegisterApi("radio", &sApi); wgtRegisterIface("radio", &sIface); } void widgetRadioAccelActivate(WidgetT *w, WidgetT *root) { (void)root; widgetRadioOnMouse(w, NULL, 0, 0); } // Shares CHECKBOX_BOX_SIZE/GAP constants with the checkbox widget // so radio buttons and checkboxes align when placed in the same // column, and the indicator + label layout is visually consistent. void widgetRadioCalcMinSize(WidgetT *w, const BitmapFontT *font) { RadioDataT *d = (RadioDataT *)w->data; w->calcMinW = CHECKBOX_BOX_SIZE + CHECKBOX_GAP + textWidthAccel(font, d->text); w->calcMinH = DVX_MAX(CHECKBOX_BOX_SIZE, font->charHeight); } // Arrow keys move focus AND selection simultaneously -- this is the // standard radio group behavior where navigating with arrows also // commits the selection. This differs from checkboxes where arrows // just move focus and Space toggles. The coupling is deliberate: // radio groups represent a single mutually-exclusive choice, so // "looking at" an option means "choosing" it. // // Key codes use DOS BIOS scancode convention: high byte 0x01 flag // ORed with the scancode. 0x50=Down, 0x4D=Right, 0x48=Up, 0x4B=Left. void widgetRadioOnKey(WidgetT *w, int32_t key, int32_t mod) { (void)mod; RadioDataT *rd = (RadioDataT *)w->data; if (key == ' ' || key == 0x0D) { // Select this radio if (w->parent && w->parent->type == sRadioGroupTypeId) { RadioGroupDataT *gd = (RadioGroupDataT *)w->parent->data; gd->selectedIdx = rd->index; if (w->parent->onChange) { w->parent->onChange(w->parent); } } wgtInvalidatePaint(w); } else if (key == KEY_DOWN || key == KEY_RIGHT) { // Down or Right -- next radio in group if (w->parent && w->parent->type == sRadioGroupTypeId) { WidgetT *next = NULL; for (WidgetT *s = w->nextSibling; s; s = s->nextSibling) { if (s->type == sRadioTypeId && s->visible && s->enabled) { next = s; break; } } if (next) { RadioDataT *nd = (RadioDataT *)next->data; RadioGroupDataT *gd = (RadioGroupDataT *)next->parent->data; invalidateOldSelection(w->parent, gd->selectedIdx); sFocusedWidget = next; gd->selectedIdx = nd->index; if (next->parent->onChange) { next->parent->onChange(next->parent); } wgtInvalidatePaint(next); } } } else if (key == KEY_UP || key == KEY_LEFT) { // Up or Left -- previous radio in group if (w->parent && w->parent->type == sRadioGroupTypeId) { WidgetT *prev = NULL; for (WidgetT *s = w->parent->firstChild; s && s != w; s = s->nextSibling) { if (s->type == sRadioTypeId && s->visible && s->enabled) { prev = s; } } if (prev) { RadioDataT *pd = (RadioDataT *)prev->data; RadioGroupDataT *gd = (RadioGroupDataT *)prev->parent->data; invalidateOldSelection(w->parent, gd->selectedIdx); sFocusedWidget = prev; gd->selectedIdx = pd->index; if (prev->parent->onChange) { prev->parent->onChange(prev->parent); } wgtInvalidatePaint(prev); } } } } void widgetRadioOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) { (void)root; (void)vx; (void)vy; sFocusedWidget = w; if (w->parent && w->parent->type == sRadioGroupTypeId) { RadioDataT *rd = (RadioDataT *)w->data; RadioGroupDataT *gd = (RadioGroupDataT *)w->parent->data; if (gd->selectedIdx != rd->index) { invalidateOldSelection(w->parent, gd->selectedIdx); } gd->selectedIdx = rd->index; if (w->parent->onChange) { w->parent->onChange(w->parent); } } } // The diamond radio indicator is rendered in three passes: // 1. Interior fill -- scanline-based diamond fill for the background // 2. Border -- upper-left edges in shadow, lower-right in highlight // (sunken appearance, same lighting model as bevels) // 3. Selection dot -- smaller filled diamond from a hardcoded 6-row // width table. The static const avoids recomputation per paint. // // This approach avoids floating point entirely. Each scanline's // left/right extent is computed with simple integer distance from // the midpoint, making the diamond perfectly symmetric at any size. void widgetRadioPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) { RadioDataT *rd = (RadioDataT *)w->data; uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg; uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg; int32_t boxY = w->y + (w->h - CHECKBOX_BOX_SIZE) / 2; // Clear full widget area so old focus rect is erased rectFill(d, ops, w->x, w->y, w->w, w->h, bg); // Draw diamond-shaped radio box int32_t bx = w->x; int32_t mid = CHECKBOX_BOX_SIZE / 2; uint32_t hi = colors->windowShadow; uint32_t sh = colors->windowHighlight; // Fill interior for (int32_t i = 0; i < CHECKBOX_BOX_SIZE; i++) { int32_t dist = i < mid ? i : CHECKBOX_BOX_SIZE - 1 - i; int32_t left = mid - dist; int32_t right = mid + dist; if (right > left) { drawHLine(d, ops, bx + left + 1, boxY + i, right - left - 1, bg); } } // Diamond border -- upper-left edges get highlight, lower-right get shadow for (int32_t i = 0; i < mid; i++) { int32_t left = mid - i; int32_t right = mid + i; drawHLine(d, ops, bx + left, boxY + i, 1, hi); drawHLine(d, ops, bx + right, boxY + i, 1, sh); } for (int32_t i = mid; i < CHECKBOX_BOX_SIZE; i++) { int32_t left = mid - (CHECKBOX_BOX_SIZE - 1 - i); int32_t right = mid + (CHECKBOX_BOX_SIZE - 1 - i); drawHLine(d, ops, bx + left, boxY + i, 1, hi); drawHLine(d, ops, bx + right, boxY + i, 1, sh); } // Draw filled diamond if selected if (w->parent && w->parent->type == sRadioGroupTypeId) { RadioGroupDataT *gd = (RadioGroupDataT *)w->parent->data; if (gd->selectedIdx == rd->index) { uint32_t dotFg = w->enabled ? fg : colors->windowShadow; static const int32_t dotW[] = {2, 4, 6, 6, 4, 2}; for (int32_t i = 0; i < 6; i++) { int32_t dw = dotW[i]; drawHLine(d, ops, bx + mid - dw / 2, boxY + mid - 3 + i, dw, dotFg); } } } int32_t labelX = w->x + CHECKBOX_BOX_SIZE + CHECKBOX_GAP; int32_t labelY = w->y + (w->h - font->charHeight) / 2; int32_t labelW = textWidthAccel(font, rd->text); drawWidgetTextAccel(d, ops, font, labelX, labelY, rd->text, fg, bg, false, w->enabled, colors); if (w == sFocusedWidget) { drawFocusRect(d, ops, labelX - 1, w->y, labelW + 2, w->h, fg); } }