4512 lines
158 KiB
C
4512 lines
158 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 {
|
|
wmResizeEnd(&ctx->stack);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle active scrollbar thumb drag
|
|
if (ctx->stack.scrollWindow >= 0) {
|
|
if (buttons & MOUSE_LEFT) {
|
|
wmScrollbarDrag(&ctx->stack, &ctx->dirty, mx, my);
|
|
} else {
|
|
wmScrollbarEnd(&ctx->stack);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle system menu interaction
|
|
if (ctx->sysMenu.active) {
|
|
if (mx >= ctx->sysMenu.popupX && mx < ctx->sysMenu.popupX + ctx->sysMenu.popupW &&
|
|
my >= ctx->sysMenu.popupY && my < ctx->sysMenu.popupY + ctx->sysMenu.popupH) {
|
|
|
|
// Hover tracking
|
|
int32_t relY = my - ctx->sysMenu.popupY - POPUP_BEVEL_WIDTH;
|
|
int32_t itemIdx = (relY >= 0) ? (int32_t)((uint32_t)relY * ctx->charHeightRecip >> 16) : 0;
|
|
|
|
if (itemIdx >= 0 && itemIdx < ctx->sysMenu.itemCount && ctx->sysMenu.items[itemIdx].separator) {
|
|
itemIdx = -1;
|
|
}
|
|
|
|
if (itemIdx != ctx->sysMenu.hoverItem) {
|
|
ctx->sysMenu.hoverItem = itemIdx;
|
|
dirtyListAdd(&ctx->dirty, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH);
|
|
}
|
|
|
|
// Click on item
|
|
if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) {
|
|
if (itemIdx >= 0 && itemIdx < ctx->sysMenu.itemCount) {
|
|
SysMenuItemT *item = &ctx->sysMenu.items[itemIdx];
|
|
|
|
if (item->enabled && !item->separator) {
|
|
executeSysMenuCmd(ctx, item->cmd);
|
|
}
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Click outside system menu — close it, let event fall through
|
|
if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) {
|
|
closeSysMenu(ctx);
|
|
}
|
|
}
|
|
|
|
// Handle popup menu interaction (with cascading submenu support).
|
|
// Popup menus form a stack (parentStack) with the deepest submenu as
|
|
// "current". Hit testing checks the current level first. If the mouse
|
|
// is in a parent level instead, all deeper levels are closed (popped)
|
|
// so the user can navigate back up the submenu tree by moving the
|
|
// mouse to a parent menu. This matches Win3.x cascading menu behavior.
|
|
if (ctx->popup.active) {
|
|
bool inCurrent = (mx >= ctx->popup.popupX && mx < ctx->popup.popupX + ctx->popup.popupW &&
|
|
my >= ctx->popup.popupY && my < ctx->popup.popupY + ctx->popup.popupH);
|
|
|
|
if (inCurrent) {
|
|
// Hover tracking: convert mouse Y to item index using fixed-point
|
|
// reciprocal multiply instead of integer divide. This avoids a
|
|
// costly division on 486 hardware where div can take 40+ cycles.
|
|
int32_t relY = my - ctx->popup.popupY - POPUP_BEVEL_WIDTH;
|
|
int32_t itemIdx = (relY >= 0) ? (int32_t)((uint32_t)relY * ctx->charHeightRecip >> 16) : 0;
|
|
|
|
if (itemIdx < 0) {
|
|
itemIdx = 0;
|
|
}
|
|
|
|
if (ctx->popup.menu && itemIdx >= ctx->popup.menu->itemCount) {
|
|
itemIdx = ctx->popup.menu->itemCount - 1;
|
|
}
|
|
|
|
if (itemIdx != ctx->popup.hoverItem) {
|
|
int32_t prevHover = ctx->popup.hoverItem;
|
|
ctx->popup.hoverItem = itemIdx;
|
|
dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY,
|
|
ctx->popup.popupW, ctx->popup.popupH);
|
|
|
|
// If hovering a submenu item, open the submenu
|
|
if (ctx->popup.menu && itemIdx >= 0 && itemIdx < ctx->popup.menu->itemCount) {
|
|
MenuItemT *hItem = &ctx->popup.menu->items[itemIdx];
|
|
|
|
if (hItem->subMenu && hItem->enabled) {
|
|
openSubMenu(ctx);
|
|
}
|
|
}
|
|
|
|
// If we moved away from a submenu item to a non-submenu item,
|
|
// close any child submenus that were opened from this level
|
|
if (prevHover >= 0 && ctx->popup.menu && prevHover < ctx->popup.menu->itemCount) {
|
|
// Already handled: openSubMenu replaces the child, and if current
|
|
// item is not a submenu, no child opens. But we may still have
|
|
// a stale child — check if depth was increased by a previous hover.
|
|
// This case is handled below by the parent-level hit test popping levels.
|
|
}
|
|
}
|
|
|
|
// Click on item in current level
|
|
if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) {
|
|
if (ctx->popup.menu && itemIdx >= 0 && itemIdx < ctx->popup.menu->itemCount) {
|
|
MenuItemT *item = &ctx->popup.menu->items[itemIdx];
|
|
|
|
if (item->subMenu && item->enabled) {
|
|
// Clicking a submenu item opens it (already open from hover, but ensure)
|
|
openSubMenu(ctx);
|
|
} else if (item->enabled && !item->separator) {
|
|
// Toggle check/radio state before closing
|
|
if (item->type == MenuItemCheckE || item->type == MenuItemRadioE) {
|
|
clickMenuCheckRadio(ctx->popup.menu, itemIdx);
|
|
}
|
|
|
|
// Close popup BEFORE calling onMenu because the menu
|
|
// handler may open a modal dialog, which runs a nested
|
|
// dvxUpdate loop. If the popup were still active, the
|
|
// nested loop would try to draw/interact with a stale popup.
|
|
int32_t menuId = item->id;
|
|
WindowT *win = findWindowById(ctx, ctx->popup.windowId);
|
|
closeAllPopups(ctx);
|
|
|
|
if (win && win->onMenu) {
|
|
win->onMenu(win, menuId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Mouse is not in current popup — check parent levels (deepest first)
|
|
bool inParent = false;
|
|
|
|
for (int32_t lvl = ctx->popup.depth - 1; lvl >= 0; lvl--) {
|
|
PopupLevelT *pl = &ctx->popup.parentStack[lvl];
|
|
|
|
if (mx >= pl->popupX && mx < pl->popupX + pl->popupW &&
|
|
my >= pl->popupY && my < pl->popupY + pl->popupH) {
|
|
|
|
// Close all levels deeper than this one
|
|
while (ctx->popup.depth > lvl + 1) {
|
|
closePopupLevel(ctx);
|
|
}
|
|
|
|
// Now close the level that was the "current" when we entered this parent
|
|
closePopupLevel(ctx);
|
|
|
|
// Now current level IS this parent — update hover
|
|
int32_t relY = my - ctx->popup.popupY - POPUP_BEVEL_WIDTH;
|
|
int32_t itemIdx = (relY >= 0) ? (int32_t)((uint32_t)relY * ctx->charHeightRecip >> 16) : 0;
|
|
|
|
if (itemIdx < 0) {
|
|
itemIdx = 0;
|
|
}
|
|
|
|
if (ctx->popup.menu && itemIdx >= ctx->popup.menu->itemCount) {
|
|
itemIdx = ctx->popup.menu->itemCount - 1;
|
|
}
|
|
|
|
ctx->popup.hoverItem = itemIdx;
|
|
dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY,
|
|
ctx->popup.popupW, ctx->popup.popupH);
|
|
|
|
// If the newly hovered item has a submenu, open it
|
|
if (ctx->popup.menu && itemIdx >= 0 && itemIdx < ctx->popup.menu->itemCount) {
|
|
MenuItemT *hItem = &ctx->popup.menu->items[itemIdx];
|
|
|
|
if (hItem->subMenu && hItem->enabled) {
|
|
openSubMenu(ctx);
|
|
}
|
|
}
|
|
|
|
inParent = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!inParent) {
|
|
if (ctx->popup.isContextMenu) {
|
|
// Context menu: any click outside closes it
|
|
if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) {
|
|
closeAllPopups(ctx);
|
|
}
|
|
} else {
|
|
// Menu bar popup: check if mouse is on the menu bar for switching
|
|
WindowT *win = findWindowById(ctx, ctx->popup.windowId);
|
|
|
|
if (win && win->menuBar) {
|
|
int32_t barY = win->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT;
|
|
|
|
if (my >= barY && my < barY + CHROME_MENU_HEIGHT &&
|
|
mx >= win->x + CHROME_TOTAL_SIDE &&
|
|
mx < win->x + win->w - CHROME_TOTAL_SIDE) {
|
|
|
|
int32_t relX = mx - win->x;
|
|
|
|
for (int32_t i = 0; i < win->menuBar->menuCount; i++) {
|
|
MenuT *menu = &win->menuBar->menus[i];
|
|
|
|
if (relX >= menu->barX && relX < menu->barX + menu->barW) {
|
|
if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT) && i == ctx->popup.menuIdx && ctx->popup.depth == 0) {
|
|
// Clicking the same menu bar entry closes the menu
|
|
closeAllPopups(ctx);
|
|
} else if (i != ctx->popup.menuIdx || ctx->popup.depth > 0) {
|
|
openPopupAtMenu(ctx, win, i);
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
} else if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) {
|
|
closeAllPopups(ctx);
|
|
}
|
|
} else if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) {
|
|
closeAllPopups(ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle left button press
|
|
if ((buttons & MOUSE_LEFT) && !(prevBtn & MOUSE_LEFT)) {
|
|
handleMouseButton(ctx, mx, my, buttons);
|
|
}
|
|
|
|
// Handle right button press — context menus.
|
|
// Context menu resolution walks UP the widget tree from the hit widget
|
|
// to find the nearest ancestor with a contextMenu set, then falls back
|
|
// to the window-level context menu. This lets containers provide menus
|
|
// that apply to all their children without requiring each child to have
|
|
// its own menu, while still allowing per-widget overrides.
|
|
if ((buttons & MOUSE_RIGHT) && !(prevBtn & MOUSE_RIGHT)) {
|
|
int32_t hitPart;
|
|
int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart);
|
|
|
|
if (hitIdx >= 0 && hitPart == HIT_CONTENT) {
|
|
WindowT *win = ctx->stack.windows[hitIdx];
|
|
|
|
if (hitIdx != ctx->stack.focusedIdx) {
|
|
wmRaiseWindow(&ctx->stack, &ctx->dirty, hitIdx);
|
|
hitIdx = ctx->stack.count - 1;
|
|
wmSetFocus(&ctx->stack, &ctx->dirty, hitIdx);
|
|
win = ctx->stack.windows[hitIdx];
|
|
}
|
|
|
|
MenuT *ctxMenu = NULL;
|
|
|
|
if (win->widgetRoot) {
|
|
int32_t relX = mx - win->x - win->contentX;
|
|
int32_t relY = my - win->y - win->contentY;
|
|
WidgetT *hit = widgetHitTest(win->widgetRoot, relX, relY);
|
|
|
|
while (hit && !hit->contextMenu) {
|
|
hit = hit->parent;
|
|
}
|
|
|
|
if (hit) {
|
|
ctxMenu = hit->contextMenu;
|
|
}
|
|
}
|
|
|
|
if (!ctxMenu) {
|
|
ctxMenu = win->contextMenu;
|
|
}
|
|
|
|
if (ctxMenu) {
|
|
openContextMenu(ctx, win, ctxMenu, mx, my);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle button release on content — send to focused window
|
|
if (!(buttons & MOUSE_LEFT) && (prevBtn & MOUSE_LEFT)) {
|
|
if (ctx->stack.focusedIdx >= 0) {
|
|
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
|
|
|
|
if (win->onMouse) {
|
|
int32_t relX = mx - win->x - win->contentX;
|
|
int32_t relY = my - win->y - win->contentY;
|
|
win->onMouse(win, relX, relY, buttons);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mouse movement in content area — send to focused window
|
|
if ((mx != ctx->prevMouseX || my != ctx->prevMouseY) &&
|
|
ctx->stack.focusedIdx >= 0 && (buttons & MOUSE_LEFT)) {
|
|
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
|
|
|
|
if (win->onMouse) {
|
|
int32_t relX = mx - win->x - win->contentX;
|
|
int32_t relY = my - win->y - win->contentY;
|
|
win->onMouse(win, relX, relY, buttons);
|
|
}
|
|
}
|
|
|
|
// Mouse wheel — scroll the focused window's vertical scrollbar.
|
|
// Each notch moves MOUSE_WHEEL_STEP lines. If no vertical scrollbar,
|
|
// try horizontal (for windows with only horizontal scroll).
|
|
if (ctx->mouseWheel != 0 && ctx->stack.focusedIdx >= 0) {
|
|
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
|
|
ScrollbarT *sb = win->vScroll ? win->vScroll : win->hScroll;
|
|
|
|
if (sb) {
|
|
int32_t oldValue = sb->value;
|
|
sb->value += ctx->mouseWheel * ctx->wheelDirection * MOUSE_WHEEL_STEP;
|
|
|
|
if (sb->value < sb->min) {
|
|
sb->value = sb->min;
|
|
}
|
|
|
|
if (sb->value > sb->max) {
|
|
sb->value = sb->max;
|
|
}
|
|
|
|
if (sb->value != oldValue) {
|
|
int32_t sbScreenX = win->x + sb->x;
|
|
int32_t sbScreenY = win->y + sb->y;
|
|
dirtyListAdd(&ctx->dirty, sbScreenX, sbScreenY,
|
|
sb->orient == ScrollbarVerticalE ? SCROLLBAR_WIDTH : sb->length,
|
|
sb->orient == ScrollbarVerticalE ? sb->length : SCROLLBAR_WIDTH);
|
|
|
|
if (win->onScroll) {
|
|
win->onScroll(win, sb->orient, sb->value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// drawCursorAt
|
|
// ============================================================
|
|
//
|
|
// Software cursor rendering using AND/XOR mask pairs, the same format
|
|
// Windows uses for cursor resources. The AND mask determines transparency
|
|
// (0=opaque, 1=transparent) and the XOR mask determines color. This
|
|
// lets cursors have transparent pixels without needing alpha blending,
|
|
// which would be expensive on a 486.
|
|
|
|
static void drawCursorAt(AppContextT *ctx, int32_t x, int32_t y) {
|
|
const CursorT *cur = &ctx->cursors[ctx->cursorId];
|
|
|
|
drawMaskedBitmap(&ctx->display, &ctx->blitOps, x - cur->hotX, y - cur->hotY, cur->width, cur->height, cur->andMask, cur->xorData, ctx->cursorFg, ctx->cursorBg);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// drawPopupLevel — draw one popup menu (bevel + items)
|
|
// ============================================================
|
|
//
|
|
// Draws a single popup menu level (the compositor calls this for each
|
|
// level in the popup stack, parent-first). Each item gets:
|
|
// - Full-width highlight bar when hovered
|
|
// - Optional check/radio glyph in a left margin column
|
|
// - Tab-split label: left part with underlined accelerator, right-aligned shortcut
|
|
// - Submenu arrow (right-pointing triangle) if item has a subMenu
|
|
// - Separators drawn as horizontal lines
|
|
//
|
|
// The check margin is conditional: only present if any item in the menu
|
|
// has check or radio type, keeping non-checkable menus compact.
|
|
|
|
static void drawPopupLevel(AppContextT *ctx, DisplayT *d, const BlitOpsT *ops, const MenuT *menu, int32_t px, int32_t py, int32_t pw, int32_t ph, int32_t hoverItem, const RectT *clipTo) {
|
|
RectT popRect = { px, py, pw, ph };
|
|
RectT popIsect;
|
|
|
|
if (!rectIntersect(clipTo, &popRect, &popIsect)) {
|
|
return;
|
|
}
|
|
|
|
// Detect if menu has check/radio items (for left margin)
|
|
bool hasCheck = false;
|
|
|
|
for (int32_t k = 0; k < menu->itemCount; k++) {
|
|
if (menu->items[k].type == MenuItemCheckE || menu->items[k].type == MenuItemRadioE) {
|
|
hasCheck = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
int32_t checkMargin = hasCheck ? MENU_CHECK_WIDTH : 0;
|
|
|
|
// Draw popup background
|
|
BevelStyleT popBevel;
|
|
popBevel.highlight = ctx->colors.windowHighlight;
|
|
popBevel.shadow = ctx->colors.windowShadow;
|
|
popBevel.face = ctx->colors.menuBg;
|
|
popBevel.width = POPUP_BEVEL_WIDTH;
|
|
drawBevel(d, ops, px, py, pw, ph, &popBevel);
|
|
|
|
// Draw menu items
|
|
int32_t itemY = py + POPUP_BEVEL_WIDTH;
|
|
|
|
for (int32_t k = 0; k < menu->itemCount; k++) {
|
|
const MenuItemT *item = &menu->items[k];
|
|
|
|
if (item->separator) {
|
|
drawHLine(d, ops, px + POPUP_BEVEL_WIDTH, itemY + ctx->font.charHeight / 2, pw - POPUP_BEVEL_WIDTH * 2, ctx->colors.windowShadow);
|
|
itemY += ctx->font.charHeight;
|
|
continue;
|
|
}
|
|
|
|
uint32_t bg = ctx->colors.menuBg;
|
|
uint32_t fg = ctx->colors.menuFg;
|
|
|
|
if (k == hoverItem) {
|
|
bg = ctx->colors.menuHighlightBg;
|
|
fg = ctx->colors.menuHighlightFg;
|
|
}
|
|
|
|
rectFill(d, ops, px + POPUP_BEVEL_WIDTH, itemY, pw - POPUP_BEVEL_WIDTH * 2, ctx->font.charHeight, bg);
|
|
|
|
// Split label at tab: left part is the menu text, right part is the shortcut
|
|
const char *tab = strchr(item->label, '\t');
|
|
|
|
if (tab) {
|
|
char leftBuf[MAX_MENU_LABEL];
|
|
int32_t leftLen = (int32_t)(tab - item->label);
|
|
|
|
if (leftLen >= MAX_MENU_LABEL) {
|
|
leftLen = MAX_MENU_LABEL - 1;
|
|
}
|
|
|
|
memcpy(leftBuf, item->label, leftLen);
|
|
leftBuf[leftLen] = '\0';
|
|
|
|
drawTextAccel(d, ops, &ctx->font, px + CHROME_TITLE_PAD + POPUP_BEVEL_WIDTH + checkMargin, itemY, leftBuf, fg, bg, true);
|
|
|
|
const char *right = tab + 1;
|
|
int32_t rightW = textWidth(&ctx->font, right);
|
|
int32_t rightX = px + pw - rightW - CHROME_TITLE_PAD - POPUP_BEVEL_WIDTH * 2;
|
|
|
|
if (item->subMenu) {
|
|
rightX -= SUBMENU_ARROW_WIDTH;
|
|
}
|
|
|
|
drawText(d, ops, &ctx->font, rightX, itemY, right, fg, bg, true);
|
|
} else {
|
|
drawTextAccel(d, ops, &ctx->font, px + CHROME_TITLE_PAD + POPUP_BEVEL_WIDTH + checkMargin, itemY, item->label, fg, bg, true);
|
|
}
|
|
|
|
// Draw check/radio indicator
|
|
if (item->checked) {
|
|
int32_t cy = itemY + ctx->font.charHeight / 2;
|
|
int32_t cx = px + POPUP_BEVEL_WIDTH + MENU_CHECK_WIDTH / 2;
|
|
|
|
if (item->type == MenuItemCheckE) {
|
|
// Checkmark: small tick shape
|
|
drawVLine(d, ops, cx - 2, cy - 1, 2, fg);
|
|
drawVLine(d, ops, cx - 1, cy, 2, fg);
|
|
drawVLine(d, ops, cx, cy + 1, 2, fg);
|
|
drawVLine(d, ops, cx + 1, cy, 2, fg);
|
|
drawVLine(d, ops, cx + 2, cy - 1, 2, fg);
|
|
drawVLine(d, ops, cx + 3, cy - 2, 2, fg);
|
|
} else if (item->type == MenuItemRadioE) {
|
|
// Filled diamond bullet (5x5)
|
|
drawHLine(d, ops, cx, cy - 2, 1, fg);
|
|
drawHLine(d, ops, cx - 1, cy - 1, 3, fg);
|
|
drawHLine(d, ops, cx - 2, cy, 5, fg);
|
|
drawHLine(d, ops, cx - 1, cy + 1, 3, fg);
|
|
drawHLine(d, ops, cx, cy + 2, 1, fg);
|
|
}
|
|
}
|
|
|
|
// Draw submenu arrow indicator
|
|
if (item->subMenu) {
|
|
int32_t arrowX = px + pw - SUBMENU_ARROW_WIDTH - POPUP_BEVEL_WIDTH;
|
|
int32_t arrowY = itemY + ctx->font.charHeight / 2;
|
|
|
|
for (int32_t row = -SUBMENU_ARROW_HALF; row <= SUBMENU_ARROW_HALF; row++) {
|
|
int32_t len = SUBMENU_ARROW_HALF + 1 - (row < 0 ? -row : row);
|
|
|
|
if (len > 0) {
|
|
drawHLine(d, ops, arrowX + (row < 0 ? SUBMENU_ARROW_HALF + row : SUBMENU_ARROW_HALF - row), arrowY + row, len, fg);
|
|
}
|
|
}
|
|
}
|
|
|
|
itemY += ctx->font.charHeight;
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxCreateWindow
|
|
// ============================================================
|
|
|
|
WindowT *dvxCreateWindow(AppContextT *ctx, const char *title, int32_t x, int32_t y, int32_t w, int32_t h, bool resizable) {
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
WindowT *win = wmCreateWindow(&ctx->stack, &ctx->display, title, x, y, w, h, resizable);
|
|
|
|
if (win) {
|
|
// Raise and focus
|
|
int32_t idx = ctx->stack.count - 1;
|
|
wmSetFocus(&ctx->stack, &ctx->dirty, idx);
|
|
dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
|
|
}
|
|
|
|
return win;
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxCreateWindowCentered
|
|
// ============================================================
|
|
|
|
WindowT *dvxCreateWindowCentered(AppContextT *ctx, const char *title, int32_t w, int32_t h, bool resizable) {
|
|
int32_t x = (ctx->display.width - w) / 2;
|
|
int32_t y = (ctx->display.height - h) / 2;
|
|
|
|
return dvxCreateWindow(ctx, title, x, y, w, h, resizable);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxAddAccel
|
|
// ============================================================
|
|
//
|
|
// Accelerator entries are pre-normalized at registration time: the key
|
|
// is uppercased and modifier bits are masked to just Ctrl|Alt. This
|
|
// moves the normalization cost from the hot path (every keypress) to
|
|
// the cold path (one-time setup), so checkAccelTable can do a simple
|
|
// integer compare per entry.
|
|
|
|
void dvxAddAccel(AccelTableT *table, int32_t key, int32_t modifiers, int32_t cmdId) {
|
|
if (!table || table->count >= MAX_ACCEL_ENTRIES) {
|
|
return;
|
|
}
|
|
|
|
int32_t normKey = key;
|
|
|
|
if (normKey >= 'a' && normKey <= 'z') {
|
|
normKey = normKey - 32;
|
|
}
|
|
|
|
AccelEntryT *e = &table->entries[table->count++];
|
|
e->key = key;
|
|
e->modifiers = modifiers;
|
|
e->cmdId = cmdId;
|
|
e->normKey = normKey;
|
|
e->normMods = modifiers & (ACCEL_CTRL | ACCEL_ALT);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxClipboardCopy / dvxClipboardGet
|
|
// ============================================================
|
|
//
|
|
// The clipboard is a simple in-process text buffer managed by the
|
|
// platform layer (clipboardCopy/clipboardGet). There is no inter-process
|
|
// clipboard because DVX runs as a single-process windowing system — all
|
|
// windows share the same address space. The thin wrappers here exist to
|
|
// keep the platform layer out of application code's include path.
|
|
|
|
void dvxClipboardCopy(const char *text, int32_t len) {
|
|
clipboardCopy(text, len);
|
|
}
|
|
|
|
|
|
const char *dvxClipboardGet(int32_t *outLen) {
|
|
return clipboardGet(outLen);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// Color scheme — name table and indexed access
|
|
// ============================================================
|
|
|
|
static const char *sColorNames[ColorCountE] = {
|
|
"desktop", "windowFace", "windowHighlight",
|
|
"windowShadow", "activeTitleBg", "activeTitleFg",
|
|
"inactiveTitleBg", "inactiveTitleFg", "contentBg",
|
|
"contentFg", "menuBg", "menuFg",
|
|
"menuHighlightBg", "menuHighlightFg", "buttonFace",
|
|
"scrollbarBg", "scrollbarFg", "scrollbarTrough"
|
|
};
|
|
|
|
// Default GEOS Ensemble Motif-style colors (RGB triplets)
|
|
static const uint8_t sDefaultColors[ColorCountE][3] = {
|
|
{ 0, 128, 128}, // desktop — GEOS teal
|
|
{192, 192, 192}, // windowFace
|
|
{255, 255, 255}, // windowHighlight
|
|
{128, 128, 128}, // windowShadow
|
|
{ 48, 48, 48}, // activeTitleBg — dark charcoal
|
|
{255, 255, 255}, // activeTitleFg
|
|
{160, 160, 160}, // inactiveTitleBg
|
|
{ 64, 64, 64}, // inactiveTitleFg
|
|
{192, 192, 192}, // contentBg
|
|
{ 0, 0, 0}, // contentFg
|
|
{192, 192, 192}, // menuBg
|
|
{ 0, 0, 0}, // menuFg
|
|
{ 48, 48, 48}, // menuHighlightBg
|
|
{255, 255, 255}, // menuHighlightFg
|
|
{192, 192, 192}, // buttonFace
|
|
{192, 192, 192}, // scrollbarBg
|
|
{128, 128, 128}, // scrollbarFg
|
|
{160, 160, 160}, // scrollbarTrough
|
|
};
|
|
|
|
// Access the packed color value in ColorSchemeT by index.
|
|
static uint32_t *colorSlot(ColorSchemeT *cs, ColorIdE id) {
|
|
return ((uint32_t *)cs) + (int32_t)id;
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxColorName
|
|
// ============================================================
|
|
|
|
const char *dvxColorName(ColorIdE id) {
|
|
if (id < 0 || id >= ColorCountE) {
|
|
return "unknown";
|
|
}
|
|
|
|
return sColorNames[id];
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxApplyColorScheme
|
|
// ============================================================
|
|
|
|
void dvxApplyColorScheme(AppContextT *ctx) {
|
|
DisplayT *d = &ctx->display;
|
|
|
|
for (int32_t i = 0; i < ColorCountE; i++) {
|
|
*colorSlot(&ctx->colors, (ColorIdE)i) = packColor(d, ctx->colorRgb[i][0], ctx->colorRgb[i][1], ctx->colorRgb[i][2]);
|
|
}
|
|
|
|
// Repaint everything
|
|
dirtyListAdd(&ctx->dirty, 0, 0, d->width, d->height);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxResetColorScheme
|
|
// ============================================================
|
|
|
|
void dvxResetColorScheme(AppContextT *ctx) {
|
|
memcpy(ctx->colorRgb, sDefaultColors, sizeof(sDefaultColors));
|
|
dvxApplyColorScheme(ctx);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxCascadeWindows
|
|
// ============================================================
|
|
//
|
|
// Arranges windows in the classic cascade pattern: each window is the same
|
|
// size (2/3 of screen), offset diagonally by the title bar height so each
|
|
// title bar remains visible. When the cascade would go off-screen, it wraps
|
|
// back to (0,0). This matches DESQview/X and Windows 3.x cascade behavior.
|
|
// The step size is title_height + border_width so exactly one title bar's
|
|
// worth of the previous window peeks out above and to the left.
|
|
|
|
void dvxCascadeWindows(AppContextT *ctx) {
|
|
int32_t screenW = ctx->display.width;
|
|
int32_t screenH = ctx->display.height;
|
|
int32_t offsetX = 0;
|
|
int32_t offsetY = 0;
|
|
int32_t step = CHROME_TITLE_HEIGHT + CHROME_BORDER_WIDTH;
|
|
|
|
int32_t winW = screenW * 2 / 3;
|
|
int32_t winH = screenH * 2 / 3;
|
|
|
|
if (winW < MIN_WINDOW_W) {
|
|
winW = MIN_WINDOW_W;
|
|
}
|
|
|
|
if (winH < MIN_WINDOW_H) {
|
|
winH = MIN_WINDOW_H;
|
|
}
|
|
|
|
for (int32_t i = 0; i < ctx->stack.count; i++) {
|
|
WindowT *win = ctx->stack.windows[i];
|
|
|
|
if (win->minimized || !win->visible) {
|
|
continue;
|
|
}
|
|
|
|
repositionWindow(ctx, win, offsetX, offsetY, winW, winH);
|
|
|
|
offsetX += step;
|
|
offsetY += step;
|
|
|
|
// Wrap around if we'd go off screen
|
|
if (offsetX + winW > screenW || offsetY + winH > screenH) {
|
|
offsetX = 0;
|
|
offsetY = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxChangeVideoMode
|
|
// ============================================================
|
|
//
|
|
// Live video mode switch. Saves the old display state, attempts to
|
|
// set the new mode, and if successful, reinitializes all dependent
|
|
// subsystems: blit ops, colors, cursors, mouse range, wallpaper,
|
|
// and all window content buffers. Windows larger than the new
|
|
// screen are clamped. On failure, the old mode is restored.
|
|
|
|
int32_t dvxChangeVideoMode(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
|
|
// Save old state for rollback
|
|
DisplayT oldDisplay = ctx->display;
|
|
|
|
// Stash old wallpaper (don't free — we may need it for rollback)
|
|
uint8_t *oldWpBuf = ctx->wallpaperBuf;
|
|
int32_t oldWpPitch = ctx->wallpaperPitch;
|
|
ctx->wallpaperBuf = NULL;
|
|
ctx->wallpaperPitch = 0;
|
|
|
|
// Free old video buffers (no text mode restore)
|
|
platformVideoFreeBuffers(&ctx->display);
|
|
|
|
// Try the new mode
|
|
if (videoInit(&ctx->display, requestedW, requestedH, preferredBpp) != 0) {
|
|
// Restore old mode
|
|
ctx->display = oldDisplay;
|
|
ctx->display.backBuf = NULL;
|
|
ctx->display.palette = NULL;
|
|
|
|
if (videoInit(&ctx->display, oldDisplay.width, oldDisplay.height, oldDisplay.format.bitsPerPixel) != 0) {
|
|
// Both failed — catastrophic
|
|
return -1;
|
|
}
|
|
|
|
// Restore wallpaper
|
|
ctx->wallpaperBuf = oldWpBuf;
|
|
ctx->wallpaperPitch = oldWpPitch;
|
|
drawInit(&ctx->blitOps, &ctx->display);
|
|
dvxApplyColorScheme(ctx);
|
|
return -1;
|
|
}
|
|
|
|
// New mode succeeded — free old wallpaper buffer
|
|
free(oldWpBuf);
|
|
|
|
// Reinit blit ops for new pixel format
|
|
drawInit(&ctx->blitOps, &ctx->display);
|
|
|
|
// Repack all colors for new pixel format
|
|
dvxApplyColorScheme(ctx);
|
|
|
|
// Repack cursor colors
|
|
ctx->cursorFg = packColor(&ctx->display, 255, 255, 255);
|
|
ctx->cursorBg = packColor(&ctx->display, 0, 0, 0);
|
|
|
|
// Reinit mouse range
|
|
platformMouseInit(ctx->display.width, ctx->display.height);
|
|
ctx->hasMouseWheel = platformMouseWheelInit();
|
|
|
|
// Clamp mouse position to new screen
|
|
if (ctx->mouseX >= ctx->display.width) {
|
|
ctx->mouseX = ctx->display.width - 1;
|
|
}
|
|
|
|
if (ctx->mouseY >= ctx->display.height) {
|
|
ctx->mouseY = ctx->display.height - 1;
|
|
}
|
|
|
|
// Clamp and reallocate all window content buffers
|
|
for (int32_t i = 0; i < ctx->stack.count; i++) {
|
|
WindowT *win = ctx->stack.windows[i];
|
|
|
|
// Clamp window position to new screen bounds
|
|
if (win->x + win->w > ctx->display.width) {
|
|
win->x = ctx->display.width - win->w;
|
|
}
|
|
|
|
if (win->x < 0) {
|
|
win->x = 0;
|
|
win->w = ctx->display.width;
|
|
}
|
|
|
|
if (win->y + win->h > ctx->display.height) {
|
|
win->y = ctx->display.height - win->h;
|
|
}
|
|
|
|
if (win->y < 0) {
|
|
win->y = 0;
|
|
win->h = ctx->display.height;
|
|
}
|
|
|
|
// Clear maximized flag since screen size changed
|
|
win->maximized = false;
|
|
|
|
wmUpdateContentRect(win);
|
|
wmReallocContentBuf(win, &ctx->display);
|
|
|
|
if (win->onResize) {
|
|
win->onResize(win, win->contentW, win->contentH);
|
|
}
|
|
|
|
if (win->onPaint) {
|
|
RectT fullRect = {0, 0, win->contentW, win->contentH};
|
|
win->onPaint(win, &fullRect);
|
|
win->contentDirty = true;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
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;
|
|
|
|
*colorSlot(&ctx->colors, id) = packColor(&ctx->display, r, g, b);
|
|
dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxSetWallpaper
|
|
// ============================================================
|
|
|
|
bool dvxSetWallpaper(AppContextT *ctx, const char *path) {
|
|
// Free existing wallpaper
|
|
free(ctx->wallpaperBuf);
|
|
ctx->wallpaperBuf = NULL;
|
|
ctx->wallpaperPitch = 0;
|
|
|
|
if (!path) {
|
|
ctx->wallpaperPath[0] = '\0';
|
|
dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
|
|
return true;
|
|
}
|
|
|
|
// Store path for reload after video mode change
|
|
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;
|
|
}
|
|
|
|
// Pre-scale to screen dimensions using bilinear interpolation and
|
|
// convert to native pixel format. Bilinear samples the 4 nearest
|
|
// source pixels and blends by fractional distance, producing smooth
|
|
// gradients instead of blocky nearest-neighbor artifacts. Uses
|
|
// 8-bit fixed-point weights (256 = 1.0) to avoid floating point.
|
|
//
|
|
// For 15/16bpp modes, ordered dithering (4x4 Bayer matrix) breaks
|
|
// up color banding that occurs when quantizing 24-bit gradients to
|
|
// 5-6-5 or 5-5-5. The dither offset is added before packColor so
|
|
// the quantization error is distributed spatially.
|
|
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 = (ctx->display.format.bitsPerPixel == 15 || ctx->display.format.bitsPerPixel == 16);
|
|
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;
|
|
uint8_t *buf = (uint8_t *)malloc(pitch * screenH);
|
|
|
|
if (!buf) {
|
|
stbi_image_free(rgb);
|
|
return false;
|
|
}
|
|
|
|
int32_t srcStride = imgW * 3;
|
|
|
|
for (int32_t y = 0; y < screenH; y++) {
|
|
// Yield every 32 rows so the UI stays responsive
|
|
if ((y & 31) == 0 && y > 0) {
|
|
dvxUpdate(ctx);
|
|
}
|
|
|
|
// Fixed-point source Y: 16.16
|
|
int32_t srcYfp = (int32_t)((int64_t)y * imgH * 65536 / screenH);
|
|
int32_t sy0 = srcYfp >> 16;
|
|
int32_t sy1 = sy0 + 1;
|
|
int32_t fy = (srcYfp >> 8) & 0xFF; // fractional Y (0-255)
|
|
int32_t ify = 256 - fy;
|
|
uint8_t *dst = buf + y * pitch;
|
|
|
|
if (sy1 >= imgH) {
|
|
sy1 = imgH - 1;
|
|
}
|
|
|
|
uint8_t *row0 = rgb + sy0 * srcStride;
|
|
uint8_t *row1 = rgb + sy1 * srcStride;
|
|
|
|
for (int32_t x = 0; x < screenW; x++) {
|
|
int32_t srcXfp = (int32_t)((int64_t)x * imgW * 65536 / screenW);
|
|
int32_t sx0 = srcXfp >> 16;
|
|
int32_t sx1 = sx0 + 1;
|
|
int32_t fx = (srcXfp >> 8) & 0xFF;
|
|
int32_t ifx = 256 - fx;
|
|
|
|
if (sx1 >= imgW) {
|
|
sx1 = imgW - 1;
|
|
}
|
|
|
|
// Sample 4 source pixels
|
|
uint8_t *p00 = row0 + sx0 * 3;
|
|
uint8_t *p10 = row0 + sx1 * 3;
|
|
uint8_t *p01 = row1 + sx0 * 3;
|
|
uint8_t *p11 = row1 + sx1 * 3;
|
|
|
|
// Bilinear blend (8-bit fixed-point, 256 = 1.0)
|
|
int32_t r = (p00[0] * ifx * ify + p10[0] * fx * ify + p01[0] * ifx * fy + p11[0] * fx * fy) >> 16;
|
|
int32_t g = (p00[1] * ifx * ify + p10[1] * fx * ify + p01[1] * ifx * fy + p11[1] * fx * fy) >> 16;
|
|
int32_t b = (p00[2] * ifx * ify + p10[2] * fx * ify + p01[2] * ifx * fy + p11[2] * fx * fy) >> 16;
|
|
|
|
// Ordered dither for 15/16bpp to reduce color banding
|
|
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;
|
|
}
|
|
|
|
uint32_t px = packColor(&ctx->display, (uint8_t)r, (uint8_t)g, (uint8_t)b);
|
|
|
|
if (bpp == 8) {
|
|
dst[x] = (uint8_t)px;
|
|
} else if (bpp == 15 || bpp == 16) {
|
|
((uint16_t *)dst)[x] = (uint16_t)px;
|
|
} else {
|
|
((uint32_t *)dst)[x] = px;
|
|
}
|
|
}
|
|
}
|
|
|
|
stbi_image_free(rgb);
|
|
|
|
ctx->wallpaperBuf = buf;
|
|
ctx->wallpaperPitch = pitch;
|
|
dirtyListAdd(&ctx->dirty, 0, 0, screenW, screenH);
|
|
return true;
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxInit
|
|
// ============================================================
|
|
//
|
|
// One-shot initialization of all GUI subsystems. The layered init order
|
|
// matters: video must be up before draw ops can be selected (since draw
|
|
// ops depend on pixel format), and colors must be packed after the
|
|
// display format is known.
|
|
|
|
int32_t dvxInit(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
|
|
memset(ctx, 0, sizeof(*ctx));
|
|
|
|
platformInit();
|
|
|
|
// 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
|
|
// pixel format, which is too expensive to do per-frame.
|
|
ctx->cursorFg = packColor(&ctx->display, 255, 255, 255);
|
|
ctx->cursorBg = packColor(&ctx->display, 0, 0, 0);
|
|
|
|
platformMouseInit(ctx->display.width, ctx->display.height);
|
|
ctx->hasMouseWheel = platformMouseWheelInit();
|
|
ctx->mouseX = ctx->display.width / 2;
|
|
ctx->mouseY = ctx->display.height / 2;
|
|
ctx->prevMouseX = ctx->mouseX;
|
|
ctx->prevMouseY = ctx->mouseY;
|
|
|
|
ctx->running = true;
|
|
ctx->lastIconClickId = -1;
|
|
ctx->lastIconClickTime = 0;
|
|
ctx->lastCloseClickId = -1;
|
|
ctx->lastCloseClickTime = 0;
|
|
ctx->lastTitleClickId = -1;
|
|
ctx->lastTitleClickTime = 0;
|
|
ctx->wheelDirection = 1;
|
|
ctx->dblClickTicks = DBLCLICK_THRESHOLD;
|
|
sDblClickTicks = DBLCLICK_THRESHOLD;
|
|
|
|
// Pre-compute fixed-point 16.16 reciprocal of character height so
|
|
// popup menu item index calculation can use multiply+shift instead
|
|
// of division. On a 486, integer divide is 40+ cycles; this
|
|
// reciprocal trick reduces it to ~10 cycles (imul + shr).
|
|
ctx->charHeightRecip = ((uint32_t)0x10000 + (uint32_t)ctx->font.charHeight - 1) / (uint32_t)ctx->font.charHeight;
|
|
|
|
// Dirty the entire screen so the first compositeAndFlush paints everything
|
|
dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
|
|
|
|
return 0;
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxLoadImage
|
|
// ============================================================
|
|
//
|
|
// Public image loading API. Loads any image file supported by stb_image
|
|
// (BMP, PNG, JPEG, GIF, etc.) and converts the RGB pixels to the
|
|
// display's native pixel format for direct use with rectCopy, wgtImage,
|
|
// wgtImageButton, or any other pixel-data consumer. The caller owns the
|
|
// returned buffer and must free it with dvxFreeImage().
|
|
|
|
uint8_t *dvxLoadImage(const AppContextT *ctx, const char *path, int32_t *outW, int32_t *outH, int32_t *outPitch) {
|
|
if (!ctx || !path) {
|
|
return NULL;
|
|
}
|
|
|
|
const DisplayT *d = &ctx->display;
|
|
|
|
int imgW;
|
|
int imgH;
|
|
int channels;
|
|
uint8_t *rgb = stbi_load(path, &imgW, &imgH, &channels, 3);
|
|
|
|
if (!rgb) {
|
|
return NULL;
|
|
}
|
|
|
|
int32_t bpp = d->format.bytesPerPixel;
|
|
int32_t pitch = imgW * bpp;
|
|
uint8_t *buf = (uint8_t *)malloc(pitch * imgH);
|
|
|
|
if (!buf) {
|
|
stbi_image_free(rgb);
|
|
return NULL;
|
|
}
|
|
|
|
for (int32_t y = 0; y < imgH; y++) {
|
|
for (int32_t x = 0; x < imgW; x++) {
|
|
const uint8_t *src = rgb + (y * imgW + x) * 3;
|
|
uint32_t color = packColor(d, src[0], src[1], src[2]);
|
|
uint8_t *dst = buf + y * pitch + x * bpp;
|
|
|
|
if (bpp == 1) {
|
|
*dst = (uint8_t)color;
|
|
} else if (bpp == 2) {
|
|
*(uint16_t *)dst = (uint16_t)color;
|
|
} else {
|
|
*(uint32_t *)dst = color;
|
|
}
|
|
}
|
|
}
|
|
|
|
stbi_image_free(rgb);
|
|
|
|
if (outW) {
|
|
*outW = imgW;
|
|
}
|
|
|
|
if (outH) {
|
|
*outH = imgH;
|
|
}
|
|
|
|
if (outPitch) {
|
|
*outPitch = pitch;
|
|
}
|
|
|
|
return buf;
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxInvalidateRect
|
|
// ============================================================
|
|
|
|
void dvxInvalidateRect(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h) {
|
|
// Convert from content-relative to screen coordinates
|
|
int32_t screenX = win->x + win->contentX + x;
|
|
int32_t screenY = win->y + win->contentY + y;
|
|
|
|
dirtyListAdd(&ctx->dirty, screenX, screenY, w, h);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxInvalidateWindow
|
|
// ============================================================
|
|
|
|
void dvxInvalidateWindow(AppContextT *ctx, WindowT *win) {
|
|
// Call the window's paint callback to update the content buffer
|
|
// before marking the screen dirty. This means raw-paint apps only
|
|
// need to call dvxInvalidateWindow — onPaint fires automatically.
|
|
if (win->onPaint) {
|
|
RectT fullRect = {0, 0, win->contentW, win->contentH};
|
|
win->onPaint(win, &fullRect);
|
|
}
|
|
|
|
win->contentDirty = true;
|
|
dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxMaximizeWindow
|
|
// ============================================================
|
|
|
|
void dvxMaximizeWindow(AppContextT *ctx, WindowT *win) {
|
|
wmMaximize(&ctx->stack, &ctx->dirty, &ctx->display, win);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxMinimizeWindow
|
|
// ============================================================
|
|
|
|
void dvxMinimizeWindow(AppContextT *ctx, WindowT *win) {
|
|
wmMinimize(&ctx->stack, &ctx->dirty, win);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxQuit
|
|
// ============================================================
|
|
|
|
void dvxQuit(AppContextT *ctx) {
|
|
ctx->running = false;
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxRun
|
|
// ============================================================
|
|
|
|
void dvxRun(AppContextT *ctx) {
|
|
while (dvxUpdate(ctx)) {
|
|
// dvxUpdate returns false when the GUI wants to exit
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// dvxUpdate
|
|
// ============================================================
|
|
//
|
|
// Single iteration of the main event loop. This is the fundamental
|
|
// heartbeat of the GUI. The sequence is:
|
|
// 1. Poll hardware (mouse position/buttons, keyboard buffer)
|
|
// 2. Dispatch events (route input to windows, menus, widgets)
|
|
// 3. Update tooltip visibility
|
|
// 4. Poll ANSI terminal widgets (check for new data from PTYs)
|
|
// 5. Periodic tasks (minimized icon thumbnail refresh)
|
|
// 6. Composite dirty regions and flush to LFB
|
|
// 7. If nothing was dirty: run idle callback or yield CPU
|
|
//
|
|
// The idle callback mechanism exists so applications can do background
|
|
// work (e.g., polling serial ports, processing network data) when the
|
|
// GUI has nothing to paint. Without it, the loop would busy-wait or
|
|
// yield the CPU slice. With it, the application gets a callback to do
|
|
// useful work. platformYield is the fallback — it calls INT 28h (DOS
|
|
// idle) or SDL_Delay (Linux) to avoid burning CPU when truly idle.
|
|
|
|
bool dvxUpdate(AppContextT *ctx) {
|
|
if (!ctx->running) {
|
|
return false;
|
|
}
|
|
|
|
pollMouse(ctx);
|
|
pollKeyboard(ctx);
|
|
dispatchEvents(ctx);
|
|
updateTooltip(ctx);
|
|
pollAnsiTermWidgets(ctx);
|
|
wgtUpdateCursorBlink();
|
|
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) {
|
|
dirtyListAdd(&ctx->dirty, oldX, oldY, kbWin->w, kbWin->h);
|
|
dirtyListAdd(&ctx->dirty, kbWin->x, kbWin->y, kbWin->w, kbWin->h);
|
|
}
|
|
} else {
|
|
// KbModeResizeE
|
|
int32_t newW = kbWin->w;
|
|
int32_t newH = kbWin->h;
|
|
|
|
if (ascii == 0 && scancode == 0x4D) {
|
|
newW += KB_MOVE_STEP;
|
|
} else if (ascii == 0 && scancode == 0x4B) {
|
|
newW -= KB_MOVE_STEP;
|
|
} else if (ascii == 0 && scancode == 0x50) {
|
|
newH += KB_MOVE_STEP;
|
|
} else if (ascii == 0 && scancode == 0x48) {
|
|
newH -= KB_MOVE_STEP;
|
|
}
|
|
|
|
if (newW < MIN_WINDOW_W) {
|
|
newW = MIN_WINDOW_W;
|
|
}
|
|
|
|
if (newH < MIN_WINDOW_H) {
|
|
newH = MIN_WINDOW_H;
|
|
}
|
|
|
|
if (kbWin->maxW > 0 && newW > kbWin->maxW) {
|
|
newW = kbWin->maxW;
|
|
}
|
|
|
|
if (kbWin->maxH > 0 && newH > kbWin->maxH) {
|
|
newH = kbWin->maxH;
|
|
}
|
|
|
|
// Clamp to screen boundaries
|
|
int32_t screenW = ctx->display.width;
|
|
int32_t screenH = ctx->display.height;
|
|
|
|
if (kbWin->x + newW > screenW) {
|
|
newW = screenW - kbWin->x;
|
|
}
|
|
|
|
if (kbWin->y + newH > screenH) {
|
|
newH = screenH - kbWin->y;
|
|
}
|
|
|
|
if (newW != kbWin->w || newH != kbWin->h) {
|
|
dirtyListAdd(&ctx->dirty, kbWin->x, kbWin->y, kbWin->w, kbWin->h);
|
|
kbWin->w = newW;
|
|
kbWin->h = newH;
|
|
wmUpdateContentRect(kbWin);
|
|
wmReallocContentBuf(kbWin, &ctx->display);
|
|
|
|
if (kbWin->onResize) {
|
|
kbWin->onResize(kbWin, kbWin->contentW, kbWin->contentH);
|
|
}
|
|
|
|
if (kbWin->onPaint) {
|
|
kbWin->onPaint(kbWin, NULL);
|
|
}
|
|
|
|
dirtyListAdd(&ctx->dirty, kbWin->x, kbWin->y, kbWin->w, kbWin->h);
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Alt+Space — open/close system menu
|
|
// Enhanced INT 16h: Alt+Space returns scancode 0x39, ascii 0x20
|
|
// Must check Alt modifier (bit 3) to distinguish from plain Space
|
|
if (scancode == 0x39 && ascii == 0x20 && (shiftFlags & KEY_MOD_ALT)) {
|
|
if (ctx->sysMenu.active) {
|
|
closeSysMenu(ctx);
|
|
} else if (ctx->stack.focusedIdx >= 0) {
|
|
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
|
|
openSysMenu(ctx, win);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// System menu keyboard navigation
|
|
if (ctx->sysMenu.active) {
|
|
// Alt+key — close system menu and let it fall through to accel dispatch
|
|
if (ascii == 0 && platformAltScanToChar(scancode)) {
|
|
closeSysMenu(ctx);
|
|
// Fall through to dispatchAccelKey below
|
|
} else if (ascii == 0x1B) {
|
|
closeSysMenu(ctx);
|
|
continue;
|
|
} else if (ascii == 0 && scancode == 0x48) {
|
|
// Up arrow
|
|
int32_t idx = ctx->sysMenu.hoverItem;
|
|
|
|
for (int32_t tries = 0; tries < ctx->sysMenu.itemCount; tries++) {
|
|
idx--;
|
|
|
|
if (idx < 0) {
|
|
idx = ctx->sysMenu.itemCount - 1;
|
|
}
|
|
|
|
if (!ctx->sysMenu.items[idx].separator) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
ctx->sysMenu.hoverItem = idx;
|
|
dirtyListAdd(&ctx->dirty, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH);
|
|
continue;
|
|
} else if (ascii == 0 && scancode == 0x50) {
|
|
// Down arrow
|
|
int32_t idx = ctx->sysMenu.hoverItem;
|
|
|
|
for (int32_t tries = 0; tries < ctx->sysMenu.itemCount; tries++) {
|
|
idx++;
|
|
|
|
if (idx >= ctx->sysMenu.itemCount) {
|
|
idx = 0;
|
|
}
|
|
|
|
if (!ctx->sysMenu.items[idx].separator) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
ctx->sysMenu.hoverItem = idx;
|
|
dirtyListAdd(&ctx->dirty, ctx->sysMenu.popupX, ctx->sysMenu.popupY, ctx->sysMenu.popupW, ctx->sysMenu.popupH);
|
|
continue;
|
|
} else if (ascii == 0x0D) {
|
|
// Enter — execute selected item
|
|
if (ctx->sysMenu.hoverItem >= 0 && ctx->sysMenu.hoverItem < ctx->sysMenu.itemCount) {
|
|
SysMenuItemT *item = &ctx->sysMenu.items[ctx->sysMenu.hoverItem];
|
|
|
|
if (item->enabled && !item->separator) {
|
|
executeSysMenuCmd(ctx, item->cmd);
|
|
}
|
|
} else {
|
|
closeSysMenu(ctx);
|
|
}
|
|
continue;
|
|
} else if (ascii != 0) {
|
|
// Accelerator key match
|
|
char lc = (char)tolower(ascii);
|
|
|
|
for (int32_t k = 0; k < ctx->sysMenu.itemCount; k++) {
|
|
if (ctx->sysMenu.items[k].accelKey == lc && ctx->sysMenu.items[k].enabled && !ctx->sysMenu.items[k].separator) {
|
|
executeSysMenuCmd(ctx, ctx->sysMenu.items[k].cmd);
|
|
goto nextKey;
|
|
}
|
|
}
|
|
|
|
// No sys menu match — try menu bar accelerators
|
|
closeSysMenu(ctx);
|
|
if (ctx->stack.focusedIdx >= 0) {
|
|
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
|
|
|
|
if (win->menuBar) {
|
|
for (int32_t i = 0; i < win->menuBar->menuCount; i++) {
|
|
if (win->menuBar->menus[i].accelKey == lc) {
|
|
dispatchAccelKey(ctx, lc);
|
|
goto nextKey;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Check for Alt+key (BIOS returns ascii=0 with specific scancodes)
|
|
if (ascii == 0 && platformAltScanToChar(scancode)) {
|
|
char accelKey = platformAltScanToChar(scancode);
|
|
|
|
if (dispatchAccelKey(ctx, accelKey)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Popup menu keyboard navigation (arrows, enter, esc)
|
|
if (ctx->popup.active && ascii == 0) {
|
|
MenuT *curMenu = ctx->popup.menu;
|
|
|
|
// Up arrow
|
|
if (scancode == 0x48) {
|
|
if (curMenu && curMenu->itemCount > 0) {
|
|
int32_t idx = ctx->popup.hoverItem;
|
|
|
|
for (int32_t tries = 0; tries < curMenu->itemCount; tries++) {
|
|
idx--;
|
|
|
|
if (idx < 0) {
|
|
idx = curMenu->itemCount - 1;
|
|
}
|
|
|
|
if (!curMenu->items[idx].separator) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
ctx->popup.hoverItem = idx;
|
|
dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Down arrow
|
|
if (scancode == 0x50) {
|
|
if (curMenu && curMenu->itemCount > 0) {
|
|
int32_t idx = ctx->popup.hoverItem;
|
|
|
|
for (int32_t tries = 0; tries < curMenu->itemCount; tries++) {
|
|
idx++;
|
|
|
|
if (idx >= curMenu->itemCount) {
|
|
idx = 0;
|
|
}
|
|
|
|
if (!curMenu->items[idx].separator) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
ctx->popup.hoverItem = idx;
|
|
dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Left arrow — close submenu, or switch to previous top-level menu
|
|
if (scancode == 0x4B) {
|
|
if (ctx->popup.depth > 0) {
|
|
closePopupLevel(ctx);
|
|
} else {
|
|
WindowT *win = findWindowById(ctx, ctx->popup.windowId);
|
|
|
|
if (win && win->menuBar && win->menuBar->menuCount > 1) {
|
|
int32_t newIdx = ctx->popup.menuIdx - 1;
|
|
|
|
if (newIdx < 0) {
|
|
newIdx = win->menuBar->menuCount - 1;
|
|
}
|
|
|
|
openPopupAtMenu(ctx, win, newIdx);
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Right arrow — open submenu, or switch to next top-level menu
|
|
if (scancode == 0x4D) {
|
|
// If hovered item has a submenu, open it
|
|
if (curMenu && ctx->popup.hoverItem >= 0 && ctx->popup.hoverItem < curMenu->itemCount) {
|
|
MenuItemT *hItem = &curMenu->items[ctx->popup.hoverItem];
|
|
|
|
if (hItem->subMenu && hItem->enabled) {
|
|
openSubMenu(ctx);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Otherwise switch to next top-level menu
|
|
WindowT *win = findWindowById(ctx, ctx->popup.windowId);
|
|
|
|
if (win && win->menuBar && win->menuBar->menuCount > 1) {
|
|
int32_t newIdx = ctx->popup.menuIdx + 1;
|
|
|
|
if (newIdx >= win->menuBar->menuCount) {
|
|
newIdx = 0;
|
|
}
|
|
|
|
openPopupAtMenu(ctx, win, newIdx);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Enter executes highlighted popup menu item (or opens submenu)
|
|
if (ctx->popup.active && ascii == 0x0D) {
|
|
MenuT *curMenu = ctx->popup.menu;
|
|
|
|
if (curMenu && ctx->popup.hoverItem >= 0 && ctx->popup.hoverItem < curMenu->itemCount) {
|
|
MenuItemT *item = &curMenu->items[ctx->popup.hoverItem];
|
|
|
|
if (item->subMenu && item->enabled) {
|
|
openSubMenu(ctx);
|
|
} else if (item->enabled && !item->separator) {
|
|
int32_t menuId = item->id;
|
|
WindowT *win = findWindowById(ctx, ctx->popup.windowId);
|
|
closeAllPopups(ctx);
|
|
|
|
if (win && win->onMenu) {
|
|
win->onMenu(win, menuId);
|
|
}
|
|
}
|
|
} else {
|
|
closeAllPopups(ctx);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Check for plain key accelerator in open popup menu
|
|
if (ctx->popup.active && ascii != 0) {
|
|
char lc = (ascii >= 'A' && ascii <= 'Z') ? (char)(ascii + 32) : (char)ascii;
|
|
MenuT *curMenu = ctx->popup.menu;
|
|
|
|
// Try matching an item in the current popup
|
|
if (curMenu) {
|
|
for (int32_t k = 0; k < curMenu->itemCount; k++) {
|
|
MenuItemT *item = &curMenu->items[k];
|
|
|
|
if (item->accelKey == lc && item->enabled && !item->separator) {
|
|
if (item->subMenu) {
|
|
ctx->popup.hoverItem = k;
|
|
dirtyListAdd(&ctx->dirty, ctx->popup.popupX, ctx->popup.popupY, ctx->popup.popupW, ctx->popup.popupH);
|
|
openSubMenu(ctx);
|
|
} else {
|
|
int32_t menuId = item->id;
|
|
WindowT *win = findWindowById(ctx, ctx->popup.windowId);
|
|
closeAllPopups(ctx);
|
|
|
|
if (win && win->onMenu) {
|
|
win->onMenu(win, menuId);
|
|
}
|
|
}
|
|
|
|
goto nextKey;
|
|
}
|
|
}
|
|
}
|
|
|
|
// No match in current popup — try switching to another top-level menu
|
|
WindowT *win = findWindowById(ctx, ctx->popup.windowId);
|
|
|
|
if (win && win->menuBar) {
|
|
for (int32_t i = 0; i < win->menuBar->menuCount; i++) {
|
|
if (win->menuBar->menus[i].accelKey == lc && i != ctx->popup.menuIdx) {
|
|
openPopupAtMenu(ctx, win, i);
|
|
goto nextKey;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ESC closes open dropdown/combobox popup
|
|
if (sOpenPopup && ascii == 0x1B) {
|
|
// Dirty the popup list area
|
|
WindowT *popWin = sOpenPopup->window;
|
|
int32_t popX;
|
|
int32_t popY;
|
|
int32_t popW;
|
|
int32_t popH;
|
|
widgetDropdownPopupRect(sOpenPopup, &ctx->font, popWin->contentH, &popX, &popY, &popW, &popH);
|
|
dirtyListAdd(&ctx->dirty, popWin->x + popWin->contentX + popX, popWin->y + popWin->contentY + popY, popW, popH);
|
|
|
|
WidgetT *closing = sOpenPopup;
|
|
sOpenPopup = NULL;
|
|
|
|
if (closing->type == WidgetDropdownE) {
|
|
closing->as.dropdown.open = false;
|
|
} else if (closing->type == WidgetComboBoxE) {
|
|
closing->as.comboBox.open = false;
|
|
}
|
|
|
|
wgtInvalidate(closing);
|
|
continue;
|
|
}
|
|
|
|
// ESC closes one popup level (or all if at top level)
|
|
if (ctx->popup.active && ascii == 0x1B) {
|
|
closePopupLevel(ctx);
|
|
continue;
|
|
}
|
|
|
|
// Check accelerator table on focused window
|
|
if (ctx->stack.focusedIdx >= 0) {
|
|
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
|
|
int32_t key = ascii ? ascii : (scancode | 0x100);
|
|
|
|
if (checkAccelTable(ctx, win, key, shiftFlags)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Tab / Shift-Tab — cycle focus between widgets
|
|
// Tab: scancode=0x0F, ascii=0x09
|
|
// Shift-Tab: scancode=0x0F, ascii=0x00
|
|
if (scancode == 0x0F && (ascii == 0x09 || ascii == 0)) {
|
|
if (ctx->stack.focusedIdx >= 0) {
|
|
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
|
|
|
|
if (win->widgetRoot) {
|
|
// Find currently focused widget
|
|
WidgetT *current = NULL;
|
|
WidgetT *fstack[64];
|
|
int32_t ftop = 0;
|
|
fstack[ftop++] = win->widgetRoot;
|
|
|
|
while (ftop > 0) {
|
|
WidgetT *w = fstack[--ftop];
|
|
|
|
if (w->focused && widgetIsFocusable(w->type)) {
|
|
// Don't tab out of the terminal — it swallows Tab
|
|
if (w->type == WidgetAnsiTermE) {
|
|
current = NULL;
|
|
break;
|
|
}
|
|
|
|
current = w;
|
|
break;
|
|
}
|
|
|
|
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
|
|
if (c->visible && ftop < 64) {
|
|
fstack[ftop++] = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Terminal swallowed Tab — send to widget system instead
|
|
if (current == NULL) {
|
|
// Check if a terminal is focused
|
|
ftop = 0;
|
|
fstack[ftop++] = win->widgetRoot;
|
|
bool termFocused = false;
|
|
|
|
while (ftop > 0) {
|
|
WidgetT *w = fstack[--ftop];
|
|
|
|
if (w->focused && w->type == WidgetAnsiTermE) {
|
|
termFocused = true;
|
|
break;
|
|
}
|
|
|
|
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
|
|
if (c->visible && ftop < 64) {
|
|
fstack[ftop++] = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (termFocused) {
|
|
// Terminal has focus — send Tab to it
|
|
if (win->onKey) {
|
|
win->onKey(win, ascii ? ascii : (scancode | 0x100), shiftFlags);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
WidgetT *next;
|
|
|
|
if (ascii == 0x09) {
|
|
next = widgetFindNextFocusable(win->widgetRoot, current);
|
|
} else {
|
|
next = widgetFindPrevFocusable(win->widgetRoot, current);
|
|
}
|
|
|
|
if (next) {
|
|
sOpenPopup = NULL;
|
|
if (sFocusedWidget) {
|
|
sFocusedWidget->focused = false;
|
|
}
|
|
sFocusedWidget = next;
|
|
next->focused = true;
|
|
|
|
// Scroll the widget into view if needed
|
|
int32_t scrollX = win->hScroll ? win->hScroll->value : 0;
|
|
int32_t scrollY = win->vScroll ? win->vScroll->value : 0;
|
|
int32_t virtX = next->x + scrollX;
|
|
int32_t virtY = next->y + scrollY;
|
|
|
|
if (win->vScroll) {
|
|
if (virtY < win->vScroll->value) {
|
|
win->vScroll->value = virtY;
|
|
} else if (virtY + next->h > win->vScroll->value + win->contentH) {
|
|
win->vScroll->value = virtY + next->h - win->contentH;
|
|
}
|
|
|
|
win->vScroll->value = clampInt(win->vScroll->value, win->vScroll->min, win->vScroll->max);
|
|
}
|
|
|
|
if (win->hScroll) {
|
|
if (virtX < win->hScroll->value) {
|
|
win->hScroll->value = virtX;
|
|
} else if (virtX + next->w > win->hScroll->value + win->contentW) {
|
|
win->hScroll->value = virtX + next->w - win->contentW;
|
|
}
|
|
|
|
win->hScroll->value = clampInt(win->hScroll->value, win->hScroll->min, win->hScroll->max);
|
|
}
|
|
|
|
wgtInvalidate(win->widgetRoot);
|
|
}
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Send to focused window
|
|
if (ctx->stack.focusedIdx >= 0) {
|
|
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
|
|
|
|
if (win->onKey) {
|
|
win->onKey(win, ascii ? ascii : (scancode | 0x100), shiftFlags);
|
|
}
|
|
}
|
|
|
|
continue;
|
|
nextKey:;
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// pollMouse
|
|
// ============================================================
|
|
|
|
static void pollMouse(AppContextT *ctx) {
|
|
int32_t mx;
|
|
int32_t my;
|
|
int32_t buttons;
|
|
|
|
platformMousePoll(&mx, &my, &buttons);
|
|
|
|
ctx->mouseX = mx;
|
|
ctx->mouseY = my;
|
|
ctx->mouseButtons = buttons;
|
|
ctx->mouseWheel = platformMouseWheelPoll();
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// refreshMinimizedIcons
|
|
// ============================================================
|
|
//
|
|
// Minimized windows show a thumbnail of their content. When the content
|
|
// changes (e.g., a terminal receives output while minimized), the icon
|
|
// thumbnail needs updating. Rather than refreshing all dirty icons every
|
|
// frame (which could cause a burst of repaints), this function refreshes
|
|
// at most ONE icon per call, using a round-robin index (iconRefreshIdx)
|
|
// so each dirty icon gets its turn. Called every ICON_REFRESH_INTERVAL
|
|
// frames, this spreads the cost across time. Windows with custom iconData
|
|
// (loaded from .bmp/.png) are skipped since their thumbnails don't change.
|
|
|
|
static void refreshMinimizedIcons(AppContextT *ctx) {
|
|
WindowStackT *ws = &ctx->stack;
|
|
DisplayT *d = &ctx->display;
|
|
int32_t count = 0;
|
|
int32_t iconIdx = 0;
|
|
|
|
for (int32_t i = 0; i < ws->count; i++) {
|
|
WindowT *win = ws->windows[i];
|
|
|
|
if (!win->visible || !win->minimized) {
|
|
continue;
|
|
}
|
|
|
|
if (!win->iconData && win->contentDirty) {
|
|
if (count >= ctx->iconRefreshIdx) {
|
|
int32_t ix;
|
|
int32_t iy;
|
|
wmMinimizedIconPos(d, iconIdx, &ix, &iy);
|
|
dirtyListAdd(&ctx->dirty, ix, iy, ICON_TOTAL_SIZE, ICON_TOTAL_SIZE);
|
|
win->contentDirty = false;
|
|
ctx->iconRefreshIdx = count + 1;
|
|
return;
|
|
}
|
|
|
|
count++;
|
|
}
|
|
|
|
iconIdx++;
|
|
}
|
|
|
|
// Wrapped past the end — reset for next cycle
|
|
ctx->iconRefreshIdx = 0;
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// repositionWindow — move/resize a window, dirty old & new, fire callbacks
|
|
// ============================================================
|
|
//
|
|
// Shared helper for tiling/cascading. Dirties both the old and new
|
|
// positions (the old area needs repainting because the window moved away,
|
|
// the new area needs repainting because the window appeared there).
|
|
// Also reallocates the content buffer and fires onResize/onPaint so the
|
|
// window's content scales to the new dimensions.
|
|
|
|
static void repositionWindow(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h) {
|
|
// Dirty old position
|
|
dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
|
|
|
|
// Un-maximize if needed
|
|
if (win->maximized) {
|
|
win->maximized = false;
|
|
}
|
|
|
|
win->x = x;
|
|
win->y = y;
|
|
win->w = w;
|
|
win->h = h;
|
|
|
|
wmUpdateContentRect(win);
|
|
wmReallocContentBuf(win, &ctx->display);
|
|
|
|
if (win->onResize) {
|
|
win->onResize(win, win->contentW, win->contentH);
|
|
}
|
|
|
|
if (win->onPaint) {
|
|
RectT fullRect = {0, 0, win->contentW, win->contentH};
|
|
win->onPaint(win, &fullRect);
|
|
win->contentDirty = true;
|
|
}
|
|
|
|
// Dirty new position
|
|
dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// updateCursorShape
|
|
// ============================================================
|
|
//
|
|
// Updates the software cursor shape based on what the mouse is hovering
|
|
// over. The cursor is software-rendered (drawn in the compositor pass)
|
|
// rather than using a hardware cursor because VESA VBE doesn't provide
|
|
// hardware cursor support, and hardware cursors on VGA are limited to
|
|
// text mode. The shape priority is:
|
|
// 1. Active resize drag — keep the edge-specific resize cursor
|
|
// 2. ListView column resize drag
|
|
// 3. Splitter drag
|
|
// 4. Hover over resize edge — show directional resize cursor
|
|
// 5. Hover over ListView column border — horizontal resize cursor
|
|
// 6. Hover over splitter bar — orientation-specific resize cursor
|
|
// 7. Default arrow cursor
|
|
|
|
static void updateCursorShape(AppContextT *ctx) {
|
|
int32_t newCursor = CURSOR_ARROW;
|
|
int32_t mx = ctx->mouseX;
|
|
int32_t my = ctx->mouseY;
|
|
|
|
// During active resize, keep the resize cursor
|
|
if (ctx->stack.resizeWindow >= 0) {
|
|
int32_t edge = ctx->stack.resizeEdge;
|
|
bool horiz = (edge & (RESIZE_LEFT | RESIZE_RIGHT)) != 0;
|
|
bool vert = (edge & (RESIZE_TOP | RESIZE_BOTTOM)) != 0;
|
|
|
|
if (horiz && vert) {
|
|
if ((edge & RESIZE_LEFT && edge & RESIZE_TOP) ||
|
|
(edge & RESIZE_RIGHT && edge & RESIZE_BOTTOM)) {
|
|
newCursor = CURSOR_RESIZE_DIAG_NWSE;
|
|
} else {
|
|
newCursor = CURSOR_RESIZE_DIAG_NESW;
|
|
}
|
|
} else if (horiz) {
|
|
newCursor = CURSOR_RESIZE_H;
|
|
} else {
|
|
newCursor = CURSOR_RESIZE_V;
|
|
}
|
|
}
|
|
// Active ListView column resize drag
|
|
else if (sResizeListView) {
|
|
newCursor = CURSOR_RESIZE_H;
|
|
}
|
|
// Active splitter drag
|
|
else if (sDragSplitter) {
|
|
newCursor = sDragSplitter->as.splitter.vertical ? CURSOR_RESIZE_H : CURSOR_RESIZE_V;
|
|
}
|
|
// Not in an active drag/resize — check what we're hovering
|
|
else if (ctx->stack.dragWindow < 0 && ctx->stack.scrollWindow < 0) {
|
|
int32_t hitPart;
|
|
int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart);
|
|
|
|
if (hitIdx >= 0 && hitPart == HIT_RESIZE) {
|
|
// Hovering over a resize edge
|
|
WindowT *win = ctx->stack.windows[hitIdx];
|
|
int32_t edge = wmResizeEdgeHit(win, mx, my);
|
|
bool horiz = (edge & (RESIZE_LEFT | RESIZE_RIGHT)) != 0;
|
|
bool vert = (edge & (RESIZE_TOP | RESIZE_BOTTOM)) != 0;
|
|
|
|
if (horiz && vert) {
|
|
if ((edge & RESIZE_LEFT && edge & RESIZE_TOP) ||
|
|
(edge & RESIZE_RIGHT && edge & RESIZE_BOTTOM)) {
|
|
newCursor = CURSOR_RESIZE_DIAG_NWSE;
|
|
} else {
|
|
newCursor = CURSOR_RESIZE_DIAG_NESW;
|
|
}
|
|
} else if (horiz) {
|
|
newCursor = CURSOR_RESIZE_H;
|
|
} else if (vert) {
|
|
newCursor = CURSOR_RESIZE_V;
|
|
}
|
|
} else if (hitIdx >= 0 && hitPart == HIT_CONTENT) {
|
|
// Hovering over content area — check for ListView column border
|
|
WindowT *win = ctx->stack.windows[hitIdx];
|
|
|
|
if (win->widgetRoot) {
|
|
int32_t cx = mx - win->x - win->contentX;
|
|
int32_t cy = my - win->y - win->contentY;
|
|
int32_t scrollX = win->hScroll ? win->hScroll->value : 0;
|
|
int32_t scrollY = win->vScroll ? win->vScroll->value : 0;
|
|
int32_t vx = cx + scrollX;
|
|
int32_t vy = cy + scrollY;
|
|
|
|
WidgetT *hit = widgetHitTest(win->widgetRoot, vx, vy);
|
|
|
|
if (hit && hit->type == WidgetListViewE && widgetListViewColBorderHit(hit, vx, vy)) {
|
|
newCursor = CURSOR_RESIZE_H;
|
|
}
|
|
|
|
// Walk into splitters (NO_HIT_RECURSE stops widgetHitTest at the outermost one)
|
|
while (hit && hit->type == WidgetSplitterE) {
|
|
int32_t pos = hit->as.splitter.dividerPos;
|
|
bool onBar;
|
|
|
|
if (hit->as.splitter.vertical) {
|
|
int32_t barX = hit->x + pos;
|
|
onBar = (vx >= barX && vx < barX + SPLITTER_BAR_W);
|
|
} else {
|
|
int32_t barY = hit->y + pos;
|
|
onBar = (vy >= barY && vy < barY + SPLITTER_BAR_W);
|
|
}
|
|
|
|
if (onBar) {
|
|
newCursor = hit->as.splitter.vertical ? CURSOR_RESIZE_H : CURSOR_RESIZE_V;
|
|
break;
|
|
}
|
|
|
|
// Not on this splitter's bar — check children for nested splitters
|
|
WidgetT *inner = NULL;
|
|
|
|
for (WidgetT *c = hit->firstChild; c; c = c->nextSibling) {
|
|
if (c->visible && vx >= c->x && vx < c->x + c->w && vy >= c->y && vy < c->y + c->h) {
|
|
inner = c;
|
|
}
|
|
}
|
|
|
|
hit = inner;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If cursor shape changed, dirty the cursor area
|
|
if (newCursor != ctx->cursorId) {
|
|
dirtyCursorArea(ctx, mx, my);
|
|
ctx->cursorId = newCursor;
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// updateTooltip — show/hide tooltip based on hover state
|
|
// ============================================================
|
|
//
|
|
// Tooltip lifecycle: when the mouse stops moving over a widget that has
|
|
// a tooltip string set, a timer starts. After TOOLTIP_DELAY_MS (500ms),
|
|
// the tooltip appears. Any mouse movement or button press hides it and
|
|
// resets the timer. This avoids tooltip flicker during normal mouse use
|
|
// while still being responsive when the user hovers intentionally.
|
|
//
|
|
// The widget lookup walks into NO_HIT_RECURSE containers (like toolbars)
|
|
// to find the deepest child with a tooltip, so toolbar buttons can have
|
|
// individual tooltips even though the toolbar itself handles hit testing.
|
|
|
|
static void updateTooltip(AppContextT *ctx) {
|
|
clock_t now = clock();
|
|
clock_t threshold = (clock_t)TOOLTIP_DELAY_MS * CLOCKS_PER_SEC / 1000;
|
|
int32_t mx = ctx->mouseX;
|
|
int32_t my = ctx->mouseY;
|
|
|
|
// Mouse moved or button pressed — hide tooltip and reset timer
|
|
if (mx != ctx->prevMouseX || my != ctx->prevMouseY || ctx->mouseButtons) {
|
|
if (ctx->tooltipText) {
|
|
// Dirty old tooltip area
|
|
dirtyListAdd(&ctx->dirty, ctx->tooltipX, ctx->tooltipY, ctx->tooltipW, ctx->tooltipH);
|
|
ctx->tooltipText = NULL;
|
|
}
|
|
|
|
ctx->tooltipHoverStart = now;
|
|
return;
|
|
}
|
|
|
|
// Already showing a tooltip
|
|
if (ctx->tooltipText) {
|
|
return;
|
|
}
|
|
|
|
// Not enough time has passed
|
|
if ((now - ctx->tooltipHoverStart) < threshold) {
|
|
return;
|
|
}
|
|
|
|
// Don't show tooltips while popups/menus are active
|
|
if (ctx->popup.active || ctx->sysMenu.active) {
|
|
return;
|
|
}
|
|
|
|
// Check minimized icons first (they sit outside any window)
|
|
const char *tipText = NULL;
|
|
int32_t iconIdx = wmMinimizedIconHit(&ctx->stack, &ctx->display, mx, my);
|
|
|
|
if (iconIdx >= 0) {
|
|
tipText = ctx->stack.windows[iconIdx]->title;
|
|
}
|
|
|
|
// Otherwise check widgets in the content area of a window
|
|
if (!tipText) {
|
|
int32_t hitPart;
|
|
int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart);
|
|
|
|
if (hitIdx < 0 || hitPart != 0) {
|
|
return;
|
|
}
|
|
|
|
WindowT *win = ctx->stack.windows[hitIdx];
|
|
|
|
if (!win->widgetRoot) {
|
|
return;
|
|
}
|
|
|
|
int32_t cx = mx - win->x - win->contentX;
|
|
int32_t cy = my - win->y - win->contentY;
|
|
int32_t scrollX = win->hScroll ? win->hScroll->value : 0;
|
|
int32_t scrollY = win->vScroll ? win->vScroll->value : 0;
|
|
int32_t vx = cx + scrollX;
|
|
int32_t vy = cy + scrollY;
|
|
|
|
WidgetT *hit = widgetHitTest(win->widgetRoot, vx, vy);
|
|
|
|
// Walk into NO_HIT_RECURSE containers to find deepest child
|
|
while (hit && hit->wclass && (hit->wclass->flags & WCLASS_NO_HIT_RECURSE)) {
|
|
WidgetT *inner = NULL;
|
|
|
|
for (WidgetT *c = hit->firstChild; c; c = c->nextSibling) {
|
|
if (c->visible && vx >= c->x && vx < c->x + c->w && vy >= c->y && vy < c->y + c->h) {
|
|
inner = c;
|
|
}
|
|
}
|
|
|
|
if (!inner) {
|
|
break;
|
|
}
|
|
|
|
if (inner->tooltip) {
|
|
hit = inner;
|
|
break;
|
|
}
|
|
|
|
if (inner->wclass && (inner->wclass->flags & WCLASS_NO_HIT_RECURSE)) {
|
|
hit = inner;
|
|
} else {
|
|
WidgetT *deep = widgetHitTest(inner, vx, vy);
|
|
hit = deep ? deep : inner;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!hit || !hit->tooltip) {
|
|
return;
|
|
}
|
|
|
|
tipText = hit->tooltip;
|
|
}
|
|
|
|
// Show the tooltip
|
|
ctx->tooltipText = tipText;
|
|
|
|
int32_t tw = textWidth(&ctx->font, tipText) + TOOLTIP_PAD * 2;
|
|
int32_t th = ctx->font.charHeight + TOOLTIP_PAD * 2;
|
|
|
|
// Position below and right of cursor
|
|
ctx->tooltipX = mx + 12;
|
|
ctx->tooltipY = my + 16;
|
|
|
|
// Keep on screen
|
|
if (ctx->tooltipX + tw > ctx->display.width) {
|
|
ctx->tooltipX = ctx->display.width - tw;
|
|
}
|
|
|
|
if (ctx->tooltipY + th > ctx->display.height) {
|
|
ctx->tooltipY = my - th - 4;
|
|
}
|
|
|
|
ctx->tooltipW = tw;
|
|
ctx->tooltipH = th;
|
|
|
|
// Dirty the tooltip area
|
|
dirtyListAdd(&ctx->dirty, ctx->tooltipX, ctx->tooltipY, tw, th);
|
|
}
|