// dvx_app.c — Layer 5: Application API for DVX GUI // // Top-level layer of the DVX windowing system. This is the only layer // that application code interacts with directly. It owns the main event // loop, input polling, popup/context menu system, tooltip management, // accelerator dispatch, clipboard, and window tiling/cascading. // // Architecture: poll-based event dispatch // ---------------------------------------- // The design uses polling (dvxUpdate) rather than an event queue because: // 1. The target platform (DOS/DPMI) has no OS-provided event queue. // BIOS keyboard and mouse services are inherently polled. // 2. Polling avoids the need for dynamic memory allocation (no malloc // per event, no queue growth) which matters on a 486 with limited RAM. // 3. It maps directly to the DOS main-loop model: poll hardware, process, // composite, repeat. No need for event serialization or priority. // 4. Modal dialogs (message boxes, file dialogs) simply run a nested // dvxUpdate loop, which is trivial with polling but would require // re-entrant queue draining with an event queue. // // The tradeoff is that all input sources are polled every frame, but on a // 486 this is actually faster than maintaining queue data structures. // // Compositing model: only dirty rectangles are redrawn and flushed to the // LFB (linear framebuffer). The compositor walks bottom-to-top for each // dirty rect, so overdraw happens in the backbuffer (system RAM), not // video memory. The final flush is the only VRAM write per dirty rect, // which is critical because VRAM writes through the PCI/ISA bus are an // order of magnitude slower than system RAM writes on period hardware. #include "dvxApp.h" #include "dvxWidget.h" #include "widgets/widgetInternal.h" #include "dvxFont.h" #include "dvxCursor.h" #include "platform/dvxPlatform.h" #include #include #include #include "thirdparty/stb_image.h" #include "thirdparty/stb_image_write.h" // Double-click timing uses CLOCKS_PER_SEC so it's portable between DJGPP // (where CLOCKS_PER_SEC is typically 91, from the PIT) and Linux/SDL. #define DBLCLICK_THRESHOLD (CLOCKS_PER_SEC / 2) // Minimized icon thumbnails are refreshed in a round-robin fashion rather // than all at once, spreading the repaint cost across multiple frames. // Every 8 frames, one dirty icon gets refreshed. #define ICON_REFRESH_INTERVAL 8 // Keyboard move/resize uses a fixed pixel step per arrow key press. // 8 pixels keeps it responsive without being too coarse on a 640x480 screen. #define KB_MOVE_STEP 8 #define MENU_CHECK_WIDTH 14 #define SUBMENU_ARROW_WIDTH 12 #define SUBMENU_ARROW_HALF 3 // half-size of submenu arrow glyph #define TOOLTIP_DELAY_MS 500 #define TOOLTIP_PAD 3 #define POPUP_BEVEL_WIDTH 2 // popup menu border bevel thickness #define POPUP_ITEM_PAD_H 8 // extra horizontal padding in popup items #define MENU_TAB_GAP_CHARS 3 // char-widths gap between label and shortcut // ============================================================ // Prototypes // ============================================================ static uint8_t *bufferToRgb(const DisplayT *d, const uint8_t *buf, int32_t w, int32_t h, int32_t pitch); static void calcPopupSize(const AppContextT *ctx, const MenuT *menu, int32_t *pw, int32_t *ph); static bool checkAccelTable(AppContextT *ctx, WindowT *win, int32_t key, int32_t modifiers); static void clickMenuCheckRadio(MenuT *menu, int32_t itemIdx); 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 openContextMenu(AppContextT *ctx, WindowT *win, MenuT *menu, int32_t screenX, int32_t screenY); 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); static void pollKeyboard(AppContextT *ctx); static void pollMouse(AppContextT *ctx); static void refreshMinimizedIcons(AppContextT *ctx); static void repositionWindow(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h); static void updateCursorShape(AppContextT *ctx); static void updateTooltip(AppContextT *ctx); // Button pressed via keyboard — shared with widgetEvent.c for Space/Enter. // Non-static so widgetEvent.c can set it when Space/Enter triggers a button. // The button stays visually pressed for one frame (see dvxUpdate), then the // click callback fires. This gives the user visual feedback that the // keyboard activation was registered, matching Win3.x/Motif behavior. WidgetT *sKeyPressedBtn = NULL; // ============================================================ // bufferToRgb — convert native pixel format to 24-bit RGB // ============================================================ // // Screenshots must produce standard RGB data for stb_image_write, but the // backbuffer uses whatever native pixel format VESA gave us (8/16/32bpp). // This function handles the conversion using the display's format metadata // (shift counts, bit widths) rather than assuming a specific layout. // The 8bpp path uses the VGA palette for lookup. static uint8_t *bufferToRgb(const DisplayT *d, const uint8_t *buf, int32_t w, int32_t h, int32_t pitch) { uint8_t *rgb = (uint8_t *)malloc((size_t)w * h * 3); if (!rgb) { return NULL; } int32_t bpp = d->format.bytesPerPixel; uint8_t *dst = rgb; for (int32_t y = 0; y < h; y++) { const uint8_t *row = buf + y * pitch; for (int32_t x = 0; x < w; x++) { uint32_t pixel; if (bpp == 1) { pixel = row[x]; } else if (bpp == 2) { pixel = ((const uint16_t *)row)[x]; } else { pixel = ((const uint32_t *)row)[x]; } if (d->format.bitsPerPixel == 8) { int32_t idx = pixel & 0xFF; dst[0] = d->palette[idx * 3 + 0]; dst[1] = d->palette[idx * 3 + 1]; dst[2] = d->palette[idx * 3 + 2]; } else { uint32_t rv = (pixel >> d->format.redShift) & ((1u << d->format.redBits) - 1); uint32_t gv = (pixel >> d->format.greenShift) & ((1u << d->format.greenBits) - 1); uint32_t bv = (pixel >> d->format.blueShift) & ((1u << d->format.blueBits) - 1); dst[0] = (uint8_t)(rv << (8 - d->format.redBits)); dst[1] = (uint8_t)(gv << (8 - d->format.greenBits)); dst[2] = (uint8_t)(bv << (8 - d->format.blueBits)); } dst += 3; } } return rgb; } // ============================================================ // calcPopupSize — compute popup width and height for a menu // ============================================================ // // Popup width is determined by the widest item, plus conditional margins // for check/radio indicators and submenu arrows. Menu labels use a tab // character to separate the item text from the keyboard shortcut string // (e.g., "&Save\tCtrl+S"), which are measured and laid out independently // so shortcuts right-align within the popup. The '&' prefix in labels // marks the accelerator underline character and is excluded from width // measurement by textWidthAccel. static void calcPopupSize(const AppContextT *ctx, const MenuT *menu, int32_t *pw, int32_t *ph) { int32_t maxW = 0; bool hasSub = false; bool hasCheck = false; for (int32_t k = 0; k < menu->itemCount; k++) { const char *label = menu->items[k].label; const char *tab = strchr(label, '\t'); int32_t itemW; if (tab) { // Left part (with accel underline) + gap + right part (shortcut text) char leftBuf[MAX_MENU_LABEL]; int32_t leftLen = (int32_t)(tab - label); if (leftLen >= MAX_MENU_LABEL) { leftLen = MAX_MENU_LABEL - 1; } memcpy(leftBuf, label, leftLen); leftBuf[leftLen] = '\0'; itemW = textWidthAccel(&ctx->font, leftBuf) + ctx->font.charWidth * MENU_TAB_GAP_CHARS + textWidth(&ctx->font, tab + 1); } else { itemW = textWidthAccel(&ctx->font, label); } if (itemW > maxW) { maxW = itemW; } if (menu->items[k].subMenu) { hasSub = true; } if (menu->items[k].type == MenuItemCheckE || menu->items[k].type == MenuItemRadioE) { hasCheck = true; } } // Width includes: padding, text, check margin (if any items are check/radio), // submenu arrow space (if any items have submenus). All items in the popup // share the same width for visual consistency, even if only some have checks. *pw = maxW + CHROME_TITLE_PAD * 2 + POPUP_ITEM_PAD_H + (hasSub ? SUBMENU_ARROW_WIDTH : 0) + (hasCheck ? MENU_CHECK_WIDTH : 0); *ph = menu->itemCount * ctx->font.charHeight + POPUP_BEVEL_WIDTH * 2; } // ============================================================ // checkAccelTable — test key against window's accelerator table // ============================================================ // // Accelerator tables map key+modifier combos (e.g., Ctrl+S) to menu // command IDs. Keys are pre-normalized to uppercase at registration time // (in dvxAddAccel) so matching here is a simple linear scan with no // allocation. Linear scan is fine because accelerator tables are small // (typically <20 entries) and this runs at most once per keypress. // // BIOS keyboard quirk: Ctrl+A through Ctrl+Z come through as ASCII // 0x01..0x1A rather than 'A'..'Z'. We reverse that mapping here so the // user-facing accelerator definition can use plain letter keys. static bool checkAccelTable(AppContextT *ctx, WindowT *win, int32_t key, int32_t modifiers) { if (!win->accelTable || !win->onMenu) { return false; } // Normalize Ctrl+letter: BIOS returns ASCII 0x01-0x1A for Ctrl+A..Z // Map back to uppercase letter for matching int32_t matchKey = key; if ((modifiers & ACCEL_CTRL) && matchKey >= 0x01 && matchKey <= 0x1A) { matchKey = matchKey + 'A' - 1; } // Uppercase for case-insensitive letter matching if (matchKey >= 'a' && matchKey <= 'z') { matchKey = matchKey - 32; } int32_t requiredMods = modifiers & (ACCEL_CTRL | ACCEL_ALT); AccelTableT *table = win->accelTable; for (int32_t i = 0; i < table->count; i++) { AccelEntryT *e = &table->entries[i]; if (e->normKey == matchKey && e->normMods == requiredMods) { win->onMenu(win, e->cmdId); return true; } } (void)ctx; return false; } // ============================================================ // clickMenuCheckRadio — toggle check or select radio on click // ============================================================ // // Check items simply toggle. Radio items use an implicit grouping // strategy: consecutive radio-type items in the menu array form a group. // This avoids needing an explicit group ID field. When a radio item is // clicked, we scan backward and forward from it to find the group // boundaries, then uncheck everything in the group except the clicked // item. This is the same approach Windows uses for menu radio groups. static void clickMenuCheckRadio(MenuT *menu, int32_t itemIdx) { MenuItemT *item = &menu->items[itemIdx]; if (item->type == MenuItemCheckE) { item->checked = !item->checked; } else if (item->type == MenuItemRadioE) { int32_t groupStart = itemIdx; while (groupStart > 0 && menu->items[groupStart - 1].type == MenuItemRadioE) { groupStart--; } int32_t groupEnd = itemIdx; while (groupEnd < menu->itemCount - 1 && menu->items[groupEnd + 1].type == MenuItemRadioE) { groupEnd++; } for (int32_t i = groupStart; i <= groupEnd; i++) { menu->items[i].checked = (i == itemIdx); } } } // ============================================================ // closeAllPopups — dirty all popup levels and deactivate // ============================================================ // // Popup menus can be nested (submenus). The popup system uses a stack // (parentStack) where the current level is always in popup.menu/popupX/etc. // and parent levels are saved in parentStack[0..depth-1]. Closing all // popups means dirtying every level's screen area so the compositor // repaints those regions, then resetting state. We must dirty every // level individually because submenus may not overlap their parents. 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); } // Clear the depressed menu bar item WindowT *popupWin = findWindowById(ctx, ctx->popup.windowId); if (popupWin && popupWin->menuBar && popupWin->menuBar->activeIdx >= 0) { popupWin->menuBar->activeIdx = -1; dirtyListAdd(&ctx->dirty, popupWin->x, popupWin->y, popupWin->w, CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT + CHROME_MENU_HEIGHT); } ctx->popup.active = false; ctx->popup.depth = 0; } // ============================================================ // closePopupLevel — close one submenu level (or deactivate if top) // ============================================================ // // Pops one level off the popup stack. If we're already at the top // level (depth==0), the entire popup system is deactivated. Otherwise, // the parent level's state is restored as the new "current" level. // This enables Left Arrow to close a submenu and return to the parent, // matching standard Windows/Motif keyboard navigation. 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 { // Clear the depressed menu bar item WindowT *popupWin = findWindowById(ctx, ctx->popup.windowId); if (popupWin && popupWin->menuBar && popupWin->menuBar->activeIdx >= 0) { popupWin->menuBar->activeIdx = -1; dirtyListAdd(&ctx->dirty, popupWin->x, popupWin->y, popupWin->w, CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT + CHROME_MENU_HEIGHT); } ctx->popup.active = false; } } // ============================================================ // closeSysMenu // ============================================================ static void closeSysMenu(AppContextT *ctx) { if (ctx->sysMenu.active) { dirtyListAdd(&ctx->dirty, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH); ctx->sysMenu.active = false; } } // ============================================================ // compositeAndFlush // ============================================================ // // The compositor is the heart of the rendering pipeline. For each dirty // rectangle, it redraws the entire Z-ordered scene into the backbuffer // (system RAM) then flushes that rectangle to the LFB (video memory). // // Rendering order per dirty rect (painter's algorithm, back-to-front): // 1. Desktop background fill // 2. Minimized window icons (always below all windows) // 3. Non-minimized windows, bottom-to-top (chrome + content + scrollbars) // 4. Popup menus (all levels, parent first, then current/deepest) // 4b. System menu (window close-gadget menu) // 5. Tooltip // 6. Hardware cursor (software-rendered, always on top) // 7. Flush dirty rect from backbuffer to LFB // // Pre-filtering the visible window list avoids redundant minimized/hidden // checks in the inner loop. The clip rect is set to each dirty rect so // draw calls outside the rect are automatically clipped by the draw layer, // avoiding unnecessary pixel writes. static void compositeAndFlush(AppContextT *ctx) { DisplayT *d = &ctx->display; BlitOpsT *ops = &ctx->blitOps; DirtyListT *dl = &ctx->dirty; WindowStackT *ws = &ctx->stack; // Merge overlapping dirty rects to reduce flush count dirtyListMerge(dl); // Pre-filter visible, non-minimized windows once to avoid // re-checking visibility in the inner dirty-rect loop int32_t visibleIdx[MAX_WINDOWS]; int32_t visibleCount = 0; for (int32_t j = 0; j < ws->count; j++) { WindowT *win = ws->windows[j]; if (win->visible && !win->minimized) { visibleIdx[visibleCount++] = j; } } for (int32_t i = 0; i < dl->count; i++) { RectT *dr = &dl->rects[i]; // Clip dirty rect to screen bounds if (dr->x < 0) { dr->w += dr->x; dr->x = 0; } if (dr->y < 0) { dr->h += dr->y; dr->y = 0; } if (dr->x + dr->w > d->width) { dr->w = d->width - dr->x; } if (dr->y + dr->h > d->height) { dr->h = d->height - dr->y; } if (dr->w <= 0 || dr->h <= 0) { continue; } // Set clip rect to this dirty rect setClipRect(d, dr->x, dr->y, dr->w, dr->h); // 1. Draw desktop background (wallpaper or solid color) if (ctx->wallpaperBuf) { int32_t bytesPerPx = d->format.bitsPerPixel / 8; for (int32_t row = dr->y; row < dr->y + dr->h; row++) { uint8_t *src = ctx->wallpaperBuf + row * ctx->wallpaperPitch + dr->x * bytesPerPx; uint8_t *dst = d->backBuf + row * d->pitch + dr->x * bytesPerPx; memcpy(dst, src, dr->w * bytesPerPx); } } else { rectFill(d, ops, dr->x, dr->y, dr->w, dr->h, ctx->colors.desktop); } // 2. Draw minimized window icons (under all windows) wmDrawMinimizedIcons(d, ops, &ctx->colors, ws, dr); // 3. Walk pre-filtered visible window list bottom-to-top for (int32_t vi = 0; vi < visibleCount; vi++) { WindowT *win = ws->windows[visibleIdx[vi]]; // Check if window intersects this dirty rect RectT winRect = {win->x, win->y, win->w, win->h}; RectT isect; if (!rectIntersect(dr, &winRect, &isect)) { continue; } wmDrawChrome(d, ops, &ctx->font, &ctx->colors, win, dr); wmDrawContent(d, ops, win, dr); if (win->vScroll || win->hScroll) { wmDrawScrollbars(d, ops, &ctx->colors, win, dr); } } // 4. Draw popup menu if active (all levels) if (ctx->popup.active) { // 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 if (ctx->sysMenu.active) { RectT smRect = { ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH }; RectT smIsect; if (rectIntersect(dr, &smRect, &smIsect)) { BevelStyleT smBevel; smBevel.highlight = ctx->colors.windowHighlight; smBevel.shadow = ctx->colors.windowShadow; smBevel.face = ctx->colors.menuBg; smBevel.width = POPUP_BEVEL_WIDTH; drawBevel(d, ops, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH, &smBevel); int32_t itemY = ctx->sysMenu.popupY + POPUP_BEVEL_WIDTH; for (int32_t k = 0; k < ctx->sysMenu.itemCount; k++) { SysMenuItemT *item = &ctx->sysMenu.items[k]; if (item->separator) { drawHLine(d, ops, ctx->sysMenu.popupX + POPUP_BEVEL_WIDTH, itemY + ctx->font.charHeight / 2, ctx->sysMenu.popupW - POPUP_BEVEL_WIDTH * 2, ctx->colors.windowShadow); itemY += ctx->font.charHeight; continue; } uint32_t bg = ctx->colors.menuBg; uint32_t fg = item->enabled ? ctx->colors.menuFg : ctx->colors.windowShadow; if (k == ctx->sysMenu.hoverItem && item->enabled) { bg = ctx->colors.menuHighlightBg; fg = ctx->colors.menuHighlightFg; } rectFill(d, ops, ctx->sysMenu.popupX + POPUP_BEVEL_WIDTH, itemY, ctx->sysMenu.popupW - POPUP_BEVEL_WIDTH * 2, ctx->font.charHeight, bg); drawTextAccel(d, ops, &ctx->font, ctx->sysMenu.popupX + CHROME_TITLE_PAD + POPUP_BEVEL_WIDTH, itemY, item->label, fg, bg, true); itemY += ctx->font.charHeight; } } } // 5. Draw tooltip if (ctx->tooltipText) { RectT ttRect = { ctx->tooltipX, ctx->tooltipY, ctx->tooltipW, ctx->tooltipH }; RectT ttIsect; if (rectIntersect(dr, &ttRect, &ttIsect)) { rectFill(d, ops, ctx->tooltipX, ctx->tooltipY, ctx->tooltipW, ctx->tooltipH, ctx->colors.menuBg); drawHLine(d, ops, ctx->tooltipX, ctx->tooltipY, ctx->tooltipW, ctx->colors.contentFg); drawHLine(d, ops, ctx->tooltipX, ctx->tooltipY + ctx->tooltipH - 1, ctx->tooltipW, ctx->colors.contentFg); drawVLine(d, ops, ctx->tooltipX, ctx->tooltipY, ctx->tooltipH, ctx->colors.contentFg); drawVLine(d, ops, ctx->tooltipX + ctx->tooltipW - 1, ctx->tooltipY, ctx->tooltipH, ctx->colors.contentFg); drawText(d, ops, &ctx->font, ctx->tooltipX + TOOLTIP_PAD, ctx->tooltipY + TOOLTIP_PAD, ctx->tooltipText, ctx->colors.menuFg, ctx->colors.menuBg, true); } } // 6. Draw cursor drawCursorAt(ctx, ctx->mouseX, ctx->mouseY); // 6. Flush this dirty rect to LFB flushRect(d, dr); } resetClipRect(d); dirtyListClear(dl); } // ============================================================ // dirtyCursorArea // ============================================================ // // Dirties a 23x23 pixel area centered on the worst-case cursor bounds. // We use a fixed size that covers ALL cursor shapes rather than the // current shape's exact bounds. This handles the case where the cursor // shape changes between frames (e.g., arrow to resize) — we need to // erase the old shape AND draw the new one, and both might have // different hotspot offsets. The 23x23 area is the union of all possible // cursor footprints (16x16 with hotspot at 0,0 or 7,7). static void dirtyCursorArea(AppContextT *ctx, int32_t x, int32_t y) { dirtyListAdd(&ctx->dirty, x - 7, y - 7, 23, 23); } // ============================================================ // dispatchAccelKey — route Alt+key to menu or widget // ============================================================ // // Handles Alt+letter keypresses. Menu bar accelerators are checked first // (e.g., Alt+F for File menu), then widget tree accelerators (e.g., // Alt+O for an "&OK" button). Labels use the '&' convention to mark the // accelerator character, matching Windows/Motif conventions. // // For non-focusable widgets like labels and frames, the accelerator // transfers focus to the next focusable sibling — this lets labels act // as access keys for adjacent input fields, following standard GUI idiom. static bool dispatchAccelKey(AppContextT *ctx, char key) { if (ctx->stack.focusedIdx < 0) { return false; } WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; // Menu bar accelerators take priority over widget accelerators if (win->menuBar) { for (int32_t i = 0; i < win->menuBar->menuCount; i++) { if (win->menuBar->menus[i].accelKey == key) { openPopupAtMenu(ctx, win, i); return true; } } } // Check widget tree if (win->widgetRoot) { WidgetT *target = widgetFindByAccel(win->widgetRoot, key); if (target) { switch (target->type) { case WidgetButtonE: if (sFocusedWidget) { sFocusedWidget->focused = false; } sFocusedWidget = target; target->focused = true; target->as.button.pressed = true; sKeyPressedBtn = target; wgtInvalidate(target); return true; case WidgetCheckboxE: if (sFocusedWidget) { sFocusedWidget->focused = false; } widgetCheckboxOnMouse(target, win->widgetRoot, 0, 0); sFocusedWidget = target; wgtInvalidate(target); return true; case WidgetRadioE: if (sFocusedWidget) { sFocusedWidget->focused = false; } widgetRadioOnMouse(target, win->widgetRoot, 0, 0); sFocusedWidget = target; wgtInvalidate(target); return true; case WidgetImageButtonE: if (sFocusedWidget) { sFocusedWidget->focused = false; } sFocusedWidget = target; target->focused = true; target->as.imageButton.pressed = true; sKeyPressedBtn = target; wgtInvalidate(target); return true; case WidgetTabPageE: { // Close any open dropdown/combobox popup if (sOpenPopup) { if (sOpenPopup->type == WidgetDropdownE) { sOpenPopup->as.dropdown.open = false; } else if (sOpenPopup->type == WidgetComboBoxE) { sOpenPopup->as.comboBox.open = false; } sOpenPopup = NULL; } // Activate this tab in its parent TabControl if (target->parent && target->parent->type == WidgetTabControlE) { int32_t tabIdx = 0; for (WidgetT *c = target->parent->firstChild; c; c = c->nextSibling) { if (c == target) { wgtTabControlSetActive(target->parent, tabIdx); break; } if (c->type == WidgetTabPageE) { tabIdx++; } } } return true; } case WidgetDropdownE: target->as.dropdown.open = true; target->as.dropdown.hoverIdx = target->as.dropdown.selectedIdx; sOpenPopup = target; if (sFocusedWidget) { sFocusedWidget->focused = false; } sFocusedWidget = target; target->focused = true; wgtInvalidate(win->widgetRoot); return true; case WidgetComboBoxE: target->as.comboBox.open = true; target->as.comboBox.hoverIdx = target->as.comboBox.selectedIdx; sOpenPopup = target; if (sFocusedWidget) { sFocusedWidget->focused = false; } sFocusedWidget = target; target->focused = true; wgtInvalidate(win->widgetRoot); return true; case WidgetLabelE: case WidgetFrameE: { // Focus the next focusable widget WidgetT *next = widgetFindNextFocusable(win->widgetRoot, target); if (next) { if (sFocusedWidget) { sFocusedWidget->focused = false; } sFocusedWidget = next; next->focused = true; // Open dropdown/combobox if that's the focused target if (next->type == WidgetDropdownE) { next->as.dropdown.open = true; next->as.dropdown.hoverIdx = next->as.dropdown.selectedIdx; sOpenPopup = next; } else if (next->type == WidgetComboBoxE) { next->as.comboBox.open = true; next->as.comboBox.hoverIdx = next->as.comboBox.selectedIdx; sOpenPopup = next; } wgtInvalidate(win->widgetRoot); } return true; } default: // For focusable widgets, just focus them if (widgetIsFocusable(target->type)) { if (sFocusedWidget) { sFocusedWidget->focused = false; } sFocusedWidget = target; target->focused = true; wgtInvalidate(win->widgetRoot); return true; } break; } } } return false; } // ============================================================ // dispatchEvents // ============================================================ // // Central event dispatcher, called once per frame after polling. Handles // mouse and input state changes in a priority chain: // 1. Cursor position changes (always dirty old+new positions) // 2. Active drag/resize/scroll operations (exclusive capture) // 3. System menu interaction // 4. Popup menu interaction (with cascading submenu support) // 5. Left-button press (window chrome hit testing and content delivery) // 6. Right-button press (context menu support) // 7. Button release and mouse-move events to focused window // // The priority chain means that once a drag is active, all mouse events // go to the drag handler until the button is released. This is simpler // and more robust than a general-purpose event capture mechanism. static void dispatchEvents(AppContextT *ctx) { int32_t mx = ctx->mouseX; int32_t my = ctx->mouseY; int32_t buttons = ctx->mouseButtons; int32_t prevBtn = ctx->prevMouseButtons; // Mouse movement always dirties old and new cursor positions if (mx != ctx->prevMouseX || my != ctx->prevMouseY) { dirtyCursorArea(ctx, ctx->prevMouseX, ctx->prevMouseY); dirtyCursorArea(ctx, mx, my); // Update cursor shape based on what the mouse is hovering over updateCursorShape(ctx); } // Handle active drag if (ctx->stack.dragWindow >= 0) { if (buttons & MOUSE_LEFT) { wmDragMove(&ctx->stack, &ctx->dirty, mx, my, ctx->display.width, ctx->display.height); } else { wmDragEnd(&ctx->stack); } return; } // Handle active resize if (ctx->stack.resizeWindow >= 0) { if (buttons & MOUSE_LEFT) { int32_t clampX = mx; int32_t clampY = my; wmResizeMove(&ctx->stack, &ctx->dirty, &ctx->display, &clampX, &clampY); if (clampX != mx || clampY != my) { platformMouseWarp(clampX, clampY); ctx->mouseX = clampX; ctx->mouseY = clampY; } } else { wmResizeEnd(&ctx->stack); } return; } // Handle active scrollbar thumb drag if (ctx->stack.scrollWindow >= 0) { if (buttons & MOUSE_LEFT) { wmScrollbarDrag(&ctx->stack, &ctx->dirty, mx, my); } else { wmScrollbarEnd(&ctx->stack); } return; } // Handle system menu interaction if (ctx->sysMenu.active) { if (mx >= ctx->sysMenu.popupX && mx < ctx->sysMenu.popupX + ctx->sysMenu.popupW && my >= ctx->sysMenu.popupY && my < ctx->sysMenu.popupY + ctx->sysMenu.popupH) { // Hover tracking int32_t relY = my - ctx->sysMenu.popupY - POPUP_BEVEL_WIDTH; int32_t itemIdx = (relY >= 0) ? (int32_t)((uint32_t)relY * ctx->charHeightRecip >> 16) : 0; if (itemIdx >= 0 && itemIdx < ctx->sysMenu.itemCount && ctx->sysMenu.items[itemIdx].separator) { itemIdx = -1; } if (itemIdx != ctx->sysMenu.hoverItem) { ctx->sysMenu.hoverItem = itemIdx; dirtyListAdd(&ctx->dirty, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH); } // Click on item if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) { if (itemIdx >= 0 && itemIdx < ctx->sysMenu.itemCount) { SysMenuItemT *item = &ctx->sysMenu.items[itemIdx]; if (item->enabled && !item->separator) { executeSysMenuCmd(ctx, item->cmd); } } } return; } // Click outside system menu — close it, let event fall through if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) { closeSysMenu(ctx); } } // Handle popup menu interaction (with cascading submenu support). // Popup menus form a stack (parentStack) with the deepest submenu as // "current". Hit testing checks the current level first. If the mouse // is in a parent level instead, all deeper levels are closed (popped) // so the user can navigate back up the submenu tree by moving the // mouse to a parent menu. This matches Win3.x cascading menu behavior. if (ctx->popup.active) { bool inCurrent = (mx >= ctx->popup.popupX && mx < ctx->popup.popupX + ctx->popup.popupW && my >= ctx->popup.popupY && my < ctx->popup.popupY + ctx->popup.popupH); if (inCurrent) { // Hover tracking: convert mouse Y to item index using fixed-point // reciprocal multiply instead of integer divide. This avoids a // costly division on 486 hardware where div can take 40+ cycles. int32_t relY = my - ctx->popup.popupY - POPUP_BEVEL_WIDTH; int32_t itemIdx = (relY >= 0) ? (int32_t)((uint32_t)relY * ctx->charHeightRecip >> 16) : 0; 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); // 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 (hItem->subMenu && hItem->enabled) { openSubMenu(ctx); } } // 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 & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) { 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) { // Toggle check/radio state before closing if (item->type == MenuItemCheckE || item->type == MenuItemRadioE) { clickMenuCheckRadio(ctx->popup.menu, itemIdx); } // Close popup BEFORE calling onMenu because the menu // handler may open a modal dialog, which runs a nested // dvxUpdate loop. If the popup were still active, the // nested loop would try to draw/interact with a stale popup. 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 - POPUP_BEVEL_WIDTH; int32_t itemIdx = (relY >= 0) ? (int32_t)((uint32_t)relY * ctx->charHeightRecip >> 16) : 0; 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) { if (ctx->popup.isContextMenu) { // Context menu: any click outside closes it if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) { closeAllPopups(ctx); } } else { // Menu bar popup: check if mouse is on the menu bar for switching 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 ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT) && i == ctx->popup.menuIdx && ctx->popup.depth == 0) { // Clicking the same menu bar entry closes the menu closeAllPopups(ctx); } else if (i != ctx->popup.menuIdx || ctx->popup.depth > 0) { openPopupAtMenu(ctx, win, i); } break; } } } else if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) { closeAllPopups(ctx); } } else if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) { closeAllPopups(ctx); } } } } return; } // Handle left button press if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) { handleMouseButton(ctx, mx, my, buttons); } // Handle right button press — context menus. // Context menu resolution walks UP the widget tree from the hit widget // to find the nearest ancestor with a contextMenu set, then falls back // to the window-level context menu. This lets containers provide menus // that apply to all their children without requiring each child to have // its own menu, while still allowing per-widget overrides. if ((buttons & MOUSE_RIGHT) && !(prevBtn & MOUSE_RIGHT)) { int32_t hitPart; int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart); if (hitIdx >= 0 && hitPart == HIT_CONTENT) { WindowT *win = ctx->stack.windows[hitIdx]; if (hitIdx != ctx->stack.focusedIdx) { wmRaiseWindow(&ctx->stack, &ctx->dirty, hitIdx); hitIdx = ctx->stack.count - 1; wmSetFocus(&ctx->stack, &ctx->dirty, hitIdx); win = ctx->stack.windows[hitIdx]; } MenuT *ctxMenu = NULL; if (win->widgetRoot) { int32_t relX = mx - win->x - win->contentX; int32_t relY = my - win->y - win->contentY; WidgetT *hit = widgetHitTest(win->widgetRoot, relX, relY); while (hit && !hit->contextMenu) { hit = hit->parent; } if (hit) { ctxMenu = hit->contextMenu; } } if (!ctxMenu) { ctxMenu = win->contextMenu; } if (ctxMenu) { openContextMenu(ctx, win, ctxMenu, mx, my); } } } // Handle button release on content — send to focused window if (!(buttons & MOUSE_LEFT) && (prevBtn & MOUSE_LEFT)) { if (ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; if (win->onMouse) { int32_t relX = mx - win->x - win->contentX; int32_t relY = my - win->y - win->contentY; win->onMouse(win, relX, relY, buttons); } } } // Mouse movement in content area — send to focused window if ((mx != ctx->prevMouseX || my != ctx->prevMouseY) && ctx->stack.focusedIdx >= 0 && (buttons & MOUSE_LEFT)) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; if (win->onMouse) { int32_t relX = mx - win->x - win->contentX; int32_t relY = my - win->y - win->contentY; win->onMouse(win, relX, relY, buttons); } } // Mouse wheel — scroll the focused window's vertical scrollbar. // Each notch moves MOUSE_WHEEL_STEP lines. If no vertical scrollbar, // try horizontal (for windows with only horizontal scroll). if (ctx->mouseWheel != 0 && ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; ScrollbarT *sb = win->vScroll ? win->vScroll : win->hScroll; if (sb) { int32_t oldValue = sb->value; sb->value += ctx->mouseWheel * ctx->wheelDirection * MOUSE_WHEEL_STEP; if (sb->value < sb->min) { sb->value = sb->min; } if (sb->value > sb->max) { sb->value = sb->max; } if (sb->value != oldValue) { int32_t sbScreenX = win->x + sb->x; int32_t sbScreenY = win->y + sb->y; dirtyListAdd(&ctx->dirty, sbScreenX, sbScreenY, sb->orient == ScrollbarVerticalE ? SCROLLBAR_WIDTH : sb->length, sb->orient == ScrollbarVerticalE ? sb->length : SCROLLBAR_WIDTH); if (win->onScroll) { win->onScroll(win, sb->orient, sb->value); } } } } } // ============================================================ // drawCursorAt // ============================================================ // // Software cursor rendering using AND/XOR mask pairs, the same format // Windows uses for cursor resources. The AND mask determines transparency // (0=opaque, 1=transparent) and the XOR mask determines color. This // lets cursors have transparent pixels without needing alpha blending, // which would be expensive on a 486. static void drawCursorAt(AppContextT *ctx, int32_t x, int32_t y) { const CursorT *cur = &ctx->cursors[ctx->cursorId]; drawMaskedBitmap(&ctx->display, &ctx->blitOps, x - cur->hotX, y - cur->hotY, cur->width, cur->height, cur->andMask, cur->xorData, ctx->cursorFg, ctx->cursorBg); } // ============================================================ // drawPopupLevel — draw one popup menu (bevel + items) // ============================================================ // // Draws a single popup menu level (the compositor calls this for each // level in the popup stack, parent-first). Each item gets: // - Full-width highlight bar when hovered // - Optional check/radio glyph in a left margin column // - Tab-split label: left part with underlined accelerator, right-aligned shortcut // - Submenu arrow (right-pointing triangle) if item has a subMenu // - Separators drawn as horizontal lines // // The check margin is conditional: only present if any item in the menu // has check or radio type, keeping non-checkable menus compact. 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; } // Detect if menu has check/radio items (for left margin) bool hasCheck = false; for (int32_t k = 0; k < menu->itemCount; k++) { if (menu->items[k].type == MenuItemCheckE || menu->items[k].type == MenuItemRadioE) { hasCheck = true; break; } } int32_t checkMargin = hasCheck ? MENU_CHECK_WIDTH : 0; // Draw popup background BevelStyleT popBevel; popBevel.highlight = ctx->colors.windowHighlight; popBevel.shadow = ctx->colors.windowShadow; popBevel.face = ctx->colors.menuBg; popBevel.width = POPUP_BEVEL_WIDTH; drawBevel(d, ops, px, py, pw, ph, &popBevel); // Draw menu items int32_t itemY = py + POPUP_BEVEL_WIDTH; for (int32_t k = 0; k < menu->itemCount; k++) { const MenuItemT *item = &menu->items[k]; if (item->separator) { drawHLine(d, ops, px + POPUP_BEVEL_WIDTH, itemY + ctx->font.charHeight / 2, pw - POPUP_BEVEL_WIDTH * 2, 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 + POPUP_BEVEL_WIDTH, itemY, pw - POPUP_BEVEL_WIDTH * 2, ctx->font.charHeight, bg); // Split label at tab: left part is the menu text, right part is the shortcut const char *tab = strchr(item->label, '\t'); if (tab) { char leftBuf[MAX_MENU_LABEL]; int32_t leftLen = (int32_t)(tab - item->label); if (leftLen >= MAX_MENU_LABEL) { leftLen = MAX_MENU_LABEL - 1; } memcpy(leftBuf, item->label, leftLen); leftBuf[leftLen] = '\0'; drawTextAccel(d, ops, &ctx->font, px + CHROME_TITLE_PAD + POPUP_BEVEL_WIDTH + checkMargin, itemY, leftBuf, fg, bg, true); const char *right = tab + 1; int32_t rightW = textWidth(&ctx->font, right); int32_t rightX = px + pw - rightW - CHROME_TITLE_PAD - POPUP_BEVEL_WIDTH * 2; if (item->subMenu) { rightX -= SUBMENU_ARROW_WIDTH; } drawText(d, ops, &ctx->font, rightX, itemY, right, fg, bg, true); } else { drawTextAccel(d, ops, &ctx->font, px + CHROME_TITLE_PAD + POPUP_BEVEL_WIDTH + checkMargin, itemY, item->label, fg, bg, true); } // Draw check/radio indicator if (item->checked) { int32_t cy = itemY + ctx->font.charHeight / 2; int32_t cx = px + POPUP_BEVEL_WIDTH + MENU_CHECK_WIDTH / 2; if (item->type == MenuItemCheckE) { // Checkmark: small tick shape drawVLine(d, ops, cx - 2, cy - 1, 2, fg); drawVLine(d, ops, cx - 1, cy, 2, fg); drawVLine(d, ops, cx, cy + 1, 2, fg); drawVLine(d, ops, cx + 1, cy, 2, fg); drawVLine(d, ops, cx + 2, cy - 1, 2, fg); drawVLine(d, ops, cx + 3, cy - 2, 2, fg); } else if (item->type == MenuItemRadioE) { // Filled diamond bullet (5x5) drawHLine(d, ops, cx, cy - 2, 1, fg); drawHLine(d, ops, cx - 1, cy - 1, 3, fg); drawHLine(d, ops, cx - 2, cy, 5, fg); drawHLine(d, ops, cx - 1, cy + 1, 3, fg); drawHLine(d, ops, cx, cy + 2, 1, fg); } } // Draw submenu arrow indicator if (item->subMenu) { int32_t arrowX = px + pw - SUBMENU_ARROW_WIDTH - POPUP_BEVEL_WIDTH; int32_t arrowY = itemY + ctx->font.charHeight / 2; for (int32_t row = -SUBMENU_ARROW_HALF; row <= SUBMENU_ARROW_HALF; row++) { int32_t len = SUBMENU_ARROW_HALF + 1 - (row < 0 ? -row : row); if (len > 0) { drawHLine(d, ops, arrowX + (row < 0 ? SUBMENU_ARROW_HALF + row : SUBMENU_ARROW_HALF - row), arrowY + row, len, fg); } } } itemY += ctx->font.charHeight; } } // ============================================================ // dvxCreateWindow // ============================================================ WindowT *dvxCreateWindow(AppContextT *ctx, const char *title, int32_t x, int32_t y, int32_t w, int32_t h, bool resizable) { WindowT *win = wmCreateWindow(&ctx->stack, &ctx->display, title, x, y, w, h, resizable); if (win) { // Raise and focus int32_t idx = ctx->stack.count - 1; wmSetFocus(&ctx->stack, &ctx->dirty, idx); dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h); } return win; } // ============================================================ // dvxCreateWindowCentered // ============================================================ WindowT *dvxCreateWindowCentered(AppContextT *ctx, const char *title, int32_t w, int32_t h, bool resizable) { int32_t x = (ctx->display.width - w) / 2; int32_t y = (ctx->display.height - h) / 2; return dvxCreateWindow(ctx, title, x, y, w, h, resizable); } // ============================================================ // dvxAddAccel // ============================================================ // // Accelerator entries are pre-normalized at registration time: the key // is uppercased and modifier bits are masked to just Ctrl|Alt. This // moves the normalization cost from the hot path (every keypress) to // the cold path (one-time setup), so checkAccelTable can do a simple // integer compare per entry. void dvxAddAccel(AccelTableT *table, int32_t key, int32_t modifiers, int32_t cmdId) { if (!table || table->count >= MAX_ACCEL_ENTRIES) { return; } int32_t normKey = key; if (normKey >= 'a' && normKey <= 'z') { normKey = normKey - 32; } AccelEntryT *e = &table->entries[table->count++]; e->key = key; e->modifiers = modifiers; e->cmdId = cmdId; e->normKey = normKey; e->normMods = modifiers & (ACCEL_CTRL | ACCEL_ALT); } // ============================================================ // dvxClipboardCopy / dvxClipboardGet // ============================================================ // // The clipboard is a simple in-process text buffer managed by the // platform layer (clipboardCopy/clipboardGet). There is no inter-process // clipboard because DVX runs as a single-process windowing system — all // windows share the same address space. The thin wrappers here exist to // keep the platform layer out of application code's include path. void dvxClipboardCopy(const char *text, int32_t len) { clipboardCopy(text, len); } const char *dvxClipboardGet(int32_t *outLen) { return clipboardGet(outLen); } // ============================================================ // Color scheme — name table and indexed access // ============================================================ static const char *sColorNames[ColorCountE] = { "desktop", "windowFace", "windowHighlight", "windowShadow", "activeTitleBg", "activeTitleFg", "inactiveTitleBg", "inactiveTitleFg", "contentBg", "contentFg", "menuBg", "menuFg", "menuHighlightBg", "menuHighlightFg", "buttonFace", "scrollbarBg", "scrollbarFg", "scrollbarTrough" }; // Default GEOS Ensemble Motif-style colors (RGB triplets) static const uint8_t sDefaultColors[ColorCountE][3] = { { 0, 128, 128}, // desktop — GEOS teal {192, 192, 192}, // windowFace {255, 255, 255}, // windowHighlight {128, 128, 128}, // windowShadow { 48, 48, 48}, // activeTitleBg — dark charcoal {255, 255, 255}, // activeTitleFg {160, 160, 160}, // inactiveTitleBg { 64, 64, 64}, // inactiveTitleFg {192, 192, 192}, // contentBg { 0, 0, 0}, // contentFg {192, 192, 192}, // menuBg { 0, 0, 0}, // menuFg { 48, 48, 48}, // menuHighlightBg {255, 255, 255}, // menuHighlightFg {192, 192, 192}, // buttonFace {192, 192, 192}, // scrollbarBg {128, 128, 128}, // scrollbarFg {160, 160, 160}, // scrollbarTrough }; // Access the packed color value in ColorSchemeT by index. static uint32_t *colorSlot(ColorSchemeT *cs, ColorIdE id) { return ((uint32_t *)cs) + (int32_t)id; } // ============================================================ // dvxColorName // ============================================================ const char *dvxColorName(ColorIdE id) { if (id < 0 || id >= ColorCountE) { return "unknown"; } return sColorNames[id]; } // ============================================================ // dvxApplyColorScheme // ============================================================ void dvxApplyColorScheme(AppContextT *ctx) { DisplayT *d = &ctx->display; for (int32_t i = 0; i < ColorCountE; i++) { *colorSlot(&ctx->colors, (ColorIdE)i) = packColor(d, ctx->colorRgb[i][0], ctx->colorRgb[i][1], ctx->colorRgb[i][2]); } // Repaint everything dirtyListAdd(&ctx->dirty, 0, 0, d->width, d->height); } // ============================================================ // dvxResetColorScheme // ============================================================ void dvxResetColorScheme(AppContextT *ctx) { memcpy(ctx->colorRgb, sDefaultColors, sizeof(sDefaultColors)); dvxApplyColorScheme(ctx); } // ============================================================ // dvxCascadeWindows // ============================================================ // // Arranges windows in the classic cascade pattern: each window is the same // size (2/3 of screen), offset diagonally by the title bar height so each // title bar remains visible. When the cascade would go off-screen, it wraps // back to (0,0). This matches DESQview/X and Windows 3.x cascade behavior. // The step size is title_height + border_width so exactly one title bar's // worth of the previous window peeks out above and to the left. void dvxCascadeWindows(AppContextT *ctx) { int32_t screenW = ctx->display.width; int32_t screenH = ctx->display.height; int32_t offsetX = 0; int32_t offsetY = 0; int32_t step = CHROME_TITLE_HEIGHT + CHROME_BORDER_WIDTH; int32_t winW = screenW * 2 / 3; int32_t winH = screenH * 2 / 3; if (winW < MIN_WINDOW_W) { winW = MIN_WINDOW_W; } if (winH < MIN_WINDOW_H) { winH = MIN_WINDOW_H; } for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; if (win->minimized || !win->visible) { continue; } repositionWindow(ctx, win, offsetX, offsetY, winW, winH); offsetX += step; offsetY += step; // Wrap around if we'd go off screen if (offsetX + winW > screenW || offsetY + winH > screenH) { offsetX = 0; offsetY = 0; } } } // ============================================================ // dvxChangeVideoMode // ============================================================ // // Live video mode switch. Saves the old display state, attempts to // set the new mode, and if successful, reinitializes all dependent // subsystems: blit ops, colors, cursors, mouse range, wallpaper, // and all window content buffers. Windows larger than the new // screen are clamped. On failure, the old mode is restored. int32_t dvxChangeVideoMode(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) { // Save old state for rollback DisplayT oldDisplay = ctx->display; // Stash old wallpaper (don't free — we may need it for rollback) uint8_t *oldWpBuf = ctx->wallpaperBuf; int32_t oldWpPitch = ctx->wallpaperPitch; ctx->wallpaperBuf = NULL; ctx->wallpaperPitch = 0; // Free old video buffers (no text mode restore) platformVideoFreeBuffers(&ctx->display); // Try the new mode if (videoInit(&ctx->display, requestedW, requestedH, preferredBpp) != 0) { // Restore old mode ctx->display = oldDisplay; ctx->display.backBuf = NULL; ctx->display.palette = NULL; if (videoInit(&ctx->display, oldDisplay.width, oldDisplay.height, oldDisplay.format.bitsPerPixel) != 0) { // Both failed — catastrophic return -1; } // Restore wallpaper ctx->wallpaperBuf = oldWpBuf; ctx->wallpaperPitch = oldWpPitch; drawInit(&ctx->blitOps, &ctx->display); dvxApplyColorScheme(ctx); return -1; } // New mode succeeded — free old wallpaper free(oldWpBuf); // Reinit blit ops for new pixel format drawInit(&ctx->blitOps, &ctx->display); // Repack all colors for new pixel format dvxApplyColorScheme(ctx); // Repack cursor colors ctx->cursorFg = packColor(&ctx->display, 255, 255, 255); ctx->cursorBg = packColor(&ctx->display, 0, 0, 0); // Reinit mouse range platformMouseInit(ctx->display.width, ctx->display.height); ctx->hasMouseWheel = platformMouseWheelInit(); // Clamp mouse position to new screen if (ctx->mouseX >= ctx->display.width) { ctx->mouseX = ctx->display.width - 1; } if (ctx->mouseY >= ctx->display.height) { ctx->mouseY = ctx->display.height - 1; } // Clamp and reallocate all window content buffers for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; // Clamp window position to new screen bounds if (win->x + win->w > ctx->display.width) { win->x = ctx->display.width - win->w; } if (win->x < 0) { win->x = 0; win->w = ctx->display.width; } if (win->y + win->h > ctx->display.height) { win->y = ctx->display.height - win->h; } if (win->y < 0) { win->y = 0; win->h = ctx->display.height; } // Clear maximized flag since screen size changed win->maximized = false; wmUpdateContentRect(win); wmReallocContentBuf(win, &ctx->display); if (win->onResize) { win->onResize(win, win->contentW, win->contentH); } if (win->onPaint) { RectT fullRect = {0, 0, win->contentW, win->contentH}; win->onPaint(win, &fullRect); win->contentDirty = true; } } // Reset clip and dirty the full screen resetClipRect(&ctx->display); dirtyListInit(&ctx->dirty); dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height); return 0; } // ============================================================ // dvxCreateAccelTable // ============================================================ AccelTableT *dvxCreateAccelTable(void) { AccelTableT *table = (AccelTableT *)calloc(1, sizeof(AccelTableT)); return table; } // ============================================================ // dvxDestroyWindow // ============================================================ void dvxDestroyWindow(AppContextT *ctx, WindowT *win) { dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h); wmDestroyWindow(&ctx->stack, win); // Focus the new top window if (ctx->stack.count > 0) { wmSetFocus(&ctx->stack, &ctx->dirty, ctx->stack.count - 1); } } // ============================================================ // dvxFitWindow // ============================================================ // // Resizes a window to exactly fit its widget tree's minimum size, // accounting for chrome overhead (title bar, borders, optional menu bar). // Used after building a dialog's widget tree to size the dialog // automatically rather than requiring the caller to compute sizes manually. void dvxFitWindow(AppContextT *ctx, WindowT *win) { if (!ctx || !win || !win->widgetRoot) { return; } // Measure the widget tree to get minimum content size widgetCalcMinSizeTree(win->widgetRoot, &ctx->font); int32_t contentW = win->widgetRoot->calcMinW; int32_t contentH = win->widgetRoot->calcMinH; // Compute chrome overhead int32_t topChrome = CHROME_TOTAL_TOP; if (win->menuBar) { topChrome += CHROME_MENU_HEIGHT; } int32_t newW = contentW + CHROME_TOTAL_SIDE * 2; int32_t newH = contentH + topChrome + CHROME_TOTAL_BOTTOM; // Dirty old position dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h); // Resize win->w = newW; win->h = newH; wmUpdateContentRect(win); wmReallocContentBuf(win, &ctx->display); // Dirty new position dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h); // Invalidate widget tree so it repaints at the new size wgtInvalidate(win->widgetRoot); } // ============================================================ // dvxFreeAccelTable // ============================================================ void dvxFreeAccelTable(AccelTableT *table) { free(table); } // ============================================================ // dvxFreeImage // ============================================================ void dvxFreeImage(uint8_t *data) { free(data); } // ============================================================ // dvxGetBlitOps // ============================================================ const BlitOpsT *dvxGetBlitOps(const AppContextT *ctx) { return &ctx->blitOps; } // ============================================================ // dvxGetColors // ============================================================ const ColorSchemeT *dvxGetColors(const AppContextT *ctx) { return &ctx->colors; } // ============================================================ // dvxGetDisplay // ============================================================ DisplayT *dvxGetDisplay(AppContextT *ctx) { return &ctx->display; } // ============================================================ // dvxGetFont // ============================================================ const BitmapFontT *dvxGetFont(const AppContextT *ctx) { return &ctx->font; } // ============================================================ // dvxGetColor // ============================================================ void dvxGetColor(const AppContextT *ctx, ColorIdE id, uint8_t *r, uint8_t *g, uint8_t *b) { if (id < 0 || id >= ColorCountE) { *r = *g = *b = 0; return; } *r = ctx->colorRgb[id][0]; *g = ctx->colorRgb[id][1]; *b = ctx->colorRgb[id][2]; } // ============================================================ // dvxLoadTheme // ============================================================ bool dvxLoadTheme(AppContextT *ctx, const char *filename) { FILE *fp = fopen(filename, "rb"); if (!fp) { return false; } char line[256]; while (fgets(line, sizeof(line), fp)) { // Strip trailing whitespace char *end = line + strlen(line) - 1; while (end >= line && (*end == '\r' || *end == '\n' || *end == ' ')) { *end-- = '\0'; } char *p = line; while (*p == ' ' || *p == '\t') { p++; } // Skip comments, blank lines, section headers if (*p == '\0' || *p == ';' || *p == '#' || *p == '[') { continue; } // Parse key = r,g,b char *eq = strchr(p, '='); if (!eq) { continue; } *eq = '\0'; // Trim key char *key = p; char *keyEnd = eq - 1; while (keyEnd >= key && (*keyEnd == ' ' || *keyEnd == '\t')) { *keyEnd-- = '\0'; } // Parse r,g,b char *val = eq + 1; while (*val == ' ' || *val == '\t') { val++; } int32_t r; int32_t g; int32_t b; if (sscanf(val, "%d,%d,%d", &r, &g, &b) != 3) { continue; } // Find matching color name for (int32_t i = 0; i < ColorCountE; i++) { if (strcmp(key, sColorNames[i]) == 0) { ctx->colorRgb[i][0] = (uint8_t)r; ctx->colorRgb[i][1] = (uint8_t)g; ctx->colorRgb[i][2] = (uint8_t)b; break; } } } fclose(fp); dvxApplyColorScheme(ctx); return true; } // ============================================================ // dvxSaveTheme // ============================================================ bool dvxSaveTheme(const AppContextT *ctx, const char *filename) { FILE *fp = fopen(filename, "wb"); if (!fp) { return false; } fprintf(fp, "; DVX Color Theme\r\n\r\n[colors]\r\n"); for (int32_t i = 0; i < ColorCountE; i++) { fprintf(fp, "%-20s = %d,%d,%d\r\n", sColorNames[i], ctx->colorRgb[i][0], ctx->colorRgb[i][1], ctx->colorRgb[i][2]); } fclose(fp); return true; } // ============================================================ // dvxSetColor // ============================================================ void dvxSetColor(AppContextT *ctx, ColorIdE id, uint8_t r, uint8_t g, uint8_t b) { if (id < 0 || id >= ColorCountE) { return; } ctx->colorRgb[id][0] = r; ctx->colorRgb[id][1] = g; ctx->colorRgb[id][2] = b; *colorSlot(&ctx->colors, id) = packColor(&ctx->display, r, g, b); dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height); } // ============================================================ // dvxSetWallpaper // ============================================================ bool dvxSetWallpaper(AppContextT *ctx, const char *path) { // Free existing wallpaper free(ctx->wallpaperBuf); ctx->wallpaperBuf = NULL; ctx->wallpaperPitch = 0; if (!path) { dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height); return true; } int32_t imgW; int32_t imgH; int32_t channels; uint8_t *rgb = stbi_load(path, &imgW, &imgH, &channels, 3); if (!rgb) { return false; } // Pre-scale to screen dimensions using bilinear interpolation and // convert to native pixel format. Bilinear samples the 4 nearest // source pixels and blends by fractional distance, producing smooth // gradients instead of blocky nearest-neighbor artifacts. Uses // 8-bit fixed-point weights (256 = 1.0) to avoid floating point. int32_t screenW = ctx->display.width; int32_t screenH = ctx->display.height; int32_t bpp = ctx->display.format.bitsPerPixel; int32_t pitch = screenW * (bpp / 8); uint8_t *buf = (uint8_t *)malloc(pitch * screenH); if (!buf) { stbi_image_free(rgb); return false; } int32_t srcStride = imgW * 3; for (int32_t y = 0; y < screenH; y++) { // Fixed-point source Y: 16.16 int32_t srcYfp = (int32_t)((int64_t)y * imgH * 65536 / screenH); int32_t sy0 = srcYfp >> 16; int32_t sy1 = sy0 + 1; int32_t fy = (srcYfp >> 8) & 0xFF; // fractional Y (0-255) int32_t ify = 256 - fy; uint8_t *dst = buf + y * pitch; if (sy1 >= imgH) { sy1 = imgH - 1; } uint8_t *row0 = rgb + sy0 * srcStride; uint8_t *row1 = rgb + sy1 * srcStride; for (int32_t x = 0; x < screenW; x++) { int32_t srcXfp = (int32_t)((int64_t)x * imgW * 65536 / screenW); int32_t sx0 = srcXfp >> 16; int32_t sx1 = sx0 + 1; int32_t fx = (srcXfp >> 8) & 0xFF; int32_t ifx = 256 - fx; if (sx1 >= imgW) { sx1 = imgW - 1; } // Sample 4 source pixels uint8_t *p00 = row0 + sx0 * 3; uint8_t *p10 = row0 + sx1 * 3; uint8_t *p01 = row1 + sx0 * 3; uint8_t *p11 = row1 + sx1 * 3; // Bilinear blend (8-bit fixed-point, 256 = 1.0) int32_t r = (p00[0] * ifx * ify + p10[0] * fx * ify + p01[0] * ifx * fy + p11[0] * fx * fy) >> 16; int32_t g = (p00[1] * ifx * ify + p10[1] * fx * ify + p01[1] * ifx * fy + p11[1] * fx * fy) >> 16; int32_t b = (p00[2] * ifx * ify + p10[2] * fx * ify + p01[2] * ifx * fy + p11[2] * fx * fy) >> 16; uint32_t px = packColor(&ctx->display, (uint8_t)r, (uint8_t)g, (uint8_t)b); if (bpp == 8) { dst[x] = (uint8_t)px; } else if (bpp == 15 || bpp == 16) { ((uint16_t *)dst)[x] = (uint16_t)px; } else { ((uint32_t *)dst)[x] = px; } } } stbi_image_free(rgb); ctx->wallpaperBuf = buf; ctx->wallpaperPitch = pitch; dirtyListAdd(&ctx->dirty, 0, 0, screenW, screenH); return true; } // ============================================================ // dvxInit // ============================================================ // // One-shot initialization of all GUI subsystems. The layered init order // matters: video must be up before draw ops can be selected (since draw // ops depend on pixel format), and colors must be packed after the // display format is known. int32_t dvxInit(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) { memset(ctx, 0, sizeof(*ctx)); platformInit(); if (videoInit(&ctx->display, requestedW, requestedH, preferredBpp) != 0) { return -1; } // Draw ops are pixel-format-dependent function pointers (e.g., 16bpp // vs 32bpp span fill). Selected once here, then used everywhere. drawInit(&ctx->blitOps, &ctx->display); wmInit(&ctx->stack); dirtyListInit(&ctx->dirty); // 8x16 is the only font size currently supported. Fixed-width bitmap // fonts are used throughout because variable-width text measurement // would add complexity and cost on every text draw without much // benefit at 640x480 resolution. ctx->font = dvxFont8x16; memcpy(ctx->cursors, dvxCursors, sizeof(dvxCursors)); ctx->cursorId = CURSOR_ARROW; initColorScheme(ctx); // Pre-pack cursor colors once. packColor converts RGB to the native // pixel format, which is too expensive to do per-frame. ctx->cursorFg = packColor(&ctx->display, 255, 255, 255); ctx->cursorBg = packColor(&ctx->display, 0, 0, 0); platformMouseInit(ctx->display.width, ctx->display.height); ctx->hasMouseWheel = platformMouseWheelInit(); ctx->mouseX = ctx->display.width / 2; ctx->mouseY = ctx->display.height / 2; ctx->prevMouseX = ctx->mouseX; ctx->prevMouseY = ctx->mouseY; ctx->running = true; ctx->lastIconClickId = -1; ctx->lastIconClickTime = 0; ctx->lastCloseClickId = -1; ctx->lastCloseClickTime = 0; ctx->lastTitleClickId = -1; ctx->lastTitleClickTime = 0; ctx->wheelDirection = 1; ctx->dblClickTicks = DBLCLICK_THRESHOLD; sDblClickTicks = DBLCLICK_THRESHOLD; // Pre-compute fixed-point 16.16 reciprocal of character height so // popup menu item index calculation can use multiply+shift instead // of division. On a 486, integer divide is 40+ cycles; this // reciprocal trick reduces it to ~10 cycles (imul + shr). ctx->charHeightRecip = ((uint32_t)0x10000 + (uint32_t)ctx->font.charHeight - 1) / (uint32_t)ctx->font.charHeight; // Dirty the entire screen so the first compositeAndFlush paints everything dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height); return 0; } // ============================================================ // dvxLoadImage // ============================================================ // // Public image loading API. Loads any image file supported by stb_image // (BMP, PNG, JPEG, GIF, etc.) and converts the RGB pixels to the // display's native pixel format for direct use with rectCopy, wgtImage, // wgtImageButton, or any other pixel-data consumer. The caller owns the // returned buffer and must free it with dvxFreeImage(). uint8_t *dvxLoadImage(const AppContextT *ctx, const char *path, int32_t *outW, int32_t *outH, int32_t *outPitch) { if (!ctx || !path) { return NULL; } const DisplayT *d = &ctx->display; int imgW; int imgH; int channels; uint8_t *rgb = stbi_load(path, &imgW, &imgH, &channels, 3); if (!rgb) { return NULL; } int32_t bpp = d->format.bytesPerPixel; int32_t pitch = imgW * bpp; uint8_t *buf = (uint8_t *)malloc(pitch * imgH); if (!buf) { stbi_image_free(rgb); return NULL; } for (int32_t y = 0; y < imgH; y++) { for (int32_t x = 0; x < imgW; x++) { const uint8_t *src = rgb + (y * imgW + x) * 3; uint32_t color = packColor(d, src[0], src[1], src[2]); uint8_t *dst = buf + y * pitch + x * bpp; if (bpp == 1) { *dst = (uint8_t)color; } else if (bpp == 2) { *(uint16_t *)dst = (uint16_t)color; } else { *(uint32_t *)dst = color; } } } stbi_image_free(rgb); if (outW) { *outW = imgW; } if (outH) { *outH = imgH; } if (outPitch) { *outPitch = pitch; } return buf; } // ============================================================ // dvxInvalidateRect // ============================================================ void dvxInvalidateRect(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h) { // Convert from content-relative to screen coordinates int32_t screenX = win->x + win->contentX + x; int32_t screenY = win->y + win->contentY + y; dirtyListAdd(&ctx->dirty, screenX, screenY, w, h); } // ============================================================ // dvxInvalidateWindow // ============================================================ void dvxInvalidateWindow(AppContextT *ctx, WindowT *win) { // Call the window's paint callback to update the content buffer // before marking the screen dirty. This means raw-paint apps only // need to call dvxInvalidateWindow — onPaint fires automatically. if (win->onPaint) { RectT fullRect = {0, 0, win->contentW, win->contentH}; win->onPaint(win, &fullRect); } win->contentDirty = true; dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h); } // ============================================================ // dvxMaximizeWindow // ============================================================ void dvxMaximizeWindow(AppContextT *ctx, WindowT *win) { wmMaximize(&ctx->stack, &ctx->dirty, &ctx->display, win); } // ============================================================ // dvxMinimizeWindow // ============================================================ void dvxMinimizeWindow(AppContextT *ctx, WindowT *win) { wmMinimize(&ctx->stack, &ctx->dirty, win); } // ============================================================ // dvxQuit // ============================================================ void dvxQuit(AppContextT *ctx) { ctx->running = false; } // ============================================================ // dvxRun // ============================================================ void dvxRun(AppContextT *ctx) { while (dvxUpdate(ctx)) { // dvxUpdate returns false when the GUI wants to exit } } // ============================================================ // dvxUpdate // ============================================================ // // Single iteration of the main event loop. This is the fundamental // heartbeat of the GUI. The sequence is: // 1. Poll hardware (mouse position/buttons, keyboard buffer) // 2. Dispatch events (route input to windows, menus, widgets) // 3. Update tooltip visibility // 4. Poll ANSI terminal widgets (check for new data from PTYs) // 5. Periodic tasks (minimized icon thumbnail refresh) // 6. Composite dirty regions and flush to LFB // 7. If nothing was dirty: run idle callback or yield CPU // // The idle callback mechanism exists so applications can do background // work (e.g., polling serial ports, processing network data) when the // GUI has nothing to paint. Without it, the loop would busy-wait or // yield the CPU slice. With it, the application gets a callback to do // useful work. platformYield is the fallback — it calls INT 28h (DOS // idle) or SDL_Delay (Linux) to avoid burning CPU when truly idle. bool dvxUpdate(AppContextT *ctx) { if (!ctx->running) { return false; } pollMouse(ctx); pollKeyboard(ctx); dispatchEvents(ctx); updateTooltip(ctx); pollAnsiTermWidgets(ctx); wgtUpdateCursorBlink(); ctx->frameCount++; if (ctx->frameCount % ICON_REFRESH_INTERVAL == 0) { refreshMinimizedIcons(ctx); } if (ctx->dirty.count > 0) { compositeAndFlush(ctx); } else if (ctx->idleCallback) { ctx->idleCallback(ctx->idleCtx); } else { platformYield(); } // Release key-pressed button after one frame. The button was set to // "pressed" state in dispatchAccelKey; here we clear it and fire // onClick. The one-frame delay ensures the pressed visual state // renders before the callback runs (which may open a dialog, etc.). if (sKeyPressedBtn) { if (sKeyPressedBtn->type == WidgetImageButtonE) { sKeyPressedBtn->as.imageButton.pressed = false; } else { sKeyPressedBtn->as.button.pressed = false; } if (sKeyPressedBtn->onClick) { sKeyPressedBtn->onClick(sKeyPressedBtn); } wgtInvalidate(sKeyPressedBtn); sKeyPressedBtn = NULL; } ctx->prevMouseX = ctx->mouseX; ctx->prevMouseY = ctx->mouseY; ctx->prevMouseButtons = ctx->mouseButtons; return ctx->running; } // ============================================================ // dvxSaveImage // ============================================================ // // Save native-format pixel data to a PNG file. Converts from the // display's native pixel format to RGB, then encodes as PNG via // stb_image_write. This is the general-purpose image save function; // dvxScreenshot and dvxWindowScreenshot are convenience wrappers // around it for common use cases. int32_t dvxSaveImage(const AppContextT *ctx, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path) { if (!ctx || !data || !path || w <= 0 || h <= 0) { return -1; } uint8_t *rgb = bufferToRgb(&ctx->display, data, w, h, pitch); if (!rgb) { return -1; } int32_t result = stbi_write_png(path, w, h, 3, rgb, w * 3) ? 0 : -1; free(rgb); return result; } // ============================================================ // dvxScreenshot // ============================================================ // // Save the entire screen (backbuffer) to a PNG file. Uses the backbuffer // rather than the LFB because reading from video memory through PCI/ISA // is extremely slow on period hardware (uncacheable MMIO reads). The // backbuffer is in system RAM and is always coherent with the LFB since // we only write to the LFB, never read. int32_t dvxScreenshot(AppContextT *ctx, const char *path) { DisplayT *d = &ctx->display; uint8_t *rgb = bufferToRgb(d, d->backBuf, d->width, d->height, d->pitch); if (!rgb) { return -1; } int32_t result = stbi_write_png(path, d->width, d->height, 3, rgb, d->width * 3) ? 0 : -1; free(rgb); return result; } // ============================================================ // dvxSetMouseConfig // ============================================================ void dvxSetMouseConfig(AppContextT *ctx, int32_t wheelDir, int32_t dblClickMs, int32_t accelThreshold) { ctx->wheelDirection = (wheelDir < 0) ? -1 : 1; ctx->dblClickTicks = (clock_t)dblClickMs * CLOCKS_PER_SEC / 1000; sDblClickTicks = ctx->dblClickTicks; if (accelThreshold > 0) { platformMouseSetAccel(accelThreshold); } } // ============================================================ // dvxShutdown // ============================================================ void dvxShutdown(AppContextT *ctx) { // Destroy all remaining windows while (ctx->stack.count > 0) { wmDestroyWindow(&ctx->stack, ctx->stack.windows[ctx->stack.count - 1]); } free(ctx->wallpaperBuf); ctx->wallpaperBuf = NULL; videoShutdown(&ctx->display); } // ============================================================ // dvxSetTitle // ============================================================ void dvxSetTitle(AppContextT *ctx, WindowT *win, const char *title) { wmSetTitle(win, &ctx->dirty, title); if (ctx->onTitleChange) { ctx->onTitleChange(ctx->titleChangeCtx); } } // ============================================================ // dvxSetWindowIcon // ============================================================ int32_t dvxSetWindowIcon(AppContextT *ctx, WindowT *win, const char *path) { return wmSetIcon(win, path, &ctx->display); } // ============================================================ // dvxWindowScreenshot // ============================================================ // // Save a window's content buffer to a PNG file. Because each window has // its own persistent content buffer (not a shared backbuffer), this // captures the full content even if the window is partially or fully // occluded by other windows. This is a unique advantage of the per-window // content buffer architecture. int32_t dvxWindowScreenshot(AppContextT *ctx, WindowT *win, const char *path) { if (!win || !win->contentBuf) { return -1; } uint8_t *rgb = bufferToRgb(&ctx->display, win->contentBuf, win->contentW, win->contentH, win->contentPitch); if (!rgb) { return -1; } int32_t result = stbi_write_png(path, win->contentW, win->contentH, 3, rgb, win->contentW * 3) ? 0 : -1; free(rgb); return result; } // ============================================================ // dvxTileWindows // ============================================================ // // Tile windows in a grid. The grid dimensions are chosen so columns = // ceil(sqrt(n)), which produces a roughly square grid. This is better than // always using rows or columns because it maximizes the minimum dimension // of each tile (a 1xN or Nx1 layout makes windows very narrow or short). // The last row may have fewer windows; those get wider tiles to fill the // remaining screen width, avoiding dead space. // // The integer sqrt is computed by a simple loop rather than calling sqrt() // to avoid pulling in floating-point math on DJGPP targets. void dvxTileWindows(AppContextT *ctx) { int32_t screenW = ctx->display.width; int32_t screenH = ctx->display.height; // Count eligible windows int32_t count = 0; for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; if (!win->minimized && win->visible) { count++; } } if (count == 0) { return; } // Integer ceil(sqrt(count)) for column count int32_t cols = 1; while (cols * cols < count) { cols++; } int32_t rows = (count + cols - 1) / cols; int32_t tileW = screenW / cols; int32_t tileH = screenH / rows; if (tileW < MIN_WINDOW_W) { tileW = MIN_WINDOW_W; } if (tileH < MIN_WINDOW_H) { tileH = MIN_WINDOW_H; } int32_t slot = 0; for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; if (win->minimized || !win->visible) { continue; } int32_t row = slot / cols; int32_t col = slot % cols; // Last row: fewer windows get wider tiles int32_t remaining = count - row * cols; int32_t rowCols = (remaining < cols) ? remaining : cols; int32_t cellW = screenW / rowCols; repositionWindow(ctx, win, col * cellW, row * tileH, cellW, tileH); slot++; } } // ============================================================ // dvxTileWindowsH // ============================================================ // // Horizontal tiling: windows side by side left to right, each the full // screen height. Good for comparing two documents or viewing output // alongside source. With many windows the tiles become very narrow, but // MIN_WINDOW_W prevents them from becoming unusably small. void dvxTileWindowsH(AppContextT *ctx) { int32_t screenW = ctx->display.width; int32_t screenH = ctx->display.height; // Count eligible windows int32_t count = 0; for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; if (!win->minimized && win->visible) { count++; } } if (count == 0) { return; } int32_t tileW = screenW / count; if (tileW < MIN_WINDOW_W) { tileW = MIN_WINDOW_W; } int32_t slot = 0; for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; if (win->minimized || !win->visible) { continue; } repositionWindow(ctx, win, slot * tileW, 0, tileW, screenH); slot++; } } // ============================================================ // dvxTileWindowsV // ============================================================ // // Vertical tiling: windows stacked top to bottom, each the full screen // width. The complement of dvxTileWindowsH. void dvxTileWindowsV(AppContextT *ctx) { int32_t screenW = ctx->display.width; int32_t screenH = ctx->display.height; // Count eligible windows int32_t count = 0; for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; if (!win->minimized && win->visible) { count++; } } if (count == 0) { return; } int32_t tileH = screenH / count; if (tileH < MIN_WINDOW_H) { tileH = MIN_WINDOW_H; } int32_t slot = 0; for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; if (win->minimized || !win->visible) { continue; } repositionWindow(ctx, win, 0, slot * tileH, screenW, tileH); slot++; } } // ============================================================ // executeSysMenuCmd // ============================================================ // // Executes a system menu (window control menu) command. The system menu // is the DESQview/X equivalent of the Win3.x control-menu box — it // provides Restore, Move, Size, Minimize, Maximize, and Close. Keyboard // move/resize mode is entered by setting kbMoveResize state, which causes // pollKeyboard to intercept arrow keys until Enter/Esc. static void executeSysMenuCmd(AppContextT *ctx, int32_t cmd) { WindowT *win = findWindowById(ctx, ctx->sysMenu.windowId); closeSysMenu(ctx); if (!win) { return; } switch (cmd) { case SysMenuRestoreE: if (win->maximized) { wmRestore(&ctx->stack, &ctx->dirty, &ctx->display, win); } break; case SysMenuMoveE: ctx->kbMoveResize.mode = KbModeMoveE; ctx->kbMoveResize.windowId = win->id; ctx->kbMoveResize.origX = win->x; ctx->kbMoveResize.origY = win->y; break; case SysMenuSizeE: ctx->kbMoveResize.mode = KbModeResizeE; ctx->kbMoveResize.windowId = win->id; ctx->kbMoveResize.origX = win->x; ctx->kbMoveResize.origY = win->y; ctx->kbMoveResize.origW = win->w; ctx->kbMoveResize.origH = win->h; break; case SysMenuMinimizeE: if (ctx->modalWindow != win) { wmMinimize(&ctx->stack, &ctx->dirty, win); int32_t iconY; int32_t iconH; wmMinimizedIconRect(&ctx->stack, &ctx->display, &iconY, &iconH); dirtyListAdd(&ctx->dirty, 0, iconY, ctx->display.width, iconH); } break; case SysMenuMaximizeE: if (win->resizable && !win->maximized) { wmMaximize(&ctx->stack, &ctx->dirty, &ctx->display, win); } break; case SysMenuCloseE: if (win->onClose) { win->onClose(win); } else { dvxDestroyWindow(ctx, win); } break; } } // ============================================================ // findWindowById // ============================================================ static WindowT *findWindowById(AppContextT *ctx, int32_t id) { for (int32_t i = 0; i < ctx->stack.count; i++) { if (ctx->stack.windows[i]->id == id) { return ctx->stack.windows[i]; } } return NULL; } // ============================================================ // handleMouseButton // ============================================================ // // Handles a left-button press that is not consumed by a drag, popup, or // system menu. Uses wmHitTest to determine what part of what window was // clicked: // hitPart 0: content area (forwarded to window's onMouse callback) // hitPart 1: title bar (begins mouse drag) // hitPart 2: close/sys-menu gadget (single-click opens sys menu, // double-click closes — DESQview/X convention) // hitPart 3: resize border (begins edge/corner resize) // hitPart 4: menu bar (opens popup for clicked menu) // hitPart 5/6: vertical/horizontal scrollbar // hitPart 7: minimize button // hitPart 8: maximize/restore button // // Windows are raised-and-focused on click regardless of which part was // hit, ensuring the clicked window always comes to the front. 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); if (iconIdx >= 0) { WindowT *iconWin = ctx->stack.windows[iconIdx]; clock_t now = clock(); if (ctx->lastIconClickId == iconWin->id && (now - ctx->lastIconClickTime) < ctx->dblClickTicks) { // Double-click: restore minimized window // Dirty the entire icon area (may span multiple rows) int32_t iconY; int32_t iconH; wmMinimizedIconRect(&ctx->stack, &ctx->display, &iconY, &iconH); dirtyListAdd(&ctx->dirty, 0, iconY, ctx->display.width, iconH); wmRestoreMinimized(&ctx->stack, &ctx->dirty, iconWin); ctx->lastIconClickId = -1; } else { // First click — record for double-click detection ctx->lastIconClickTime = now; ctx->lastIconClickId = iconWin->id; } return; } int32_t hitPart; int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart); if (hitIdx < 0) { return; // clicked on desktop } WindowT *win = ctx->stack.windows[hitIdx]; // Raise and focus if not already if (hitIdx != ctx->stack.focusedIdx) { wmRaiseWindow(&ctx->stack, &ctx->dirty, hitIdx); // After raise, the window is now at count-1 hitIdx = ctx->stack.count - 1; wmSetFocus(&ctx->stack, &ctx->dirty, hitIdx); } switch (hitPart) { case HIT_CONTENT: if (win->onMouse) { int32_t relX = mx - win->x - win->contentX; int32_t relY = my - win->y - win->contentY; win->onMouse(win, relX, relY, buttons); } break; case HIT_TITLE: { clock_t now = clock(); if (win->resizable && ctx->lastTitleClickId == win->id && (now - ctx->lastTitleClickTime) < ctx->dblClickTicks) { // Double-click: toggle maximize/restore ctx->lastTitleClickId = -1; if (win->maximized) { wmRestore(&ctx->stack, &ctx->dirty, &ctx->display, win); } else { wmMaximize(&ctx->stack, &ctx->dirty, &ctx->display, win); } } else { ctx->lastTitleClickTime = now; ctx->lastTitleClickId = win->id; wmDragBegin(&ctx->stack, hitIdx, mx, my); } } break; case HIT_CLOSE: { clock_t now = clock(); if (ctx->lastCloseClickId == win->id && (now - ctx->lastCloseClickTime) < ctx->dblClickTicks) { ctx->lastCloseClickId = -1; closeSysMenu(ctx); if (win->onClose) { win->onClose(win); } else { dvxDestroyWindow(ctx, win); } } else { ctx->lastCloseClickTime = now; ctx->lastCloseClickId = win->id; openSysMenu(ctx, win); } } break; case HIT_RESIZE: { int32_t edge = wmResizeEdgeHit(win, mx, my); wmResizeBegin(&ctx->stack, hitIdx, edge, mx, my); } break; case HIT_MENU: { if (!win->menuBar) { break; } 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) { openPopupAtMenu(ctx, win, i); break; } } } break; case HIT_VSCROLL: wmScrollbarClick(&ctx->stack, &ctx->dirty, hitIdx, SCROLL_VERTICAL, mx, my); break; case HIT_HSCROLL: wmScrollbarClick(&ctx->stack, &ctx->dirty, hitIdx, SCROLL_HORIZONTAL, mx, my); break; case HIT_MINIMIZE: if (ctx->modalWindow != win) { wmMinimize(&ctx->stack, &ctx->dirty, win); int32_t iconY; int32_t iconH; wmMinimizedIconRect(&ctx->stack, &ctx->display, &iconY, &iconH); dirtyListAdd(&ctx->dirty, 0, iconY, ctx->display.width, iconH); } break; case HIT_MAXIMIZE: if (win->maximized) { wmRestore(&ctx->stack, &ctx->dirty, &ctx->display, win); } else { wmMaximize(&ctx->stack, &ctx->dirty, &ctx->display, win); } break; } } // ============================================================ // initColorScheme // ============================================================ // // Colors are pre-packed to native pixel format at init time so no // per-pixel conversion is needed during drawing. The scheme is inspired // by GEOS Ensemble with Motif-style 3D bevels: teal desktop, grey window // chrome with white highlights and dark shadows to create the raised/sunken // illusion. The dark charcoal active title bar distinguishes it from // GEOS's blue, giving DV/X its own identity. static void initColorScheme(AppContextT *ctx) { // Load defaults into the RGB source array, then pack all at once memcpy(ctx->colorRgb, sDefaultColors, sizeof(sDefaultColors)); dvxApplyColorScheme(ctx); } // ============================================================ // openContextMenu — open a context menu at a screen position // ============================================================ // // Context menus reuse the same popup system as menu bar popups but with // isContextMenu=true. The difference affects dismiss behavior: context // menus close on any click outside (since there's no menu bar to switch // to), while menu bar popups allow horizontal mouse movement to switch // between top-level menus. Position is clamped to screen edges so the // popup doesn't go off-screen. static void openContextMenu(AppContextT *ctx, WindowT *win, MenuT *menu, int32_t screenX, int32_t screenY) { if (!menu || menu->itemCount <= 0) { return; } closeAllPopups(ctx); closeSysMenu(ctx); ctx->popup.active = true; ctx->popup.isContextMenu = true; ctx->popup.windowId = win->id; ctx->popup.menuIdx = -1; ctx->popup.menu = menu; ctx->popup.hoverItem = -1; ctx->popup.depth = 0; calcPopupSize(ctx, menu, &ctx->popup.popupW, &ctx->popup.popupH); // Position at mouse, clamped to screen ctx->popup.popupX = screenX; ctx->popup.popupY = screenY; if (ctx->popup.popupX + ctx->popup.popupW > ctx->display.width) { ctx->popup.popupX = ctx->display.width - ctx->popup.popupW; } if (ctx->popup.popupY + ctx->popup.popupH > ctx->display.height) { ctx->popup.popupY = ctx->display.height - ctx->popup.popupH; } dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH); } // ============================================================ // openPopupAtMenu — open top-level popup for a menu bar menu // ============================================================ // // Opens the dropdown for a menu bar item (e.g., "File", "Edit"). Any // existing popup chain is closed first, then a new top-level popup is // positioned directly below the menu bar item, aligned with its barX // coordinate. This is called both from mouse clicks on the menu bar // and from keyboard navigation (Alt+key, Left/Right arrows). 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.isContextMenu = false; 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; // Mark the menu bar item as active (depressed look) win->menuBar->activeIdx = menuIdx; dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, 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 // ============================================================ // // Pushes the current popup state onto parentStack and opens the submenu // as the new current level. The submenu is positioned at the right edge // of the current popup, vertically aligned with the hovered item. // MAX_SUBMENU_DEPTH prevents runaway nesting from overflowing the stack. 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 - POPUP_BEVEL_WIDTH; ctx->popup.popupY = pl->popupY + POPUP_BEVEL_WIDTH + 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 // ============================================================ // // The system menu is a separate popup from the regular menu system // because it has different semantics: it's tied to the window's close // gadget (top-left icon), uses its own SysMenuItemT type with // enabled/disabled state, and dispatches to executeSysMenuCmd rather // than the window's onMenu callback. Items are dynamically enabled // based on window state (e.g., Restore is only enabled when maximized, // Size is disabled when maximized or non-resizable). Triggered by // single-click on the close gadget or Alt+Space. static void openSysMenu(AppContextT *ctx, WindowT *win) { closeAllPopups(ctx); if (ctx->sysMenu.active) { closeSysMenu(ctx); return; } ctx->sysMenu.itemCount = 0; ctx->sysMenu.windowId = win->id; SysMenuItemT *item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++]; strncpy(item->label, "&Restore", MAX_MENU_LABEL - 1); item->cmd = SysMenuRestoreE; item->separator = false; item->enabled = win->maximized; item->accelKey = accelParse(item->label); // Move — disabled when maximized item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++]; strncpy(item->label, "&Move", MAX_MENU_LABEL - 1); item->cmd = SysMenuMoveE; item->separator = false; item->enabled = !win->maximized; item->accelKey = accelParse(item->label); // Size — only if resizable and not maximized item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++]; strncpy(item->label, "&Size", MAX_MENU_LABEL - 1); item->cmd = SysMenuSizeE; item->separator = false; item->enabled = win->resizable && !win->maximized; item->accelKey = accelParse(item->label); // 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 = !win->modal; item->accelKey = accelParse(item->label); // Maximize — only if resizable and not maximized item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++]; strncpy(item->label, "Ma&ximize", MAX_MENU_LABEL - 1); item->cmd = SysMenuMaximizeE; item->separator = false; item->enabled = win->resizable && !win->maximized; item->accelKey = accelParse(item->label); // Separator item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++]; memset(item, 0, sizeof(*item)); item->separator = true; // Close item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++]; strncpy(item->label, "&Close", MAX_MENU_LABEL - 1); item->cmd = SysMenuCloseE; item->separator = false; item->enabled = true; item->accelKey = accelParse(item->label); // Compute popup geometry — position below the close gadget ctx->sysMenu.popupX = win->x + CHROME_BORDER_WIDTH; ctx->sysMenu.popupY = win->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT; if (win->menuBar) { ctx->sysMenu.popupY += CHROME_MENU_HEIGHT; } int32_t maxW = 0; for (int32_t i = 0; i < ctx->sysMenu.itemCount; i++) { if (!ctx->sysMenu.items[i].separator) { int32_t w = textWidthAccel(&ctx->font, ctx->sysMenu.items[i].label); if (w > maxW) { maxW = w; } } } ctx->sysMenu.popupW = maxW + CHROME_TITLE_PAD * 2 + POPUP_ITEM_PAD_H; ctx->sysMenu.popupH = ctx->sysMenu.itemCount * ctx->font.charHeight + POPUP_BEVEL_WIDTH * 2; ctx->sysMenu.hoverItem = -1; ctx->sysMenu.active = true; dirtyListAdd(&ctx->dirty, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH); } // ============================================================ // pollAnsiTermWidgets — poll and repaint all ANSI term widgets // ============================================================ // // ANSI terminal widgets have asynchronous data sources (PTYs, serial // ports) that produce output between frames. This function walks every // window's widget tree looking for AnsiTerm widgets, polls them for new // data, and if data arrived, triggers a targeted repaint of just the // affected rows. The fine-grained dirty rect (just the changed rows // rather than the whole window) is critical for terminal performance — // a single character echo should only flush one row to the LFB, not // the entire terminal viewport. static void pollAnsiTermWidgets(AppContextT *ctx) { for (int32_t i = 0; i < ctx->stack.count; i++) { WindowT *win = ctx->stack.windows[i]; if (win->widgetRoot) { pollAnsiTermWidgetsWalk(ctx, win->widgetRoot, win); } } } // ============================================================ // pollAnsiTermWidgetsWalk — recursive helper // ============================================================ static void pollAnsiTermWidgetsWalk(AppContextT *ctx, WidgetT *w, WindowT *win) { if (w->type == WidgetAnsiTermE) { wgtAnsiTermPoll(w); int32_t dirtyY = 0; int32_t dirtyH = 0; if (wgtAnsiTermRepaint(w, &dirtyY, &dirtyH) > 0) { win->contentDirty = true; // Dirty only the affected rows (in screen coords) instead of // the entire window. This dramatically reduces compositor and // LFB flush work for cursor blink and single-row updates. int32_t scrollY = win->vScroll ? win->vScroll->value : 0; int32_t rectX = win->x + win->contentX; int32_t rectY = win->y + win->contentY + dirtyY - scrollY; int32_t rectW = win->contentW; dirtyListAdd(&ctx->dirty, rectX, rectY, rectW, dirtyH); } } for (WidgetT *child = w->firstChild; child; child = child->nextSibling) { pollAnsiTermWidgetsWalk(ctx, child, win); } } // ============================================================ // pollKeyboard // ============================================================ // // Drains the keyboard buffer and dispatches each key through a priority // chain. The priority order is important — higher priority handlers // consume the key and skip lower ones via 'continue': // // 1. Alt+Tab / Shift+Alt+Tab — window cycling (always works) // 2. Alt+F4 — close focused window // 3. Ctrl+Esc — system-wide hotkey (task manager) // 4. F10 — activate/toggle menu bar // 4. Keyboard move/resize mode (arrow keys captured exclusively) // 5. Alt+Space — system menu toggle // 6. System menu keyboard navigation (arrows, enter, esc, accel) // 7. Alt+key — menu bar / widget accelerator dispatch // 8. Popup menu keyboard navigation (arrows, enter, esc, accel) // 9. Accelerator table on focused window (Ctrl+S, etc.) // 10. Tab/Shift+Tab — widget focus cycling // 11. Fall-through to focused window's onKey callback // // Key encoding: ASCII keys use their ASCII value; extended keys (arrows, // function keys) use scancode | 0x100 to distinguish from ASCII 0. // This avoids needing a separate "is_extended" flag. static void pollKeyboard(AppContextT *ctx) { int32_t shiftFlags = platformKeyboardGetModifiers(); ctx->keyModifiers = shiftFlags; bool shiftHeld = (shiftFlags & KEY_MOD_SHIFT) != 0; PlatformKeyEventT evt; while (platformKeyboardRead(&evt)) { int32_t scancode = evt.scancode; int32_t ascii = evt.ascii; // Alt+Tab / Shift+Alt+Tab — cycle windows. // Unlike Windows, there's no task-switcher overlay here — each press // immediately rotates the window stack and focuses the new top. // Alt+Tab rotates the top window to the bottom of the stack (so the // second window becomes visible). Shift+Alt+Tab does the reverse, // pulling the bottom window to the top. if (ascii == 0 && scancode == 0xA5) { if (ctx->stack.count > 1) { if (shiftHeld) { wmRaiseWindow(&ctx->stack, &ctx->dirty, 0); wmSetFocus(&ctx->stack, &ctx->dirty, ctx->stack.count - 1); } else { // Rotate: move top to bottom, shift everything else up WindowT *top = ctx->stack.windows[ctx->stack.count - 1]; dirtyListAdd(&ctx->dirty, top->x, top->y, top->w, top->h); // Shift all windows up for (int32_t i = ctx->stack.count - 1; i > 0; i--) { ctx->stack.windows[i] = ctx->stack.windows[i - 1]; } ctx->stack.windows[0] = top; top->focused = false; // Focus the new top window wmSetFocus(&ctx->stack, &ctx->dirty, ctx->stack.count - 1); dirtyListAdd(&ctx->dirty, ctx->stack.windows[ctx->stack.count - 1]->x, ctx->stack.windows[ctx->stack.count - 1]->y, ctx->stack.windows[ctx->stack.count - 1]->w, ctx->stack.windows[ctx->stack.count - 1]->h); } } continue; } // Alt+F4 — close focused window if (ascii == 0 && scancode == 0x6B) { if (ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; if (win->onClose) { win->onClose(win); } } continue; } // Ctrl+Esc — system-wide hotkey (e.g. task manager) if (scancode == 0x01 && ascii == 0x1B && (shiftFlags & KEY_MOD_CTRL)) { if (ctx->onCtrlEsc) { ctx->onCtrlEsc(ctx->ctrlEscCtx); } continue; } // F10 — activate menu bar if (ascii == 0 && scancode == 0x44) { if (ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; if (win->menuBar && win->menuBar->menuCount > 0) { if (ctx->popup.active) { closeAllPopups(ctx); } else { dispatchAccelKey(ctx, win->menuBar->menus[0].accelKey); } } } continue; } // Keyboard move/resize mode — intercept all keys if (ctx->kbMoveResize.mode != KbModeNoneE) { WindowT *kbWin = findWindowById(ctx, ctx->kbMoveResize.windowId); if (!kbWin) { ctx->kbMoveResize.mode = KbModeNoneE; continue; } if (ascii == 0x1B) { // Cancel — restore original position/size dirtyListAdd(&ctx->dirty, kbWin->x, kbWin->y, kbWin->w, kbWin->h); kbWin->x = ctx->kbMoveResize.origX; kbWin->y = ctx->kbMoveResize.origY; if (ctx->kbMoveResize.mode == KbModeResizeE) { kbWin->w = ctx->kbMoveResize.origW; kbWin->h = ctx->kbMoveResize.origH; wmUpdateContentRect(kbWin); wmReallocContentBuf(kbWin, &ctx->display); if (kbWin->onResize) { kbWin->onResize(kbWin, kbWin->contentW, kbWin->contentH); } if (kbWin->onPaint) { kbWin->onPaint(kbWin, NULL); } } dirtyListAdd(&ctx->dirty, kbWin->x, kbWin->y, kbWin->w, kbWin->h); ctx->kbMoveResize.mode = KbModeNoneE; continue; } if (ascii == 0x0D) { // Confirm ctx->kbMoveResize.mode = KbModeNoneE; continue; } if (ctx->kbMoveResize.mode == KbModeMoveE) { int32_t oldX = kbWin->x; int32_t oldY = kbWin->y; if (ascii == 0 && scancode == 0x48) { kbWin->y -= KB_MOVE_STEP; } else if (ascii == 0 && scancode == 0x50) { kbWin->y += KB_MOVE_STEP; } else if (ascii == 0 && scancode == 0x4B) { kbWin->x -= KB_MOVE_STEP; } else if (ascii == 0 && scancode == 0x4D) { kbWin->x += KB_MOVE_STEP; } // Clamp: keep title bar reachable int32_t screenW = ctx->display.width; int32_t screenH = ctx->display.height; int32_t minVisible = 50; if (kbWin->y < 0) { kbWin->y = 0; } if (kbWin->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT > screenH) { kbWin->y = screenH - CHROME_BORDER_WIDTH - CHROME_TITLE_HEIGHT; } if (kbWin->x + kbWin->w < minVisible) { kbWin->x = minVisible - kbWin->w; } if (kbWin->x > screenW - minVisible) { kbWin->x = screenW - minVisible; } if (kbWin->x != oldX || kbWin->y != oldY) { dirtyListAdd(&ctx->dirty, oldX, oldY, kbWin->w, kbWin->h); dirtyListAdd(&ctx->dirty, kbWin->x, kbWin->y, kbWin->w, kbWin->h); } } else { // KbModeResizeE int32_t newW = kbWin->w; int32_t newH = kbWin->h; if (ascii == 0 && scancode == 0x4D) { newW += KB_MOVE_STEP; } else if (ascii == 0 && scancode == 0x4B) { newW -= KB_MOVE_STEP; } else if (ascii == 0 && scancode == 0x50) { newH += KB_MOVE_STEP; } else if (ascii == 0 && scancode == 0x48) { newH -= KB_MOVE_STEP; } if (newW < MIN_WINDOW_W) { newW = MIN_WINDOW_W; } if (newH < MIN_WINDOW_H) { newH = MIN_WINDOW_H; } if (kbWin->maxW > 0 && newW > kbWin->maxW) { newW = kbWin->maxW; } if (kbWin->maxH > 0 && newH > kbWin->maxH) { newH = kbWin->maxH; } // Clamp to screen boundaries int32_t screenW = ctx->display.width; int32_t screenH = ctx->display.height; if (kbWin->x + newW > screenW) { newW = screenW - kbWin->x; } if (kbWin->y + newH > screenH) { newH = screenH - kbWin->y; } if (newW != kbWin->w || newH != kbWin->h) { dirtyListAdd(&ctx->dirty, kbWin->x, kbWin->y, kbWin->w, kbWin->h); kbWin->w = newW; kbWin->h = newH; wmUpdateContentRect(kbWin); wmReallocContentBuf(kbWin, &ctx->display); if (kbWin->onResize) { kbWin->onResize(kbWin, kbWin->contentW, kbWin->contentH); } if (kbWin->onPaint) { kbWin->onPaint(kbWin, NULL); } dirtyListAdd(&ctx->dirty, kbWin->x, kbWin->y, kbWin->w, kbWin->h); } } continue; } // Alt+Space — open/close system menu // Enhanced INT 16h: Alt+Space returns scancode 0x39, ascii 0x20 // Must check Alt modifier (bit 3) to distinguish from plain Space if (scancode == 0x39 && ascii == 0x20 && (shiftFlags & KEY_MOD_ALT)) { if (ctx->sysMenu.active) { closeSysMenu(ctx); } else if (ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; openSysMenu(ctx, win); } continue; } // System menu keyboard navigation if (ctx->sysMenu.active) { // Alt+key — close system menu and let it fall through to accel dispatch if (ascii == 0 && platformAltScanToChar(scancode)) { closeSysMenu(ctx); // Fall through to dispatchAccelKey below } else if (ascii == 0x1B) { closeSysMenu(ctx); continue; } else if (ascii == 0 && scancode == 0x48) { // Up arrow int32_t idx = ctx->sysMenu.hoverItem; for (int32_t tries = 0; tries < ctx->sysMenu.itemCount; tries++) { idx--; if (idx < 0) { idx = ctx->sysMenu.itemCount - 1; } if (!ctx->sysMenu.items[idx].separator) { break; } } ctx->sysMenu.hoverItem = idx; dirtyListAdd(&ctx->dirty, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH); continue; } else if (ascii == 0 && scancode == 0x50) { // Down arrow int32_t idx = ctx->sysMenu.hoverItem; for (int32_t tries = 0; tries < ctx->sysMenu.itemCount; tries++) { idx++; if (idx >= ctx->sysMenu.itemCount) { idx = 0; } if (!ctx->sysMenu.items[idx].separator) { break; } } ctx->sysMenu.hoverItem = idx; dirtyListAdd(&ctx->dirty, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH); continue; } else if (ascii == 0x0D) { // Enter — execute selected item if (ctx->sysMenu.hoverItem >= 0 && ctx->sysMenu.hoverItem < ctx->sysMenu.itemCount) { SysMenuItemT *item = &ctx->sysMenu.items[ctx->sysMenu.hoverItem]; if (item->enabled && !item->separator) { executeSysMenuCmd(ctx, item->cmd); } } else { closeSysMenu(ctx); } continue; } else if (ascii != 0) { // Accelerator key match char lc = (char)tolower(ascii); for (int32_t k = 0; k < ctx->sysMenu.itemCount; k++) { if (ctx->sysMenu.items[k].accelKey == lc && ctx->sysMenu.items[k].enabled && !ctx->sysMenu.items[k].separator) { executeSysMenuCmd(ctx, ctx->sysMenu.items[k].cmd); goto nextKey; } } // No sys menu match — try menu bar accelerators closeSysMenu(ctx); if (ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; if (win->menuBar) { for (int32_t i = 0; i < win->menuBar->menuCount; i++) { if (win->menuBar->menus[i].accelKey == lc) { dispatchAccelKey(ctx, lc); goto nextKey; } } } } continue; } else { continue; } } // Check for Alt+key (BIOS returns ascii=0 with specific scancodes) if (ascii == 0 && platformAltScanToChar(scancode)) { char accelKey = platformAltScanToChar(scancode); if (dispatchAccelKey(ctx, accelKey)) { continue; } } // Popup menu keyboard navigation (arrows, enter, esc) if (ctx->popup.active && ascii == 0) { MenuT *curMenu = ctx->popup.menu; // Up arrow if (scancode == 0x48) { if (curMenu && curMenu->itemCount > 0) { int32_t idx = ctx->popup.hoverItem; for (int32_t tries = 0; tries < curMenu->itemCount; tries++) { idx--; if (idx < 0) { idx = curMenu->itemCount - 1; } if (!curMenu->items[idx].separator) { break; } } 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) { if (curMenu && curMenu->itemCount > 0) { int32_t idx = ctx->popup.hoverItem; for (int32_t tries = 0; tries < curMenu->itemCount; tries++) { idx++; if (idx >= curMenu->itemCount) { idx = 0; } if (!curMenu->items[idx].separator) { break; } } ctx->popup.hoverItem = idx; dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH); } continue; } // Left arrow — close submenu, or switch to previous top-level menu if (scancode == 0x4B) { 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 (newIdx < 0) { newIdx = win->menuBar->menuCount - 1; } openPopupAtMenu(ctx, win, newIdx); } } continue; } // 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) { int32_t newIdx = ctx->popup.menuIdx + 1; if (newIdx >= win->menuBar->menuCount) { newIdx = 0; } openPopupAtMenu(ctx, win, newIdx); } continue; } } // Enter executes highlighted popup menu item (or opens submenu) if (ctx->popup.active && ascii == 0x0D) { MenuT *curMenu = ctx->popup.menu; if (curMenu && ctx->popup.hoverItem >= 0 && ctx->popup.hoverItem < curMenu->itemCount) { MenuItemT *item = &curMenu->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 (win && win->onMenu) { win->onMenu(win, menuId); } } } else { closeAllPopups(ctx); } 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; MenuT *curMenu = ctx->popup.menu; // 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 (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); if (win && win->onMenu) { win->onMenu(win, menuId); } } goto nextKey; } } } // 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; } } } } // ESC closes open dropdown/combobox popup if (sOpenPopup && ascii == 0x1B) { // Dirty the popup list area WindowT *popWin = sOpenPopup->window; int32_t popX; int32_t popY; int32_t popW; int32_t popH; widgetDropdownPopupRect(sOpenPopup, &ctx->font, popWin->contentH, &popX, &popY, &popW, &popH); dirtyListAdd(&ctx->dirty, popWin->x + popWin->contentX + popX, popWin->y + popWin->contentY + popY, popW, popH); WidgetT *closing = sOpenPopup; sOpenPopup = NULL; if (closing->type == WidgetDropdownE) { closing->as.dropdown.open = false; } else if (closing->type == WidgetComboBoxE) { closing->as.comboBox.open = false; } wgtInvalidate(closing); continue; } // ESC closes one popup level (or all if at top level) if (ctx->popup.active && ascii == 0x1B) { closePopupLevel(ctx); continue; } // Check accelerator table on focused window if (ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; int32_t key = ascii ? ascii : (scancode | 0x100); if (checkAccelTable(ctx, win, key, shiftFlags)) { continue; } } // Tab / Shift-Tab — cycle focus between widgets // Tab: scancode=0x0F, ascii=0x09 // Shift-Tab: scancode=0x0F, ascii=0x00 if (scancode == 0x0F && (ascii == 0x09 || ascii == 0)) { if (ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; if (win->widgetRoot) { // Find currently focused widget WidgetT *current = NULL; WidgetT *fstack[64]; int32_t ftop = 0; fstack[ftop++] = win->widgetRoot; while (ftop > 0) { WidgetT *w = fstack[--ftop]; if (w->focused && widgetIsFocusable(w->type)) { // Don't tab out of the terminal — it swallows Tab if (w->type == WidgetAnsiTermE) { current = NULL; break; } current = w; break; } for (WidgetT *c = w->firstChild; c; c = c->nextSibling) { if (c->visible && ftop < 64) { fstack[ftop++] = c; } } } // Terminal swallowed Tab — send to widget system instead if (current == NULL) { // Check if a terminal is focused ftop = 0; fstack[ftop++] = win->widgetRoot; bool termFocused = false; while (ftop > 0) { WidgetT *w = fstack[--ftop]; if (w->focused && w->type == WidgetAnsiTermE) { termFocused = true; break; } for (WidgetT *c = w->firstChild; c; c = c->nextSibling) { if (c->visible && ftop < 64) { fstack[ftop++] = c; } } } if (termFocused) { // Terminal has focus — send Tab to it if (win->onKey) { win->onKey(win, ascii ? ascii : (scancode | 0x100), shiftFlags); } continue; } } WidgetT *next; if (ascii == 0x09) { next = widgetFindNextFocusable(win->widgetRoot, current); } else { next = widgetFindPrevFocusable(win->widgetRoot, current); } if (next) { sOpenPopup = NULL; if (sFocusedWidget) { sFocusedWidget->focused = false; } sFocusedWidget = next; next->focused = true; // Scroll the widget into view if needed int32_t scrollX = win->hScroll ? win->hScroll->value : 0; int32_t scrollY = win->vScroll ? win->vScroll->value : 0; int32_t virtX = next->x + scrollX; int32_t virtY = next->y + scrollY; if (win->vScroll) { if (virtY < win->vScroll->value) { win->vScroll->value = virtY; } else if (virtY + next->h > win->vScroll->value + win->contentH) { win->vScroll->value = virtY + next->h - win->contentH; } win->vScroll->value = clampInt(win->vScroll->value, win->vScroll->min, win->vScroll->max); } if (win->hScroll) { if (virtX < win->hScroll->value) { win->hScroll->value = virtX; } else if (virtX + next->w > win->hScroll->value + win->contentW) { win->hScroll->value = virtX + next->w - win->contentW; } win->hScroll->value = clampInt(win->hScroll->value, win->hScroll->min, win->hScroll->max); } wgtInvalidate(win->widgetRoot); } } } continue; } // Send to focused window if (ctx->stack.focusedIdx >= 0) { WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx]; if (win->onKey) { win->onKey(win, ascii ? ascii : (scancode | 0x100), shiftFlags); } } continue; nextKey:; } } // ============================================================ // pollMouse // ============================================================ static void pollMouse(AppContextT *ctx) { int32_t mx; int32_t my; int32_t buttons; platformMousePoll(&mx, &my, &buttons); ctx->mouseX = mx; ctx->mouseY = my; ctx->mouseButtons = buttons; ctx->mouseWheel = platformMouseWheelPoll(); } // ============================================================ // refreshMinimizedIcons // ============================================================ // // Minimized windows show a thumbnail of their content. When the content // changes (e.g., a terminal receives output while minimized), the icon // thumbnail needs updating. Rather than refreshing all dirty icons every // frame (which could cause a burst of repaints), this function refreshes // at most ONE icon per call, using a round-robin index (iconRefreshIdx) // so each dirty icon gets its turn. Called every ICON_REFRESH_INTERVAL // frames, this spreads the cost across time. Windows with custom iconData // (loaded from .bmp/.png) are skipped since their thumbnails don't change. static void refreshMinimizedIcons(AppContextT *ctx) { WindowStackT *ws = &ctx->stack; DisplayT *d = &ctx->display; int32_t count = 0; int32_t iconIdx = 0; for (int32_t i = 0; i < ws->count; i++) { WindowT *win = ws->windows[i]; if (!win->visible || !win->minimized) { continue; } if (!win->iconData && win->contentDirty) { if (count >= ctx->iconRefreshIdx) { int32_t ix; int32_t iy; wmMinimizedIconPos(d, iconIdx, &ix, &iy); dirtyListAdd(&ctx->dirty, ix, iy, ICON_TOTAL_SIZE, ICON_TOTAL_SIZE); win->contentDirty = false; ctx->iconRefreshIdx = count + 1; return; } count++; } iconIdx++; } // Wrapped past the end — reset for next cycle ctx->iconRefreshIdx = 0; } // ============================================================ // repositionWindow — move/resize a window, dirty old & new, fire callbacks // ============================================================ // // Shared helper for tiling/cascading. Dirties both the old and new // positions (the old area needs repainting because the window moved away, // the new area needs repainting because the window appeared there). // Also reallocates the content buffer and fires onResize/onPaint so the // window's content scales to the new dimensions. static void repositionWindow(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h) { // Dirty old position dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h); // Un-maximize if needed if (win->maximized) { win->maximized = false; } win->x = x; win->y = y; win->w = w; win->h = h; wmUpdateContentRect(win); wmReallocContentBuf(win, &ctx->display); if (win->onResize) { win->onResize(win, win->contentW, win->contentH); } if (win->onPaint) { RectT fullRect = {0, 0, win->contentW, win->contentH}; win->onPaint(win, &fullRect); win->contentDirty = true; } // Dirty new position dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h); } // ============================================================ // updateCursorShape // ============================================================ // // Updates the software cursor shape based on what the mouse is hovering // over. The cursor is software-rendered (drawn in the compositor pass) // rather than using a hardware cursor because VESA VBE doesn't provide // hardware cursor support, and hardware cursors on VGA are limited to // text mode. The shape priority is: // 1. Active resize drag — keep the edge-specific resize cursor // 2. ListView column resize drag // 3. Splitter drag // 4. Hover over resize edge — show directional resize cursor // 5. Hover over ListView column border — horizontal resize cursor // 6. Hover over splitter bar — orientation-specific resize cursor // 7. Default arrow cursor static void updateCursorShape(AppContextT *ctx) { int32_t newCursor = CURSOR_ARROW; int32_t mx = ctx->mouseX; int32_t my = ctx->mouseY; // During active resize, keep the resize cursor if (ctx->stack.resizeWindow >= 0) { int32_t edge = ctx->stack.resizeEdge; bool horiz = (edge & (RESIZE_LEFT | RESIZE_RIGHT)) != 0; bool vert = (edge & (RESIZE_TOP | RESIZE_BOTTOM)) != 0; if (horiz && vert) { if ((edge & RESIZE_LEFT && edge & RESIZE_TOP) || (edge & RESIZE_RIGHT && edge & RESIZE_BOTTOM)) { newCursor = CURSOR_RESIZE_DIAG_NWSE; } else { newCursor = CURSOR_RESIZE_DIAG_NESW; } } else if (horiz) { newCursor = CURSOR_RESIZE_H; } else { newCursor = CURSOR_RESIZE_V; } } // Active ListView column resize drag else if (sResizeListView) { newCursor = CURSOR_RESIZE_H; } // Active splitter drag else if (sDragSplitter) { newCursor = sDragSplitter->as.splitter.vertical ? CURSOR_RESIZE_H : CURSOR_RESIZE_V; } // Not in an active drag/resize — check what we're hovering else if (ctx->stack.dragWindow < 0 && ctx->stack.scrollWindow < 0) { int32_t hitPart; int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart); if (hitIdx >= 0 && hitPart == HIT_RESIZE) { // Hovering over a resize edge WindowT *win = ctx->stack.windows[hitIdx]; int32_t edge = wmResizeEdgeHit(win, mx, my); bool horiz = (edge & (RESIZE_LEFT | RESIZE_RIGHT)) != 0; bool vert = (edge & (RESIZE_TOP | RESIZE_BOTTOM)) != 0; if (horiz && vert) { if ((edge & RESIZE_LEFT && edge & RESIZE_TOP) || (edge & RESIZE_RIGHT && edge & RESIZE_BOTTOM)) { newCursor = CURSOR_RESIZE_DIAG_NWSE; } else { newCursor = CURSOR_RESIZE_DIAG_NESW; } } else if (horiz) { newCursor = CURSOR_RESIZE_H; } else if (vert) { newCursor = CURSOR_RESIZE_V; } } else if (hitIdx >= 0 && hitPart == HIT_CONTENT) { // 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; } // Walk into splitters (NO_HIT_RECURSE stops widgetHitTest at the outermost one) while (hit && hit->type == WidgetSplitterE) { int32_t pos = hit->as.splitter.dividerPos; bool onBar; if (hit->as.splitter.vertical) { int32_t barX = hit->x + pos; onBar = (vx >= barX && vx < barX + SPLITTER_BAR_W); } else { int32_t barY = hit->y + pos; onBar = (vy >= barY && vy < barY + SPLITTER_BAR_W); } if (onBar) { newCursor = hit->as.splitter.vertical ? CURSOR_RESIZE_H : CURSOR_RESIZE_V; break; } // Not on this splitter's bar — check children for nested splitters WidgetT *inner = NULL; for (WidgetT *c = hit->firstChild; c; c = c->nextSibling) { if (c->visible && vx >= c->x && vx < c->x + c->w && vy >= c->y && vy < c->y + c->h) { inner = c; } } hit = inner; } } } } // If cursor shape changed, dirty the cursor area if (newCursor != ctx->cursorId) { dirtyCursorArea(ctx, mx, my); ctx->cursorId = newCursor; } } // ============================================================ // updateTooltip — show/hide tooltip based on hover state // ============================================================ // // Tooltip lifecycle: when the mouse stops moving over a widget that has // a tooltip string set, a timer starts. After TOOLTIP_DELAY_MS (500ms), // the tooltip appears. Any mouse movement or button press hides it and // resets the timer. This avoids tooltip flicker during normal mouse use // while still being responsive when the user hovers intentionally. // // The widget lookup walks into NO_HIT_RECURSE containers (like toolbars) // to find the deepest child with a tooltip, so toolbar buttons can have // individual tooltips even though the toolbar itself handles hit testing. static void updateTooltip(AppContextT *ctx) { clock_t now = clock(); clock_t threshold = (clock_t)TOOLTIP_DELAY_MS * CLOCKS_PER_SEC / 1000; int32_t mx = ctx->mouseX; int32_t my = ctx->mouseY; // Mouse moved or button pressed — hide tooltip and reset timer if (mx != ctx->prevMouseX || my != ctx->prevMouseY || ctx->mouseButtons) { if (ctx->tooltipText) { // Dirty old tooltip area dirtyListAdd(&ctx->dirty, ctx->tooltipX, ctx->tooltipY, ctx->tooltipW, ctx->tooltipH); ctx->tooltipText = NULL; } ctx->tooltipHoverStart = now; return; } // Already showing a tooltip if (ctx->tooltipText) { return; } // Not enough time has passed if ((now - ctx->tooltipHoverStart) < threshold) { return; } // Don't show tooltips while popups/menus are active if (ctx->popup.active || ctx->sysMenu.active) { return; } // Check minimized icons first (they sit outside any window) const char *tipText = NULL; int32_t iconIdx = wmMinimizedIconHit(&ctx->stack, &ctx->display, mx, my); if (iconIdx >= 0) { tipText = ctx->stack.windows[iconIdx]->title; } // Otherwise check widgets in the content area of a window if (!tipText) { int32_t hitPart; int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart); if (hitIdx < 0 || hitPart != 0) { return; } WindowT *win = ctx->stack.windows[hitIdx]; if (!win->widgetRoot) { return; } 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); // Walk into NO_HIT_RECURSE containers to find deepest child while (hit && hit->wclass && (hit->wclass->flags & WCLASS_NO_HIT_RECURSE)) { WidgetT *inner = NULL; for (WidgetT *c = hit->firstChild; c; c = c->nextSibling) { if (c->visible && vx >= c->x && vx < c->x + c->w && vy >= c->y && vy < c->y + c->h) { inner = c; } } if (!inner) { break; } if (inner->tooltip) { hit = inner; break; } if (inner->wclass && (inner->wclass->flags & WCLASS_NO_HIT_RECURSE)) { hit = inner; } else { WidgetT *deep = widgetHitTest(inner, vx, vy); hit = deep ? deep : inner; break; } } if (!hit || !hit->tooltip) { return; } tipText = hit->tooltip; } // Show the tooltip ctx->tooltipText = tipText; int32_t tw = textWidth(&ctx->font, tipText) + TOOLTIP_PAD * 2; int32_t th = ctx->font.charHeight + TOOLTIP_PAD * 2; // Position below and right of cursor ctx->tooltipX = mx + 12; ctx->tooltipY = my + 16; // Keep on screen if (ctx->tooltipX + tw > ctx->display.width) { ctx->tooltipX = ctx->display.width - tw; } if (ctx->tooltipY + th > ctx->display.height) { ctx->tooltipY = my - th - 4; } ctx->tooltipW = tw; ctx->tooltipH = th; // Dirty the tooltip area dirtyListAdd(&ctx->dirty, ctx->tooltipX, ctx->tooltipY, tw, th); }