DVX_GUI/dvx/dvxApp.c

4741 lines
166 KiB
C

// 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 "dvxDialog.h"
#include "dvxWidget.h"
#include "widgets/widgetInternal.h"
#include "dvxFont.h"
#include "dvxCursor.h"
#include "platform/dvxPlatform.h"
#include "thirdparty/stb_ds.h"
#include <string.h>
#include <ctype.h>
#include <time.h>
#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 interactiveScreenshot(AppContextT *ctx);
static void interactiveWindowScreenshot(AppContextT *ctx, WindowT *win);
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 enumModeCb(int32_t w, int32_t h, int32_t bpp, void *userData);
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 bpp = d->format.bytesPerPixel;
for (int32_t row = dr->y; row < dr->y + dr->h; row++) {
uint8_t *src = ctx->wallpaperBuf + row * ctx->wallpaperPitch + dr->x * bpp;
uint8_t *dst = d->backBuf + row * d->pitch + dr->x * bpp;
memcpy(dst, src, dr->w * bpp);
}
} 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 {
// Resize drag ended. Call onPaint one final time with
// resizeWindow == -1 so the app can do deferred work
// (e.g. image viewer rescale).
WindowT *rWin = ctx->stack.windows[ctx->stack.resizeWindow];
wmResizeEnd(&ctx->stack);
if (rWin->onPaint) {
RectT fullRect = {0, 0, rWin->contentW, rWin->contentH};
rWin->onPaint(rWin, &fullRect);
rWin->contentDirty = true;
}
dirtyListAdd(&ctx->dirty, rWin->x, rWin->y, rWin->w, rWin->h);
}
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. First try the focused
// widget's key handler with Up/Down arrows (which scrollable widgets
// like ListBox, TextArea, TreeView handle natively). Fall back to
// WM-level window scrollbars if no widget consumes it.
if (ctx->mouseWheel != 0 && ctx->stack.focusedIdx >= 0) {
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
// If a scrollable widget has focus, forward wheel to it.
// Otherwise fall through to WM-level window scrollbars.
bool consumed = false;
if (win->widgetRoot) {
WidgetT *focus = wgtGetFocused();
if (focus && focus->wclass && focus->wclass->onKey) {
// Find the scrollable target: either the focused widget
// itself or a ScrollPane ancestor that contains it.
WidgetT *target = NULL;
if (focus->type == WidgetListBoxE ||
focus->type == WidgetListViewE ||
focus->type == WidgetTreeViewE ||
focus->type == WidgetTextAreaE ||
focus->type == WidgetAnsiTermE ||
focus->type == WidgetSliderE ||
focus->type == WidgetSpinnerE ||
focus->type == WidgetDropdownE ||
focus->type == WidgetComboBoxE) {
target = focus;
} else {
// Walk up to find a ScrollPane ancestor
for (WidgetT *p = focus->parent; p; p = p->parent) {
if (p->type == WidgetScrollPaneE) {
target = p;
break;
}
}
}
if (target && target->wclass && target->wclass->onKey) {
int32_t delta = ctx->mouseWheel * ctx->wheelDirection;
int32_t arrowKey = (delta > 0) ? (0x50 | 0x100) : (0x48 | 0x100);
int32_t steps = abs(delta) * MOUSE_WHEEL_STEP;
for (int32_t s = 0; s < steps; s++) {
target->wclass->onKey(target, arrowKey, 0);
}
// Ensure the window repaints even if the widget's
// onKey didn't invalidate (belt and suspenders)
wgtInvalidatePaint(target);
consumed = true;
}
}
}
ScrollbarT *sb = !consumed ? (win->vScroll ? win->vScroll : win->hScroll) : NULL;
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) {
// Auto-cascade: if another window already occupies this exact position,
// offset diagonally by the title bar height so the new window doesn't
// sit directly on top. Keeps offsetting while collisions exist, wrapping
// back to the origin if we'd go off screen.
int32_t step = CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT;
for (;;) {
bool collision = false;
for (int32_t i = 0; i < ctx->stack.count; i++) {
WindowT *other = ctx->stack.windows[i];
if (other->x == x && other->y == y) {
collision = true;
break;
}
}
if (!collision) {
break;
}
x += step;
y += step;
if (x + w > ctx->display.width || y + h > ctx->display.height) {
x = step;
y = step;
break;
}
}
// Clamp to screen so the window is fully visible
if (x + w > ctx->display.width) {
x = ctx->display.width - w;
}
if (y + h > ctx->display.height) {
y = ctx->display.height - h;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
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
// ============================================================
// INI/theme key names (must not change -- used for save/load)
static const char *sColorNames[ColorCountE] = {
"desktop", "windowFace", "windowHighlight",
"windowShadow", "activeTitleBg", "activeTitleFg",
"inactiveTitleBg", "inactiveTitleFg", "contentBg",
"contentFg", "menuBg", "menuFg",
"menuHighlightBg", "menuHighlightFg", "buttonFace",
"scrollbarBg", "scrollbarFg", "scrollbarTrough",
"cursorColor", "cursorOutline"
};
// Human-readable display names for the UI
static const char *sColorLabels[ColorCountE] = {
"Desktop", "Window Face", "Window Highlight",
"Window Shadow", "Active Title Bar", "Active Title Text",
"Inactive Title Bar", "Inactive Title Text", "Content Background",
"Content Text", "Menu Background", "Menu Text",
"Menu Highlight", "Menu Highlight Text", "Button Face",
"Scrollbar Background", "Scrollbar Foreground", "Scrollbar Trough",
"Cursor Color", "Cursor Outline"
};
// 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
{255, 255, 255}, // cursorFg -- white
{ 0, 0, 0}, // cursorBg -- black
};
// 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];
}
// ============================================================
// dvxColorLabel
// ============================================================
const char *dvxColorLabel(ColorIdE id) {
if (id < 0 || id >= ColorCountE) {
return "Unknown";
}
return sColorLabels[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]);
}
// Sync cursor colors from the scheme so the compositor uses them
ctx->cursorFg = ctx->colors.cursorFg;
ctx->cursorBg = ctx->colors.cursorBg;
// 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 buffer
free(oldWpBuf);
// Reinit blit ops for new pixel format
drawInit(&ctx->blitOps, &ctx->display);
// Repack all colors for new pixel format
dvxApplyColorScheme(ctx);
// 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;
}
}
// Reload wallpaper at the new resolution/bpp
if (ctx->wallpaperPath[0]) {
dvxSetWallpaper(ctx, ctx->wallpaperPath);
}
// 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);
// If the window is minimized, dirty the icon strip so the icon
// disappears and remaining icons repack correctly.
if (win->minimized) {
int32_t iconY;
int32_t iconH;
wmMinimizedIconRect(&ctx->stack, &ctx->display, &iconY, &iconH);
dirtyListAdd(&ctx->dirty, 0, iconY, ctx->display.width, iconH);
}
wmDestroyWindow(&ctx->stack, win);
// Dirty icon area again with the updated count (one fewer icon)
if (win->minimized) {
int32_t iconY;
int32_t iconH;
wmMinimizedIconRect(&ctx->stack, &ctx->display, &iconY, &iconH);
dirtyListAdd(&ctx->dirty, 0, iconY, ctx->display.width, iconH);
}
// 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;
// Shift position so the window stays fully on screen
if (win->x + newW > ctx->display.width) {
win->x = ctx->display.width - newW;
}
if (win->y + newH > ctx->display.height) {
win->y = ctx->display.height - newH;
}
if (win->x < 0) {
win->x = 0;
}
if (win->y < 0) {
win->y = 0;
}
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;
}
// ============================================================
// dvxGetVideoModes
// ============================================================
const VideoModeInfoT *dvxGetVideoModes(const AppContextT *ctx, int32_t *count) {
*count = ctx->videoModeCount;
return ctx->videoModes;
}
// ============================================================
// enumModeCb -- used during dvxInit to capture available modes
// ============================================================
static void enumModeCb(int32_t w, int32_t h, int32_t bpp, void *userData) {
if (w < 640 || h < 480) {
return;
}
AppContextT *ctx = (AppContextT *)userData;
VideoModeInfoT m;
m.w = w;
m.h = h;
m.bpp = bpp;
arrput(ctx->videoModes, m);
ctx->videoModeCount++;
}
// ============================================================
// 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;
uint32_t packed = packColor(&ctx->display, r, g, b);
*colorSlot(&ctx->colors, id) = packed;
// Keep cursor color cache in sync
if (id == ColorCursorFgE) {
ctx->cursorFg = packed;
} else if (id == ColorCursorBgE) {
ctx->cursorBg = packed;
}
// Invalidate all windows so scrollbar/chrome changes are visible
for (int32_t i = 0; i < ctx->stack.count; i++) {
WindowT *win = ctx->stack.windows[i];
win->contentDirty = true;
}
dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
}
// ============================================================
// dvxSetWallpaper
// ============================================================
// writePixel -- write a packed pixel to a buffer at position x
static void writePixel(uint8_t *row, int32_t x, uint32_t px, int32_t bpp) {
if (bpp == 8) {
row[x] = (uint8_t)px;
} else if (bpp == 15 || bpp == 16) {
((uint16_t *)row)[x] = (uint16_t)px;
} else {
((uint32_t *)row)[x] = px;
}
}
// buildWallpaperBuf -- render the full-screen wallpaper buffer from RGB source
static uint8_t *buildWallpaperBuf(AppContextT *ctx, const uint8_t *rgb, int32_t imgW, int32_t imgH, WallpaperModeE mode) {
int32_t screenW = ctx->display.width;
int32_t screenH = ctx->display.height;
int32_t bpp = ctx->display.format.bitsPerPixel;
int32_t bytesPerPx = ctx->display.format.bytesPerPixel;
int32_t pitch = screenW * bytesPerPx;
int32_t srcStride = imgW * 3;
static const int32_t bayerMatrix[4][4] = {
{ -7, 1, -5, 3},
{ 5, -3, 7, -1},
{ -4, 4, -6, 2},
{ 6, -2, 8, 0}
};
bool dither = (bpp == 15 || bpp == 16);
uint8_t *buf = (uint8_t *)malloc(pitch * screenH);
if (!buf) {
return NULL;
}
// Fill entire buffer with desktop color first (used by center mode
// for the border area, but also ensures no garbage pixels)
uint32_t bgPx = ctx->colors.desktop;
for (int32_t y = 0; y < screenH; y++) {
uint8_t *dst = buf + y * pitch;
for (int32_t x = 0; x < screenW; x++) {
writePixel(dst, x, bgPx, bpp);
}
}
if (mode == WallpaperStretchE) {
// Bilinear scale to screen dimensions with optional dither
for (int32_t y = 0; y < screenH; y++) {
if ((y & 31) == 0 && y > 0) {
dvxUpdate(ctx);
}
int32_t srcYfp = (int32_t)((int64_t)y * imgH * 65536 / screenH);
int32_t sy0 = srcYfp >> 16;
int32_t sy1 = (sy0 + 1 < imgH) ? sy0 + 1 : imgH - 1;
int32_t fy = (srcYfp >> 8) & 0xFF;
int32_t ify = 256 - fy;
uint8_t *dst = buf + y * pitch;
const uint8_t *row0 = rgb + sy0 * srcStride;
const 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 < imgW) ? sx0 + 1 : imgW - 1;
int32_t fx = (srcXfp >> 8) & 0xFF;
int32_t ifx = 256 - fx;
const uint8_t *p00 = row0 + sx0 * 3;
const uint8_t *p10 = row0 + sx1 * 3;
const uint8_t *p01 = row1 + sx0 * 3;
const uint8_t *p11 = row1 + sx1 * 3;
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;
if (dither) {
int32_t d = bayerMatrix[y & 3][x & 3];
r += d; g += d; b += d;
if (r < 0) r = 0; if (r > 255) r = 255;
if (g < 0) g = 0; if (g > 255) g = 255;
if (b < 0) b = 0; if (b > 255) b = 255;
}
writePixel(dst, x, packColor(&ctx->display, (uint8_t)r, (uint8_t)g, (uint8_t)b), bpp);
}
}
} else if (mode == WallpaperTileE) {
// Tile: repeat the image at native size across the screen
for (int32_t y = 0; y < screenH; y++) {
if ((y & 31) == 0 && y > 0) {
dvxUpdate(ctx);
}
int32_t srcY = y % imgH;
uint8_t *dst = buf + y * pitch;
const uint8_t *srcRow = rgb + srcY * srcStride;
for (int32_t x = 0; x < screenW; x++) {
int32_t srcX = x % imgW;
const uint8_t *src = srcRow + srcX * 3;
int32_t r = src[0];
int32_t g = src[1];
int32_t b = src[2];
if (dither) {
int32_t d = bayerMatrix[y & 3][x & 3];
r += d; g += d; b += d;
if (r < 0) r = 0; if (r > 255) r = 255;
if (g < 0) g = 0; if (g > 255) g = 255;
if (b < 0) b = 0; if (b > 255) b = 255;
}
writePixel(dst, x, packColor(&ctx->display, (uint8_t)r, (uint8_t)g, (uint8_t)b), bpp);
}
}
} else {
// Center: place at native size in the center of the screen.
// Buffer was already filled with desktop color above.
// Clip the source region to only iterate visible pixels.
int32_t offX = (screenW - imgW) / 2;
int32_t offY = (screenH - imgH) / 2;
int32_t srcStartX = (offX < 0) ? -offX : 0;
int32_t srcStartY = (offY < 0) ? -offY : 0;
int32_t srcEndX = imgW;
int32_t srcEndY = imgH;
if (offX + srcEndX > screenW) {
srcEndX = screenW - offX;
}
if (offY + srcEndY > screenH) {
srcEndY = screenH - offY;
}
for (int32_t sy = srcStartY; sy < srcEndY; sy++) {
if (((sy - srcStartY) & 31) == 0 && sy > srcStartY) {
dvxUpdate(ctx);
}
int32_t dy = offY + sy;
uint8_t *dst = buf + dy * pitch;
const uint8_t *srcRow = rgb + sy * srcStride;
for (int32_t sx = srcStartX; sx < srcEndX; sx++) {
int32_t dx = offX + sx;
const uint8_t *src = srcRow + sx * 3;
int32_t r = src[0];
int32_t g = src[1];
int32_t b = src[2];
if (dither) {
int32_t d = bayerMatrix[dy & 3][dx & 3];
r += d; g += d; b += d;
if (r < 0) r = 0; if (r > 255) r = 255;
if (g < 0) g = 0; if (g > 255) g = 255;
if (b < 0) b = 0; if (b > 255) b = 255;
}
writePixel(dst, dx, packColor(&ctx->display, (uint8_t)r, (uint8_t)g, (uint8_t)b), bpp);
}
}
}
return buf;
}
bool dvxSetWallpaper(AppContextT *ctx, const char *path) {
free(ctx->wallpaperBuf);
ctx->wallpaperBuf = NULL;
ctx->wallpaperPitch = 0;
if (!path) {
ctx->wallpaperPath[0] = '\0';
dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
return true;
}
strncpy(ctx->wallpaperPath, path, sizeof(ctx->wallpaperPath) - 1);
ctx->wallpaperPath[sizeof(ctx->wallpaperPath) - 1] = '\0';
int32_t imgW;
int32_t imgH;
int32_t channels;
uint8_t *rgb = stbi_load(path, &imgW, &imgH, &channels, 3);
if (!rgb) {
return false;
}
int32_t pitch = ctx->display.width * ctx->display.format.bytesPerPixel;
ctx->wallpaperBuf = buildWallpaperBuf(ctx, rgb, imgW, imgH, ctx->wallpaperMode);
ctx->wallpaperPitch = pitch;
stbi_image_free(rgb);
if (!ctx->wallpaperBuf) {
return false;
}
dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
return true;
}
// ============================================================
// dvxSetWallpaperMode
// ============================================================
void dvxSetWallpaperMode(AppContextT *ctx, WallpaperModeE mode) {
ctx->wallpaperMode = mode;
if (ctx->wallpaperPath[0]) {
dvxSetWallpaper(ctx, ctx->wallpaperPath);
}
}
// ============================================================
// 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();
// Enumerate available video modes BEFORE setting one. Some VBE
// BIOSes return a stale or truncated mode list once a graphics
// mode is active, so we must query while still in text mode.
ctx->videoModes = NULL;
ctx->videoModeCount = 0;
platformVideoEnumModes(enumModeCb, ctx);
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
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();
wgtUpdateTimers();
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;
arrfree(ctx->videoModes);
ctx->videoModes = NULL;
ctx->videoModeCount = 0;
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++;
}
}
// ============================================================
// interactiveScreenshot -- snapshot screen, prompt for save path
// ============================================================
static void interactiveScreenshot(AppContextT *ctx) {
FileFilterT filters[] = {
{ "PNG Images (*.png)", "*.png" },
{ "BMP Images (*.bmp)", "*.bmp" }
};
char path[260];
int32_t scrW = ctx->display.width;
int32_t scrH = ctx->display.height;
int32_t scrPitch = ctx->display.pitch;
int32_t scrSize = scrPitch * scrH;
uint8_t *scrBuf = (uint8_t *)malloc(scrSize);
if (scrBuf) {
memcpy(scrBuf, ctx->display.backBuf, scrSize);
if (dvxFileDialog(ctx, "Save Screenshot", FD_SAVE, NULL, filters, 2, path, sizeof(path))) {
dvxSaveImage(ctx, scrBuf, scrW, scrH, scrPitch, path);
}
free(scrBuf);
}
}
// ============================================================
// interactiveWindowScreenshot -- snapshot window content, prompt for save path
// ============================================================
static void interactiveWindowScreenshot(AppContextT *ctx, WindowT *win) {
if (!win || !win->contentBuf) {
return;
}
FileFilterT filters[] = {
{ "PNG Images (*.png)", "*.png" },
{ "BMP Images (*.bmp)", "*.bmp" }
};
char path[260];
int32_t capW = win->contentW;
int32_t capH = win->contentH;
int32_t capPitch = win->contentPitch;
int32_t capSize = capPitch * capH;
uint8_t *capBuf = (uint8_t *)malloc(capSize);
if (capBuf) {
memcpy(capBuf, win->contentBuf, capSize);
if (dvxFileDialog(ctx, "Save Window Screenshot", FD_SAVE, NULL, filters, 2, path, sizeof(path))) {
dvxSaveImage(ctx, capBuf, capW, capH, capPitch, path);
}
free(capBuf);
}
}
// ============================================================
// 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 SysMenuScreenshotE:
// Sys menu is already closed. Composite a clean frame first.
compositeAndFlush(ctx);
interactiveScreenshot(ctx);
break;
case SysMenuWinScreenshotE:
interactiveWindowScreenshot(ctx, 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;
// Screenshot (full screen)
item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++];
strncpy(item->label, "Scree&nshot...", MAX_MENU_LABEL - 1);
item->cmd = SysMenuScreenshotE;
item->separator = false;
item->enabled = true;
item->accelKey = accelParse(item->label);
// Screenshot (this window)
item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++];
strncpy(item->label, "&Window Shot...", MAX_MENU_LABEL - 1);
item->cmd = SysMenuWinScreenshotE;
item->separator = false;
item->enabled = true;
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+F12 / Ctrl+Shift+F12 -- screenshot (full / window)
// 4. Ctrl+Esc -- system-wide hotkey (task manager)
// 5. 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+F12 -- save full screen screenshot
// Ctrl+Shift+F12 -- save focused window screenshot
// BIOS returns scancode 0x58 for F12; Ctrl+F12 = scancode 0x8A.
if (ascii == 0 && scancode == 0x8A && (shiftFlags & KEY_MOD_CTRL)) {
if (shiftHeld && ctx->stack.focusedIdx >= 0) {
interactiveWindowScreenshot(ctx, ctx->stack.windows[ctx->stack.focusedIdx]);
} else {
interactiveScreenshot(ctx);
}
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) {
kbWin->maximized = false;
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) {
kbWin->maximized = false;
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);
}