diff --git a/dvx/Makefile b/dvx/Makefile index 1b1bd2b..df6cfa0 100644 --- a/dvx/Makefile +++ b/dvx/Makefile @@ -11,7 +11,7 @@ OBJDIR = ../obj/dvx WOBJDIR = ../obj/dvx/widgets LIBDIR = ../lib -SRCS = dvxVideo.c dvxDraw.c dvxComp.c dvxWm.c dvxIcon.c dvxImageWrite.c dvxApp.c +SRCS = dvxVideo.c dvxDraw.c dvxComp.c dvxWm.c dvxIcon.c dvxImageWrite.c dvxApp.c dvxDialog.c WSRCS = widgets/widgetAnsiTerm.c \ widgets/widgetClass.c \ @@ -29,6 +29,7 @@ WSRCS = widgets/widgetAnsiTerm.c \ widgets/widgetImageButton.c \ widgets/widgetLabel.c \ widgets/widgetListBox.c \ + widgets/widgetListView.c \ widgets/widgetProgressBar.c \ widgets/widgetRadio.c \ widgets/widgetSeparator.c \ @@ -75,6 +76,7 @@ $(OBJDIR)/dvxWm.o: dvxWm.c dvxWm.h dvxTypes.h dvxDraw.h dvxComp.h dvxVid $(OBJDIR)/dvxIcon.o: dvxIcon.c thirdparty/stb_image.h $(OBJDIR)/dvxImageWrite.o: dvxImageWrite.c thirdparty/stb_image_write.h $(OBJDIR)/dvxApp.o: dvxApp.c dvxApp.h dvxTypes.h dvxVideo.h dvxDraw.h dvxComp.h dvxWm.h dvxFont.h dvxCursor.h +$(OBJDIR)/dvxDialog.o: dvxDialog.c dvxDialog.h dvxApp.h dvxWidget.h widgets/widgetInternal.h dvxTypes.h dvxDraw.h # Widget file dependencies WIDGET_DEPS = widgets/widgetInternal.h dvxWidget.h dvxTypes.h dvxApp.h dvxDraw.h dvxWm.h dvxVideo.h @@ -94,6 +96,7 @@ $(WOBJDIR)/widgetImage.o: widgets/widgetImage.c $(WIDGET_DEPS) thirdparty/ $(WOBJDIR)/widgetImageButton.o: widgets/widgetImageButton.c $(WIDGET_DEPS) $(WOBJDIR)/widgetLabel.o: widgets/widgetLabel.c $(WIDGET_DEPS) $(WOBJDIR)/widgetListBox.o: widgets/widgetListBox.c $(WIDGET_DEPS) +$(WOBJDIR)/widgetListView.o: widgets/widgetListView.c $(WIDGET_DEPS) $(WOBJDIR)/widgetProgressBar.o: widgets/widgetProgressBar.c $(WIDGET_DEPS) $(WOBJDIR)/widgetRadio.o: widgets/widgetRadio.c $(WIDGET_DEPS) $(WOBJDIR)/widgetSeparator.o: widgets/widgetSeparator.c $(WIDGET_DEPS) diff --git a/dvx/dvxApp.c b/dvx/dvxApp.c index 88ef97a..55e0c9b 100644 --- a/dvx/dvxApp.c +++ b/dvx/dvxApp.c @@ -15,22 +15,29 @@ #define DBLCLICK_THRESHOLD (CLOCKS_PER_SEC / 2) #define ICON_REFRESH_INTERVAL 8 #define KB_MOVE_STEP 8 +#define SUBMENU_ARROW_WIDTH 12 // ============================================================ // Prototypes // ============================================================ +static void calcPopupSize(const AppContextT *ctx, const MenuT *menu, int32_t *pw, int32_t *ph); +static void closeAllPopups(AppContextT *ctx); +static void closePopupLevel(AppContextT *ctx); static void closeSysMenu(AppContextT *ctx); static void compositeAndFlush(AppContextT *ctx); static void dirtyCursorArea(AppContextT *ctx, int32_t x, int32_t y); static bool dispatchAccelKey(AppContextT *ctx, char key); static void dispatchEvents(AppContextT *ctx); static void drawCursorAt(AppContextT *ctx, int32_t x, int32_t y); +static void drawPopupLevel(AppContextT *ctx, DisplayT *d, const BlitOpsT *ops, const MenuT *menu, int32_t px, int32_t py, int32_t pw, int32_t ph, int32_t hoverItem, const RectT *clipTo); static void executeSysMenuCmd(AppContextT *ctx, int32_t cmd); static WindowT *findWindowById(AppContextT *ctx, int32_t id); static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t buttons); static void initColorScheme(AppContextT *ctx); static void initMouse(AppContextT *ctx); +static void openPopupAtMenu(AppContextT *ctx, WindowT *win, int32_t menuIdx); +static void openSubMenu(AppContextT *ctx); static void openSysMenu(AppContextT *ctx, WindowT *win); static void pollAnsiTermWidgets(AppContextT *ctx); static void pollAnsiTermWidgetsWalk(AppContextT *ctx, WidgetT *w, WindowT *win); @@ -60,6 +67,85 @@ static const char sAltScanToAscii[256] = { }; +// ============================================================ +// calcPopupSize — compute popup width and height for a menu +// ============================================================ + +static void calcPopupSize(const AppContextT *ctx, const MenuT *menu, int32_t *pw, int32_t *ph) { + int32_t maxW = 0; + bool hasSub = false; + + for (int32_t k = 0; k < menu->itemCount; k++) { + int32_t itemW = textWidthAccel(&ctx->font, menu->items[k].label); + + if (itemW > maxW) { + maxW = itemW; + } + + if (menu->items[k].subMenu) { + hasSub = true; + } + } + + *pw = maxW + CHROME_TITLE_PAD * 2 + 8 + (hasSub ? SUBMENU_ARROW_WIDTH : 0); + *ph = menu->itemCount * ctx->font.charHeight + 4; +} + + +// ============================================================ +// closeAllPopups — dirty all popup levels and deactivate +// ============================================================ + +static void closeAllPopups(AppContextT *ctx) { + if (!ctx->popup.active) { + return; + } + + // Dirty current (deepest) level + dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, + ctx->popup.popupW, ctx->popup.popupH); + + // Dirty all parent levels + for (int32_t i = 0; i < ctx->popup.depth; i++) { + PopupLevelT *pl = &ctx->popup.parentStack[i]; + dirtyListAdd(&ctx->dirty, pl->popupX, pl->popupY, pl->popupW, pl->popupH); + } + + ctx->popup.active = false; + ctx->popup.depth = 0; +} + + +// ============================================================ +// closePopupLevel — close one submenu level (or deactivate if top) +// ============================================================ + +static void closePopupLevel(AppContextT *ctx) { + if (!ctx->popup.active) { + return; + } + + // Dirty current level + dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, + ctx->popup.popupW, ctx->popup.popupH); + + if (ctx->popup.depth > 0) { + // Pop parent + ctx->popup.depth--; + PopupLevelT *pl = &ctx->popup.parentStack[ctx->popup.depth]; + ctx->popup.menu = pl->menu; + ctx->popup.menuIdx = pl->menuIdx; + ctx->popup.popupX = pl->popupX; + ctx->popup.popupY = pl->popupY; + ctx->popup.popupW = pl->popupW; + ctx->popup.popupH = pl->popupH; + ctx->popup.hoverItem = pl->hoverItem; + } else { + ctx->popup.active = false; + } +} + + // ============================================================ // closeSysMenu // ============================================================ @@ -127,71 +213,16 @@ static void compositeAndFlush(AppContextT *ctx) { } } - // 4. Draw popup menu if active + // 4. Draw popup menu if active (all levels) if (ctx->popup.active) { - // Draw popup dropdown - RectT popRect = { - ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH - }; - RectT popIsect; - - if (rectIntersect(dr, &popRect, &popIsect)) { - // Find the window and menu - for (int32_t j = 0; j < ws->count; j++) { - if (ws->windows[j]->id == ctx->popup.windowId) { - WindowT *win = ws->windows[j]; - MenuBarT *bar = win->menuBar; - - if (bar && ctx->popup.menuIdx < bar->menuCount) { - MenuT *menu = &bar->menus[ctx->popup.menuIdx]; - - // Draw popup background - BevelStyleT popBevel; - popBevel.highlight = ctx->colors.windowHighlight; - popBevel.shadow = ctx->colors.windowShadow; - popBevel.face = ctx->colors.menuBg; - popBevel.width = 2; - drawBevel(d, ops, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH, &popBevel); - - // Draw menu items - int32_t itemY = ctx->popup.popupY + 2; - - for (int32_t k = 0; k < menu->itemCount; k++) { - MenuItemT *item = &menu->items[k]; - - if (item->separator) { - drawHLine(d, ops, ctx->popup.popupX + 2, - itemY + ctx->font.charHeight / 2, - ctx->popup.popupW - 4, - ctx->colors.windowShadow); - itemY += ctx->font.charHeight; - continue; - } - - uint32_t bg = ctx->colors.menuBg; - uint32_t fg = ctx->colors.menuFg; - - if (k == ctx->popup.hoverItem) { - bg = ctx->colors.menuHighlightBg; - fg = ctx->colors.menuHighlightFg; - } - - rectFill(d, ops, ctx->popup.popupX + 2, itemY, - ctx->popup.popupW - 4, ctx->font.charHeight, bg); - drawTextAccel(d, ops, &ctx->font, - ctx->popup.popupX + CHROME_TITLE_PAD + 2, itemY, - item->label, fg, bg, true); - - itemY += ctx->font.charHeight; - } - } - - break; - } - } + // Draw parent levels first (bottom to top) + for (int32_t lvl = 0; lvl < ctx->popup.depth; lvl++) { + PopupLevelT *pl = &ctx->popup.parentStack[lvl]; + drawPopupLevel(ctx, d, ops, pl->menu, pl->popupX, pl->popupY, pl->popupW, pl->popupH, pl->hoverItem, dr); } + + // Draw current (deepest) level + drawPopupLevel(ctx, d, ops, ctx->popup.menu, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH, ctx->popup.hoverItem, dr); } // 4b. Draw system menu if active @@ -272,39 +303,8 @@ static bool dispatchAccelKey(AppContextT *ctx, char key) { // Check menu bar first if (win->menuBar) { for (int32_t i = 0; i < win->menuBar->menuCount; i++) { - MenuT *menu = &win->menuBar->menus[i]; - - if (menu->accelKey == key) { - // Close existing popup first - if (ctx->popup.active) { - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH); - } - - // Open this menu's popup - ctx->popup.active = true; - ctx->popup.windowId = win->id; - ctx->popup.menuIdx = i; - ctx->popup.popupX = win->x + menu->barX; - ctx->popup.popupY = win->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT + CHROME_MENU_HEIGHT; - ctx->popup.hoverItem = -1; - - // Calculate popup size - int32_t maxW = 0; - - for (int32_t k = 0; k < menu->itemCount; k++) { - int32_t itemW = textWidthAccel(&ctx->font, menu->items[k].label); - - if (itemW > maxW) { - maxW = itemW; - } - } - - ctx->popup.popupW = maxW + CHROME_TITLE_PAD * 2 + 8; - ctx->popup.popupH = menu->itemCount * ctx->font.charHeight + 4; - - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH); + if (win->menuBar->menus[i].accelKey == key) { + openPopupAtMenu(ctx, win, i); return true; } } @@ -534,56 +534,150 @@ static void dispatchEvents(AppContextT *ctx) { } } - // Handle popup menu interaction + // Handle popup menu interaction (with cascading submenu support) if (ctx->popup.active) { - // Check if mouse is inside popup - if (mx >= ctx->popup.popupX && mx < ctx->popup.popupX + ctx->popup.popupW && - my >= ctx->popup.popupY && my < ctx->popup.popupY + ctx->popup.popupH) { + // Check if mouse is inside current (deepest) popup level + bool inCurrent = (mx >= ctx->popup.popupX && mx < ctx->popup.popupX + ctx->popup.popupW && + my >= ctx->popup.popupY && my < ctx->popup.popupY + ctx->popup.popupH); - // Find which item is hovered + if (inCurrent) { + // Hover tracking in current level int32_t relY = my - ctx->popup.popupY - 2; int32_t itemIdx = relY / ctx->font.charHeight; + if (itemIdx < 0) { + itemIdx = 0; + } + + if (ctx->popup.menu && itemIdx >= ctx->popup.menu->itemCount) { + itemIdx = ctx->popup.menu->itemCount - 1; + } + if (itemIdx != ctx->popup.hoverItem) { + int32_t prevHover = ctx->popup.hoverItem; ctx->popup.hoverItem = itemIdx; dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH); - } - // Click on item - if ((buttons & 1) && !(prevBtn & 1)) { - // Find the window and menu - for (int32_t j = 0; j < ctx->stack.count; j++) { - if (ctx->stack.windows[j]->id == ctx->popup.windowId) { - WindowT *win = ctx->stack.windows[j]; - MenuBarT *bar = win->menuBar; + // If hovering a submenu item, open the submenu + if (ctx->popup.menu && itemIdx >= 0 && itemIdx < ctx->popup.menu->itemCount) { + MenuItemT *hItem = &ctx->popup.menu->items[itemIdx]; - if (bar && ctx->popup.menuIdx < bar->menuCount) { - MenuT *menu = &bar->menus[ctx->popup.menuIdx]; - - if (itemIdx >= 0 && itemIdx < menu->itemCount) { - MenuItemT *item = &menu->items[itemIdx]; - - if (item->enabled && !item->separator && win->onMenu) { - win->onMenu(win, item->id); - } - } - } - - break; + if (hItem->subMenu && hItem->enabled) { + openSubMenu(ctx); } } - // Close popup - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH); - ctx->popup.active = false; + // If we moved away from a submenu item to a non-submenu item, + // close any child submenus that were opened from this level + if (prevHover >= 0 && ctx->popup.menu && prevHover < ctx->popup.menu->itemCount) { + // Already handled: openSubMenu replaces the child, and if current + // item is not a submenu, no child opens. But we may still have + // a stale child — check if depth was increased by a previous hover. + // This case is handled below by the parent-level hit test popping levels. + } + } + + // Click on item in current level + if ((buttons & 1) && !(prevBtn & 1)) { + if (ctx->popup.menu && itemIdx >= 0 && itemIdx < ctx->popup.menu->itemCount) { + MenuItemT *item = &ctx->popup.menu->items[itemIdx]; + + if (item->subMenu && item->enabled) { + // Clicking a submenu item opens it (already open from hover, but ensure) + openSubMenu(ctx); + } else if (item->enabled && !item->separator) { + // Close popup BEFORE calling onMenu (onMenu may run a nested event loop) + int32_t menuId = item->id; + WindowT *win = findWindowById(ctx, ctx->popup.windowId); + closeAllPopups(ctx); + + if (win && win->onMenu) { + win->onMenu(win, menuId); + } + } + } + } + } else { + // Mouse is not in current popup — check parent levels (deepest first) + bool inParent = false; + + for (int32_t lvl = ctx->popup.depth - 1; lvl >= 0; lvl--) { + PopupLevelT *pl = &ctx->popup.parentStack[lvl]; + + if (mx >= pl->popupX && mx < pl->popupX + pl->popupW && + my >= pl->popupY && my < pl->popupY + pl->popupH) { + + // Close all levels deeper than this one + while (ctx->popup.depth > lvl + 1) { + closePopupLevel(ctx); + } + + // Now close the level that was the "current" when we entered this parent + closePopupLevel(ctx); + + // Now current level IS this parent — update hover + int32_t relY = my - ctx->popup.popupY - 2; + int32_t itemIdx = relY / ctx->font.charHeight; + + if (itemIdx < 0) { + itemIdx = 0; + } + + if (ctx->popup.menu && itemIdx >= ctx->popup.menu->itemCount) { + itemIdx = ctx->popup.menu->itemCount - 1; + } + + ctx->popup.hoverItem = itemIdx; + dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, + ctx->popup.popupW, ctx->popup.popupH); + + // If the newly hovered item has a submenu, open it + if (ctx->popup.menu && itemIdx >= 0 && itemIdx < ctx->popup.menu->itemCount) { + MenuItemT *hItem = &ctx->popup.menu->items[itemIdx]; + + if (hItem->subMenu && hItem->enabled) { + openSubMenu(ctx); + } + } + + inParent = true; + break; + } + } + + if (!inParent) { + // Check if mouse is on the menu bar (for switching top-level menus) + WindowT *win = findWindowById(ctx, ctx->popup.windowId); + + if (win && win->menuBar) { + int32_t barY = win->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT; + + if (my >= barY && my < barY + CHROME_MENU_HEIGHT && + mx >= win->x + CHROME_TOTAL_SIDE && + mx < win->x + win->w - CHROME_TOTAL_SIDE) { + + int32_t relX = mx - win->x; + + for (int32_t i = 0; i < win->menuBar->menuCount; i++) { + MenuT *menu = &win->menuBar->menus[i]; + + if (relX >= menu->barX && relX < menu->barX + menu->barW) { + if (i != ctx->popup.menuIdx || ctx->popup.depth > 0) { + openPopupAtMenu(ctx, win, i); + } + + break; + } + } + } else if ((buttons & 1) && !(prevBtn & 1)) { + // Click outside all popups and menu bar — close everything + closeAllPopups(ctx); + } + } else if ((buttons & 1) && !(prevBtn & 1)) { + closeAllPopups(ctx); + } } - } else if ((buttons & 1) && !(prevBtn & 1)) { - // Click outside popup — close it - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH); - ctx->popup.active = false; } return; @@ -632,6 +726,68 @@ static void drawCursorAt(AppContextT *ctx, int32_t x, int32_t y) { } +// ============================================================ +// drawPopupLevel — draw one popup menu (bevel + items) +// ============================================================ + +static void drawPopupLevel(AppContextT *ctx, DisplayT *d, const BlitOpsT *ops, const MenuT *menu, int32_t px, int32_t py, int32_t pw, int32_t ph, int32_t hoverItem, const RectT *clipTo) { + RectT popRect = { px, py, pw, ph }; + RectT popIsect; + + if (!rectIntersect(clipTo, &popRect, &popIsect)) { + return; + } + + // Draw popup background + BevelStyleT popBevel; + popBevel.highlight = ctx->colors.windowHighlight; + popBevel.shadow = ctx->colors.windowShadow; + popBevel.face = ctx->colors.menuBg; + popBevel.width = 2; + drawBevel(d, ops, px, py, pw, ph, &popBevel); + + // Draw menu items + int32_t itemY = py + 2; + + for (int32_t k = 0; k < menu->itemCount; k++) { + const MenuItemT *item = &menu->items[k]; + + if (item->separator) { + drawHLine(d, ops, px + 2, itemY + ctx->font.charHeight / 2, pw - 4, ctx->colors.windowShadow); + itemY += ctx->font.charHeight; + continue; + } + + uint32_t bg = ctx->colors.menuBg; + uint32_t fg = ctx->colors.menuFg; + + if (k == hoverItem) { + bg = ctx->colors.menuHighlightBg; + fg = ctx->colors.menuHighlightFg; + } + + rectFill(d, ops, px + 2, itemY, pw - 4, ctx->font.charHeight, bg); + drawTextAccel(d, ops, &ctx->font, px + CHROME_TITLE_PAD + 2, itemY, item->label, fg, bg, true); + + // Draw submenu arrow indicator + if (item->subMenu) { + int32_t arrowX = px + pw - SUBMENU_ARROW_WIDTH - 2; + int32_t arrowY = itemY + ctx->font.charHeight / 2; + + for (int32_t row = -3; row <= 3; row++) { + int32_t len = 4 - (row < 0 ? -row : row); + + if (len > 0) { + drawHLine(d, ops, arrowX + (row < 0 ? 3 + row : 3 - row), arrowY + row, len, fg); + } + } + } + + itemY += ctx->font.charHeight; + } +} + + // ============================================================ // dvxCreateWindow // ============================================================ @@ -957,8 +1113,10 @@ static void executeSysMenuCmd(AppContextT *ctx, int32_t cmd) { break; case SysMenuMinimizeE: - wmMinimize(&ctx->stack, &ctx->dirty, win); - dirtyListAdd(&ctx->dirty, 0, ctx->display.height - ICON_TOTAL_SIZE - ICON_SPACING, ctx->display.width, ICON_TOTAL_SIZE + ICON_SPACING); + if (ctx->modalWindow != win) { + wmMinimize(&ctx->stack, &ctx->dirty, win); + dirtyListAdd(&ctx->dirty, 0, ctx->display.height - ICON_TOTAL_SIZE - ICON_SPACING, ctx->display.width, ICON_TOTAL_SIZE + ICON_SPACING); + } break; case SysMenuMaximizeE: @@ -998,6 +1156,16 @@ static WindowT *findWindowById(AppContextT *ctx, int32_t id) { // ============================================================ static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t buttons) { + // Modal window gating: only the modal window receives clicks + if (ctx->modalWindow) { + int32_t hitPart; + int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart); + + if (hitIdx >= 0 && ctx->stack.windows[hitIdx] != ctx->modalWindow) { + return; + } + } + // Check for click on minimized icon first int32_t iconIdx = wmMinimizedIconHit(&ctx->stack, &ctx->display, mx, my); @@ -1084,7 +1252,6 @@ static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t case 4: // menu bar { - // Determine which menu was clicked if (!win->menuBar) { break; } @@ -1095,30 +1262,7 @@ static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t MenuT *menu = &win->menuBar->menus[i]; if (relX >= menu->barX && relX < menu->barX + menu->barW) { - // Open popup - ctx->popup.active = true; - ctx->popup.windowId = win->id; - ctx->popup.menuIdx = i; - ctx->popup.popupX = win->x + menu->barX; - ctx->popup.popupY = win->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT + CHROME_MENU_HEIGHT; - ctx->popup.hoverItem = -1; - - // Calculate popup size - int32_t maxW = 0; - - for (int32_t k = 0; k < menu->itemCount; k++) { - int32_t itemW = textWidthAccel(&ctx->font, menu->items[k].label); - - if (itemW > maxW) { - maxW = itemW; - } - } - - ctx->popup.popupW = maxW + CHROME_TITLE_PAD * 2 + 8; - ctx->popup.popupH = menu->itemCount * ctx->font.charHeight + 4; - - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH); + openPopupAtMenu(ctx, win, i); break; } } @@ -1133,12 +1277,14 @@ static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t wmScrollbarClick(&ctx->stack, &ctx->dirty, hitIdx, 1, mx, my); break; - case 7: // minimize - wmMinimize(&ctx->stack, &ctx->dirty, win); - // Dirty the icon strip area so the new icon gets drawn - dirtyListAdd(&ctx->dirty, 0, - ctx->display.height - ICON_TOTAL_SIZE - ICON_SPACING, - ctx->display.width, ICON_TOTAL_SIZE + ICON_SPACING); + case 7: // minimize (not allowed for modal windows) + if (ctx->modalWindow != win) { + wmMinimize(&ctx->stack, &ctx->dirty, win); + // Dirty the icon strip area so the new icon gets drawn + dirtyListAdd(&ctx->dirty, 0, + ctx->display.height - ICON_TOTAL_SIZE - ICON_SPACING, + ctx->display.width, ICON_TOTAL_SIZE + ICON_SPACING); + } break; case 8: // maximize / restore @@ -1222,16 +1368,92 @@ static void initMouse(AppContextT *ctx) { } +// ============================================================ +// openPopupAtMenu — open top-level popup for a menu bar menu +// ============================================================ + +static void openPopupAtMenu(AppContextT *ctx, WindowT *win, int32_t menuIdx) { + if (!win->menuBar || menuIdx < 0 || menuIdx >= win->menuBar->menuCount) { + return; + } + + // Close any existing popup chain first + closeAllPopups(ctx); + + MenuT *menu = &win->menuBar->menus[menuIdx]; + + ctx->popup.active = true; + ctx->popup.windowId = win->id; + ctx->popup.menuIdx = menuIdx; + ctx->popup.menu = menu; + ctx->popup.popupX = win->x + menu->barX; + ctx->popup.popupY = win->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT + CHROME_MENU_HEIGHT; + ctx->popup.hoverItem = -1; + ctx->popup.depth = 0; + + calcPopupSize(ctx, menu, &ctx->popup.popupW, &ctx->popup.popupH); + + dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, + ctx->popup.popupW, ctx->popup.popupH); +} + + +// ============================================================ +// openSubMenu — open submenu for the currently hovered item +// ============================================================ + +static void openSubMenu(AppContextT *ctx) { + if (!ctx->popup.active || !ctx->popup.menu) { + return; + } + + int32_t idx = ctx->popup.hoverItem; + + if (idx < 0 || idx >= ctx->popup.menu->itemCount) { + return; + } + + MenuItemT *item = &ctx->popup.menu->items[idx]; + + if (!item->subMenu) { + return; + } + + if (ctx->popup.depth >= MAX_SUBMENU_DEPTH) { + return; + } + + // Push current state to parent stack + PopupLevelT *pl = &ctx->popup.parentStack[ctx->popup.depth]; + pl->menu = ctx->popup.menu; + pl->menuIdx = ctx->popup.menuIdx; + pl->popupX = ctx->popup.popupX; + pl->popupY = ctx->popup.popupY; + pl->popupW = ctx->popup.popupW; + pl->popupH = ctx->popup.popupH; + pl->hoverItem = ctx->popup.hoverItem; + ctx->popup.depth++; + + // Open submenu at right edge of current popup, aligned with hovered item + ctx->popup.menu = item->subMenu; + ctx->popup.popupX = pl->popupX + pl->popupW - 2; + ctx->popup.popupY = pl->popupY + 2 + idx * ctx->font.charHeight; + ctx->popup.hoverItem = -1; + + calcPopupSize(ctx, ctx->popup.menu, &ctx->popup.popupW, &ctx->popup.popupH); + + dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, + ctx->popup.popupW, ctx->popup.popupH); +} + + // ============================================================ // openSysMenu // ============================================================ static void openSysMenu(AppContextT *ctx, WindowT *win) { // Close any existing popup menus first - if (ctx->popup.active) { - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH); - ctx->popup.active = false; - } + closeAllPopups(ctx); if (ctx->sysMenu.active) { closeSysMenu(ctx); @@ -1265,12 +1487,12 @@ static void openSysMenu(AppContextT *ctx, WindowT *win) { item->enabled = win->resizable && !win->maximized; item->accelKey = accelParse(item->label); - // Minimize + // Minimize — not available on modal windows item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++]; strncpy(item->label, "Mi&nimize", MAX_MENU_LABEL - 1); item->cmd = SysMenuMinimizeE; item->separator = false; - item->enabled = true; + item->enabled = !win->modal; item->accelKey = accelParse(item->label); // Maximize — only if resizable and not maximized @@ -1461,10 +1683,7 @@ static void pollKeyboard(AppContextT *ctx) { if (win->menuBar && win->menuBar->menuCount > 0) { if (ctx->popup.active) { - // F10 again closes the menu - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH); - ctx->popup.active = false; + closeAllPopups(ctx); } else { dispatchAccelKey(ctx, win->menuBar->menus[0].accelKey); } @@ -1699,22 +1918,21 @@ static void pollKeyboard(AppContextT *ctx) { // Popup menu keyboard navigation (arrows, enter, esc) if (ctx->popup.active && ascii == 0) { + MenuT *curMenu = ctx->popup.menu; + // Up arrow if (scancode == 0x48) { - WindowT *win = findWindowById(ctx, ctx->popup.windowId); + if (curMenu && curMenu->itemCount > 0) { + int32_t idx = ctx->popup.hoverItem; - if (win && win->menuBar && ctx->popup.menuIdx < win->menuBar->menuCount) { - MenuT *menu = &win->menuBar->menus[ctx->popup.menuIdx]; - int32_t idx = ctx->popup.hoverItem; - - for (int32_t tries = 0; tries < menu->itemCount; tries++) { + for (int32_t tries = 0; tries < curMenu->itemCount; tries++) { idx--; if (idx < 0) { - idx = menu->itemCount - 1; + idx = curMenu->itemCount - 1; } - if (!menu->items[idx].separator) { + if (!curMenu->items[idx].separator) { break; } } @@ -1722,25 +1940,23 @@ static void pollKeyboard(AppContextT *ctx) { ctx->popup.hoverItem = idx; dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH); } + continue; } // Down arrow if (scancode == 0x50) { - WindowT *win = findWindowById(ctx, ctx->popup.windowId); + if (curMenu && curMenu->itemCount > 0) { + int32_t idx = ctx->popup.hoverItem; - if (win && win->menuBar && ctx->popup.menuIdx < win->menuBar->menuCount) { - MenuT *menu = &win->menuBar->menus[ctx->popup.menuIdx]; - int32_t idx = ctx->popup.hoverItem; - - for (int32_t tries = 0; tries < menu->itemCount; tries++) { + for (int32_t tries = 0; tries < curMenu->itemCount; tries++) { idx++; - if (idx >= menu->itemCount) { + if (idx >= curMenu->itemCount) { idx = 0; } - if (!menu->items[idx].separator) { + if (!curMenu->items[idx].separator) { break; } } @@ -1748,27 +1964,44 @@ static void pollKeyboard(AppContextT *ctx) { ctx->popup.hoverItem = idx; dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH); } + continue; } - // Left arrow — switch to previous menu + // Left arrow — close submenu, or switch to previous top-level menu if (scancode == 0x4B) { - WindowT *win = findWindowById(ctx, ctx->popup.windowId); + if (ctx->popup.depth > 0) { + closePopupLevel(ctx); + } else { + WindowT *win = findWindowById(ctx, ctx->popup.windowId); - if (win && win->menuBar && win->menuBar->menuCount > 1) { - int32_t newIdx = ctx->popup.menuIdx - 1; + if (win && win->menuBar && win->menuBar->menuCount > 1) { + int32_t newIdx = ctx->popup.menuIdx - 1; - if (newIdx < 0) { - newIdx = win->menuBar->menuCount - 1; + if (newIdx < 0) { + newIdx = win->menuBar->menuCount - 1; + } + + openPopupAtMenu(ctx, win, newIdx); } - - dispatchAccelKey(ctx, win->menuBar->menus[newIdx].accelKey); } + continue; } - // Right arrow — switch to next menu + // Right arrow — open submenu, or switch to next top-level menu if (scancode == 0x4D) { + // If hovered item has a submenu, open it + if (curMenu && ctx->popup.hoverItem >= 0 && ctx->popup.hoverItem < curMenu->itemCount) { + MenuItemT *hItem = &curMenu->items[ctx->popup.hoverItem]; + + if (hItem->subMenu && hItem->enabled) { + openSubMenu(ctx); + continue; + } + } + + // Otherwise switch to next top-level menu WindowT *win = findWindowById(ctx, ctx->popup.windowId); if (win && win->menuBar && win->menuBar->menuCount > 1) { @@ -1778,74 +2011,77 @@ static void pollKeyboard(AppContextT *ctx) { newIdx = 0; } - dispatchAccelKey(ctx, win->menuBar->menus[newIdx].accelKey); + openPopupAtMenu(ctx, win, newIdx); } + continue; } } - // Enter executes highlighted popup menu item + // Enter executes highlighted popup menu item (or opens submenu) if (ctx->popup.active && ascii == 0x0D) { - if (ctx->popup.hoverItem >= 0) { - WindowT *win = findWindowById(ctx, ctx->popup.windowId); + MenuT *curMenu = ctx->popup.menu; - if (win && win->menuBar && ctx->popup.menuIdx < win->menuBar->menuCount) { - MenuT *menu = &win->menuBar->menus[ctx->popup.menuIdx]; + if (curMenu && ctx->popup.hoverItem >= 0 && ctx->popup.hoverItem < curMenu->itemCount) { + MenuItemT *item = &curMenu->items[ctx->popup.hoverItem]; - if (ctx->popup.hoverItem < menu->itemCount) { - MenuItemT *item = &menu->items[ctx->popup.hoverItem]; + if (item->subMenu && item->enabled) { + openSubMenu(ctx); + } else if (item->enabled && !item->separator) { + int32_t menuId = item->id; + WindowT *win = findWindowById(ctx, ctx->popup.windowId); + closeAllPopups(ctx); - if (item->enabled && !item->separator && win->onMenu) { - win->onMenu(win, item->id); - } + if (win && win->onMenu) { + win->onMenu(win, menuId); } } + } else { + closeAllPopups(ctx); } - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH); - ctx->popup.active = false; continue; } // Check for plain key accelerator in open popup menu if (ctx->popup.active && ascii != 0) { - char lc = (ascii >= 'A' && ascii <= 'Z') ? (char)(ascii + 32) : (char)ascii; + char lc = (ascii >= 'A' && ascii <= 'Z') ? (char)(ascii + 32) : (char)ascii; + MenuT *curMenu = ctx->popup.menu; - for (int32_t j = 0; j < ctx->stack.count; j++) { - if (ctx->stack.windows[j]->id == ctx->popup.windowId) { - WindowT *win = ctx->stack.windows[j]; - MenuBarT *bar = win->menuBar; + // Try matching an item in the current popup + if (curMenu) { + for (int32_t k = 0; k < curMenu->itemCount; k++) { + MenuItemT *item = &curMenu->items[k]; - if (bar && ctx->popup.menuIdx < bar->menuCount) { - MenuT *menu = &bar->menus[ctx->popup.menuIdx]; + if (item->accelKey == lc && item->enabled && !item->separator) { + if (item->subMenu) { + ctx->popup.hoverItem = k; + dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH); + openSubMenu(ctx); + } else { + int32_t menuId = item->id; + WindowT *win = findWindowById(ctx, ctx->popup.windowId); + closeAllPopups(ctx); - // Try matching an item in the current popup - for (int32_t k = 0; k < menu->itemCount; k++) { - MenuItemT *item = &menu->items[k]; - - if (item->accelKey == lc && item->enabled && !item->separator) { - if (win->onMenu) { - win->onMenu(win, item->id); - } - - // Close popup - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH); - ctx->popup.active = false; - goto nextKey; + if (win && win->onMenu) { + win->onMenu(win, menuId); } } - // No match in current popup — try switching to another menu - for (int32_t i = 0; i < bar->menuCount; i++) { - if (bar->menus[i].accelKey == lc && i != ctx->popup.menuIdx) { - dispatchAccelKey(ctx, lc); - goto nextKey; - } - } + goto nextKey; } + } + } - break; + // No match in current popup — try switching to another top-level menu + WindowT *win = findWindowById(ctx, ctx->popup.windowId); + + if (win && win->menuBar) { + for (int32_t i = 0; i < win->menuBar->menuCount; i++) { + if (win->menuBar->menus[i].accelKey == lc && i != ctx->popup.menuIdx) { + openPopupAtMenu(ctx, win, i); + goto nextKey; + } } } } @@ -1874,11 +2110,9 @@ static void pollKeyboard(AppContextT *ctx) { continue; } - // ESC closes popup menu + // ESC closes one popup level (or all if at top level) if (ctx->popup.active && ascii == 0x1B) { - dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, - ctx->popup.popupW, ctx->popup.popupH); - ctx->popup.active = false; + closePopupLevel(ctx); continue; } @@ -2101,6 +2335,10 @@ static void updateCursorShape(AppContextT *ctx) { newCursor = CURSOR_RESIZE_V; } } + // Active ListView column resize drag + else if (sResizeListView) { + newCursor = CURSOR_RESIZE_H; + } // Not in an active drag/resize — check what we're hovering else if (ctx->stack.dragWindow < 0 && ctx->stack.scrollWindow < 0) { int32_t hitPart; @@ -2125,6 +2363,24 @@ static void updateCursorShape(AppContextT *ctx) { } else if (vert) { newCursor = CURSOR_RESIZE_V; } + } else if (hitIdx >= 0 && hitPart == 0) { + // Hovering over content area — check for ListView column border + WindowT *win = ctx->stack.windows[hitIdx]; + + if (win->widgetRoot) { + int32_t cx = mx - win->x - win->contentX; + int32_t cy = my - win->y - win->contentY; + int32_t scrollX = win->hScroll ? win->hScroll->value : 0; + int32_t scrollY = win->vScroll ? win->vScroll->value : 0; + int32_t vx = cx + scrollX; + int32_t vy = cy + scrollY; + + WidgetT *hit = widgetHitTest(win->widgetRoot, vx, vy); + + if (hit && hit->type == WidgetListViewE && widgetListViewColBorderHit(hit, vx, vy)) { + newCursor = CURSOR_RESIZE_H; + } + } } } diff --git a/dvx/dvxApp.h b/dvx/dvxApp.h index 20c0661..9eb5ef7 100644 --- a/dvx/dvxApp.h +++ b/dvx/dvxApp.h @@ -43,6 +43,7 @@ typedef struct AppContextT { int32_t frameCount; // frame counter for periodic tasks void (*idleCallback)(void *ctx); // called instead of yield when non-NULL void *idleCtx; + WindowT *modalWindow; // if non-NULL, only this window receives input } AppContextT; // Initialize the application (VESA mode, input, etc.) diff --git a/dvx/dvxDialog.c b/dvx/dvxDialog.c new file mode 100644 index 0000000..9f4d61d --- /dev/null +++ b/dvx/dvxDialog.c @@ -0,0 +1,486 @@ +// dvxDialog.c — Modal dialogs for DV/X GUI + +#include "dvxDialog.h" +#include "dvxWidget.h" +#include "widgets/widgetInternal.h" + +#include + +// ============================================================ +// Constants +// ============================================================ + +#define MSG_MAX_WIDTH 320 +#define MSG_PADDING 8 +#define ICON_AREA_WIDTH 40 +#define BUTTON_WIDTH 80 +#define BUTTON_HEIGHT 24 +#define BUTTON_GAP 8 + +// ============================================================ +// Prototypes +// ============================================================ + +static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color); +static void onButtonClick(WidgetT *w); +static void onMsgBoxClose(WindowT *win); +static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea); +static int32_t wordWrapHeight(const BitmapFontT *font, const char *text, int32_t maxW); +static void wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t maxW, uint32_t fg, uint32_t bg); + +// ============================================================ +// Message box state (one active at a time) +// ============================================================ + +typedef struct { + AppContextT *ctx; + int32_t result; + bool done; + const char *message; + int32_t iconType; + int32_t textX; + int32_t textY; + int32_t textMaxW; + int32_t msgAreaH; +} MsgBoxStateT; + +static MsgBoxStateT sMsgBox; + + +// ============================================================ +// drawIconGlyph — draw a simple icon shape +// ============================================================ + +static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color) { + if (iconType == MB_ICONINFO) { + // Circle outline with 'i' + for (int32_t row = 0; row < 24; row++) { + for (int32_t col = 0; col < 24; col++) { + int32_t dx = col - 12; + int32_t dy = row - 12; + int32_t d2 = dx * dx + dy * dy; + + if (d2 <= 144 && d2 >= 100) { + rectFill(d, ops, x + col, y + row, 1, 1, color); + } + } + } + + rectFill(d, ops, x + 11, y + 6, 2, 2, color); + rectFill(d, ops, x + 11, y + 10, 2, 8, color); + } else if (iconType == MB_ICONWARNING) { + // Triangle outline with '!' + for (int32_t row = 0; row < 24; row++) { + int32_t halfW = row / 2; + int32_t lx = 12 - halfW; + int32_t rx = 12 + halfW; + + rectFill(d, ops, x + lx, y + row, 1, 1, color); + rectFill(d, ops, x + rx, y + row, 1, 1, color); + + if (row == 23) { + drawHLine(d, ops, x + lx, y + row, rx - lx + 1, color); + } + } + + rectFill(d, ops, x + 11, y + 8, 2, 9, color); + rectFill(d, ops, x + 11, y + 19, 2, 2, color); + } else if (iconType == MB_ICONERROR) { + // Circle outline with X + for (int32_t row = 0; row < 24; row++) { + for (int32_t col = 0; col < 24; col++) { + int32_t dx = col - 12; + int32_t dy = row - 12; + int32_t d2 = dx * dx + dy * dy; + + if (d2 <= 144 && d2 >= 100) { + rectFill(d, ops, x + col, y + row, 1, 1, color); + } + } + } + + for (int32_t i = 0; i < 12; i++) { + rectFill(d, ops, x + 6 + i, y + 6 + i, 2, 2, color); + rectFill(d, ops, x + 18 - i, y + 6 + i, 2, 2, color); + } + } else if (iconType == MB_ICONQUESTION) { + // Circle outline with '?' + for (int32_t row = 0; row < 24; row++) { + for (int32_t col = 0; col < 24; col++) { + int32_t dx = col - 12; + int32_t dy = row - 12; + int32_t d2 = dx * dx + dy * dy; + + if (d2 <= 144 && d2 >= 100) { + rectFill(d, ops, x + col, y + row, 1, 1, color); + } + } + } + + rectFill(d, ops, x + 9, y + 6, 6, 2, color); + rectFill(d, ops, x + 13, y + 8, 2, 4, color); + rectFill(d, ops, x + 11, y + 12, 2, 3, color); + rectFill(d, ops, x + 11, y + 17, 2, 2, color); + } +} + + +// ============================================================ +// dvxMessageBox +// ============================================================ + +int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags) { + int32_t btnFlags = flags & 0x000F; + int32_t iconFlags = flags & 0x00F0; + + // Determine button labels and IDs + const char *btnLabels[3]; + int32_t btnIds[3]; + int32_t btnCount = 0; + + switch (btnFlags) { + case MB_OK: + btnLabels[0] = "&OK"; + btnIds[0] = ID_OK; + btnCount = 1; + break; + + case MB_OKCANCEL: + btnLabels[0] = "&OK"; + btnIds[0] = ID_OK; + btnLabels[1] = "&Cancel"; + btnIds[1] = ID_CANCEL; + btnCount = 2; + break; + + case MB_YESNO: + btnLabels[0] = "&Yes"; + btnIds[0] = ID_YES; + btnLabels[1] = "&No"; + btnIds[1] = ID_NO; + btnCount = 2; + break; + + case MB_YESNOCANCEL: + btnLabels[0] = "&Yes"; + btnIds[0] = ID_YES; + btnLabels[1] = "&No"; + btnIds[1] = ID_NO; + btnLabels[2] = "&Cancel"; + btnIds[2] = ID_CANCEL; + btnCount = 3; + break; + + case MB_RETRYCANCEL: + btnLabels[0] = "&Retry"; + btnIds[0] = ID_RETRY; + btnLabels[1] = "&Cancel"; + btnIds[1] = ID_CANCEL; + btnCount = 2; + break; + + default: + btnLabels[0] = "&OK"; + btnIds[0] = ID_OK; + btnCount = 1; + break; + } + + // Calculate message text dimensions + bool hasIcon = (iconFlags != 0); + int32_t textMaxW = MSG_MAX_WIDTH - MSG_PADDING * 2 - (hasIcon ? ICON_AREA_WIDTH : 0); + int32_t textH = wordWrapHeight(&ctx->font, message, textMaxW); + + // Calculate content area sizes + int32_t msgAreaH = textH + MSG_PADDING * 2; + int32_t iconAreaH = hasIcon ? (24 + MSG_PADDING * 2) : 0; + + if (msgAreaH < iconAreaH) { + msgAreaH = iconAreaH; + } + + int32_t buttonsW = btnCount * BUTTON_WIDTH + (btnCount - 1) * BUTTON_GAP; + int32_t contentW = MSG_MAX_WIDTH; + + if (buttonsW + MSG_PADDING * 2 > contentW) { + contentW = buttonsW + MSG_PADDING * 2; + } + + int32_t contentH = msgAreaH + BUTTON_HEIGHT + MSG_PADDING * 3; + + // Create the dialog window (non-resizable) + int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE; + int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP; + + WindowT *win = dvxCreateWindow(ctx, title, winX, winY, + contentW + CHROME_TOTAL_SIDE * 2, + contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM, + false); + + if (!win) { + return ID_CANCEL; + } + + win->modal = true; + + // Set up state + sMsgBox.ctx = ctx; + sMsgBox.result = ID_CANCEL; + sMsgBox.done = false; + sMsgBox.message = message; + sMsgBox.iconType = iconFlags; + sMsgBox.textX = MSG_PADDING + (hasIcon ? ICON_AREA_WIDTH : 0); + sMsgBox.textY = MSG_PADDING; + sMsgBox.textMaxW = textMaxW; + sMsgBox.msgAreaH = msgAreaH; + + // Create button widgets using wgtInitWindow for proper root setup + // (sets onPaint, onMouse, onKey, onResize, userData on root) + WidgetT *root = wgtInitWindow(ctx, win); + + // Override onPaint with our custom handler, set window-level state + win->userData = &sMsgBox; + win->onPaint = onMsgBoxPaint; + win->onClose = onMsgBoxClose; + win->maxW = win->w; + win->maxH = win->h; + + if (root) { + // Spacer for message area (text/icon drawn by onPaint) + WidgetT *msgSpacer = wgtSpacer(root); + + if (msgSpacer) { + msgSpacer->minH = wgtPixels(msgAreaH); + } + + // Button row centered + WidgetT *btnRow = wgtHBox(root); + + if (btnRow) { + btnRow->align = AlignCenterE; + + for (int32_t i = 0; i < btnCount; i++) { + WidgetT *btn = wgtButton(btnRow, btnLabels[i]); + + if (btn) { + btn->minW = wgtPixels(BUTTON_WIDTH); + btn->minH = wgtPixels(BUTTON_HEIGHT); + btn->userData = (void *)(intptr_t)btnIds[i]; + btn->onClick = onButtonClick; + } + } + } + + // Bottom padding + WidgetT *bottomSpacer = wgtSpacer(root); + + if (bottomSpacer) { + bottomSpacer->minH = wgtPixels(MSG_PADDING); + } + } + + // Initial paint (window is already correctly sized, don't call dvxFitWindow) + RectT fullRect = { 0, 0, win->contentW, win->contentH }; + win->onPaint(win, &fullRect); + + // Set as modal + ctx->modalWindow = win; + + // Nested event loop + while (!sMsgBox.done && ctx->running) { + dvxUpdate(ctx); + } + + // Clean up + ctx->modalWindow = NULL; + dvxDestroyWindow(ctx, win); + + return sMsgBox.result; +} + + +// ============================================================ +// onButtonClick +// ============================================================ + +static void onButtonClick(WidgetT *w) { + sMsgBox.result = (int32_t)(intptr_t)w->userData; + sMsgBox.done = true; +} + + +// ============================================================ +// onMsgBoxClose +// ============================================================ + +static void onMsgBoxClose(WindowT *win) { + (void)win; + sMsgBox.result = ID_CANCEL; + sMsgBox.done = true; +} + + +// ============================================================ +// onMsgBoxPaint — custom paint: background + text/icon + widgets +// ============================================================ + +static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea) { + (void)dirtyArea; + + MsgBoxStateT *state = (MsgBoxStateT *)win->userData; + AppContextT *ctx = state->ctx; + + // Set up display context pointing at content buffer + DisplayT cd = ctx->display; + cd.lfb = win->contentBuf; + 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; + + // Fill background with window face color (not content bg — dialog style) + rectFill(&cd, &ctx->blitOps, 0, 0, win->contentW, win->contentH, ctx->colors.windowFace); + + // Draw word-wrapped message text + wordWrapDraw(&cd, &ctx->blitOps, &ctx->font, state->textX, state->textY, state->message, state->textMaxW, ctx->colors.contentFg, ctx->colors.windowFace); + + // Draw icon + if (state->iconType != 0) { + drawIconGlyph(&cd, &ctx->blitOps, MSG_PADDING, MSG_PADDING, state->iconType, ctx->colors.contentFg); + } + + // Draw separator line above buttons + drawHLine(&cd, &ctx->blitOps, 0, state->msgAreaH, win->contentW, ctx->colors.windowShadow); + drawHLine(&cd, &ctx->blitOps, 0, state->msgAreaH + 1, win->contentW, ctx->colors.windowHighlight); + + // Paint widget tree (buttons) on top + if (win->widgetRoot) { + WidgetT *root = win->widgetRoot; + + // Layout widgets + widgetCalcMinSizeTree(root, &ctx->font); + root->x = 0; + root->y = 0; + root->w = win->contentW; + root->h = win->contentH; + widgetLayoutChildren(root, &ctx->font); + + // Paint widgets + wgtPaint(root, &cd, &ctx->blitOps, &ctx->font, &ctx->colors); + } +} + + +// ============================================================ +// wordWrapDraw — draw word-wrapped text +// ============================================================ + +static void wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t maxW, uint32_t fg, uint32_t bg) { + int32_t charW = font->charWidth; + int32_t lineH = font->charHeight; + int32_t maxChars = maxW / charW; + int32_t curY = y; + + if (maxChars < 1) { + maxChars = 1; + } + + while (*text) { + // Skip leading spaces + while (*text == ' ') { + text++; + } + + if (*text == '\0') { + break; + } + + // Handle explicit newlines + if (*text == '\n') { + curY += lineH; + text++; + continue; + } + + // Find how many characters fit on this line + int32_t lineLen = 0; + int32_t lastSpace = -1; + + while (text[lineLen] && text[lineLen] != '\n' && lineLen < maxChars) { + if (text[lineLen] == ' ') { + lastSpace = lineLen; + } + + lineLen++; + } + + // If we didn't reach end and didn't hit newline, wrap at word boundary + if (text[lineLen] && text[lineLen] != '\n' && lastSpace > 0) { + lineLen = lastSpace; + } + + drawTextN(d, ops, font, x, curY, text, lineLen, fg, bg, true); + curY += lineH; + text += lineLen; + } +} + + +// ============================================================ +// wordWrapHeight — compute height of word-wrapped text +// ============================================================ + +static int32_t wordWrapHeight(const BitmapFontT *font, const char *text, int32_t maxW) { + int32_t charW = font->charWidth; + int32_t lineH = font->charHeight; + int32_t maxChars = maxW / charW; + int32_t lines = 0; + + if (maxChars < 1) { + maxChars = 1; + } + + while (*text) { + while (*text == ' ') { + text++; + } + + if (*text == '\0') { + break; + } + + if (*text == '\n') { + lines++; + text++; + continue; + } + + int32_t lineLen = 0; + int32_t lastSpace = -1; + + while (text[lineLen] && text[lineLen] != '\n' && lineLen < maxChars) { + if (text[lineLen] == ' ') { + lastSpace = lineLen; + } + + lineLen++; + } + + if (text[lineLen] && text[lineLen] != '\n' && lastSpace > 0) { + lineLen = lastSpace; + } + + lines++; + text += lineLen; + } + + if (lines == 0) { + lines = 1; + } + + return lines * lineH; +} diff --git a/dvx/dvxDialog.h b/dvx/dvxDialog.h new file mode 100644 index 0000000..72acea9 --- /dev/null +++ b/dvx/dvxDialog.h @@ -0,0 +1,40 @@ +// dvxDialog.h — Modal dialogs for DV/X GUI +#ifndef DVX_DIALOG_H +#define DVX_DIALOG_H + +#include "dvxApp.h" + +// ============================================================ +// Message box button flags +// ============================================================ + +#define MB_OK 0x0000 +#define MB_OKCANCEL 0x0001 +#define MB_YESNO 0x0002 +#define MB_YESNOCANCEL 0x0003 +#define MB_RETRYCANCEL 0x0004 + +// ============================================================ +// Message box icon flags +// ============================================================ + +#define MB_ICONINFO 0x0010 +#define MB_ICONWARNING 0x0020 +#define MB_ICONERROR 0x0030 +#define MB_ICONQUESTION 0x0040 + +// ============================================================ +// Message box return values +// ============================================================ + +#define ID_OK 1 +#define ID_CANCEL 2 +#define ID_YES 3 +#define ID_NO 4 +#define ID_RETRY 5 + +// Show a modal message box and return which button was pressed. +// flags = MB_xxx button flag | MB_ICONxxx icon flag +int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags); + +#endif // DVX_DIALOG_H diff --git a/dvx/dvxTypes.h b/dvx/dvxTypes.h index 5f80c5c..81b41e5 100644 --- a/dvx/dvxTypes.h +++ b/dvx/dvxTypes.h @@ -152,6 +152,9 @@ typedef struct { #define MAX_MENUS 8 #define MAX_MENU_LABEL 32 +// Forward declaration for submenu pointers +typedef struct MenuT MenuT; + typedef struct { char label[MAX_MENU_LABEL]; int32_t id; // application-defined command ID @@ -159,16 +162,17 @@ typedef struct { bool enabled; bool checked; char accelKey; // lowercase accelerator character, 0 if none + MenuT *subMenu; // child menu for cascading submenus (NULL if leaf item) } MenuItemT; -typedef struct { +struct MenuT { char label[MAX_MENU_LABEL]; // menu bar label (e.g. "File") MenuItemT items[MAX_MENU_ITEMS]; int32_t itemCount; int32_t barX; // computed position on menu bar int32_t barW; // computed width on menu bar char accelKey; // lowercase accelerator character, 0 if none -} MenuT; +}; typedef struct { MenuT menus[MAX_MENUS]; @@ -234,6 +238,7 @@ typedef struct WindowT { bool minimized; bool maximized; bool resizable; + bool modal; bool contentDirty; // true when contentBuf has changed since last icon refresh int32_t maxW; // maximum width (-1 = screen width) int32_t maxH; // maximum height (-1 = screen height) @@ -305,18 +310,34 @@ typedef struct { } CursorT; // ============================================================ -// Popup state for dropdown menus +// Popup state for dropdown menus (with cascading submenu support) // ============================================================ +#define MAX_SUBMENU_DEPTH 4 + +// Saved parent popup state when a submenu is open typedef struct { - bool active; - int32_t windowId; // which window owns this popup - int32_t menuIdx; // which menu is open - int32_t popupX; // screen position of popup + MenuT *menu; + int32_t menuIdx; + int32_t popupX; int32_t popupY; int32_t popupW; int32_t popupH; - int32_t hoverItem; // which item is highlighted (-1 = none) + int32_t hoverItem; +} PopupLevelT; + +typedef struct { + bool active; + int32_t windowId; // which window owns this popup chain + int32_t menuIdx; // which menu bar menu is open (top level) + int32_t popupX; // screen position of current (deepest) popup + int32_t popupY; + int32_t popupW; + int32_t popupH; + int32_t hoverItem; // highlighted item in current popup (-1 = none) + MenuT *menu; // direct pointer to current menu (avoids lookup for submenus) + int32_t depth; // 0 = top-level only, 1+ = submenu depth + PopupLevelT parentStack[MAX_SUBMENU_DEPTH]; } PopupStateT; // ============================================================ diff --git a/dvx/dvxVideo.c b/dvx/dvxVideo.c index 7c9264f..ced9665 100644 --- a/dvx/dvxVideo.c +++ b/dvx/dvxVideo.c @@ -292,10 +292,18 @@ void resetClipRect(DisplayT *d) { // ============================================================ void setClipRect(DisplayT *d, int32_t x, int32_t y, int32_t w, int32_t h) { + int32_t x2 = x + w; + int32_t y2 = y + h; + + if (x < 0) { x = 0; } + if (y < 0) { y = 0; } + if (x2 > d->width) { x2 = d->width; } + if (y2 > d->height) { y2 = d->height; } + d->clipX = x; d->clipY = y; - d->clipW = w; - d->clipH = h; + d->clipW = (x2 > x) ? x2 - x : 0; + d->clipH = (y2 > y) ? y2 - y : 0; } diff --git a/dvx/dvxWidget.h b/dvx/dvxWidget.h index 40a796f..8fcf93a 100644 --- a/dvx/dvxWidget.h +++ b/dvx/dvxWidget.h @@ -67,9 +67,34 @@ typedef enum { WidgetImageE, WidgetImageButtonE, WidgetCanvasE, - WidgetAnsiTermE + WidgetAnsiTermE, + WidgetListViewE } WidgetTypeE; +// ============================================================ +// ListView types +// ============================================================ + +#define LISTVIEW_MAX_COLS 16 + +typedef enum { + ListViewAlignLeftE, + ListViewAlignCenterE, + ListViewAlignRightE +} ListViewAlignE; + +typedef enum { + ListViewSortNoneE, + ListViewSortAscE, + ListViewSortDescE +} ListViewSortE; + +typedef struct { + const char *title; + int32_t width; // tagged size (wgtPixels/wgtChars/wgtPercent, 0 = auto) + ListViewAlignE align; +} ListViewColT; + // ============================================================ // Alignment enum // ============================================================ @@ -376,6 +401,22 @@ typedef struct WidgetT { int32_t (*commRead)(void *ctx, uint8_t *buf, int32_t maxLen); int32_t (*commWrite)(void *ctx, const uint8_t *buf, int32_t len); } ansiTerm; + + struct { + const ListViewColT *cols; + int32_t colCount; + const char **cellData; + int32_t rowCount; + int32_t selectedIdx; + int32_t scrollPos; + int32_t scrollPosH; + int32_t sortCol; + ListViewSortE sortDir; + int32_t resolvedColW[LISTVIEW_MAX_COLS]; + int32_t totalColW; + int32_t *sortIndex; + void (*onHeaderClick)(struct WidgetT *w, int32_t col, ListViewSortE dir); + } listView; } as; } WidgetT; @@ -485,6 +526,18 @@ WidgetT *wgtTreeItem(WidgetT *parent, const char *text); void wgtTreeItemSetExpanded(WidgetT *w, bool expanded); bool wgtTreeItemIsExpanded(const WidgetT *w); +// ============================================================ +// ListView (multi-column list) +// ============================================================ + +WidgetT *wgtListView(WidgetT *parent); +void wgtListViewSetColumns(WidgetT *w, const ListViewColT *cols, int32_t count); +void wgtListViewSetData(WidgetT *w, const char **cellData, int32_t rowCount); +int32_t wgtListViewGetSelected(const WidgetT *w); +void wgtListViewSetSelected(WidgetT *w, int32_t idx); +void wgtListViewSetSort(WidgetT *w, int32_t col, ListViewSortE dir); +void wgtListViewSetHeaderClickCallback(WidgetT *w, void (*cb)(WidgetT *w, int32_t col, ListViewSortE dir)); + // ============================================================ // ImageButton // ============================================================ diff --git a/dvx/dvxWm.c b/dvx/dvxWm.c index 6663abc..4921856 100644 --- a/dvx/dvxWm.c +++ b/dvx/dvxWm.c @@ -41,6 +41,7 @@ typedef struct { static void computeMenuBarPositions(WindowT *win, const BitmapFontT *font); static void computeTitleGeom(const WindowT *win, TitleGeomT *g); +static void freeMenuRecursive(MenuT *menu); static void drawBorderFrame(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, int32_t x, int32_t y, int32_t w, int32_t h); static void drawMenuBar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, WindowT *win); static void drawResizeBreaks(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, WindowT *win); @@ -104,11 +105,16 @@ static void computeTitleGeom(const WindowT *win, TitleGeomT *g) { g->maxX = -1; } - g->minX = rightX; + if (win->modal) { + g->minX = -1; + } else { + g->minX = rightX; + rightX -= g->gadgetS + GADGET_PAD; + } - // Text area between close gadget and minimize gadget + // Text area between close gadget and first right-side gadget g->textLeftEdge = g->closeX + g->gadgetS + GADGET_PAD; - g->textRightEdge = g->minX - GADGET_PAD; + g->textRightEdge = rightX + g->gadgetS; } @@ -468,12 +474,14 @@ static void drawTitleBar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *fo } } - // Minimize gadget (always present) - drawTitleGadget(d, ops, colors, g.minX, g.gadgetY, g.gadgetS); - // Small square centered in minimize gadget - int32_t miniSize = 4; - rectFill(d, ops, g.minX + (g.gadgetS - miniSize) / 2, g.gadgetY + (g.gadgetS - miniSize) / 2, - miniSize, miniSize, colors->contentFg); + // Minimize gadget (not on modal windows) + if (g.minX >= 0) { + drawTitleGadget(d, ops, colors, g.minX, g.gadgetY, g.gadgetS); + // Small square centered in minimize gadget + int32_t miniSize = 4; + rectFill(d, ops, g.minX + (g.gadgetS - miniSize) / 2, g.gadgetY + (g.gadgetS - miniSize) / 2, + miniSize, miniSize, colors->contentFg); + } // Title text — centered between close gadget and minimize, truncated to fit int32_t availW = g.textRightEdge - g.textLeftEdge; @@ -502,6 +510,21 @@ static void drawTitleBar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *fo } +// ============================================================ +// freeMenuRecursive +// ============================================================ + +static void freeMenuRecursive(MenuT *menu) { + for (int32_t i = 0; i < menu->itemCount; i++) { + if (menu->items[i].subMenu) { + freeMenuRecursive(menu->items[i].subMenu); + free(menu->items[i].subMenu); + menu->items[i].subMenu = NULL; + } + } +} + + // ============================================================ // minimizedIconPos // ============================================================ @@ -639,6 +662,36 @@ void wmAddMenuSeparator(MenuT *menu) { } +// ============================================================ +// wmAddSubMenu +// ============================================================ + +MenuT *wmAddSubMenu(MenuT *parentMenu, const char *label) { + if (parentMenu->itemCount >= MAX_MENU_ITEMS) { + return NULL; + } + + MenuT *child = (MenuT *)calloc(1, sizeof(MenuT)); + + if (!child) { + return NULL; + } + + MenuItemT *item = &parentMenu->items[parentMenu->itemCount]; + memset(item, 0, sizeof(*item)); + strncpy(item->label, label, MAX_MENU_LABEL - 1); + item->label[MAX_MENU_LABEL - 1] = '\0'; + item->id = -1; + item->separator = false; + item->enabled = true; + item->accelKey = accelParse(label); + item->subMenu = child; + parentMenu->itemCount++; + + return child; +} + + // ============================================================ // wmAddVScrollbar // ============================================================ @@ -756,6 +809,10 @@ void wmDestroyWindow(WindowStackT *stack, WindowT *win) { } if (win->menuBar) { + for (int32_t i = 0; i < win->menuBar->menuCount; i++) { + freeMenuRecursive(&win->menuBar->menus[i]); + } + free(win->menuBar); } @@ -1026,8 +1083,9 @@ int32_t wmHitTest(const WindowStackT *stack, int32_t mx, int32_t my, int32_t *hi return i; } - // Minimize gadget (always present) - if (mx >= g.minX && mx < g.minX + g.gadgetS && + // Minimize gadget (not on modal windows) + if (g.minX >= 0 && + mx >= g.minX && mx < g.minX + g.gadgetS && my >= g.gadgetY && my < g.gadgetY + g.gadgetS) { *hitPart = 7; return i; diff --git a/dvx/dvxWm.h b/dvx/dvxWm.h index 50f50f2..8e9065d 100644 --- a/dvx/dvxWm.h +++ b/dvx/dvxWm.h @@ -37,6 +37,9 @@ void wmAddMenuItem(MenuT *menu, const char *label, int32_t id); // Add a separator to a menu void wmAddMenuSeparator(MenuT *menu); +// Add a submenu item (returns the child MenuT to populate, or NULL on failure) +MenuT *wmAddSubMenu(MenuT *parentMenu, const char *label); + // Add a vertical scrollbar to a window ScrollbarT *wmAddVScrollbar(WindowT *win, int32_t min, int32_t max, int32_t pageSize); diff --git a/dvx/widgets/widgetClass.c b/dvx/widgets/widgetClass.c index b510d4a..b4bf272 100644 --- a/dvx/widgets/widgetClass.c +++ b/dvx/widgets/widgetClass.c @@ -344,6 +344,19 @@ static const WidgetClassT sClassCanvas = { .setText = NULL }; +static const WidgetClassT sClassListView = { + .flags = WCLASS_FOCUSABLE | WCLASS_NO_HIT_RECURSE, + .paint = widgetListViewPaint, + .paintOverlay = NULL, + .calcMinSize = widgetListViewCalcMinSize, + .layout = NULL, + .onMouse = widgetListViewOnMouse, + .onKey = widgetListViewOnKey, + .destroy = widgetListViewDestroy, + .getText = NULL, + .setText = NULL +}; + static const WidgetClassT sClassAnsiTerm = { .flags = WCLASS_FOCUSABLE, .paint = widgetAnsiTermPaint, @@ -388,5 +401,6 @@ const WidgetClassT *widgetClassTable[] = { [WidgetImageE] = &sClassImage, [WidgetImageButtonE] = &sClassImageButton, [WidgetCanvasE] = &sClassCanvas, - [WidgetAnsiTermE] = &sClassAnsiTerm + [WidgetAnsiTermE] = &sClassAnsiTerm, + [WidgetListViewE] = &sClassListView }; diff --git a/dvx/widgets/widgetCore.c b/dvx/widgets/widgetCore.c index aec52ad..8031a40 100644 --- a/dvx/widgets/widgetCore.c +++ b/dvx/widgets/widgetCore.c @@ -14,6 +14,10 @@ WidgetT *sDragSlider = NULL; WidgetT *sDrawingCanvas = NULL; WidgetT *sDragTextSelect = NULL; int32_t sDragOffset = 0; +WidgetT *sResizeListView = NULL; +int32_t sResizeCol = -1; +int32_t sResizeStartX = 0; +int32_t sResizeOrigW = 0; // ============================================================ @@ -130,6 +134,11 @@ void widgetDestroyChildren(WidgetT *w) { sDrawingCanvas = NULL; } + if (sResizeListView == child) { + sResizeListView = NULL; + sResizeCol = -1; + } + free(child); child = next; } diff --git a/dvx/widgets/widgetEvent.c b/dvx/widgets/widgetEvent.c index 2a2ef46..503ef7a 100644 --- a/dvx/widgets/widgetEvent.c +++ b/dvx/widgets/widgetEvent.c @@ -249,6 +249,40 @@ void widgetOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) { return; } + // Handle ListView column resize release + if (sResizeListView && !(buttons & 1)) { + sResizeListView = NULL; + sResizeCol = -1; + return; + } + + // Handle ListView column resize drag + if (sResizeListView && (buttons & 1)) { + int32_t scrollX = win->hScroll ? win->hScroll->value : 0; + int32_t delta = (x + scrollX) - sResizeStartX; + int32_t newW = sResizeOrigW + delta; + + if (newW < 20) { + newW = 20; + } + + if (newW != sResizeListView->as.listView.resolvedColW[sResizeCol]) { + sResizeListView->as.listView.resolvedColW[sResizeCol] = newW; + + // Recalculate totalColW + int32_t total = 0; + + for (int32_t c = 0; c < sResizeListView->as.listView.colCount; c++) { + total += sResizeListView->as.listView.resolvedColW[c]; + } + + sResizeListView->as.listView.totalColW = total; + wgtInvalidatePaint(root); + } + + return; + } + // Handle button press release if (sPressedButton && !(buttons & 1)) { if (sPressedButton->type == WidgetImageButtonE) { diff --git a/dvx/widgets/widgetInternal.h b/dvx/widgets/widgetInternal.h index 525300b..fc30499 100644 --- a/dvx/widgets/widgetInternal.h +++ b/dvx/widgets/widgetInternal.h @@ -95,6 +95,10 @@ extern WidgetT *sDragSlider; extern WidgetT *sDrawingCanvas; extern WidgetT *sDragTextSelect; extern int32_t sDragOffset; +extern WidgetT *sResizeListView; +extern int32_t sResizeCol; +extern int32_t sResizeStartX; +extern int32_t sResizeOrigW; // ============================================================ // Core functions (widgetCore.c) @@ -161,6 +165,8 @@ void widgetFramePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitmap void widgetImagePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetLabelPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetListBoxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); +void widgetListViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); +bool widgetListViewColBorderHit(const WidgetT *w, int32_t vx, int32_t vy); void widgetProgressBarPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetRadioPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); void widgetSeparatorPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors); @@ -186,6 +192,7 @@ void widgetDropdownCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetImageCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetLabelCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetListBoxCalcMinSize(WidgetT *w, const BitmapFontT *font); +void widgetListViewCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetProgressBarCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetRadioCalcMinSize(WidgetT *w, const BitmapFontT *font); void widgetSeparatorCalcMinSize(WidgetT *w, const BitmapFontT *font); @@ -234,6 +241,7 @@ void widgetCanvasDestroy(WidgetT *w); void widgetComboBoxDestroy(WidgetT *w); void widgetImageButtonDestroy(WidgetT *w); void widgetImageDestroy(WidgetT *w); +void widgetListViewDestroy(WidgetT *w); void widgetTextAreaDestroy(WidgetT *w); void widgetTextInputDestroy(WidgetT *w); @@ -262,6 +270,8 @@ void widgetImageButtonOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) void widgetImageOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); void widgetListBoxOnKey(WidgetT *w, int32_t key, int32_t mod); void widgetListBoxOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); +void widgetListViewOnKey(WidgetT *w, int32_t key, int32_t mod); +void widgetListViewOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); void widgetRadioOnKey(WidgetT *w, int32_t key, int32_t mod); void widgetRadioOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy); void widgetSliderOnKey(WidgetT *w, int32_t key, int32_t mod); diff --git a/dvx/widgets/widgetListView.c b/dvx/widgets/widgetListView.c new file mode 100644 index 0000000..c533b66 --- /dev/null +++ b/dvx/widgets/widgetListView.c @@ -0,0 +1,1009 @@ +// widgetListView.c — ListView (multi-column list) widget + +#include "widgetInternal.h" + +#include +#include + +#define LISTVIEW_BORDER 2 +#define LISTVIEW_PAD 3 +#define LISTVIEW_SB_W 14 +#define LISTVIEW_MIN_ROWS 4 +#define LISTVIEW_COL_PAD 6 +#define LISTVIEW_SORT_W 10 + + +// ============================================================ +// Prototypes +// ============================================================ + +static void drawListViewHScrollbar(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, int32_t sbX, int32_t sbY, int32_t sbW, int32_t totalW, int32_t visibleW); +static void drawListViewVScrollbar(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, int32_t sbX, int32_t sbY, int32_t sbH, int32_t totalRows, int32_t visibleRows); +static void listViewBuildSortIndex(WidgetT *w); +static void resolveColumnWidths(WidgetT *w, const BitmapFontT *font); + + +// ============================================================ +// drawListViewHScrollbar +// ============================================================ + +static void drawListViewHScrollbar(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, int32_t sbX, int32_t sbY, int32_t sbW, int32_t totalW, int32_t visibleW) { + if (sbW < LISTVIEW_SB_W * 3) { + return; + } + + // Trough background + BevelStyleT troughBevel = BEVEL_TROUGH(colors); + drawBevel(d, ops, sbX, sbY, sbW, LISTVIEW_SB_W, &troughBevel); + + // Left arrow button + BevelStyleT btnBevel = BEVEL_RAISED(colors, 1); + drawBevel(d, ops, sbX, sbY, LISTVIEW_SB_W, LISTVIEW_SB_W, &btnBevel); + + // Left arrow triangle + { + int32_t cx = sbX + LISTVIEW_SB_W / 2; + int32_t cy = sbY + LISTVIEW_SB_W / 2; + uint32_t fg = colors->contentFg; + + for (int32_t i = 0; i < 4; i++) { + drawVLine(d, ops, cx - 2 + i, cy - i, 1 + i * 2, fg); + } + } + + // Right arrow button + int32_t rightX = sbX + sbW - LISTVIEW_SB_W; + drawBevel(d, ops, rightX, sbY, LISTVIEW_SB_W, LISTVIEW_SB_W, &btnBevel); + + // Right arrow triangle + { + int32_t cx = rightX + LISTVIEW_SB_W / 2; + int32_t cy = sbY + LISTVIEW_SB_W / 2; + uint32_t fg = colors->contentFg; + + for (int32_t i = 0; i < 4; i++) { + drawVLine(d, ops, cx + 2 - i, cy - i, 1 + i * 2, fg); + } + } + + // Thumb + int32_t trackLen = sbW - LISTVIEW_SB_W * 2; + + if (trackLen > 0 && totalW > 0) { + int32_t thumbPos; + int32_t thumbSize; + widgetScrollbarThumb(trackLen, totalW, visibleW, w->as.listView.scrollPosH, &thumbPos, &thumbSize); + + drawBevel(d, ops, sbX + LISTVIEW_SB_W + thumbPos, sbY, thumbSize, LISTVIEW_SB_W, &btnBevel); + } +} + + +// ============================================================ +// drawListViewVScrollbar +// ============================================================ + +static void drawListViewVScrollbar(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, int32_t sbX, int32_t sbY, int32_t sbH, int32_t totalRows, int32_t visibleRows) { + if (sbH < LISTVIEW_SB_W * 3) { + return; + } + + // Trough background + BevelStyleT troughBevel = BEVEL_TROUGH(colors); + drawBevel(d, ops, sbX, sbY, LISTVIEW_SB_W, sbH, &troughBevel); + + // Up arrow button + BevelStyleT btnBevel = BEVEL_RAISED(colors, 1); + drawBevel(d, ops, sbX, sbY, LISTVIEW_SB_W, LISTVIEW_SB_W, &btnBevel); + + // Up arrow triangle + { + int32_t cx = sbX + LISTVIEW_SB_W / 2; + int32_t cy = sbY + LISTVIEW_SB_W / 2; + uint32_t fg = colors->contentFg; + + for (int32_t i = 0; i < 4; i++) { + drawHLine(d, ops, cx - i, cy - 2 + i, 1 + i * 2, fg); + } + } + + // Down arrow button + int32_t downY = sbY + sbH - LISTVIEW_SB_W; + drawBevel(d, ops, sbX, downY, LISTVIEW_SB_W, LISTVIEW_SB_W, &btnBevel); + + // Down arrow triangle + { + int32_t cx = sbX + LISTVIEW_SB_W / 2; + int32_t cy = downY + LISTVIEW_SB_W / 2; + uint32_t fg = colors->contentFg; + + for (int32_t i = 0; i < 4; i++) { + drawHLine(d, ops, cx - i, cy + 2 - i, 1 + i * 2, fg); + } + } + + // Thumb + int32_t trackLen = sbH - LISTVIEW_SB_W * 2; + + if (trackLen > 0 && totalRows > 0) { + int32_t thumbPos; + int32_t thumbSize; + widgetScrollbarThumb(trackLen, totalRows, visibleRows, w->as.listView.scrollPos, &thumbPos, &thumbSize); + + drawBevel(d, ops, sbX, sbY + LISTVIEW_SB_W + thumbPos, LISTVIEW_SB_W, thumbSize, &btnBevel); + } +} + + +// ============================================================ +// listViewBuildSortIndex +// ============================================================ +// +// Build or rebuild the sortIndex array. Uses insertion sort +// (stable) on the sort column. If no sort is active, frees +// the index so paint uses natural order. + +static void listViewBuildSortIndex(WidgetT *w) { + int32_t rowCount = w->as.listView.rowCount; + int32_t sortCol = w->as.listView.sortCol; + int32_t colCount = w->as.listView.colCount; + + // No sort active — clear index + if (sortCol < 0 || w->as.listView.sortDir == ListViewSortNoneE || rowCount <= 0) { + if (w->as.listView.sortIndex) { + free(w->as.listView.sortIndex); + w->as.listView.sortIndex = NULL; + } + + return; + } + + // Allocate or reuse + int32_t *idx = w->as.listView.sortIndex; + + if (!idx) { + idx = (int32_t *)malloc(rowCount * sizeof(int32_t)); + + if (!idx) { + return; + } + + w->as.listView.sortIndex = idx; + } + + // Initialize identity + for (int32_t i = 0; i < rowCount; i++) { + idx[i] = i; + } + + // Insertion sort — stable, O(n^2) but fine for typical row counts + bool ascending = (w->as.listView.sortDir == ListViewSortAscE); + + for (int32_t i = 1; i < rowCount; i++) { + int32_t key = idx[i]; + const char *keyStr = w->as.listView.cellData[key * colCount + sortCol]; + + if (!keyStr) { + keyStr = ""; + } + + int32_t j = i - 1; + + while (j >= 0) { + const char *jStr = w->as.listView.cellData[idx[j] * colCount + sortCol]; + + if (!jStr) { + jStr = ""; + } + + int32_t cmp = strcmp(jStr, keyStr); + + if (!ascending) { + cmp = -cmp; + } + + if (cmp <= 0) { + break; + } + + idx[j + 1] = idx[j]; + j--; + } + + idx[j + 1] = key; + } +} + + +// ============================================================ +// resolveColumnWidths +// ============================================================ +// +// Resolve tagged column sizes (pixels, chars, percent, or auto) +// to actual pixel widths. Auto-sized columns (width==0) scan +// cellData for the widest string in that column. + +static void resolveColumnWidths(WidgetT *w, const BitmapFontT *font) { + int32_t colCount = w->as.listView.colCount; + int32_t parentW = w->w - LISTVIEW_BORDER * 2; + + if (parentW < 0) { + parentW = 0; + } + + int32_t totalW = 0; + + for (int32_t c = 0; c < colCount; c++) { + int32_t taggedW = w->as.listView.cols[c].width; + + if (taggedW == 0) { + // Auto-size: scan data for widest string in this column + int32_t maxLen = (int32_t)strlen(w->as.listView.cols[c].title); + + for (int32_t r = 0; r < w->as.listView.rowCount; r++) { + const char *cell = w->as.listView.cellData[r * colCount + c]; + + if (cell) { + int32_t slen = (int32_t)strlen(cell); + + if (slen > maxLen) { + maxLen = slen; + } + } + } + + w->as.listView.resolvedColW[c] = maxLen * font->charWidth + LISTVIEW_COL_PAD; + } else { + w->as.listView.resolvedColW[c] = wgtResolveSize(taggedW, parentW, font->charWidth); + } + + totalW += w->as.listView.resolvedColW[c]; + } + + w->as.listView.totalColW = totalW; +} + + +// ============================================================ +// widgetListViewColBorderHit +// ============================================================ +// +// Returns true if (vx, vy) is on a column header border (resize zone). +// Coordinates are in widget/virtual space (scroll-adjusted). + +bool widgetListViewColBorderHit(const WidgetT *w, int32_t vx, int32_t vy) { + if (!w || w->type != WidgetListViewE || w->as.listView.colCount == 0) { + return false; + } + + AppContextT *ctx = (AppContextT *)w->window->widgetRoot->userData; + const BitmapFontT *font = &ctx->font; + int32_t headerH = font->charHeight + 4; + int32_t headerTop = w->y + LISTVIEW_BORDER; + + if (vy < headerTop || vy >= headerTop + headerH) { + return false; + } + + int32_t colX = w->x + LISTVIEW_BORDER - w->as.listView.scrollPosH; + + for (int32_t c = 0; c < w->as.listView.colCount; c++) { + int32_t border = colX + w->as.listView.resolvedColW[c]; + + if (vx >= border - 3 && vx <= border + 3) { + return true; + } + + colX += w->as.listView.resolvedColW[c]; + } + + return false; +} + + +// ============================================================ +// widgetListViewDestroy +// ============================================================ + +void widgetListViewDestroy(WidgetT *w) { + if (w->as.listView.sortIndex) { + free(w->as.listView.sortIndex); + w->as.listView.sortIndex = NULL; + } +} + + +// ============================================================ +// wgtListView +// ============================================================ + +WidgetT *wgtListView(WidgetT *parent) { + WidgetT *w = widgetAlloc(parent, WidgetListViewE); + + if (w) { + w->as.listView.selectedIdx = -1; + w->as.listView.sortCol = -1; + w->as.listView.sortDir = ListViewSortNoneE; + w->weight = 100; + } + + return w; +} + + +// ============================================================ +// wgtListViewGetSelected +// ============================================================ + +int32_t wgtListViewGetSelected(const WidgetT *w) { + if (!w || w->type != WidgetListViewE) { + return -1; + } + + return w->as.listView.selectedIdx; +} + + +// ============================================================ +// wgtListViewSetColumns +// ============================================================ + +void wgtListViewSetColumns(WidgetT *w, const ListViewColT *cols, int32_t count) { + if (!w || w->type != WidgetListViewE) { + return; + } + + if (count > LISTVIEW_MAX_COLS) { + count = LISTVIEW_MAX_COLS; + } + + w->as.listView.cols = cols; + w->as.listView.colCount = count; + w->as.listView.totalColW = 0; +} + + +// ============================================================ +// wgtListViewSetData +// ============================================================ + +void wgtListViewSetData(WidgetT *w, const char **cellData, int32_t rowCount) { + if (!w || w->type != WidgetListViewE) { + return; + } + + // Free old sort index since row count may have changed + if (w->as.listView.sortIndex) { + free(w->as.listView.sortIndex); + w->as.listView.sortIndex = NULL; + } + + w->as.listView.cellData = cellData; + w->as.listView.rowCount = rowCount; + w->as.listView.totalColW = 0; + + if (w->as.listView.selectedIdx >= rowCount) { + w->as.listView.selectedIdx = rowCount > 0 ? 0 : -1; + } + + if (w->as.listView.selectedIdx < 0 && rowCount > 0) { + w->as.listView.selectedIdx = 0; + } + + // Rebuild sort index if sort is active + listViewBuildSortIndex(w); +} + + +// ============================================================ +// wgtListViewSetHeaderClickCallback +// ============================================================ + +void wgtListViewSetHeaderClickCallback(WidgetT *w, void (*cb)(WidgetT *w, int32_t col, ListViewSortE dir)) { + if (!w || w->type != WidgetListViewE) { + return; + } + + w->as.listView.onHeaderClick = cb; +} + + +// ============================================================ +// wgtListViewSetSelected +// ============================================================ + +void wgtListViewSetSelected(WidgetT *w, int32_t idx) { + if (!w || w->type != WidgetListViewE) { + return; + } + + w->as.listView.selectedIdx = idx; +} + + +// ============================================================ +// wgtListViewSetSort +// ============================================================ + +void wgtListViewSetSort(WidgetT *w, int32_t col, ListViewSortE dir) { + if (!w || w->type != WidgetListViewE) { + return; + } + + w->as.listView.sortCol = col; + w->as.listView.sortDir = dir; + listViewBuildSortIndex(w); +} + + +// ============================================================ +// widgetListViewCalcMinSize +// ============================================================ + +void widgetListViewCalcMinSize(WidgetT *w, const BitmapFontT *font) { + int32_t headerH = font->charHeight + 4; + int32_t minW = font->charWidth * 12 + LISTVIEW_BORDER * 2 + LISTVIEW_SB_W; + + w->calcMinW = minW; + w->calcMinH = headerH + LISTVIEW_MIN_ROWS * font->charHeight + LISTVIEW_BORDER * 2; +} + + +// ============================================================ +// widgetListViewOnKey +// ============================================================ + +void widgetListViewOnKey(WidgetT *w, int32_t key, int32_t mod) { + (void)mod; + + if (!w || w->type != WidgetListViewE || w->as.listView.rowCount == 0) { + return; + } + + int32_t rowCount = w->as.listView.rowCount; + int32_t *sortIdx = w->as.listView.sortIndex; + + // Find current display row from selectedIdx (data row) + int32_t displaySel = -1; + + if (w->as.listView.selectedIdx >= 0) { + if (sortIdx) { + for (int32_t i = 0; i < rowCount; i++) { + if (sortIdx[i] == w->as.listView.selectedIdx) { + displaySel = i; + break; + } + } + } else { + displaySel = w->as.listView.selectedIdx; + } + } + + // Compute visible rows for page up/down + AppContextT *ctx = (AppContextT *)w->window->widgetRoot->userData; + const BitmapFontT *font = &ctx->font; + int32_t headerH = font->charHeight + 4; + int32_t innerH = w->h - LISTVIEW_BORDER * 2 - headerH; + int32_t visibleRows = innerH / font->charHeight; + + if (visibleRows < 1) { + visibleRows = 1; + } + + if (key == (0x50 | 0x100)) { + // Down arrow + if (displaySel < rowCount - 1) { + displaySel++; + } else if (displaySel < 0) { + displaySel = 0; + } + } else if (key == (0x48 | 0x100)) { + // Up arrow + if (displaySel > 0) { + displaySel--; + } else if (displaySel < 0) { + displaySel = 0; + } + } else if (key == (0x47 | 0x100)) { + // Home + displaySel = 0; + } else if (key == (0x4F | 0x100)) { + // End + displaySel = rowCount - 1; + } else if (key == (0x51 | 0x100)) { + // Page Down + displaySel += visibleRows; + + if (displaySel >= rowCount) { + displaySel = rowCount - 1; + } + } else if (key == (0x49 | 0x100)) { + // Page Up + displaySel -= visibleRows; + + if (displaySel < 0) { + displaySel = 0; + } + } else { + return; + } + + // Convert display row back to data row + w->as.listView.selectedIdx = sortIdx ? sortIdx[displaySel] : displaySel; + + // Scroll to keep selection visible (in display-row space) + if (displaySel >= 0) { + if (displaySel < w->as.listView.scrollPos) { + w->as.listView.scrollPos = displaySel; + } else if (displaySel >= w->as.listView.scrollPos + visibleRows) { + w->as.listView.scrollPos = displaySel - visibleRows + 1; + } + } + + if (w->onChange) { + w->onChange(w); + } + + wgtInvalidatePaint(w); +} + + +// ============================================================ +// widgetListViewOnMouse +// ============================================================ + +void widgetListViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) { + hit->focused = true; + + AppContextT *ctx = (AppContextT *)root->userData; + const BitmapFontT *font = &ctx->font; + + // Resolve column widths if needed + if (hit->as.listView.totalColW == 0 && hit->as.listView.colCount > 0) { + resolveColumnWidths(hit, font); + } + + int32_t headerH = font->charHeight + 4; + int32_t innerH = hit->h - LISTVIEW_BORDER * 2 - headerH; + int32_t innerW = hit->w - LISTVIEW_BORDER * 2; + int32_t visibleRows = innerH / font->charHeight; + int32_t totalColW = hit->as.listView.totalColW; + bool needVSb = (hit->as.listView.rowCount > visibleRows); + bool needHSb = false; + + if (needVSb) { + innerW -= LISTVIEW_SB_W; + } + + if (totalColW > innerW) { + needHSb = true; + innerH -= LISTVIEW_SB_W; + visibleRows = innerH / font->charHeight; + + if (!needVSb && hit->as.listView.rowCount > visibleRows) { + needVSb = true; + innerW -= LISTVIEW_SB_W; + } + } + + if (visibleRows < 1) { + visibleRows = 1; + } + + // Clamp scroll positions + int32_t maxScrollV = hit->as.listView.rowCount - visibleRows; + int32_t maxScrollH = totalColW - innerW; + + if (maxScrollV < 0) { + maxScrollV = 0; + } + + if (maxScrollH < 0) { + maxScrollH = 0; + } + + hit->as.listView.scrollPos = clampInt(hit->as.listView.scrollPos, 0, maxScrollV); + hit->as.listView.scrollPosH = clampInt(hit->as.listView.scrollPosH, 0, maxScrollH); + + // Check vertical scrollbar + if (needVSb) { + int32_t sbX = hit->x + hit->w - LISTVIEW_BORDER - LISTVIEW_SB_W; + int32_t sbY = hit->y + LISTVIEW_BORDER + headerH; + int32_t sbH = innerH; + + if (vx >= sbX && vy >= sbY && vy < sbY + sbH) { + int32_t relY = vy - sbY; + int32_t trackLen = sbH - LISTVIEW_SB_W * 2; + + if (relY < LISTVIEW_SB_W) { + // Up arrow + if (hit->as.listView.scrollPos > 0) { + hit->as.listView.scrollPos--; + } + } else if (relY >= sbH - LISTVIEW_SB_W) { + // Down arrow + if (hit->as.listView.scrollPos < maxScrollV) { + hit->as.listView.scrollPos++; + } + } else if (trackLen > 0) { + // Track — page up/down + int32_t thumbPos; + int32_t thumbSize; + widgetScrollbarThumb(trackLen, hit->as.listView.rowCount, visibleRows, hit->as.listView.scrollPos, &thumbPos, &thumbSize); + + int32_t trackRelY = relY - LISTVIEW_SB_W; + + if (trackRelY < thumbPos) { + hit->as.listView.scrollPos -= visibleRows; + hit->as.listView.scrollPos = clampInt(hit->as.listView.scrollPos, 0, maxScrollV); + } else if (trackRelY >= thumbPos + thumbSize) { + hit->as.listView.scrollPos += visibleRows; + hit->as.listView.scrollPos = clampInt(hit->as.listView.scrollPos, 0, maxScrollV); + } + } + + return; + } + } + + // Check horizontal scrollbar + if (needHSb) { + int32_t sbX = hit->x + LISTVIEW_BORDER; + int32_t sbY = hit->y + hit->h - LISTVIEW_BORDER - LISTVIEW_SB_W; + int32_t sbW = innerW; + + if (vy >= sbY && vx >= sbX && vx < sbX + sbW) { + int32_t relX = vx - sbX; + int32_t trackLen = sbW - LISTVIEW_SB_W * 2; + int32_t pageSize = innerW - font->charWidth; + + if (pageSize < font->charWidth) { + pageSize = font->charWidth; + } + + if (relX < LISTVIEW_SB_W) { + // Left arrow + hit->as.listView.scrollPosH -= font->charWidth; + } else if (relX >= sbW - LISTVIEW_SB_W) { + // Right arrow + hit->as.listView.scrollPosH += font->charWidth; + } else if (trackLen > 0) { + // Track — page left/right + int32_t thumbPos; + int32_t thumbSize; + widgetScrollbarThumb(trackLen, totalColW, innerW, hit->as.listView.scrollPosH, &thumbPos, &thumbSize); + + int32_t trackRelX = relX - LISTVIEW_SB_W; + + if (trackRelX < thumbPos) { + hit->as.listView.scrollPosH -= pageSize; + } else if (trackRelX >= thumbPos + thumbSize) { + hit->as.listView.scrollPosH += pageSize; + } + } + + hit->as.listView.scrollPosH = clampInt(hit->as.listView.scrollPosH, 0, maxScrollH); + return; + } + } + + // Check dead corner (both scrollbars present) + if (needVSb && needHSb) { + int32_t cornerX = hit->x + hit->w - LISTVIEW_BORDER - LISTVIEW_SB_W; + int32_t cornerY = hit->y + hit->h - LISTVIEW_BORDER - LISTVIEW_SB_W; + + if (vx >= cornerX && vy >= cornerY) { + return; + } + } + + // Check column header area + int32_t headerTop = hit->y + LISTVIEW_BORDER; + + if (vy >= headerTop && vy < headerTop + headerH) { + // Check for column border resize (3px zone on each side of border) + int32_t colX = hit->x + LISTVIEW_BORDER - hit->as.listView.scrollPosH; + + for (int32_t c = 0; c < hit->as.listView.colCount; c++) { + int32_t cw = hit->as.listView.resolvedColW[c]; + int32_t border = colX + cw; + + if (vx >= border - 3 && vx <= border + 3 && c < hit->as.listView.colCount) { + // Start column resize drag + sResizeListView = hit; + sResizeCol = c; + sResizeStartX = vx; + sResizeOrigW = cw; + return; + } + + colX += cw; + } + + // Not on a border — check for sort click + colX = hit->x + LISTVIEW_BORDER - hit->as.listView.scrollPosH; + + for (int32_t c = 0; c < hit->as.listView.colCount; c++) { + int32_t cw = hit->as.listView.resolvedColW[c]; + + if (vx >= colX && vx < colX + cw) { + // Toggle sort direction for this column + if (hit->as.listView.sortCol == c) { + if (hit->as.listView.sortDir == ListViewSortAscE) { + hit->as.listView.sortDir = ListViewSortDescE; + } else { + hit->as.listView.sortDir = ListViewSortAscE; + } + } else { + hit->as.listView.sortCol = c; + hit->as.listView.sortDir = ListViewSortAscE; + } + + listViewBuildSortIndex(hit); + + if (hit->as.listView.onHeaderClick) { + hit->as.listView.onHeaderClick(hit, c, hit->as.listView.sortDir); + } + + wgtInvalidatePaint(hit); + return; + } + + colX += cw; + } + + return; + } + + // Click on data area + int32_t dataTop = headerTop + headerH; + int32_t relY = vy - dataTop; + + if (relY < 0) { + return; + } + + int32_t clickedRow = relY / font->charHeight; + int32_t displayRow = hit->as.listView.scrollPos + clickedRow; + + if (displayRow >= 0 && displayRow < hit->as.listView.rowCount) { + int32_t dataRow = hit->as.listView.sortIndex ? hit->as.listView.sortIndex[displayRow] : displayRow; + hit->as.listView.selectedIdx = dataRow; + + if (hit->onChange) { + hit->onChange(hit); + } + } + + wgtInvalidatePaint(hit); +} + + +// ============================================================ +// widgetListViewPaint +// ============================================================ + +void widgetListViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) { + uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg; + uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg; + + // Resolve column widths if needed + if (w->as.listView.totalColW == 0 && w->as.listView.colCount > 0) { + resolveColumnWidths(w, font); + } + + int32_t headerH = font->charHeight + 4; + int32_t innerH = w->h - LISTVIEW_BORDER * 2 - headerH; + int32_t innerW = w->w - LISTVIEW_BORDER * 2; + int32_t visibleRows = innerH / font->charHeight; + int32_t totalColW = w->as.listView.totalColW; + bool needVSb = (w->as.listView.rowCount > visibleRows); + bool needHSb = false; + + if (needVSb) { + innerW -= LISTVIEW_SB_W; + } + + if (totalColW > innerW) { + needHSb = true; + innerH -= LISTVIEW_SB_W; + visibleRows = innerH / font->charHeight; + + if (!needVSb && w->as.listView.rowCount > visibleRows) { + needVSb = true; + innerW -= LISTVIEW_SB_W; + } + } + + if (visibleRows < 1) { + visibleRows = 1; + } + + // Sunken border + BevelStyleT bevel = BEVEL_SUNKEN(colors, bg, 2); + drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel); + + // Clamp scroll positions + int32_t maxScrollV = w->as.listView.rowCount - visibleRows; + int32_t maxScrollH = totalColW - innerW; + + if (maxScrollV < 0) { + maxScrollV = 0; + } + + if (maxScrollH < 0) { + maxScrollH = 0; + } + + w->as.listView.scrollPos = clampInt(w->as.listView.scrollPos, 0, maxScrollV); + w->as.listView.scrollPosH = clampInt(w->as.listView.scrollPosH, 0, maxScrollH); + + int32_t baseX = w->x + LISTVIEW_BORDER; + int32_t baseY = w->y + LISTVIEW_BORDER; + int32_t colCount = w->as.listView.colCount; + + // ---- Draw column headers ---- + { + // Clip headers to the content area width + int32_t oldClipX = d->clipX; + int32_t oldClipY = d->clipY; + int32_t oldClipW = d->clipW; + int32_t oldClipH = d->clipH; + setClipRect(d, baseX, baseY, innerW, headerH); + + int32_t hdrX = baseX - w->as.listView.scrollPosH; + BevelStyleT hdrBevel = BEVEL_RAISED(colors, 1); + + for (int32_t c = 0; c < colCount; c++) { + int32_t cw = w->as.listView.resolvedColW[c]; + + // Draw raised button for header + drawBevel(d, ops, hdrX, baseY, cw, headerH, &hdrBevel); + + // Header text + int32_t textX = hdrX + LISTVIEW_PAD; + int32_t textY = baseY + 2; + int32_t availTextW = cw - LISTVIEW_PAD * 2; + + // Reserve space for sort indicator if this is the sort column + if (c == w->as.listView.sortCol && w->as.listView.sortDir != ListViewSortNoneE) { + availTextW -= LISTVIEW_SORT_W; + } + + if (w->as.listView.cols[c].title) { + int32_t titleLen = (int32_t)strlen(w->as.listView.cols[c].title); + int32_t titleW = titleLen * font->charWidth; + + if (titleW > availTextW) { + titleLen = availTextW / font->charWidth; + } + + if (titleLen > 0) { + drawTextN(d, ops, font, textX, textY, w->as.listView.cols[c].title, titleLen, colors->contentFg, colors->windowFace, true); + } + } + + // Sort indicator + if (c == w->as.listView.sortCol && w->as.listView.sortDir != ListViewSortNoneE) { + int32_t cx = hdrX + cw - LISTVIEW_SORT_W / 2 - LISTVIEW_PAD; + int32_t cy = baseY + headerH / 2; + + if (w->as.listView.sortDir == ListViewSortAscE) { + // Up triangle (ascending) + for (int32_t i = 0; i < 4; i++) { + drawHLine(d, ops, cx - i, cy - 2 + i, 1 + i * 2, colors->contentFg); + } + } else { + // Down triangle (descending) + for (int32_t i = 0; i < 4; i++) { + drawHLine(d, ops, cx - 3 + i, cy - 1 + i, 7 - i * 2, colors->contentFg); + } + } + } + + hdrX += cw; + } + + // Fill any remaining header space to the right of columns + if (hdrX < baseX + innerW) { + drawBevel(d, ops, hdrX, baseY, baseX + innerW - hdrX, headerH, &hdrBevel); + } + + setClipRect(d, oldClipX, oldClipY, oldClipW, oldClipH); + } + + // ---- Draw data rows ---- + { + int32_t dataY = baseY + headerH; + + // Set clip rect to data area + int32_t oldClipX = d->clipX; + int32_t oldClipY = d->clipY; + int32_t oldClipW = d->clipW; + int32_t oldClipH = d->clipH; + setClipRect(d, baseX, dataY, innerW, innerH); + + int32_t scrollPos = w->as.listView.scrollPos; + int32_t *sortIdx = w->as.listView.sortIndex; + + // Fill entire data area background first + rectFill(d, ops, baseX, dataY, innerW, innerH, bg); + + for (int32_t i = 0; i < visibleRows && (scrollPos + i) < w->as.listView.rowCount; i++) { + int32_t displayRow = scrollPos + i; + int32_t dataRow = sortIdx ? sortIdx[displayRow] : displayRow; + int32_t iy = dataY + i * font->charHeight; + uint32_t ifg = fg; + uint32_t ibg = bg; + + if (dataRow == w->as.listView.selectedIdx) { + ifg = colors->menuHighlightFg; + ibg = colors->menuHighlightBg; + rectFill(d, ops, baseX, iy, innerW, font->charHeight, ibg); + } + + // Draw each cell + int32_t cellX = baseX - w->as.listView.scrollPosH; + + for (int32_t c = 0; c < colCount; c++) { + int32_t cw = w->as.listView.resolvedColW[c]; + const char *cell = w->as.listView.cellData[dataRow * colCount + c]; + + if (cell) { + int32_t cellLen = (int32_t)strlen(cell); + int32_t maxChars = (cw - LISTVIEW_PAD * 2) / font->charWidth; + + if (maxChars < 0) { + maxChars = 0; + } + + if (cellLen > maxChars) { + cellLen = maxChars; + } + + int32_t tx = cellX + LISTVIEW_PAD; + + if (w->as.listView.cols[c].align == ListViewAlignRightE) { + int32_t renderedW = cellLen * font->charWidth; + int32_t availW = cw - LISTVIEW_PAD * 2; + tx = cellX + LISTVIEW_PAD + (availW - renderedW); + } else if (w->as.listView.cols[c].align == ListViewAlignCenterE) { + int32_t renderedW = cellLen * font->charWidth; + int32_t availW = cw - LISTVIEW_PAD * 2; + tx = cellX + LISTVIEW_PAD + (availW - renderedW) / 2; + } + + if (cellLen > 0) { + drawTextN(d, ops, font, tx, iy, cell, cellLen, ifg, ibg, true); + } + } + + cellX += cw; + } + } + + setClipRect(d, oldClipX, oldClipY, oldClipW, oldClipH); + } + + // ---- Draw scrollbars ---- + if (needVSb) { + int32_t sbX = w->x + w->w - LISTVIEW_BORDER - LISTVIEW_SB_W; + int32_t sbY = w->y + LISTVIEW_BORDER + headerH; + drawListViewVScrollbar(w, d, ops, colors, sbX, sbY, innerH, w->as.listView.rowCount, visibleRows); + } + + if (needHSb) { + int32_t sbX = w->x + LISTVIEW_BORDER; + int32_t sbY = w->y + w->h - LISTVIEW_BORDER - LISTVIEW_SB_W; + drawListViewHScrollbar(w, d, ops, colors, sbX, sbY, innerW, totalColW, innerW); + + // Fill dead corner when both scrollbars present + if (needVSb) { + rectFill(d, ops, sbX + innerW, sbY, LISTVIEW_SB_W, LISTVIEW_SB_W, colors->windowFace); + } + } + + if (w->focused) { + drawFocusRect(d, ops, w->x + 1, w->y + 1, w->w - 2, w->h - 2, fg); + } +} diff --git a/dvx/widgets/widgetOps.c b/dvx/widgets/widgetOps.c index ba5873c..57fdb5c 100644 --- a/dvx/widgets/widgetOps.c +++ b/dvx/widgets/widgetOps.c @@ -238,15 +238,19 @@ void wgtInvalidate(WidgetT *w) { } // Manage scrollbars (measures, adds/removes scrollbars, relayouts) - widgetManageScrollbars(w->window, ctx); + // Skip if window has a custom paint handler (e.g. dialog) that manages its own layout + if (w->window->onPaint == widgetOnPaint) { + widgetManageScrollbars(w->window, ctx); + } - // Repaint - RectT fullRect = {0, 0, w->window->contentW, w->window->contentH}; - widgetOnPaint(w->window, &fullRect); - w->window->contentDirty = true; + // Repaint (use win->onPaint so custom paint handlers like dialogs work) + WindowT *win = w->window; + RectT fullRect = {0, 0, win->contentW, win->contentH}; + win->onPaint(win, &fullRect); + win->contentDirty = true; // Dirty the window on screen - dvxInvalidateWindow(ctx, w->window); + dvxInvalidateWindow(ctx, win); } @@ -275,12 +279,13 @@ void wgtInvalidatePaint(WidgetT *w) { return; } - // Repaint without measure/layout - RectT fullRect = {0, 0, w->window->contentW, w->window->contentH}; - widgetOnPaint(w->window, &fullRect); - w->window->contentDirty = true; + // Repaint without measure/layout (use win->onPaint so custom handlers work) + WindowT *win = w->window; + RectT fullRect = {0, 0, win->contentW, win->contentH}; + win->onPaint(win, &fullRect); + win->contentDirty = true; - dvxInvalidateWindow(ctx, w->window); + dvxInvalidateWindow(ctx, win); } diff --git a/dvx/widgets/widgetTabControl.c b/dvx/widgets/widgetTabControl.c index 8d66129..595f5f3 100644 --- a/dvx/widgets/widgetTabControl.c +++ b/dvx/widgets/widgetTabControl.c @@ -84,7 +84,7 @@ void widgetTabControlCalcMinSize(WidgetT *w, const BitmapFontT *font) { tabHeaderW += labelW; } - w->calcMinW = DVX_MAX(maxPageW + TAB_BORDER * 2, tabHeaderW); + w->calcMinW = maxPageW + TAB_BORDER * 2; w->calcMinH = tabH + maxPageH + TAB_BORDER * 2; } diff --git a/dvxdemo/demo.c b/dvxdemo/demo.c index 25b7b5e..3e0794d 100644 --- a/dvxdemo/demo.c +++ b/dvxdemo/demo.c @@ -1,6 +1,7 @@ // demo.c — DV/X GUI demonstration application #include "dvxApp.h" +#include "dvxDialog.h" #include "dvxWidget.h" #include @@ -23,6 +24,9 @@ #define CMD_EDIT_PASTE 202 #define CMD_VIEW_TERM 300 #define CMD_VIEW_CTRL 301 +#define CMD_VIEW_ZOOM_IN 302 +#define CMD_VIEW_ZOOM_OUT 303 +#define CMD_VIEW_ZOOM_FIT 304 #define CMD_HELP_ABOUT 400 // ============================================================ @@ -119,7 +123,13 @@ static void onCloseMainCb(WindowT *win) { AppContextT *ctx = (AppContextT *)win->userData; if (ctx) { - dvxQuit(ctx); + int32_t result = dvxMessageBox(ctx, "Exit", + "Are you sure you want to exit?", + MB_YESNO | MB_ICONQUESTION); + + if (result == ID_YES) { + dvxQuit(ctx); + } } } @@ -134,7 +144,13 @@ static void onMenuCb(WindowT *win, int32_t menuId) { switch (menuId) { case CMD_FILE_EXIT: if (ctx) { - dvxQuit(ctx); + int32_t result = dvxMessageBox(ctx, "Exit", + "Are you sure you want to exit?", + MB_YESNO | MB_ICONQUESTION); + + if (result == ID_YES) { + dvxQuit(ctx); + } } break; @@ -147,6 +163,10 @@ static void onMenuCb(WindowT *win, int32_t menuId) { break; case CMD_HELP_ABOUT: + dvxMessageBox(sCtx, "About DV/X Demo", + "DV/X GUI Demonstration\n\n" + "A DESQview/X-style windowing system for DOS.", + MB_OK | MB_ICONINFO); break; } } @@ -419,10 +439,39 @@ static void setupControlsWindow(AppContextT *ctx) { wgtTreeItem(config, "settings.ini"); wgtTreeItem(config, "palette.dat"); - // --- Tab 3: Toolbar (ImageButtons + VSeparator) --- - WidgetT *page3 = wgtTabPage(tabs, "Tool&bar"); + // --- Tab 3: ListView (multi-column list) --- + WidgetT *page3 = wgtTabPage(tabs, "&List"); - WidgetT *tb = wgtToolbar(page3); + static const ListViewColT lvCols[] = { + {"Name", (int32_t)(WGT_SIZE_CHARS | 16), ListViewAlignLeftE}, + {"Size", (int32_t)(WGT_SIZE_CHARS | 8), ListViewAlignRightE}, + {"Type", (int32_t)(WGT_SIZE_CHARS | 12), ListViewAlignLeftE}, + {"Modified", (int32_t)(WGT_SIZE_CHARS | 12), ListViewAlignLeftE} + }; + + static const char *lvData[] = { + "AUTOEXEC.BAT", "412", "Batch File", "03/15/1994", + "CONFIG.SYS", "256", "System File", "03/15/1994", + "COMMAND.COM", "54,645", "Application", "09/30/1993", + "HIMEM.SYS", "29,136", "System Driver", "09/30/1993", + "EMM386.EXE", "120,926", "Application", "09/30/1993", + "MOUSE.COM", "56,408", "Application", "06/01/1994", + "DOSKEY.COM", "5,883", "Application", "09/30/1993", + "EDIT.COM", "413", "Application", "09/30/1993", + "README.TXT", "8,192", "Text File", "01/10/1994", + "DVXDEMO.EXE", "98,304", "Application", "03/15/2026" + }; + + WidgetT *lv = wgtListView(page3); + wgtListViewSetColumns(lv, lvCols, 4); + wgtListViewSetData(lv, lvData, 10); + wgtListViewSetSelected(lv, 0); + lv->weight = 100; + + // --- Tab 4: Toolbar (ImageButtons + VSeparator) --- + WidgetT *page4 = wgtTabPage(tabs, "Tool&bar"); + + WidgetT *tb = wgtToolbar(page4); int32_t imgW; int32_t imgH; @@ -453,18 +502,18 @@ static void setupControlsWindow(AppContextT *ctx) { WidgetT *btnHelp = wgtButton(tb, "&Help"); btnHelp->onClick = onToolbarClick; - wgtLabel(page3, "ImageButtons with VSeparator."); + wgtLabel(page4, "ImageButtons with VSeparator."); - // --- Tab 4: Media (Image, ImageFromFile) --- - WidgetT *page4 = wgtTabPage(tabs, "&Media"); + // --- Tab 5: Media (Image, ImageFromFile) --- + WidgetT *page5 = wgtTabPage(tabs, "&Media"); - wgtLabel(page4, "ImageFromFile (sample.bmp):"); - wgtImageFromFile(page4, "sample.bmp"); + wgtLabel(page5, "ImageFromFile (sample.bmp):"); + wgtImageFromFile(page5, "sample.bmp"); - wgtHSeparator(page4); + wgtHSeparator(page5); - wgtLabel(page4, "Image (logo.bmp):"); - WidgetT *imgRow = wgtHBox(page4); + wgtLabel(page5, "Image (logo.bmp):"); + WidgetT *imgRow = wgtHBox(page5); uint8_t *logoData = loadBmpPixels(ctx, "logo.bmp", &imgW, &imgH, &imgPitch); if (logoData) { wgtImage(imgRow, logoData, imgW, imgH, imgPitch); @@ -472,19 +521,19 @@ static void setupControlsWindow(AppContextT *ctx) { wgtVSeparator(imgRow); wgtLabel(imgRow, "32x32 DV/X logo"); - // --- Tab 5: Editor (TextArea, Canvas) --- - WidgetT *page5 = wgtTabPage(tabs, "&Editor"); + // --- Tab 6: Editor (TextArea, Canvas) --- + WidgetT *page6 = wgtTabPage(tabs, "&Editor"); - wgtLabel(page5, "TextArea:"); - WidgetT *ta = wgtTextArea(page5, 512); + wgtLabel(page6, "TextArea:"); + WidgetT *ta = wgtTextArea(page6, 512); ta->weight = 100; wgtSetText(ta, "Multi-line text editor.\n\nFeatures:\n- Word wrap\n- Selection\n- Copy/Paste\n- Undo (Ctrl+Z)"); - wgtHSeparator(page5); + wgtHSeparator(page6); - wgtLabel(page5, "Canvas (draw with mouse):"); + wgtLabel(page6, "Canvas (draw with mouse):"); const DisplayT *d = dvxGetDisplay(ctx); - WidgetT *cv = wgtCanvas(page5, 280, 80); + WidgetT *cv = wgtCanvas(page6, 280, 80); wgtCanvasSetPenColor(cv, packColor(d, 200, 0, 0)); wgtCanvasDrawRect(cv, 5, 5, 50, 35); wgtCanvasSetPenColor(cv, packColor(d, 0, 0, 200)); @@ -544,6 +593,16 @@ static void setupMainWindow(AppContextT *ctx) { if (viewMenu) { wmAddMenuItem(viewMenu, "&Terminal", CMD_VIEW_TERM); wmAddMenuItem(viewMenu, "&Controls", CMD_VIEW_CTRL); + wmAddMenuSeparator(viewMenu); + + MenuT *zoomMenu = wmAddSubMenu(viewMenu, "&Zoom"); + + if (zoomMenu) { + wmAddMenuItem(zoomMenu, "Zoom &In", CMD_VIEW_ZOOM_IN); + wmAddMenuItem(zoomMenu, "Zoom &Out", CMD_VIEW_ZOOM_OUT); + wmAddMenuSeparator(zoomMenu); + wmAddMenuItem(zoomMenu, "&Fit to Window", CMD_VIEW_ZOOM_FIT); + } } MenuT *helpMenu = wmAddMenu(bar, "&Help");