DVX_GUI/dvx/dvxWidget.c
2026-03-09 20:55:12 -05:00

1634 lines
45 KiB
C

// dvxWidget.c — Widget system for DV/X GUI
#include "dvxWidget.h"
#include "dvxApp.h"
#include "dvxDraw.h"
#include "dvxWm.h"
#include "dvxVideo.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
// ============================================================
// Constants
// ============================================================
#define DEFAULT_SPACING 4
#define DEFAULT_PADDING 4
#define SEPARATOR_THICKNESS 2
#define BUTTON_PAD_H 8
#define BUTTON_PAD_V 4
#define CHECKBOX_BOX_SIZE 12
#define CHECKBOX_GAP 4
#define FRAME_BORDER 2
#define FRAME_TITLE_GAP 4
#define TEXT_INPUT_PAD 3
// ============================================================
// Prototypes
// ============================================================
static void addChild(WidgetT *parent, WidgetT *child);
static WidgetT *allocWidget(WidgetT *parent, WidgetTypeE type);
static void calcMinSizeBox(WidgetT *w, const BitmapFontT *font);
static void calcMinSizeLeaf(WidgetT *w, const BitmapFontT *font);
static void calcMinSizeTree(WidgetT *w, const BitmapFontT *font);
static int32_t countVisibleChildren(const WidgetT *w);
static void destroyChildren(WidgetT *w);
static WidgetT *hitTest(WidgetT *w, int32_t x, int32_t y);
static void layoutBox(WidgetT *w, const BitmapFontT *font);
static void layoutChildren(WidgetT *w, const BitmapFontT *font);
static void paintWidget(WidgetT *w, DisplayT *d, const BlitOpsT *ops,
const BitmapFontT *font, const ColorSchemeT *colors);
static void removeChild(WidgetT *parent, WidgetT *child);
static void widgetManageScrollbars(WindowT *win, AppContextT *ctx);
static void widgetOnKey(WindowT *win, int32_t key, int32_t mod);
static void widgetOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons);
static void widgetOnPaint(WindowT *win, RectT *dirtyArea);
static void widgetOnResize(WindowT *win, int32_t newW, int32_t newH);
static void widgetOnScroll(WindowT *win, ScrollbarOrientE orient, int32_t value);
// ============================================================
// addChild
// ============================================================
static void addChild(WidgetT *parent, WidgetT *child) {
child->parent = parent;
child->nextSibling = NULL;
if (parent->lastChild) {
parent->lastChild->nextSibling = child;
parent->lastChild = child;
} else {
parent->firstChild = child;
parent->lastChild = child;
}
}
// ============================================================
// allocWidget
// ============================================================
static WidgetT *allocWidget(WidgetT *parent, WidgetTypeE type) {
WidgetT *w = (WidgetT *)malloc(sizeof(WidgetT));
if (!w) {
return NULL;
}
memset(w, 0, sizeof(*w));
w->type = type;
w->visible = true;
w->enabled = true;
if (parent) {
w->window = parent->window;
addChild(parent, w);
}
return w;
}
// ============================================================
// calcMinSizeBox
// ============================================================
static void calcMinSizeBox(WidgetT *w, const BitmapFontT *font) {
bool horiz = (w->type == WidgetHBoxE); // RadioGroupE and VBoxE are vertical
int32_t pad = wgtResolveSize(w->padding, 0, font->charWidth);
int32_t gap = wgtResolveSize(w->spacing, 0, font->charWidth);
int32_t mainSize = 0;
int32_t crossSize = 0;
int32_t count = 0;
if (pad == 0) {
pad = DEFAULT_PADDING;
}
if (gap == 0) {
gap = DEFAULT_SPACING;
}
// Frame adds title height and border
int32_t frameExtraTop = 0;
if (w->type == WidgetFrameE) {
frameExtraTop = font->charHeight + FRAME_TITLE_GAP;
pad = DEFAULT_PADDING;
}
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (!c->visible) {
continue;
}
calcMinSizeTree(c, font);
if (horiz) {
mainSize += c->calcMinW;
crossSize = DVX_MAX(crossSize, c->calcMinH);
} else {
mainSize += c->calcMinH;
crossSize = DVX_MAX(crossSize, c->calcMinW);
}
count++;
}
// Add spacing between children
if (count > 1) {
mainSize += gap * (count - 1);
}
// Add padding
mainSize += pad * 2;
crossSize += pad * 2;
if (horiz) {
w->calcMinW = mainSize;
w->calcMinH = crossSize + frameExtraTop;
} else {
w->calcMinW = crossSize;
w->calcMinH = mainSize + frameExtraTop;
}
// Frame border
if (w->type == WidgetFrameE) {
w->calcMinW += FRAME_BORDER * 2;
w->calcMinH += FRAME_BORDER * 2;
}
}
// ============================================================
// calcMinSizeLeaf
// ============================================================
static void calcMinSizeLeaf(WidgetT *w, const BitmapFontT *font) {
switch (w->type) {
case WidgetLabelE:
w->calcMinW = (int32_t)strlen(w->as.label.text) * font->charWidth;
w->calcMinH = font->charHeight;
break;
case WidgetButtonE:
w->calcMinW = (int32_t)strlen(w->as.button.text) * font->charWidth + BUTTON_PAD_H * 2;
w->calcMinH = font->charHeight + BUTTON_PAD_V * 2;
break;
case WidgetCheckboxE:
w->calcMinW = CHECKBOX_BOX_SIZE + CHECKBOX_GAP +
(int32_t)strlen(w->as.checkbox.text) * font->charWidth;
w->calcMinH = DVX_MAX(CHECKBOX_BOX_SIZE, font->charHeight);
break;
case WidgetRadioE:
w->calcMinW = CHECKBOX_BOX_SIZE + CHECKBOX_GAP +
(int32_t)strlen(w->as.radio.text) * font->charWidth;
w->calcMinH = DVX_MAX(CHECKBOX_BOX_SIZE, font->charHeight);
break;
case WidgetTextInputE:
w->calcMinW = font->charWidth * 8 + TEXT_INPUT_PAD * 2;
w->calcMinH = font->charHeight + TEXT_INPUT_PAD * 2;
break;
case WidgetSpacerE:
w->calcMinW = 0;
w->calcMinH = 0;
break;
case WidgetSeparatorE:
if (w->as.separator.vertical) {
w->calcMinW = SEPARATOR_THICKNESS;
w->calcMinH = 0;
} else {
w->calcMinW = 0;
w->calcMinH = SEPARATOR_THICKNESS;
}
break;
default:
w->calcMinW = 0;
w->calcMinH = 0;
break;
}
}
// ============================================================
// calcMinSizeTree
// ============================================================
static void calcMinSizeTree(WidgetT *w, const BitmapFontT *font) {
if (w->type == WidgetVBoxE || w->type == WidgetHBoxE || w->type == WidgetFrameE || w->type == WidgetRadioGroupE) {
calcMinSizeBox(w, font);
} else {
calcMinSizeLeaf(w, font);
}
// Apply size hints (override calculated minimum)
if (w->minW) {
int32_t hintW = wgtResolveSize(w->minW, 0, font->charWidth);
if (hintW > w->calcMinW) {
w->calcMinW = hintW;
}
}
if (w->minH) {
int32_t hintH = wgtResolveSize(w->minH, 0, font->charWidth);
if (hintH > w->calcMinH) {
w->calcMinH = hintH;
}
}
}
// ============================================================
// countVisibleChildren
// ============================================================
static int32_t countVisibleChildren(const WidgetT *w) {
int32_t count = 0;
for (const WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (c->visible) {
count++;
}
}
return count;
}
// ============================================================
// destroyChildren
// ============================================================
static void destroyChildren(WidgetT *w) {
WidgetT *child = w->firstChild;
while (child) {
WidgetT *next = child->nextSibling;
destroyChildren(child);
if (child->type == WidgetTextInputE) {
free(child->as.textInput.buf);
} else if (child->type == WidgetTextAreaE) {
free(child->as.textArea.buf);
}
free(child);
child = next;
}
w->firstChild = NULL;
w->lastChild = NULL;
}
// ============================================================
// hitTest
// ============================================================
static WidgetT *hitTest(WidgetT *w, int32_t x, int32_t y) {
if (!w->visible) {
return NULL;
}
if (x < w->x || x >= w->x + w->w || y < w->y || y >= w->y + w->h) {
return NULL;
}
// Check children in reverse order (last child is on top)
// Walk to last visible child, then check backwards
// Since we use a singly-linked list, just check all and take the last match
WidgetT *hit = NULL;
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
WidgetT *childHit = hitTest(c, x, y);
if (childHit) {
hit = childHit;
}
}
return hit ? hit : w;
}
// ============================================================
// layoutBox
// ============================================================
static void layoutBox(WidgetT *w, const BitmapFontT *font) {
bool horiz = (w->type == WidgetHBoxE);
int32_t pad = wgtResolveSize(w->padding, 0, font->charWidth);
int32_t gap = wgtResolveSize(w->spacing, 0, font->charWidth);
if (pad == 0) {
pad = DEFAULT_PADDING;
}
if (gap == 0) {
gap = DEFAULT_SPACING;
}
// Frame adjustments
int32_t frameExtraTop = 0;
int32_t frameBorder = 0;
if (w->type == WidgetFrameE) {
frameExtraTop = font->charHeight + FRAME_TITLE_GAP;
frameBorder = FRAME_BORDER;
pad = DEFAULT_PADDING;
}
int32_t innerX = w->x + pad + frameBorder;
int32_t innerY = w->y + pad + frameBorder + frameExtraTop;
int32_t innerW = w->w - pad * 2 - frameBorder * 2;
int32_t innerH = w->h - pad * 2 - frameBorder * 2 - frameExtraTop;
if (innerW < 0) { innerW = 0; }
if (innerH < 0) { innerH = 0; }
int32_t count = countVisibleChildren(w);
if (count == 0) {
return;
}
int32_t totalGap = gap * (count - 1);
int32_t availMain = horiz ? (innerW - totalGap) : (innerH - totalGap);
int32_t availCross = horiz ? innerH : innerW;
// First pass: sum minimum sizes and total weight
int32_t totalMin = 0;
int32_t totalWeight = 0;
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (!c->visible) {
continue;
}
int32_t cmin = horiz ? c->calcMinW : c->calcMinH;
totalMin += cmin;
totalWeight += c->weight;
}
int32_t extraSpace = availMain - totalMin;
if (extraSpace < 0) {
extraSpace = 0;
}
// Compute alignment offset for main axis
int32_t alignOffset = 0;
if (totalWeight == 0 && extraSpace > 0) {
if (w->align == AlignCenterE) {
alignOffset = extraSpace / 2;
} else if (w->align == AlignEndE) {
alignOffset = extraSpace;
}
}
// Second pass: assign positions and sizes
int32_t pos = (horiz ? innerX : innerY) + alignOffset;
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (!c->visible) {
continue;
}
int32_t cmin = horiz ? c->calcMinW : c->calcMinH;
int32_t mainSize = cmin;
// Distribute extra space by weight
if (totalWeight > 0 && c->weight > 0 && extraSpace > 0) {
mainSize += (extraSpace * c->weight) / totalWeight;
}
// Apply max size constraint
if (horiz && c->maxW) {
int32_t maxPx = wgtResolveSize(c->maxW, innerW, font->charWidth);
if (mainSize > maxPx) {
mainSize = maxPx;
}
} else if (!horiz && c->maxH) {
int32_t maxPx = wgtResolveSize(c->maxH, innerH, font->charWidth);
if (mainSize > maxPx) {
mainSize = maxPx;
}
}
// Assign geometry
if (horiz) {
c->x = pos;
c->y = innerY;
c->w = mainSize;
c->h = availCross;
} else {
c->x = innerX;
c->y = pos;
c->w = availCross;
c->h = mainSize;
}
// Apply preferred/max on cross axis
if (horiz && c->maxH) {
int32_t maxPx = wgtResolveSize(c->maxH, innerH, font->charWidth);
if (c->h > maxPx) {
c->h = maxPx;
}
} else if (!horiz && c->maxW) {
int32_t maxPx = wgtResolveSize(c->maxW, innerW, font->charWidth);
if (c->w > maxPx) {
c->w = maxPx;
}
}
pos += mainSize + gap;
// Recurse into child containers
layoutChildren(c, font);
}
}
// ============================================================
// layoutChildren
// ============================================================
static void layoutChildren(WidgetT *w, const BitmapFontT *font) {
if (w->type == WidgetVBoxE || w->type == WidgetHBoxE || w->type == WidgetFrameE || w->type == WidgetRadioGroupE) {
layoutBox(w, font);
}
}
// ============================================================
// paintWidget
// ============================================================
static void paintWidget(WidgetT *w, DisplayT *d, const BlitOpsT *ops,
const BitmapFontT *font, const ColorSchemeT *colors) {
if (!w->visible) {
return;
}
switch (w->type) {
case WidgetVBoxE:
case WidgetHBoxE:
// Containers are transparent — just paint children
break;
case WidgetFrameE: {
// Draw beveled border
BevelStyleT bevel;
bevel.highlight = colors->windowHighlight;
bevel.shadow = colors->windowShadow;
bevel.face = 0;
bevel.width = FRAME_BORDER;
drawBevel(d, ops, w->x, w->y + font->charHeight / 2,
w->w, w->h - font->charHeight / 2, &bevel);
// Draw title over the top border
if (w->as.frame.title && w->as.frame.title[0]) {
int32_t titleW = (int32_t)strlen(w->as.frame.title) * font->charWidth;
int32_t titleX = w->x + DEFAULT_PADDING + FRAME_BORDER;
rectFill(d, ops, titleX - 2, w->y,
titleW + 4, font->charHeight, colors->windowFace);
drawText(d, ops, font, titleX, w->y,
w->as.frame.title, colors->contentFg, colors->windowFace, true);
}
break;
}
case WidgetLabelE:
drawText(d, ops, font, w->x, w->y + (w->h - font->charHeight) / 2,
w->as.label.text, colors->contentFg, colors->contentBg, false);
break;
case WidgetButtonE: {
BevelStyleT bevel;
bevel.highlight = w->as.button.pressed ? colors->windowShadow : colors->windowHighlight;
bevel.shadow = w->as.button.pressed ? colors->windowHighlight : colors->windowShadow;
bevel.face = colors->buttonFace;
bevel.width = 2;
drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
int32_t textW = (int32_t)strlen(w->as.button.text) * font->charWidth;
int32_t textX = w->x + (w->w - textW) / 2;
int32_t textY = w->y + (w->h - font->charHeight) / 2;
if (w->as.button.pressed) {
textX++;
textY++;
}
drawText(d, ops, font, textX, textY,
w->as.button.text,
w->enabled ? colors->contentFg : colors->windowShadow,
colors->buttonFace, true);
break;
}
case WidgetCheckboxE: {
int32_t boxY = w->y + (w->h - CHECKBOX_BOX_SIZE) / 2;
// Draw checkbox box
BevelStyleT bevel;
bevel.highlight = colors->windowShadow;
bevel.shadow = colors->windowHighlight;
bevel.face = colors->contentBg;
bevel.width = 1;
drawBevel(d, ops, w->x, boxY, CHECKBOX_BOX_SIZE, CHECKBOX_BOX_SIZE, &bevel);
// Draw check mark if checked
if (w->as.checkbox.checked) {
int32_t cx = w->x + 3;
int32_t cy = boxY + 3;
int32_t cs = CHECKBOX_BOX_SIZE - 6;
for (int32_t i = 0; i < cs; i++) {
drawHLine(d, ops, cx + i, cy + i, 1, colors->contentFg);
drawHLine(d, ops, cx + cs - 1 - i, cy + i, 1, colors->contentFg);
}
}
// Draw label
drawText(d, ops, font,
w->x + CHECKBOX_BOX_SIZE + CHECKBOX_GAP,
w->y + (w->h - font->charHeight) / 2,
w->as.checkbox.text, colors->contentFg, colors->contentBg, false);
break;
}
case WidgetRadioE: {
int32_t boxY = w->y + (w->h - CHECKBOX_BOX_SIZE) / 2;
// Draw radio box (same as checkbox for now, could use circle later)
BevelStyleT bevel;
bevel.highlight = colors->windowShadow;
bevel.shadow = colors->windowHighlight;
bevel.face = colors->contentBg;
bevel.width = 1;
drawBevel(d, ops, w->x, boxY, CHECKBOX_BOX_SIZE, CHECKBOX_BOX_SIZE, &bevel);
// Draw filled dot if selected
if (w->parent && w->parent->type == WidgetRadioGroupE &&
w->parent->as.radioGroup.selectedIdx == w->as.radio.index) {
rectFill(d, ops, w->x + 3, boxY + 3,
CHECKBOX_BOX_SIZE - 6, CHECKBOX_BOX_SIZE - 6, colors->contentFg);
}
drawText(d, ops, font,
w->x + CHECKBOX_BOX_SIZE + CHECKBOX_GAP,
w->y + (w->h - font->charHeight) / 2,
w->as.radio.text, colors->contentFg, colors->contentBg, false);
break;
}
case WidgetTextInputE: {
// Sunken border
BevelStyleT bevel;
bevel.highlight = colors->windowShadow;
bevel.shadow = colors->windowHighlight;
bevel.face = colors->contentBg;
bevel.width = 2;
drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
// Draw text
if (w->as.textInput.buf) {
int32_t textX = w->x + TEXT_INPUT_PAD;
int32_t textY = w->y + (w->h - font->charHeight) / 2;
int32_t maxChars = (w->w - TEXT_INPUT_PAD * 2) / font->charWidth;
int32_t off = w->as.textInput.scrollOff;
int32_t len = w->as.textInput.len - off;
if (len > maxChars) {
len = maxChars;
}
for (int32_t i = 0; i < len; i++) {
drawChar(d, ops, font, textX + i * font->charWidth, textY,
w->as.textInput.buf[off + i],
colors->contentFg, colors->contentBg, true);
}
// Draw cursor
if (w->focused) {
int32_t cursorX = textX + (w->as.textInput.cursorPos - off) * font->charWidth;
if (cursorX >= w->x + TEXT_INPUT_PAD &&
cursorX < w->x + w->w - TEXT_INPUT_PAD) {
drawVLine(d, ops, cursorX, textY, font->charHeight, colors->contentFg);
}
}
}
break;
}
case WidgetSpacerE:
// Invisible — draws nothing
break;
case WidgetSeparatorE:
if (w->as.separator.vertical) {
int32_t cx = w->x + w->w / 2;
drawVLine(d, ops, cx, w->y, w->h, colors->windowShadow);
drawVLine(d, ops, cx + 1, w->y, w->h, colors->windowHighlight);
} else {
int32_t cy = w->y + w->h / 2;
drawHLine(d, ops, w->x, cy, w->w, colors->windowShadow);
drawHLine(d, ops, w->x, cy + 1, w->w, colors->windowHighlight);
}
break;
default:
break;
}
// Paint children
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
paintWidget(c, d, ops, font, colors);
}
}
// ============================================================
// removeChild
// ============================================================
static void removeChild(WidgetT *parent, WidgetT *child) {
WidgetT *prev = NULL;
for (WidgetT *c = parent->firstChild; c; c = c->nextSibling) {
if (c == child) {
if (prev) {
prev->nextSibling = c->nextSibling;
} else {
parent->firstChild = c->nextSibling;
}
if (parent->lastChild == child) {
parent->lastChild = prev;
}
child->nextSibling = NULL;
child->parent = NULL;
return;
}
prev = c;
}
}
// ============================================================
// widgetManageScrollbars
// ============================================================
//
// Checks whether the widget tree's minimum size exceeds the
// window content area. Adds or removes WM scrollbars as needed,
// then relayouts the widget tree at the virtual content size.
static void widgetManageScrollbars(WindowT *win, AppContextT *ctx) {
WidgetT *root = win->widgetRoot;
if (!root) {
return;
}
// Measure the tree without any layout pass
calcMinSizeTree(root, &ctx->font);
// Save old scroll positions before removing scrollbars
int32_t oldVValue = win->vScroll ? win->vScroll->value : 0;
int32_t oldHValue = win->hScroll ? win->hScroll->value : 0;
bool hadVScroll = (win->vScroll != NULL);
bool hadHScroll = (win->hScroll != NULL);
// Remove existing scrollbars to measure full available area
if (hadVScroll) {
free(win->vScroll);
win->vScroll = NULL;
}
if (hadHScroll) {
free(win->hScroll);
win->hScroll = NULL;
}
wmUpdateContentRect(win);
int32_t availW = win->contentW;
int32_t availH = win->contentH;
int32_t minW = root->calcMinW;
int32_t minH = root->calcMinH;
bool needV = (minH > availH);
bool needH = (minW > availW);
// Adding one scrollbar reduces space, which may require the other
if (needV && !needH) {
needH = (minW > availW - SCROLLBAR_WIDTH);
}
if (needH && !needV) {
needV = (minH > availH - SCROLLBAR_WIDTH);
}
bool changed = (needV != hadVScroll) || (needH != hadHScroll);
if (needV) {
int32_t pageV = needH ? availH - SCROLLBAR_WIDTH : availH;
int32_t maxV = minH - pageV;
if (maxV < 0) {
maxV = 0;
}
wmAddVScrollbar(win, 0, maxV, pageV);
win->vScroll->value = DVX_MIN(oldVValue, maxV);
}
if (needH) {
int32_t pageH = needV ? availW - SCROLLBAR_WIDTH : availW;
int32_t maxH = minW - pageH;
if (maxH < 0) {
maxH = 0;
}
wmAddHScrollbar(win, 0, maxH, pageH);
win->hScroll->value = DVX_MIN(oldHValue, maxH);
}
if (changed) {
// wmAddVScrollbar/wmAddHScrollbar already call wmUpdateContentRect
wmReallocContentBuf(win, &ctx->display);
}
// Install scroll handler
win->onScroll = widgetOnScroll;
// Layout at the virtual content size (the larger of content area and min size)
int32_t layoutW = DVX_MAX(win->contentW, minW);
int32_t layoutH = DVX_MAX(win->contentH, minH);
wgtLayout(root, layoutW, layoutH, &ctx->font);
}
// ============================================================
// widgetOnKey
// ============================================================
static void widgetOnKey(WindowT *win, int32_t key, int32_t mod) {
(void)mod;
WidgetT *root = win->widgetRoot;
if (!root) {
return;
}
// Find the focused widget
// For now, simple: find a focused text input and send keys to it
// TODO: proper focus chain / tab navigation
WidgetT *focus = NULL;
// Simple linear scan for focused widget
WidgetT *stack[64];
int32_t top = 0;
stack[top++] = root;
while (top > 0) {
WidgetT *w = stack[--top];
if (w->focused && w->type == WidgetTextInputE) {
focus = w;
break;
}
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (top < 64) {
stack[top++] = c;
}
}
}
if (!focus || focus->type != WidgetTextInputE) {
return;
}
// Handle key input for text input widget
if (key >= 32 && key < 127) {
// Printable character
if (focus->as.textInput.len < focus->as.textInput.bufSize - 1) {
int32_t pos = focus->as.textInput.cursorPos;
memmove(focus->as.textInput.buf + pos + 1,
focus->as.textInput.buf + pos,
focus->as.textInput.len - pos + 1);
focus->as.textInput.buf[pos] = (char)key;
focus->as.textInput.len++;
focus->as.textInput.cursorPos++;
if (focus->onChange) {
focus->onChange(focus);
}
}
} else if (key == 8) {
// Backspace
if (focus->as.textInput.cursorPos > 0) {
int32_t pos = focus->as.textInput.cursorPos;
memmove(focus->as.textInput.buf + pos - 1,
focus->as.textInput.buf + pos,
focus->as.textInput.len - pos + 1);
focus->as.textInput.len--;
focus->as.textInput.cursorPos--;
if (focus->onChange) {
focus->onChange(focus);
}
}
} else if (key == (0x4B | 0x100)) {
// Left arrow
if (focus->as.textInput.cursorPos > 0) {
focus->as.textInput.cursorPos--;
}
} else if (key == (0x4D | 0x100)) {
// Right arrow
if (focus->as.textInput.cursorPos < focus->as.textInput.len) {
focus->as.textInput.cursorPos++;
}
} else if (key == (0x47 | 0x100)) {
// Home
focus->as.textInput.cursorPos = 0;
} else if (key == (0x4F | 0x100)) {
// End
focus->as.textInput.cursorPos = focus->as.textInput.len;
} else if (key == (0x53 | 0x100)) {
// Delete
if (focus->as.textInput.cursorPos < focus->as.textInput.len) {
int32_t pos = focus->as.textInput.cursorPos;
memmove(focus->as.textInput.buf + pos,
focus->as.textInput.buf + pos + 1,
focus->as.textInput.len - pos);
focus->as.textInput.len--;
if (focus->onChange) {
focus->onChange(focus);
}
}
}
// Adjust scroll offset to keep cursor visible
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
int32_t visibleChars = (focus->w - TEXT_INPUT_PAD * 2) / font->charWidth;
if (focus->as.textInput.cursorPos < focus->as.textInput.scrollOff) {
focus->as.textInput.scrollOff = focus->as.textInput.cursorPos;
}
if (focus->as.textInput.cursorPos >= focus->as.textInput.scrollOff + visibleChars) {
focus->as.textInput.scrollOff = focus->as.textInput.cursorPos - visibleChars + 1;
}
// Repaint the window
wgtInvalidate(focus);
}
// ============================================================
// widgetOnMouse
// ============================================================
static void widgetOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) {
WidgetT *root = win->widgetRoot;
if (!root || !(buttons & 1)) {
return;
}
// Adjust mouse coordinates for scroll offset
int32_t scrollX = win->hScroll ? win->hScroll->value : 0;
int32_t scrollY = win->vScroll ? win->vScroll->value : 0;
int32_t vx = x + scrollX;
int32_t vy = y + scrollY;
WidgetT *hit = hitTest(root, vx, vy);
if (!hit) {
return;
}
// Clear focus from all text inputs, set focus on clicked text input
WidgetT *stack[64];
int32_t top = 0;
stack[top++] = root;
while (top > 0) {
WidgetT *w = stack[--top];
w->focused = false;
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (top < 64) {
stack[top++] = c;
}
}
}
if (hit->type == WidgetTextInputE) {
hit->focused = true;
// Place cursor at click position
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
int32_t relX = x - hit->x - TEXT_INPUT_PAD;
int32_t charPos = relX / font->charWidth + hit->as.textInput.scrollOff;
if (charPos < 0) {
charPos = 0;
}
if (charPos > hit->as.textInput.len) {
charPos = hit->as.textInput.len;
}
hit->as.textInput.cursorPos = charPos;
}
if (hit->type == WidgetButtonE && hit->enabled) {
hit->as.button.pressed = true;
wgtInvalidate(hit);
// The button release will be handled by the next mouse event
// For now, just fire onClick on press
if (hit->onClick) {
hit->onClick(hit);
}
hit->as.button.pressed = false;
}
if (hit->type == WidgetCheckboxE && hit->enabled) {
hit->as.checkbox.checked = !hit->as.checkbox.checked;
if (hit->onChange) {
hit->onChange(hit);
}
}
if (hit->type == WidgetRadioE && hit->enabled && hit->parent &&
hit->parent->type == WidgetRadioGroupE) {
hit->parent->as.radioGroup.selectedIdx = hit->as.radio.index;
if (hit->parent->onChange) {
hit->parent->onChange(hit->parent);
}
}
wgtInvalidate(root);
}
// ============================================================
// widgetOnPaint
// ============================================================
static void widgetOnPaint(WindowT *win, RectT *dirtyArea) {
(void)dirtyArea;
WidgetT *root = win->widgetRoot;
if (!root) {
return;
}
// Get context from root's userData
AppContextT *ctx = (AppContextT *)root->userData;
if (!ctx) {
return;
}
// Set up a display context pointing at the content buffer
DisplayT cd = ctx->display;
cd.backBuf = win->contentBuf;
cd.width = win->contentW;
cd.height = win->contentH;
cd.pitch = win->contentPitch;
cd.clipX = 0;
cd.clipY = 0;
cd.clipW = win->contentW;
cd.clipH = win->contentH;
// Clear background
rectFill(&cd, &ctx->blitOps, 0, 0, win->contentW, win->contentH, ctx->colors.contentBg);
// Apply scroll offset — layout at virtual size, positioned at -scroll
int32_t scrollX = win->hScroll ? win->hScroll->value : 0;
int32_t scrollY = win->vScroll ? win->vScroll->value : 0;
int32_t layoutW = DVX_MAX(win->contentW, root->calcMinW);
int32_t layoutH = DVX_MAX(win->contentH, root->calcMinH);
root->x = -scrollX;
root->y = -scrollY;
root->w = layoutW;
root->h = layoutH;
layoutChildren(root, &ctx->font);
// Paint widget tree (clip rect limits drawing to visible area)
wgtPaint(root, &cd, &ctx->blitOps, &ctx->font, &ctx->colors);
}
// ============================================================
// widgetOnResize
// ============================================================
static void widgetOnResize(WindowT *win, int32_t newW, int32_t newH) {
(void)newW;
(void)newH;
WidgetT *root = win->widgetRoot;
if (!root) {
return;
}
AppContextT *ctx = (AppContextT *)root->userData;
if (!ctx) {
return;
}
widgetManageScrollbars(win, ctx);
}
// ============================================================
// widgetOnScroll
// ============================================================
static void widgetOnScroll(WindowT *win, ScrollbarOrientE orient, int32_t value) {
(void)orient;
(void)value;
// Repaint with new scroll position
if (win->onPaint) {
RectT fullRect = {0, 0, win->contentW, win->contentH};
win->onPaint(win, &fullRect);
}
}
// ============================================================
// wgtButton
// ============================================================
WidgetT *wgtButton(WidgetT *parent, const char *text) {
WidgetT *w = allocWidget(parent, WidgetButtonE);
if (w) {
w->as.button.text = text;
w->as.button.pressed = false;
}
return w;
}
// ============================================================
// wgtCheckbox
// ============================================================
WidgetT *wgtCheckbox(WidgetT *parent, const char *text) {
WidgetT *w = allocWidget(parent, WidgetCheckboxE);
if (w) {
w->as.checkbox.text = text;
w->as.checkbox.checked = false;
}
return w;
}
// ============================================================
// wgtDestroy
// ============================================================
void wgtDestroy(WidgetT *w) {
if (!w) {
return;
}
if (w->parent) {
removeChild(w->parent, w);
}
destroyChildren(w);
if (w->type == WidgetTextInputE) {
free(w->as.textInput.buf);
} else if (w->type == WidgetTextAreaE) {
free(w->as.textArea.buf);
}
// If this is the root, clear the window's reference
if (w->window && w->window->widgetRoot == w) {
w->window->widgetRoot = NULL;
}
free(w);
}
// ============================================================
// wgtFind
// ============================================================
WidgetT *wgtFind(WidgetT *root, const char *name) {
if (!root || !name) {
return NULL;
}
if (root->name[0] && strcmp(root->name, name) == 0) {
return root;
}
for (WidgetT *c = root->firstChild; c; c = c->nextSibling) {
WidgetT *found = wgtFind(c, name);
if (found) {
return found;
}
}
return NULL;
}
// ============================================================
// wgtFrame
// ============================================================
WidgetT *wgtFrame(WidgetT *parent, const char *title) {
WidgetT *w = allocWidget(parent, WidgetFrameE);
if (w) {
w->as.frame.title = title;
}
return w;
}
// ============================================================
// wgtGetText
// ============================================================
const char *wgtGetText(const WidgetT *w) {
if (!w) {
return "";
}
switch (w->type) {
case WidgetLabelE: return w->as.label.text;
case WidgetButtonE: return w->as.button.text;
case WidgetCheckboxE: return w->as.checkbox.text;
case WidgetRadioE: return w->as.radio.text;
case WidgetTextInputE: return w->as.textInput.buf ? w->as.textInput.buf : "";
default: return "";
}
}
// ============================================================
// wgtHBox
// ============================================================
WidgetT *wgtHBox(WidgetT *parent) {
return allocWidget(parent, WidgetHBoxE);
}
// ============================================================
// wgtHSeparator
// ============================================================
WidgetT *wgtHSeparator(WidgetT *parent) {
WidgetT *w = allocWidget(parent, WidgetSeparatorE);
if (w) {
w->as.separator.vertical = false;
}
return w;
}
// ============================================================
// wgtInitWindow
// ============================================================
WidgetT *wgtInitWindow(AppContextT *ctx, WindowT *win) {
WidgetT *root = allocWidget(NULL, WidgetVBoxE);
if (!root) {
return NULL;
}
root->window = win;
root->userData = ctx;
win->widgetRoot = root;
win->onPaint = widgetOnPaint;
win->onMouse = widgetOnMouse;
win->onKey = widgetOnKey;
win->onResize = widgetOnResize;
// Layout and paint are deferred until the caller adds widgets
// and calls wgtInvalidate(root) or until the first resize/paint event.
return root;
}
// ============================================================
// wgtInvalidate
// ============================================================
void wgtInvalidate(WidgetT *w) {
if (!w || !w->window) {
return;
}
// Find the root
WidgetT *root = w;
while (root->parent) {
root = root->parent;
}
AppContextT *ctx = (AppContextT *)root->userData;
if (!ctx) {
return;
}
// Manage scrollbars (measures, adds/removes scrollbars, relayouts)
widgetManageScrollbars(w->window, ctx);
// Repaint
RectT fullRect = {0, 0, w->window->contentW, w->window->contentH};
widgetOnPaint(w->window, &fullRect);
// Dirty the window on screen
dvxInvalidateWindow(ctx, w->window);
}
// ============================================================
// wgtLabel
// ============================================================
WidgetT *wgtLabel(WidgetT *parent, const char *text) {
WidgetT *w = allocWidget(parent, WidgetLabelE);
if (w) {
w->as.label.text = text;
}
return w;
}
// ============================================================
// wgtLayout
// ============================================================
void wgtLayout(WidgetT *root, int32_t availW, int32_t availH,
const BitmapFontT *font) {
if (!root) {
return;
}
// Measure pass
calcMinSizeTree(root, font);
// Layout pass
root->x = 0;
root->y = 0;
root->w = availW;
root->h = availH;
layoutChildren(root, font);
}
// ============================================================
// wgtListBox
// ============================================================
WidgetT *wgtListBox(WidgetT *parent) {
WidgetT *w = allocWidget(parent, WidgetListBoxE);
if (w) {
w->as.listBox.selectedIdx = -1;
}
return w;
}
// ============================================================
// wgtListBoxGetSelected
// ============================================================
int32_t wgtListBoxGetSelected(const WidgetT *w) {
if (!w || w->type != WidgetListBoxE) {
return -1;
}
return w->as.listBox.selectedIdx;
}
// ============================================================
// wgtListBoxSetItems
// ============================================================
void wgtListBoxSetItems(WidgetT *w, const char **items, int32_t count) {
if (!w || w->type != WidgetListBoxE) {
return;
}
w->as.listBox.items = items;
w->as.listBox.itemCount = count;
if (w->as.listBox.selectedIdx >= count) {
w->as.listBox.selectedIdx = -1;
}
}
// ============================================================
// wgtListBoxSetSelected
// ============================================================
void wgtListBoxSetSelected(WidgetT *w, int32_t idx) {
if (!w || w->type != WidgetListBoxE) {
return;
}
w->as.listBox.selectedIdx = idx;
}
// ============================================================
// wgtPaint
// ============================================================
void wgtPaint(WidgetT *root, DisplayT *d, const BlitOpsT *ops,
const BitmapFontT *font, const ColorSchemeT *colors) {
if (!root) {
return;
}
paintWidget(root, d, ops, font, colors);
}
// ============================================================
// wgtRadio
// ============================================================
WidgetT *wgtRadio(WidgetT *parent, const char *text) {
WidgetT *w = allocWidget(parent, WidgetRadioE);
if (w) {
w->as.radio.text = text;
// 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 == WidgetRadioE) {
idx++;
}
}
w->as.radio.index = idx;
}
return w;
}
// ============================================================
// wgtRadioGroup
// ============================================================
WidgetT *wgtRadioGroup(WidgetT *parent) {
WidgetT *w = allocWidget(parent, WidgetRadioGroupE);
if (w) {
w->as.radioGroup.selectedIdx = 0;
}
return w;
}
// ============================================================
// wgtResolveSize
// ============================================================
int32_t wgtResolveSize(int32_t taggedSize, int32_t parentSize, int32_t charWidth) {
if (taggedSize == 0) {
return 0;
}
uint32_t sizeType = (uint32_t)taggedSize & WGT_SIZE_TYPE_MASK;
int32_t value = taggedSize & WGT_SIZE_VAL_MASK;
switch (sizeType) {
case WGT_SIZE_PIXELS:
return value;
case WGT_SIZE_CHARS:
return value * charWidth;
case WGT_SIZE_PERCENT:
return (parentSize * value) / 100;
default:
return value;
}
}
// ============================================================
// wgtSetEnabled
// ============================================================
void wgtSetEnabled(WidgetT *w, bool enabled) {
if (w) {
w->enabled = enabled;
}
}
// ============================================================
// wgtSetText
// ============================================================
void wgtSetText(WidgetT *w, const char *text) {
if (!w) {
return;
}
switch (w->type) {
case WidgetLabelE:
w->as.label.text = text;
break;
case WidgetButtonE:
w->as.button.text = text;
break;
case WidgetCheckboxE:
w->as.checkbox.text = text;
break;
case WidgetRadioE:
w->as.radio.text = text;
break;
case WidgetTextInputE:
if (w->as.textInput.buf) {
strncpy(w->as.textInput.buf, text, w->as.textInput.bufSize - 1);
w->as.textInput.buf[w->as.textInput.bufSize - 1] = '\0';
w->as.textInput.len = (int32_t)strlen(w->as.textInput.buf);
w->as.textInput.cursorPos = w->as.textInput.len;
w->as.textInput.scrollOff = 0;
}
break;
default:
break;
}
}
// ============================================================
// wgtSetVisible
// ============================================================
void wgtSetVisible(WidgetT *w, bool visible) {
if (w) {
w->visible = visible;
}
}
// ============================================================
// wgtSpacer
// ============================================================
WidgetT *wgtSpacer(WidgetT *parent) {
WidgetT *w = allocWidget(parent, WidgetSpacerE);
if (w) {
w->weight = 100; // spacers stretch by default
}
return w;
}
// ============================================================
// wgtTextArea
// ============================================================
WidgetT *wgtTextArea(WidgetT *parent, int32_t maxLen) {
WidgetT *w = allocWidget(parent, WidgetTextAreaE);
if (w) {
int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;
w->as.textArea.buf = (char *)malloc(bufSize);
w->as.textArea.bufSize = bufSize;
if (w->as.textArea.buf) {
w->as.textArea.buf[0] = '\0';
}
}
return w;
}
// ============================================================
// wgtTextInput
// ============================================================
WidgetT *wgtTextInput(WidgetT *parent, int32_t maxLen) {
WidgetT *w = allocWidget(parent, WidgetTextInputE);
if (w) {
int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;
w->as.textInput.buf = (char *)malloc(bufSize);
w->as.textInput.bufSize = bufSize;
if (w->as.textInput.buf) {
w->as.textInput.buf[0] = '\0';
}
w->weight = 100; // text inputs stretch by default
}
return w;
}
// ============================================================
// wgtVBox
// ============================================================
WidgetT *wgtVBox(WidgetT *parent) {
return allocWidget(parent, WidgetVBoxE);
}
// ============================================================
// wgtVSeparator
// ============================================================
WidgetT *wgtVSeparator(WidgetT *parent) {
WidgetT *w = allocWidget(parent, WidgetSeparatorE);
if (w) {
w->as.separator.vertical = true;
}
return w;
}