Much new documentation added as comments to code.

This commit is contained in:
Scott Duensing 2026-03-17 23:51:01 -05:00
parent 14bc2027df
commit 786353fa08
76 changed files with 6217 additions and 572 deletions

View file

@ -1,7 +1,22 @@
// clock.c — Clock DXE application (main-loop with tsYield)
//
// Demonstrates a main-loop app: runs its own loop calling tsYield(),
// updates a clock display every second, and invalidates its window.
// This is a main-loop DXE app (hasMainLoop = true), in contrast to callback-
// only apps like notepad or progman. The difference is fundamental:
//
// Callback-only: appMain creates UI, returns. Shell calls callbacks.
// Main-loop: appMain runs forever in its own task, calling tsYield()
// to cooperatively yield to the shell and other tasks.
//
// A main-loop app is needed when the app has ongoing work that can't be
// expressed purely as event callbacks — here, polling the system clock
// every second and repainting. The shell allocates a dedicated task stack
// and schedules this task alongside the main event loop.
//
// The onPaint/onClose callbacks still execute in task 0 during dvxUpdate(),
// not in this task. The main loop only handles the clock-tick polling; the
// actual rendering and window management happen through the normal callback
// path. tsYield() is what makes this cooperative — without it, this task
// would starve the shell and all other apps.
#include "dvxApp.h"
#include "dvxWidget.h"
@ -45,6 +60,9 @@ static void updateTime(void);
// App descriptor
// ============================================================
// hasMainLoop = true tells the shell to create a dedicated task for appMain.
// TS_PRIORITY_LOW because clock updates are cosmetic and should never
// preempt interactive apps or the shell's event processing.
AppDescriptorT appDescriptor = {
.name = "Clock",
.hasMainLoop = true,
@ -56,12 +74,23 @@ AppDescriptorT appDescriptor = {
// Callbacks (fire in task 0 during dvxUpdate)
// ============================================================
// Setting quit = true tells the main loop (running in a separate task) to
// exit. The main loop then destroys the window and returns from appMain,
// which causes the shell to reap this task and unload the DXE.
static void onClose(WindowT *win) {
(void)win;
sState.quit = true;
}
// onPaint demonstrates the raw paint callback approach (no widget tree).
// Instead of using wgtInitWindow + widgets, this app renders directly into
// the window's content buffer. This is the lower-level alternative to the
// widget system — useful for custom rendering like this centered clock display.
//
// We create a temporary DisplayT struct pointing at the window's content buffer
// so the drawing primitives (rectFill, drawText) can operate on the window
// content directly rather than the backbuffer.
static void onPaint(WindowT *win, RectT *dirty) {
(void)dirty;
@ -70,7 +99,9 @@ static void onPaint(WindowT *win, RectT *dirty) {
const BitmapFontT *font = dvxGetFont(ac);
const ColorSchemeT *colors = dvxGetColors(ac);
// Local display copy pointing at content buffer
// Shallow copy of the display, redirected to the window's content buffer.
// This lets us reuse the standard drawing API without modifying the
// global display state.
DisplayT cd = *dvxGetDisplay(ac);
cd.backBuf = win->contentBuf;
cd.width = win->contentW;
@ -121,6 +152,10 @@ static void updateTime(void) {
// Shutdown hook (optional DXE export)
// ============================================================
// The shell calls appShutdown (if exported) when force-killing an app via
// Task Manager or during shell shutdown. For main-loop apps, this is the
// signal to break out of the main loop. Without this, shellForceKillApp
// would have to terminate the task forcibly, potentially leaking resources.
void appShutdown(void) {
sState.quit = true;
}
@ -129,6 +164,8 @@ void appShutdown(void) {
// Entry point (runs in its own task)
// ============================================================
// This runs in its own task. The shell creates the task before calling
// appMain, and reaps it when appMain returns.
int32_t appMain(DxeAppContextT *ctx) {
sCtx = ctx;
AppContextT *ac = ctx->shellCtx;
@ -136,11 +173,13 @@ int32_t appMain(DxeAppContextT *ctx) {
memset(&sState, 0, sizeof(sState));
updateTime();
// Position in the upper-right corner, out of the way of other windows
int32_t winW = 200;
int32_t winH = 100;
int32_t winX = ac->display.width - winW - 40;
int32_t winY = 40;
// resizable=false: clock has a fixed size, no resize handle
sWin = dvxCreateWindow(ac, "Clock", winX, winY, winW, winH, false);
if (!sWin) {
@ -155,7 +194,12 @@ int32_t appMain(DxeAppContextT *ctx) {
onPaint(sWin, &full);
dvxInvalidateWindow(ac, sWin);
// Main loop: update time, invalidate, yield
// Main loop: check if the second has changed, repaint if so, then yield.
// tsYield() transfers control back to the shell's task scheduler.
// On a 486, time() resolution is 1 second, so we yield many times per
// second between actual updates — this keeps CPU usage near zero.
// dvxInvalidateWindow marks the window dirty so the compositor will
// flush it to the LFB on the next composite pass.
while (!sState.quit) {
time_t now = time(NULL);

View file

@ -1,7 +1,18 @@
// dvxdemo.c — DVX GUI demonstration app (DXE version)
//
// Callback-only app that opens several windows showcasing the DVX
// widget system, paint callbacks, menus, accelerators, etc.
// Callback-only DXE app (hasMainLoop = false) that opens several windows
// showcasing the DVX widget system, paint callbacks, menus, accelerators, etc.
//
// This serves as a comprehensive widget catalog and integration test:
// - setupMainWindow: raw onPaint callbacks (text, gradient, pattern)
// - setupWidgetDemo: form widgets (input, checkbox, radio, listbox)
// - setupControlsWindow: advanced widgets in a TabControl (dropdown,
// progress, slider, spinner, tree, listview,
// scrollpane, toolbar, canvas, splitter, image)
// - setupTerminalWindow: ANSI terminal emulator widget
//
// Each window is independent; closing one doesn't affect the others.
// The app has no persistent state — it's purely a showcase.
#include "dvxApp.h"
#include "dvxDialog.h"
@ -79,6 +90,9 @@ static DxeAppContextT *sDxeCtx = NULL;
// App descriptor
// ============================================================
// Callback-only: all windows use either widget trees (which handle their own
// input) or onPaint callbacks (which are invoked by the compositor). No need
// for a dedicated task since there's no ongoing computation.
AppDescriptorT appDescriptor = {
.name = "DVX Demo",
.hasMainLoop = false,
@ -218,6 +232,9 @@ static void onMenuCb(WindowT *win, int32_t menuId) {
}
// Walk up the widget tree to find the root, then use wgtFind() to locate
// the named status label. This pattern avoids static coupling between the
// button and the status label — they're connected only by the name string.
static void onOkClick(WidgetT *w) {
WidgetT *root = w;
@ -233,6 +250,11 @@ static void onOkClick(WidgetT *w) {
}
// Renders a vertical gradient directly into the window's content buffer.
// This demonstrates the raw onPaint approach: the app has full pixel control
// and uses the BlitOps span primitives for performance. The gradient is
// computed per-scanline using the span fill operation (rep stosl on x86),
// which is much faster than per-pixel writes.
static void onPaintColor(WindowT *win, RectT *dirtyArea) {
(void)dirtyArea;
@ -256,6 +278,9 @@ static void onPaintColor(WindowT *win, RectT *dirtyArea) {
}
// Renders a checkerboard pattern pixel-by-pixel. This is the slowest
// approach (per-pixel BPP branching) and exists to test that the content
// buffer renders correctly at all supported bit depths (8/16/32 bpp).
static void onPaintPattern(WindowT *win, RectT *dirtyArea) {
(void)dirtyArea;
@ -287,6 +312,10 @@ static void onPaintPattern(WindowT *win, RectT *dirtyArea) {
}
// Renders text manually using the bitmap font glyph data, bypassing the
// normal drawText helper. This demonstrates direct glyph rendering and
// also serves as a regression test for the font subsystem — if the glyph
// data format ever changes, this paint callback would visibly break.
static void onPaintText(WindowT *win, RectT *dirtyArea) {
(void)dirtyArea;
@ -398,9 +427,14 @@ static void onToolbarClick(WidgetT *w) {
// setupControlsWindow — advanced widgets with tabs
// ============================================================
// Item arrays are static because dropdown/combobox widgets store pointers,
// not copies. They must outlive the widgets that reference them.
static const char *colorItems[] = {"Red", "Green", "Blue", "Yellow", "Cyan", "Magenta"};
static const char *sizeItems[] = {"Small", "Medium", "Large", "Extra Large"};
// Demonstrates every advanced widget type, organized into tabs. The TabControl
// widget manages tab pages; each page is a container that shows/hides based
// on the selected tab. This is the densest widget test in the project.
static void setupControlsWindow(void) {
WindowT *win = dvxCreateWindow(sAc, "Advanced Widgets", 380, 50, 360, 440, true);
@ -597,9 +631,11 @@ static void setupControlsWindow(void) {
wgtCanvasSetMouseCallback(cv, onCanvasDraw);
// --- Tab 8: Splitter ---
// Demonstrates nested splitters: a horizontal split (top/bottom) where
// the top pane contains a vertical split (left/right). This creates a
// three-pane file-explorer layout similar to Windows Explorer.
WidgetT *page8s = wgtTabPage(tabs, "S&plit");
// Outer horizontal splitter: explorer on top, detail on bottom
WidgetT *hSplit = wgtSplitter(page8s, false);
hSplit->weight = 100;
wgtSplitterSetPos(hSplit, 120);
@ -637,9 +673,9 @@ static void setupControlsWindow(void) {
wgtLabel(detailFrame, "Select a file above to preview.");
// --- Tab 9: Disabled ---
// Side-by-side comparison of every widget in enabled vs disabled state.
// Tests that wgtSetEnabled(w, false) correctly greys out each widget type.
WidgetT *page9d = wgtTabPage(tabs, "&Disabled");
// Left column: enabled widgets Right column: disabled widgets
WidgetT *disRow = wgtHBox(page9d);
disRow->weight = 100;
@ -736,8 +772,11 @@ static void setupControlsWindow(void) {
// setupMainWindow — info window + paint demos
// ============================================================
// Creates three windows that demonstrate raw onPaint rendering (no widgets):
// 1. Text info window with full menu bar, accelerators, and context menu
// 2. Color gradient window
// 3. Checkerboard pattern window with scrollbars
static void setupMainWindow(void) {
// Window 1: Text information window with menu bar
WindowT *win1 = dvxCreateWindow(sAc, "DVX Information", 50, 40, 340, 350, true);
if (win1) {
@ -809,7 +848,9 @@ static void setupMainWindow(void) {
}
}
// Accelerator table (global hotkeys)
// Accelerator table: keyboard shortcuts that work even without the
// menu being open. The WM dispatches these via the window's onMenu
// callback, making them indistinguishable from menu clicks.
AccelTableT *accel = dvxCreateAccelTable();
dvxAddAccel(accel, 'N', ACCEL_CTRL, CMD_FILE_NEW);
dvxAddAccel(accel, 'O', ACCEL_CTRL, CMD_FILE_OPEN);
@ -818,7 +859,8 @@ static void setupMainWindow(void) {
dvxAddAccel(accel, KEY_F1, 0, CMD_HELP_ABOUT);
win1->accelTable = accel;
// Window-level context menu
// Right-click context menu, attached at the window level. If a widget
// has its own contextMenu it takes priority; otherwise this one fires.
MenuT *winCtx = wmCreateMenu();
wmAddMenuItem(winCtx, "Cu&t", CMD_CTX_CUT);
wmAddMenuItem(winCtx, "&Copy", CMD_CTX_COPY);
@ -827,6 +869,10 @@ static void setupMainWindow(void) {
wmAddMenuItem(winCtx, "&Properties...", CMD_CTX_PROPS);
win1->contextMenu = winCtx;
// For raw-paint windows (no widget tree), we must manually update
// the content rect and allocate the content buffer after adding
// menu bars or scrollbars, since those shrink the content area.
// Then we do an initial paint to fill the buffer before it's composited.
wmUpdateContentRect(win1);
wmReallocContentBuf(win1, &sAc->display);
@ -867,6 +913,10 @@ static void setupMainWindow(void) {
// setupTerminalWindow — ANSI terminal widget demo
// ============================================================
// Creates an ANSI terminal widget with sample output demonstrating all
// supported attributes (bold, reverse, blink), 8+8 colors, background
// colors, and CP437 box-drawing/graphic characters. The terminal is
// minimized on creation to avoid cluttering the initial demo view.
static void setupTerminalWindow(void) {
WindowT *win = dvxCreateWindow(sAc, "ANSI Terminal", 60, 60, 660, 420, true);
@ -946,6 +996,10 @@ static void setupTerminalWindow(void) {
// setupWidgetDemo — form with accelerators
// ============================================================
// Demonstrates the standard form pattern: labeled inputs in frames, checkbox
// and radio groups, list boxes (single and multi-select with context menus),
// and a button row. The '&' in label text marks the Alt+key mnemonic that
// moves focus to the associated widget — this is the accelerator mechanism.
static void setupWidgetDemo(void) {
WindowT *win = dvxCreateWindow(sAc, "Widget Demo", 80, 200, 280, 360, true);
@ -1037,6 +1091,9 @@ static void setupWidgetDemo(void) {
// Entry point
// ============================================================
// Create all demo windows at once. Because this is a callback-only app,
// appMain returns immediately and the shell's event loop takes over.
// Each window is self-contained with its own callbacks.
int32_t appMain(DxeAppContextT *ctx) {
sDxeCtx = ctx;
sAc = ctx->shellCtx;

View file

@ -1,6 +1,14 @@
// notepad.c — Simple text editor DXE application (callback-only)
//
// Demonstrates a callback-only app with menus, text editing, and file I/O.
// A callback-only DXE app (hasMainLoop = false) that provides basic text
// editing with file I/O. Demonstrates the standard DXE app pattern:
// 1. appMain creates window + widget tree, registers callbacks, returns 0
// 2. All editing happens through the TextArea widget's built-in behavior
// 3. File operations and dirty tracking are handled by menu callbacks
//
// The TextArea widget handles keyboard input, cursor movement, selection,
// scrolling, word wrap, and undo internally. Notepad only needs to wire
// up menus and file I/O around it.
#include "dvxApp.h"
#include "dvxDialog.h"
@ -18,6 +26,9 @@
// Constants
// ============================================================
// 32KB text buffer limit. Keeps memory usage bounded on DOS; larger files
// are silently truncated on load. The TextArea widget allocates this buffer
// internally when constructed.
#define TEXT_BUF_SIZE 32768
#define CMD_NEW 100
@ -38,6 +49,7 @@ static DxeAppContextT *sCtx = NULL;
static WindowT *sWin = NULL;
static WidgetT *sTextArea = NULL;
static char sFilePath[260] = "";
// Hash of text content at last save/open, used for cheap dirty detection
static uint32_t sCleanHash = 0;
// ============================================================
@ -60,6 +72,8 @@ static void onMenu(WindowT *win, int32_t menuId);
// App descriptor
// ============================================================
// Callback-only: the TextArea widget handles all interactive editing
// entirely within the shell's event dispatch, so no dedicated task needed.
AppDescriptorT appDescriptor = {
.name = "Notepad",
.hasMainLoop = false,
@ -71,6 +85,10 @@ AppDescriptorT appDescriptor = {
// Dirty tracking
// ============================================================
// djb2-xor hash for dirty detection. Not cryptographic — just a fast way
// to detect changes without storing a full copy of the last-saved text.
// False negatives are theoretically possible but vanishingly unlikely for
// text edits. This avoids the memory cost of keeping a shadow buffer.
static uint32_t hashText(const char *text) {
if (!text) {
return 0;
@ -151,6 +169,8 @@ static void doOpen(void) {
return;
}
// Open in binary mode to avoid DJGPP's CR/LF translation; the TextArea
// widget handles line endings internally.
FILE *f = fopen(path, "rb");
if (!f) {
@ -158,6 +178,9 @@ static void doOpen(void) {
return;
}
// Read entire file into a temporary buffer. Files larger than the
// TextArea's buffer are silently truncated — this matches the behavior
// of Windows Notepad on large files.
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
@ -234,6 +257,9 @@ static void doSaveAs(void) {
// Callbacks
// ============================================================
// onClose is the only cleanup path for a callback-only app. We null our
// pointers after destroying the window; the shell will reap the DXE on the
// next frame when it detects no windows remain for this appId.
static void onClose(WindowT *win) {
if (!askSaveChanges()) {
return;
@ -287,6 +313,8 @@ static void onMenu(WindowT *win, int32_t menuId) {
// Entry point
// ============================================================
// Entry point. Offset +20 from center so multiple notepad instances cascade
// naturally rather than stacking exactly on top of each other.
int32_t appMain(DxeAppContextT *ctx) {
sCtx = ctx;
AppContextT *ac = ctx->shellCtx;
@ -326,7 +354,10 @@ int32_t appMain(DxeAppContextT *ctx) {
wmAddMenuSeparator(editMenu);
wmAddMenuItem(editMenu, "Select &All\tCtrl+A", CMD_SELALL);
// Widget tree
// The widget tree is minimal: just a TextArea filling the entire content
// area (weight=100). The TextArea widget provides editing, scrolling,
// selection, copy/paste, and undo — all driven by keyboard events that
// the shell dispatches to the focused widget.
WidgetT *root = wgtInitWindow(ac, sWin);
sTextArea = wgtTextArea(root, TEXT_BUF_SIZE);

View file

@ -2,8 +2,21 @@
//
// Displays a grid of available apps from the apps/ directory.
// Double-click or Enter launches an app. Includes Task Manager (Ctrl+Esc).
// This is a callback-only DXE app: creates windows, registers callbacks,
// and returns.
//
// DXE App Contract:
// This is a callback-only DXE app (hasMainLoop = false). It exports two
// symbols: appDescriptor (metadata) and appMain (entry point). The shell
// calls appMain once; we create windows, register callbacks, and return 0.
// From that point on, the shell's event loop drives everything through
// our window callbacks (onClose, onMenu, widget onClick, etc.).
//
// Because we have no main loop, we don't need a dedicated task stack.
// The shell runs our callbacks in task 0 during dvxUpdate().
//
// Progman is special: it's the desktop app. It calls
// shellRegisterDesktopUpdate() so the shell notifies us whenever an app
// is loaded, reaped, or crashes, keeping our status bar and Task Manager
// list current without polling.
#include "dvxApp.h"
#include "dvxDialog.h"
@ -23,8 +36,11 @@
// Constants
// ============================================================
// 64 entries is generous; limited by screen real estate before this cap
#define MAX_APP_FILES 64
// DOS 8.3 paths are short, but long names under DJGPP can reach ~260
#define MAX_PATH_LEN 260
// Grid layout for app buttons: 4 columns, rows created dynamically
#define PM_GRID_COLS 4
#define PM_BTN_W 100
#define PM_BTN_H 24
@ -44,11 +60,15 @@
// Module state
// ============================================================
// Each discovered .app file in the apps/ directory tree
typedef struct {
char name[SHELL_APP_NAME_MAX]; // display name (filename without .app)
char path[MAX_PATH_LEN]; // full path
} AppEntryT;
// Module-level statics (s prefix). DXE apps use file-scoped statics because
// each DXE is a separate shared object with its own data segment. No risk
// of collision between apps even though the names look global.
static DxeAppContextT *sCtx = NULL;
static AppContextT *sAc = NULL;
static int32_t sMyAppId = 0;
@ -86,6 +106,10 @@ static void refreshTaskList(void);
// App descriptor
// ============================================================
// The shell reads this exported symbol to determine how to manage the app.
// hasMainLoop = false means the shell won't create a dedicated task; our
// appMain runs to completion and all subsequent work happens via callbacks.
// stackSize = 0 means "use the shell default" (irrelevant for callback apps).
AppDescriptorT appDescriptor = {
.name = "Program Manager",
.hasMainLoop = false,
@ -97,6 +121,9 @@ AppDescriptorT appDescriptor = {
// Static functions (alphabetical)
// ============================================================
// Build the main Program Manager window with app buttons, menus, and status bar.
// Window is centered horizontally and placed in the upper quarter vertically
// so spawned app windows don't hide behind it.
static void buildPmWindow(void) {
int32_t screenW = sAc->display.width;
int32_t screenH = sAc->display.height;
@ -135,10 +162,12 @@ static void buildPmWindow(void) {
wmAddMenuSeparator(helpMenu);
wmAddMenuItem(helpMenu, "&Task Manager\tCtrl+Esc", CMD_TASK_MGR);
// Widget tree
// wgtInitWindow creates the root VBox widget that the window's content
// area maps to. All child widgets are added to this root.
WidgetT *root = wgtInitWindow(sAc, sPmWindow);
// App button grid in a frame
// App button grid in a labeled frame. weight=100 tells the layout engine
// this frame should consume all available vertical space (flex weight).
WidgetT *appFrame = wgtFrame(root, "Applications");
appFrame->weight = 100;
@ -146,7 +175,9 @@ static void buildPmWindow(void) {
WidgetT *lbl = wgtLabel(appFrame, "(No applications found in apps/ directory)");
(void)lbl;
} else {
// Build rows of buttons
// Build rows of buttons. Each row is an HBox holding PM_GRID_COLS
// buttons. userData points back to the AppEntryT so the click
// callback knows which app to launch.
int32_t row = 0;
WidgetT *hbox = NULL;
@ -168,19 +199,26 @@ static void buildPmWindow(void) {
(void)row;
}
// Status bar
// Status bar at bottom; weight=100 on the label makes it fill the bar
// width so text can be left-aligned naturally.
WidgetT *statusBar = wgtStatusBar(root);
sStatusLabel = wgtLabel(statusBar, "");
sStatusLabel->weight = 100;
updateStatusText();
// dvxFitWindow sizes the window to tightly fit the widget tree,
// honoring preferred sizes. Without this, the window would use the
// initial dimensions from dvxCreateWindow even if widgets don't fit.
dvxFitWindow(sAc, sPmWindow);
}
// Build or raise the Task Manager window. Singleton pattern: if sTmWindow is
// already live, we just raise it to the top of the Z-order instead of
// creating a duplicate, mimicking Windows 3.x Task Manager behavior.
static void buildTaskManager(void) {
if (sTmWindow) {
// Already open — just raise it
// Already open — find it in the window stack and bring to front
for (int32_t i = 0; i < sAc->stack.count; i++) {
if (sAc->stack.windows[i] == sTmWindow) {
wmRaiseWindow(&sAc->stack, &sAc->dirty, i);
@ -208,7 +246,8 @@ static void buildTaskManager(void) {
WidgetT *root = wgtInitWindow(sAc, sTmWindow);
// List view of running apps
// ListView widget shows running apps with Name/Type/Status columns.
// wgtPercent() returns a size encoding recognized by the layout engine.
ListViewColT tmCols[3];
tmCols[0].title = "Name";
tmCols[0].width = wgtPercent(50);
@ -225,7 +264,7 @@ static void buildTaskManager(void) {
sTmListView->prefH = wgtPixels(160);
wgtListViewSetColumns(sTmListView, tmCols, 3);
// Button row
// Button row right-aligned (AlignEndE) to follow Windows UI convention
WidgetT *btnRow = wgtHBox(root);
btnRow->align = AlignEndE;
btnRow->spacing = wgtPixels(8);
@ -243,6 +282,8 @@ static void buildTaskManager(void) {
}
// Shell calls this via shellRegisterDesktopUpdate whenever an app is loaded,
// reaped, or crashes. We refresh the running count and Task Manager list.
static void desktopUpdate(void) {
updateStatusText();
@ -252,6 +293,8 @@ static void desktopUpdate(void) {
}
// Widget click handler for app grid buttons. userData was set to the
// AppEntryT pointer during window construction, giving us the .app path.
static void onAppButtonClick(WidgetT *w) {
AppEntryT *entry = (AppEntryT *)w->userData;
@ -268,9 +311,11 @@ static void onAppButtonClick(WidgetT *w) {
}
// Closing the Program Manager is equivalent to shutting down the entire shell.
// dvxQuit() signals the main event loop to exit, which triggers
// shellTerminateAllApps() to gracefully tear down all loaded DXEs.
static void onPmClose(WindowT *win) {
(void)win;
// Confirm exit
int32_t result = dvxMessageBox(sAc, "Exit Shell", "Are you sure you want to exit DVX Shell?", MB_YESNO | MB_ICONQUESTION);
if (result == ID_YES) {
@ -337,6 +382,8 @@ static void onPmMenu(WindowT *win, int32_t menuId) {
}
// Null the static pointers before destroying so buildTaskManager() knows
// the window is gone and will create a fresh one next time.
static void onTmClose(WindowT *win) {
sTmListView = NULL;
sTmWindow = NULL;
@ -357,7 +404,10 @@ static void onTmEndTask(WidgetT *w) {
return;
}
// Map list index to app ID (skip our own appId)
// The list view rows don't carry app IDs directly, so we re-walk the
// app slot table in the same order as refreshTaskList() to map the
// selected row index back to the correct ShellAppT. We skip our own
// appId so progman can't kill itself.
int32_t idx = 0;
for (int32_t i = 1; i < SHELL_MAX_APPS; i++) {
@ -394,7 +444,9 @@ static void onTmSwitchTo(WidgetT *w) {
return;
}
// Map list index to app ID (skip our own appId), find topmost window
// Same index-to-appId mapping as onTmEndTask. We scan the window
// stack top-down (highest Z first) to find the app's topmost window,
// restore it if minimized, then raise and focus it.
int32_t idx = 0;
for (int32_t i = 1; i < SHELL_MAX_APPS; i++) {
@ -430,12 +482,15 @@ static void onTmSwitchTo(WidgetT *w) {
}
// Rebuild the Task Manager list view from the shell's app slot table.
// Uses static arrays because the list view data pointers must remain valid
// until the next call to wgtListViewSetData (the widget doesn't copy strings).
static void refreshTaskList(void) {
if (!sTmListView) {
return;
}
// 3 columns per row: Name, Type, Status
// Flat array of cell strings: [row0_col0, row0_col1, row0_col2, row1_col0, ...]
static const char *cells[SHELL_MAX_APPS * 3];
static char typeStrs[SHELL_MAX_APPS][12];
int32_t rowCount = 0;
@ -461,6 +516,9 @@ static void refreshTaskList(void) {
}
// Top-level scan entry point. Recursively walks apps/ looking for .app files.
// The apps/ path is relative to the working directory, which the shell sets
// to the root of the DVX install before loading any apps.
static void scanAppsDir(void) {
sAppCount = 0;
scanAppsDirRecurse("apps");
@ -468,6 +526,10 @@ static void scanAppsDir(void) {
}
// Recursive directory walker. Subdirectories under apps/ allow organizing
// apps (e.g., apps/games/, apps/tools/). Each .app file is a DXE3 shared
// object that the shell can dlopen(). We skip progman.app to avoid listing
// ourselves in the launcher grid.
static void scanAppsDirRecurse(const char *dirPath) {
DIR *dir = opendir(dirPath);
@ -555,7 +617,8 @@ static void updateStatusText(void) {
static char buf[64];
// Subtract 1 to exclude ourselves from the count
// shellRunningAppCount() includes us. Subtract 1 so the user sees
// only the apps they launched, not the Program Manager itself.
int32_t count = shellRunningAppCount() - 1;
if (count < 0) {
@ -577,6 +640,10 @@ static void updateStatusText(void) {
// Entry point
// ============================================================
// The shell calls appMain exactly once after dlopen() resolves our symbols.
// We scan for apps, build the UI, register our desktop update callback, then
// return 0 (success). From here on the shell drives us through callbacks.
// Returning non-zero would signal a load failure and the shell would unload us.
int32_t appMain(DxeAppContextT *ctx) {
sCtx = ctx;
sAc = ctx->shellCtx;
@ -585,7 +652,8 @@ int32_t appMain(DxeAppContextT *ctx) {
scanAppsDir();
buildPmWindow();
// Register for state change notifications from the shell
// Register for state change notifications from the shell so our status
// bar and Task Manager stay current without polling
shellRegisterDesktopUpdate(desktopUpdate);
return 0;

View file

@ -1,4 +1,32 @@
// dvx_app.c — Layer 5: Application API for DVX GUI
//
// Top-level layer of the DVX windowing system. This is the only layer
// that application code interacts with directly. It owns the main event
// loop, input polling, popup/context menu system, tooltip management,
// accelerator dispatch, clipboard, and window tiling/cascading.
//
// Architecture: poll-based event dispatch
// ----------------------------------------
// The design uses polling (dvxUpdate) rather than an event queue because:
// 1. The target platform (DOS/DPMI) has no OS-provided event queue.
// BIOS keyboard and mouse services are inherently polled.
// 2. Polling avoids the need for dynamic memory allocation (no malloc
// per event, no queue growth) which matters on a 486 with limited RAM.
// 3. It maps directly to the DOS main-loop model: poll hardware, process,
// composite, repeat. No need for event serialization or priority.
// 4. Modal dialogs (message boxes, file dialogs) simply run a nested
// dvxUpdate loop, which is trivial with polling but would require
// re-entrant queue draining with an event queue.
//
// The tradeoff is that all input sources are polled every frame, but on a
// 486 this is actually faster than maintaining queue data structures.
//
// Compositing model: only dirty rectangles are redrawn and flushed to the
// LFB (linear framebuffer). The compositor walks bottom-to-top for each
// dirty rect, so overdraw happens in the backbuffer (system RAM), not
// video memory. The final flush is the only VRAM write per dirty rect,
// which is critical because VRAM writes through the PCI/ISA bus are an
// order of magnitude slower than system RAM writes on period hardware.
#include "dvxApp.h"
#include "dvxWidget.h"
@ -14,9 +42,19 @@
#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
@ -60,14 +98,24 @@ static void repositionWindow(AppContextT *ctx, WindowT *win, int32_t x, int32_t
static void updateCursorShape(AppContextT *ctx);
static void updateTooltip(AppContextT *ctx);
// Button pressed via keyboard — shared with widgetEvent.c for Space/Enter
// 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;
// ============================================================
// calcPopupSize — compute popup width and height for a menu
// 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);
@ -115,6 +163,18 @@ static uint8_t *bufferToRgb(const DisplayT *d, const uint8_t *buf, int32_t w, in
}
// ============================================================
// 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;
@ -155,6 +215,9 @@ static void calcPopupSize(const AppContextT *ctx, const MenuT *menu, int32_t *pw
}
}
// 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;
}
@ -163,6 +226,16 @@ static void calcPopupSize(const AppContextT *ctx, const MenuT *menu, int32_t *pw
// ============================================================
// 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) {
@ -185,7 +258,6 @@ static bool checkAccelTable(AppContextT *ctx, WindowT *win, int32_t key, int32_t
int32_t requiredMods = modifiers & (ACCEL_CTRL | ACCEL_ALT);
AccelTableT *table = win->accelTable;
// Match against pre-normalized keys (Item 6)
for (int32_t i = 0; i < table->count; i++) {
AccelEntryT *e = &table->entries[i];
@ -203,6 +275,13 @@ static bool checkAccelTable(AppContextT *ctx, WindowT *win, int32_t key, int32_t
// ============================================================
// 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];
@ -210,22 +289,18 @@ static void clickMenuCheckRadio(MenuT *menu, int32_t itemIdx) {
if (item->type == MenuItemCheckE) {
item->checked = !item->checked;
} else if (item->type == MenuItemRadioE) {
// Uncheck all radio items in the same group (consecutive radio items)
// Search backward to find group start
int32_t groupStart = itemIdx;
while (groupStart > 0 && menu->items[groupStart - 1].type == MenuItemRadioE) {
groupStart--;
}
// Search forward to find group end
int32_t groupEnd = itemIdx;
while (groupEnd < menu->itemCount - 1 && menu->items[groupEnd + 1].type == MenuItemRadioE) {
groupEnd++;
}
// Uncheck all in group, check the clicked one
for (int32_t i = groupStart; i <= groupEnd; i++) {
menu->items[i].checked = (i == itemIdx);
}
@ -236,6 +311,13 @@ static void clickMenuCheckRadio(MenuT *menu, int32_t 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) {
@ -260,6 +342,12 @@ static void closeAllPopups(AppContextT *ctx) {
// ============================================================
// 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) {
@ -303,6 +391,25 @@ static void closeSysMenu(AppContextT *ctx) {
// ============================================================
// 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;
@ -310,9 +417,11 @@ static void compositeAndFlush(AppContextT *ctx) {
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 (Item 7)
// 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;
@ -444,11 +553,16 @@ static void compositeAndFlush(AppContextT *ctx) {
// ============================================================
// 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) {
// Dirty the union of all cursor shapes at this position to handle shape changes
// All cursors are 16x16 with hotspot at either (0,0) or (7,7), so worst case
// covers from (x-7, y-7) to (x+15, y+15) = 23x23 area
dirtyListAdd(&ctx->dirty, x - 7, y - 7, 23, 23);
}
@ -456,6 +570,15 @@ static void dirtyCursorArea(AppContextT *ctx, int32_t x, int32_t y) {
// ============================================================
// 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) {
@ -464,7 +587,7 @@ static bool dispatchAccelKey(AppContextT *ctx, char key) {
WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
// Check menu bar first
// 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) {
@ -613,6 +736,20 @@ static bool dispatchAccelKey(AppContextT *ctx, char key) {
// ============================================================
// 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;
@ -697,14 +834,20 @@ static void dispatchEvents(AppContextT *ctx) {
}
}
// Handle popup menu interaction (with cascading submenu support)
// 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) {
// Check if mouse is inside current (deepest) popup level
bool inCurrent = (mx >= ctx->popup.popupX && mx < ctx->popup.popupX + ctx->popup.popupW &&
my >= ctx->popup.popupY && my < ctx->popup.popupY + ctx->popup.popupH);
if (inCurrent) {
// Hover tracking in current level
// 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;
@ -755,7 +898,10 @@ static void dispatchEvents(AppContextT *ctx) {
clickMenuCheckRadio(ctx->popup.menu, itemIdx);
}
// Close popup BEFORE calling onMenu (onMenu may run a nested event loop)
// 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);
@ -862,7 +1008,12 @@ static void dispatchEvents(AppContextT *ctx) {
handleMouseButton(ctx, mx, my, buttons);
}
// Handle right button press — context menus
// 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 & 2) && !(prevBtn & 2)) {
int32_t hitPart;
int32_t hitIdx = wmHitTest(&ctx->stack, mx, my, &hitPart);
@ -870,7 +1021,6 @@ static void dispatchEvents(AppContextT *ctx) {
if (hitIdx >= 0 && hitPart == 0) {
WindowT *win = ctx->stack.windows[hitIdx];
// Raise and focus if not already
if (hitIdx != ctx->stack.focusedIdx) {
wmRaiseWindow(&ctx->stack, &ctx->dirty, hitIdx);
hitIdx = ctx->stack.count - 1;
@ -878,7 +1028,6 @@ static void dispatchEvents(AppContextT *ctx) {
win = ctx->stack.windows[hitIdx];
}
// Check widget context menu first
MenuT *ctxMenu = NULL;
if (win->widgetRoot) {
@ -886,7 +1035,6 @@ static void dispatchEvents(AppContextT *ctx) {
int32_t relY = my - win->y - win->contentY;
WidgetT *hit = widgetHitTest(win->widgetRoot, relX, relY);
// Walk up the tree to find a context menu
while (hit && !hit->contextMenu) {
hit = hit->parent;
}
@ -896,7 +1044,6 @@ static void dispatchEvents(AppContextT *ctx) {
}
}
// Fall back to window context menu
if (!ctxMenu) {
ctxMenu = win->contextMenu;
}
@ -937,6 +1084,12 @@ static void dispatchEvents(AppContextT *ctx) {
// ============================================================
// 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];
@ -948,6 +1101,17 @@ static void drawCursorAt(AppContextT *ctx, int32_t x, int32_t y) {
// ============================================================
// 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 };
@ -1103,13 +1267,18 @@ WindowT *dvxCreateWindowCentered(AppContextT *ctx, const char *title, int32_t w,
// ============================================================
// 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;
}
// Pre-normalize key for fast matching (Item 6)
int32_t normKey = key;
if (normKey >= 'a' && normKey <= 'z') {
@ -1126,18 +1295,20 @@ void dvxAddAccel(AccelTableT *table, int32_t key, int32_t modifiers, int32_t cmd
// ============================================================
// dvxClipboardCopy
// 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);
}
// ============================================================
// dvxClipboardGet
// ============================================================
const char *dvxClipboardGet(int32_t *outLen) {
return clipboardGet(outLen);
}
@ -1147,8 +1318,12 @@ const char *dvxClipboardGet(int32_t *outLen) {
// dvxCascadeWindows
// ============================================================
//
// Arrange all visible, non-minimized windows in a staggered
// diagonal pattern from the top-left corner.
// 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;
@ -1157,7 +1332,6 @@ void dvxCascadeWindows(AppContextT *ctx) {
int32_t offsetY = 0;
int32_t step = CHROME_TITLE_HEIGHT + CHROME_BORDER_WIDTH;
// Default cascade size: 2/3 of screen
int32_t winW = screenW * 2 / 3;
int32_t winH = screenH * 2 / 3;
@ -1218,6 +1392,11 @@ void dvxDestroyWindow(AppContextT *ctx, WindowT *win) {
// ============================================================
// 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) {
@ -1304,42 +1483,44 @@ const BitmapFontT *dvxGetFont(const AppContextT *ctx) {
// ============================================================
// 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));
// Platform-specific initialization (signal handling, etc.)
platformInit();
// Initialize video
if (videoInit(&ctx->display, requestedW, requestedH, preferredBpp) != 0) {
return -1;
}
// Initialize blit ops
// 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);
// Initialize window stack
wmInit(&ctx->stack);
// Initialize dirty list
dirtyListInit(&ctx->dirty);
// Set up font (use 8x16)
// 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;
// Set up cursors
memcpy(ctx->cursors, dvxCursors, sizeof(dvxCursors));
ctx->cursorId = CURSOR_ARROW;
// Initialize colors
initColorScheme(ctx);
// Cache cursor colors
// 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);
// Initialize mouse
platformMouseInit(ctx->display.width, ctx->display.height);
ctx->mouseX = ctx->display.width / 2;
ctx->mouseY = ctx->display.height / 2;
@ -1351,9 +1532,14 @@ int32_t dvxInit(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_
ctx->lastIconClickTime = 0;
ctx->lastCloseClickId = -1;
ctx->lastCloseClickTime = 0;
// 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 for first paint
// Dirty the entire screen so the first compositeAndFlush paints everything
dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
return 0;
@ -1424,6 +1610,23 @@ void dvxRun(AppContextT *ctx) {
// ============================================================
// 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) {
@ -1436,7 +1639,6 @@ bool dvxUpdate(AppContextT *ctx) {
updateTooltip(ctx);
pollAnsiTermWidgets(ctx);
// Periodically refresh one minimized window thumbnail (staggered)
ctx->frameCount++;
if (ctx->frameCount % ICON_REFRESH_INTERVAL == 0) {
@ -1451,7 +1653,10 @@ bool dvxUpdate(AppContextT *ctx) {
platformYield();
}
// After compositing, release key-pressed button (one frame of animation)
// 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;
@ -1479,7 +1684,11 @@ bool dvxUpdate(AppContextT *ctx) {
// dvxScreenshot
// ============================================================
//
// Save the entire screen (backbuffer) to a PNG file.
// 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;
@ -1533,8 +1742,11 @@ int32_t dvxSetWindowIcon(AppContextT *ctx, WindowT *win, const char *path) {
// dvxWindowScreenshot
// ============================================================
//
// Save a window's content buffer to a PNG file. This captures the
// full content area regardless of whether the window is occluded.
// 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) {
@ -1559,9 +1771,15 @@ int32_t dvxWindowScreenshot(AppContextT *ctx, WindowT *win, const char *path) {
// dvxTileWindows
// ============================================================
//
// Tile all visible, non-minimized windows in a grid pattern.
// Columns = ceil(sqrt(count)), rows = ceil(count / cols).
// Last row may have fewer windows, which are widened to fill.
// 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;
@ -1629,8 +1847,10 @@ void dvxTileWindows(AppContextT *ctx) {
// dvxTileWindowsH
// ============================================================
//
// Tile all visible, non-minimized windows horizontally:
// side by side left to right, each window gets full screen height.
// 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;
@ -1677,8 +1897,8 @@ void dvxTileWindowsH(AppContextT *ctx) {
// dvxTileWindowsV
// ============================================================
//
// Tile all visible, non-minimized windows vertically:
// stacked top to bottom, each window gets full screen width.
// 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;
@ -1724,6 +1944,12 @@ void dvxTileWindowsV(AppContextT *ctx) {
// ============================================================
// 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);
@ -1799,6 +2025,22 @@ static WindowT *findWindowById(AppContextT *ctx, int32_t id) {
// ============================================================
// 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
@ -1946,6 +2188,13 @@ static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t
// ============================================================
// 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) {
DisplayT *d = &ctx->display;
@ -1977,6 +2226,13 @@ static void initColorScheme(AppContextT *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) {
@ -2016,6 +2272,12 @@ static void openContextMenu(AppContextT *ctx, WindowT *win, MenuT *menu, int32_t
// ============================================================
// 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) {
@ -2047,6 +2309,11 @@ static void openPopupAtMenu(AppContextT *ctx, WindowT *win, int32_t menuIdx) {
// ============================================================
// 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) {
@ -2096,9 +2363,17 @@ static void openSubMenu(AppContextT *ctx) {
// ============================================================
// 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) {
// Close any existing popup menus first
closeAllPopups(ctx);
if (ctx->sysMenu.active) {
@ -2108,8 +2383,6 @@ static void openSysMenu(AppContextT *ctx, WindowT *win) {
ctx->sysMenu.itemCount = 0;
ctx->sysMenu.windowId = win->id;
// Restore — enabled only when maximized
SysMenuItemT *item = &ctx->sysMenu.items[ctx->sysMenu.itemCount++];
strncpy(item->label, "&Restore", MAX_MENU_LABEL - 1);
item->cmd = SysMenuRestoreE;
@ -2194,6 +2467,15 @@ static void openSysMenu(AppContextT *ctx, WindowT *win) {
// ============================================================
// 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++) {
@ -2240,30 +2522,51 @@ static void pollAnsiTermWidgetsWalk(AppContextT *ctx, WidgetT *w, WindowT *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. 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) {
// Read modifier state once per poll
int32_t shiftFlags = platformKeyboardGetModifiers();
ctx->keyModifiers = shiftFlags;
bool shiftHeld = (shiftFlags & 0x03) != 0; // left or right shift
// Process buffered keys
PlatformKeyEventT evt;
while (platformKeyboardRead(&evt)) {
int32_t scancode = evt.scancode;
int32_t ascii = evt.ascii;
// Alt+Tab / Shift+Alt+Tab — cycle windows
// Alt+Tab: scancode=0xA5, ascii=0x00
// 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) {
// Shift+Alt+Tab — focus the bottom window, raise it
wmRaiseWindow(&ctx->stack, &ctx->dirty, 0);
wmSetFocus(&ctx->stack, &ctx->dirty, ctx->stack.count - 1);
} else {
// Alt+Tab — send top window to bottom, focus new top
// 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);
@ -2903,9 +3206,14 @@ static void pollMouse(AppContextT *ctx) {
// refreshMinimizedIcons
// ============================================================
//
// Dirty the next minimized window icon whose content has changed
// since the last refresh. Only considers windows without custom
// iconData. Called every ICON_REFRESH_INTERVAL frames to stagger.
// 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;
@ -2944,6 +3252,12 @@ static void refreshMinimizedIcons(AppContextT *ctx) {
// ============================================================
// 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
@ -2980,6 +3294,19 @@ static void repositionWindow(AppContextT *ctx, WindowT *win, int32_t x, int32_t
// ============================================================
// 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;
@ -3099,6 +3426,16 @@ static void updateCursorShape(AppContextT *ctx) {
// ============================================================
// 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();

View file

@ -1,4 +1,15 @@
// dvx_app.h — Layer 5: Application API for DVX GUI
//
// The topmost layer and the public-facing API for applications. Aggregates
// all lower layers into a single AppContextT and provides a clean interface
// for window creation, event dispatch, and utilities. Applications interact
// exclusively through dvx*() functions and window callbacks — they never
// need to call the lower WM, compositor, or draw layers directly.
//
// The event loop (dvxRun/dvxUpdate) follows a cooperative model: poll mouse
// and keyboard, dispatch events to the focused window, run the compositor
// for dirty regions, then yield. There's no preemptive scheduling — the
// application must return from callbacks promptly.
#ifndef DVX_APP_H
#define DVX_APP_H
@ -13,6 +24,13 @@
// ============================================================
// Application context
// ============================================================
//
// Single monolithic context that owns all GUI state. Allocated on the
// caller's stack (or statically) — no internal heap allocation for the
// context itself. This "god struct" approach keeps the API simple (one
// pointer to pass everywhere) and avoids the overhead of a handle-based
// system with opaque lookups. The tradeoff is a large struct, but on DOS
// memory layout is flat and cache pressure isn't a concern at this level.
typedef struct AppContextT {
DisplayT display;
@ -33,57 +51,88 @@ typedef struct AppContextT {
int32_t mouseY;
int32_t mouseButtons;
int32_t keyModifiers; // current BIOS shift state (KEY_MOD_xxx)
// Previous-frame mouse state for edge detection (button press/release
// transitions and delta-based cursor movement).
int32_t prevMouseX;
int32_t prevMouseY;
int32_t prevMouseButtons;
// Double-click detection for minimized window icons: timestamps and
// window IDs track whether two clicks land on the same icon within
// the system double-click interval.
clock_t lastIconClickTime;
int32_t lastIconClickId; // window ID of last-clicked minimized icon (-1 = none)
clock_t lastCloseClickTime;
int32_t lastCloseClickId; // window ID of last-clicked close gadget (-1 = none)
// Minimized icon thumbnails are refreshed one per frame (round-robin)
// rather than all at once, to amortize the cost of scaling the content
// buffer down to icon size.
int32_t iconRefreshIdx; // next minimized icon to refresh (staggered)
int32_t frameCount; // frame counter for periodic tasks
// The idle callback allows the host application (e.g. the DV/X shell)
// to do background work (poll serial ports, service DXE apps) during
// frames where no GUI events occurred, instead of just yielding the CPU.
void (*idleCallback)(void *ctx); // called instead of yield when non-NULL
void *idleCtx;
WindowT *modalWindow; // if non-NULL, only this window receives input
// Tooltip state
// Tooltip state — tooltip appears after the mouse hovers over a widget
// with a tooltip string for a brief delay. Pre-computing W/H avoids
// re-measuring on every paint frame.
clock_t tooltipHoverStart; // when mouse stopped moving
const char *tooltipText; // text to show (NULL = hidden)
int32_t tooltipX; // screen position
int32_t tooltipY;
int32_t tooltipW; // size (pre-computed)
int32_t tooltipH;
// Fixed-point reciprocal (16.16) of font.charHeight, pre-computed so
// that dividing by charHeight (needed for pixel-to-row conversion in
// terminal/text widgets) becomes a multiply+shift instead of an
// integer divide, which is very slow on 486 (40+ cycles per DIV).
uint32_t charHeightRecip; // fixed-point 16.16 reciprocal of font.charHeight
} AppContextT;
// Initialize the application (VESA mode, input, etc.)
// Initialize the entire GUI stack: video mode, input devices, font,
// color scheme, cursor shapes, and internal state. This is the single
// entry point for starting a DVX application. Returns 0 on success.
int32_t dvxInit(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
// Shut down and restore text mode
// Tear down the GUI stack in reverse order: destroy all windows, restore
// text mode, release input devices. Safe to call after a failed dvxInit().
void dvxShutdown(AppContextT *ctx);
// Run the main event loop (returns when ctx->running is set to false)
// Enter the main event loop. Polls input, dispatches events, composites
// dirty regions, and yields on each iteration. Returns when ctx->running
// becomes false (typically from dvxQuit() or a close callback).
void dvxRun(AppContextT *ctx);
// Process one iteration of the event loop.
// Returns true if the GUI is still running, false if it wants to exit.
// Process exactly one frame of the event loop. Provided for applications
// that need to integrate the GUI into their own main loop (e.g. the DV/X
// shell polling serial ports between frames). Returns false when the GUI
// wants to exit.
bool dvxUpdate(AppContextT *ctx);
// Create a window
// Create a window at an explicit screen position. The window is raised
// to the top, focused, and its entire region is dirtied for the next
// composite pass.
WindowT *dvxCreateWindow(AppContextT *ctx, const char *title, int32_t x, int32_t y, int32_t w, int32_t h, bool resizable);
// Create a centered window (position computed from screen size)
// Convenience wrapper that centers the window on screen.
WindowT *dvxCreateWindowCentered(AppContextT *ctx, const char *title, int32_t w, int32_t h, bool resizable);
// Destroy a window
// Destroy a window, free all its resources, and dirty its former region.
void dvxDestroyWindow(AppContextT *ctx, WindowT *win);
// Resize a window to fit its widget tree's minimum size
// Resize a window to exactly fit its widget tree's computed minimum size
// (plus chrome). Used for dialog boxes and other fixed-layout windows
// where the window should shrink-wrap its content.
void dvxFitWindow(AppContextT *ctx, WindowT *win);
// Invalidate a region of a window's content area (triggers repaint)
// Mark a sub-region of a window's content area as needing repaint. The
// coordinates are relative to the content area, not the screen. The
// onPaint callback will be called during the next composite pass with
// the dirty area.
void dvxInvalidateRect(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h);
// Invalidate entire window content
// Mark the entire window content area as dirty.
void dvxInvalidateWindow(AppContextT *ctx, WindowT *win);
// Minimize a window (show as icon at bottom of screen)
@ -95,7 +144,9 @@ void dvxMaximizeWindow(AppContextT *ctx, WindowT *win);
// Request exit from main loop
void dvxQuit(AppContextT *ctx);
// Save the entire screen to a PNG file (returns 0 on success, -1 on failure)
// Save the entire screen (backbuffer contents) to a PNG file. Converts
// from native pixel format to RGB for the PNG encoder. Returns 0 on
// success, -1 on failure.
int32_t dvxScreenshot(AppContextT *ctx, const char *path);
// Set window title
@ -119,31 +170,43 @@ int32_t dvxSetWindowIcon(AppContextT *ctx, WindowT *win, const char *path);
// Save a window's content to a PNG file (returns 0 on success, -1 on failure)
int32_t dvxWindowScreenshot(AppContextT *ctx, WindowT *win, const char *path);
// Create an accelerator table (caller must free with dvxFreeAccelTable)
// Allocate a new accelerator table. Attach it to a window via
// win->accelTable. The event loop checks the focused window's accel
// table on every keystroke and fires onMenu(cmdId) on match.
AccelTableT *dvxCreateAccelTable(void);
// Free an accelerator table
// Free an accelerator table and its entries.
void dvxFreeAccelTable(AccelTableT *table);
// Add an entry to an accelerator table
// Register a keyboard shortcut. key is an ASCII char or KEY_Fxx constant.
// modifiers is a bitmask of ACCEL_CTRL/ACCEL_SHIFT/ACCEL_ALT. cmdId is
// passed to the window's onMenu callback, matching the convention used
// for menu item IDs so the same handler works for both menus and hotkeys.
void dvxAddAccel(AccelTableT *table, int32_t key, int32_t modifiers, int32_t cmdId);
// Arrange windows in a staggered diagonal cascade pattern
// Window arrangement functions — operate on all visible, non-minimized
// windows. These mirror the classic Windows 3.x "Window" menu commands.
// Cascade: offset each window diagonally by the title bar height.
void dvxCascadeWindows(AppContextT *ctx);
// Tile windows in a grid pattern
// Grid tile: arrange windows in an NxM grid filling the screen.
void dvxTileWindows(AppContextT *ctx);
// Tile windows horizontally (side by side left to right, full height)
// Horizontal tile: side by side, equal width, full height.
void dvxTileWindowsH(AppContextT *ctx);
// Tile windows vertically (stacked top to bottom, full width)
// Vertical tile: stacked, full width, equal height.
void dvxTileWindowsV(AppContextT *ctx);
// Copy text to the shared clipboard
// Copy text to the process-wide clipboard buffer. The clipboard is a
// simple static buffer (not inter-process) — adequate for copy/paste
// within the DVX environment on single-tasking DOS.
void dvxClipboardCopy(const char *text, int32_t len);
// Get clipboard contents (returns pointer to internal buffer, NULL if empty)
// Retrieve the current clipboard contents. Returns a pointer to the
// internal static buffer (valid until the next dvxClipboardCopy), or
// NULL if the clipboard is empty.
const char *dvxClipboardGet(int32_t *outLen);
#endif // DVX_APP_H

View file

@ -1,10 +1,33 @@
// dvx_comp.c — Layer 3: Dirty rectangle compositor for DVX GUI (optimized)
//
// This layer implements dirty rectangle tracking and merging. The compositor
// avoids full-screen redraws, which would be prohibitively expensive on the
// target 486/Pentium hardware over ISA bus VESA LFB. A full 640x480x16bpp
// framebuffer is ~600KB — at ISA's ~8MB/s theoretical peak, a blind full
// flush costs ~75ms (>1 frame at 60Hz). By tracking which rectangles have
// actually changed and flushing only those regions from the system RAM
// backbuffer to the LFB, the bandwidth consumed per frame scales with the
// amount of visual change rather than the screen resolution.
//
// The compositing loop lives in dvxApp.c (compositeAndFlush). For each dirty
// rect, it repaints the desktop, then walks the window stack bottom-to-top
// painting chrome, content, scrollbars, popup menus, and the cursor — all
// clipped to the dirty rect. Only then is the dirty rect flushed to the LFB.
// This means each pixel in a dirty region is written to system RAM potentially
// multiple times (painter's algorithm), but the expensive LFB write happens
// exactly once per pixel per frame.
#include "dvxComp.h"
#include "platform/dvxPlatform.h"
#include <string.h>
// Rects within this many pixels of each other get merged even if they don't
// overlap. A small gap tolerance absorbs jitter from mouse movement and
// closely-spaced UI invalidations (e.g. title bar + content during a drag)
// without bloating merged rects excessively. The value 4 was chosen to match
// the chrome border width — adjacent chrome/content invalidations merge
// naturally.
#define DIRTY_MERGE_GAP 4
// ============================================================
@ -18,16 +41,33 @@ static inline void rectUnion(const RectT *a, const RectT *b, RectT *result);
// ============================================================
// dirtyListAdd
// ============================================================
//
// Appends a dirty rect to the list. Uses a fixed-size array (MAX_DIRTY_RECTS
// = 128) rather than a dynamic allocation — this is called on every UI
// mutation (drag, repaint, focus change) so allocation overhead must be zero.
//
// When the list fills up, an eager merge pass tries to consolidate rects.
// If the list is STILL full after merging (pathological scatter), the
// nuclear option collapses everything into one bounding box. This guarantees
// the list never overflows, at the cost of potentially over-painting a large
// rect. In practice the merge pass almost always frees enough slots because
// GUI mutations tend to cluster spatially.
void dirtyListAdd(DirtyListT *dl, int32_t x, int32_t y, int32_t w, int32_t h) {
// Branch hint: degenerate rects are rare — callers usually validate first
if (__builtin_expect(w <= 0 || h <= 0, 0)) {
return;
}
// Overflow path: try merging, then fall back to a single bounding rect
if (__builtin_expect(dl->count >= MAX_DIRTY_RECTS, 0)) {
dirtyListMerge(dl);
if (dl->count >= MAX_DIRTY_RECTS) {
// Still full — collapse the entire list plus the new rect into one
// bounding box. This is a last resort; it means the next flush will
// repaint a potentially large region, but at least we won't lose
// dirty information or crash.
RectT merged = dl->rects[0];
for (int32_t i = 1; i < dl->count; i++) {
@ -72,16 +112,31 @@ void dirtyListInit(DirtyListT *dl) {
// ============================================================
// dirtyListMerge
// ============================================================
//
// Coalesces overlapping or nearby dirty rects to reduce the number of
// composite+flush passes. The trade-off: merging two rects into their
// bounding box may add "clean" pixels that get needlessly repainted, but
// this is far cheaper than the per-rect overhead of an extra composite
// pass (clip setup, window-stack walk, LFB flush). On 486/Pentium ISA,
// the LFB write latency per-rect dominates, so fewer larger rects win.
//
// Algorithm: O(N^2) pairwise sweep with bounded restarts. For each rect i,
// scan all rects j>i and merge any that overlap or are within DIRTY_MERGE_GAP
// pixels. When a merge happens, rect i grows and may now overlap rects that
// it previously missed, so the inner scan restarts — but restarts are capped
// at 3 per slot to prevent O(N^3) cascading in pathological layouts (e.g.
// a diagonal scatter of tiny rects). The cap of 3 was chosen empirically:
// typical GUI operations produce clustered invalidations that converge in
// 1-2 passes; 3 gives a safety margin without measurable overhead.
//
// Merged-away rects are removed by swap-with-last (O(1) removal from an
// unordered list), which is why the rects array is not kept sorted.
void dirtyListMerge(DirtyListT *dl) {
if (dl->count <= 1) {
return;
}
// O(N²) with bounded restarts: for each rect, try to merge it
// into an earlier rect. When a merge succeeds the merged rect
// may now overlap others, so restart the inner scan — but cap
// restarts per slot to avoid O(N³) pathological cascades.
for (int32_t i = 0; i < dl->count; i++) {
int32_t restarts = 0;
bool merged = true;
@ -92,6 +147,7 @@ void dirtyListMerge(DirtyListT *dl) {
for (int32_t j = i + 1; j < dl->count; j++) {
if (rectsOverlapOrAdjacent(&dl->rects[i], &dl->rects[j], DIRTY_MERGE_GAP)) {
rectUnion(&dl->rects[i], &dl->rects[j], &dl->rects[i]);
// Swap-with-last removal: order doesn't matter for merging
dl->rects[j] = dl->rects[dl->count - 1];
dl->count--;
j--;
@ -108,6 +164,16 @@ void dirtyListMerge(DirtyListT *dl) {
// ============================================================
// flushRect
// ============================================================
//
// Copies one dirty rect from the system RAM backbuffer to the VESA LFB.
// This is the single most bandwidth-sensitive operation in the entire GUI:
// the LFB lives behind the ISA/PCI bus, so every byte written here is a
// bus transaction. The platform layer (platformFlushRect) uses rep movsd
// on 486+ to move aligned 32-bit words, maximizing bus utilization.
//
// Crucially, we flush per dirty rect AFTER all painting for that rect is
// complete. This avoids visible tearing — the LFB is never in a half-painted
// state for any given region.
void flushRect(DisplayT *d, const RectT *r) {
platformFlushRect(d, r);
@ -117,6 +183,12 @@ void flushRect(DisplayT *d, const RectT *r) {
// ============================================================
// rectIntersect
// ============================================================
//
// Used heavily in the compositing loop to test whether a window overlaps
// a dirty rect before painting it. The branch hint marks the non-overlapping
// case as unlikely because the compositing loop already does a coarse AABB
// check before calling this — when we get here, intersection is expected.
// The min/max formulation avoids branches in the hot path.
bool rectIntersect(const RectT *a, const RectT *b, RectT *result) {
int32_t ix1 = a->x > b->x ? a->x : b->x;
@ -149,6 +221,15 @@ bool rectIsEmpty(const RectT *r) {
// ============================================================
// rectsOverlapOrAdjacent
// ============================================================
//
// Separating-axis test with a gap tolerance. Two rects merge if they
// overlap OR if the gap between them is <= DIRTY_MERGE_GAP pixels.
// The gap tolerance is the key tuning parameter for the merge algorithm:
// too small and you get many tiny rects (expensive per-rect flush overhead);
// too large and you merge distant rects into one huge bounding box
// (wasted repaint of clean pixels). The early-out on each axis makes this
// very cheap for non-overlapping rects, which is the common case during
// the inner loop of dirtyListMerge.
static inline bool rectsOverlapOrAdjacent(const RectT *a, const RectT *b, int32_t gap) {
if (a->x + a->w + gap < b->x) { return false; }
@ -162,6 +243,15 @@ static inline bool rectsOverlapOrAdjacent(const RectT *a, const RectT *b, int32_
// ============================================================
// rectUnion
// ============================================================
//
// Axis-aligned bounding box of two rects. Supports in-place operation
// (result == a) for the merge loop. Note that this may grow the rect
// substantially if the two inputs are far apart — this is the inherent
// cost of bounding-box merging vs. maintaining a true region (list of
// non-overlapping rects). Bounding-box was chosen because the merge
// list is bounded to 128 entries and the extra repaint cost of a few
// clean pixels is negligible compared to the complexity of a proper
// region algebra on 486-class hardware.
static inline void rectUnion(const RectT *a, const RectT *b, RectT *result) {
int32_t x1 = a->x < b->x ? a->x : b->x;

View file

@ -1,28 +1,56 @@
// dvx_comp.h — Layer 3: Dirty rectangle compositor for DVX GUI
//
// The compositor tracks which screen regions have changed and ensures
// only those regions are redrawn and flushed to video memory. This is
// the key to acceptable frame rates on 486/Pentium hardware — a full
// 640x480x32bpp frame is 1.2 MB, but a typical dirty region during
// normal interaction (e.g. typing in a text field) might be a few KB.
//
// The compositing pipeline each frame:
// 1. Layers above (WM, app) call dirtyListAdd() for changed regions
// 2. dirtyListMerge() consolidates overlapping/adjacent rects
// 3. For each merged dirty rect, the compositor clips and redraws
// the desktop, then each window (back-to-front, painter's algorithm)
// 4. flushRect() copies each dirty rect from backBuf to the LFB
//
// The merge step is important because many small dirty rects (e.g. from
// a window drag) often cluster together, and flushing one large rect is
// much faster than many small ones due to reduced per-rect overhead and
// better memory bus utilization.
#ifndef DVX_COMP_H
#define DVX_COMP_H
#include "dvxTypes.h"
// Initialize the dirty list
// Zero the dirty rect count. Called at the start of each frame.
void dirtyListInit(DirtyListT *dl);
// Add a rectangle to the dirty list
// Enqueue a dirty rectangle. If the list is full (MAX_DIRTY_RECTS),
// this should degrade gracefully (e.g. expand an existing rect).
void dirtyListAdd(DirtyListT *dl, int32_t x, int32_t y, int32_t w, int32_t h);
// Merge overlapping/adjacent dirty rects to reduce redraw work
// Consolidate the dirty list by merging overlapping and adjacent rects.
// Uses an iterative pairwise merge: for each pair of rects, if merging
// them doesn't increase the total area by more than a threshold, they're
// combined. This reduces the number of compositor passes and LFB flush
// operations at the cost of potentially redrawing some clean pixels.
void dirtyListMerge(DirtyListT *dl);
// Clear the dirty list
// Reset the dirty list to empty.
void dirtyListClear(DirtyListT *dl);
// Flush a single rectangle from backbuffer to LFB
// Copy a rectangle from the system RAM backbuffer to the LFB (video memory).
// This is the final step — the only place where the real framebuffer is
// written. Uses platform-specific fast copy (rep movsd on DOS) for each
// scanline of the rect.
void flushRect(DisplayT *d, const RectT *r);
// Intersect two rectangles, returns true if intersection is non-empty
// Compute the intersection of two rectangles. Returns true if they overlap,
// with the intersection written to result. Used extensively during
// compositing to clip window content to dirty regions.
bool rectIntersect(const RectT *a, const RectT *b, RectT *result);
// Test if a rectangle is empty (zero or negative area)
// Returns true if the rectangle has zero or negative area.
bool rectIsEmpty(const RectT *r);
#endif // DVX_COMP_H

View file

@ -1,4 +1,19 @@
// dvxCursor.h — Embedded mouse cursor bitmaps for DVX GUI
//
// All cursor shapes are compiled in as static const data — no external
// cursor files to load. This is intentional: the cursors are needed
// before any file I/O infrastructure is ready, and embedding them avoids
// a runtime dependency on a file path.
//
// Each cursor is a 16x16 bitmap defined as AND mask + XOR data arrays
// (one uint16_t per row). The AND/XOR encoding is the standard IBM VGA
// hardware cursor scheme: AND mask selects transparency, XOR data selects
// black vs. white for opaque pixels. See CursorT in dvxTypes.h for
// the full pixel-state truth table.
//
// The cursor table (dvxCursors[]) at the bottom provides all five shapes
// in an array indexed by CURSOR_xxx constants, with hotspot coordinates
// set appropriately (arrow hot spot at tip, resize cursors at center).
#ifndef DVX_CURSOR_H
#define DVX_CURSOR_H
@ -255,7 +270,8 @@ static const CursorT dvxCursors[CURSOR_COUNT] = {
{ 16, 16, 7, 7, cursorResizeDiagNESWAnd, cursorResizeDiagNESWXor }, // CURSOR_RESIZE_DIAG_NESW
};
// Legacy alias for backward compatibility
// Legacy alias — kept for backward compatibility with code that predates
// the multi-cursor support.
static const CursorT dvxCursor = { 16, 16, 0, 0, cursorArrowAnd, cursorArrowXor };
#endif // DVX_CURSOR_H

View file

@ -1,4 +1,24 @@
// dvxDialog.c — Modal dialogs for DVX GUI
//
// Provides two standard dialog types: message boxes and file dialogs.
// Both use the nested-event-loop modal pattern: the dialog creates a
// window, sets it as the AppContext's modalWindow, then runs dvxUpdate
// in a tight loop until the user dismisses the dialog. This blocks the
// caller's code flow, which is the simplest possible modal API — the
// caller gets the result as a return value, no callbacks needed.
//
// The nested loop approach works because dvxUpdate is re-entrant: it
// polls input, dispatches events, and composites. The modalWindow field
// causes handleMouseButton to reject clicks on non-modal windows, so
// only the dialog receives interaction. This is exactly how Windows
// MessageBox and GetOpenFileName work internally.
//
// State for each dialog type is stored in a single static struct (sMsgBox,
// sFd) rather than on the heap. This means only one dialog of each type
// can be active at a time, but that's fine — you never need two file
// dialogs simultaneously. The static approach avoids malloc/free and
// keeps the state accessible to callback functions without threading
// context pointers through every widget callback.
#include "dvxDialog.h"
#include "platform/dvxPlatform.h"
@ -17,12 +37,21 @@
// Constants
// ============================================================
// Message box layout constants. MSG_MAX_WIDTH caps dialog width so long
// messages wrap rather than producing absurdly wide dialogs.
#define MSG_MAX_WIDTH 320
#define MSG_PADDING 8
#define ICON_AREA_WIDTH 40
#define BUTTON_WIDTH 80
#define BUTTON_HEIGHT 24
#define BUTTON_GAP 8
// Icon glyph constants. Icons are drawn procedurally (pixel by pixel)
// rather than using bitmap resources. This avoids needing to ship and
// load icon files, and the glyphs scale-by-design since they're
// defined in terms of geometric shapes. The circle shapes use the
// distance-squared test (d2 between INNER_R2 and OUTER_R2) to draw
// a ring without needing floating-point sqrt.
#define ICON_GLYPH_SIZE 24 // icon glyph drawing area (pixels)
#define ICON_GLYPH_CENTER 12 // center of icon glyph (ICON_GLYPH_SIZE / 2)
#define ICON_OUTER_R2 144 // outer circle radius squared (12*12)
@ -58,17 +87,23 @@ static void wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *fo
// ============================================================
// Message box state (one active at a time)
// ============================================================
//
// Static because only one message box can be active at a time.
// The 'done' flag is set by button clicks or window close, which
// breaks the nested dvxUpdate loop in dvxMessageBox. Layout values
// (textX, textY, textMaxW, msgAreaH) are computed once when the
// dialog opens and reused by the paint callback.
typedef struct {
AppContextT *ctx;
int32_t result;
bool done;
int32_t result; // ID_OK, ID_CANCEL, ID_YES, ID_NO, etc.
bool done; // set true to break the modal loop
const char *message;
int32_t iconType;
int32_t textX;
int32_t textX; // pre-computed text origin (accounts for icon)
int32_t textY;
int32_t textMaxW;
int32_t msgAreaH;
int32_t textMaxW; // max text width for word wrapping
int32_t msgAreaH; // height of the message+icon area above the buttons
} MsgBoxStateT;
static MsgBoxStateT sMsgBox;
@ -77,6 +112,26 @@ static MsgBoxStateT sMsgBox;
// ============================================================
// drawIconGlyph — draw a simple icon shape
// ============================================================
//
// Procedurally draws message box icons using only integer math:
// MB_ICONINFO: circle with 'i' (information)
// MB_ICONWARNING: triangle with '!' (warning)
// MB_ICONERROR: circle with 'X' (error)
// MB_ICONQUESTION: circle with '?' (question)
//
// Circles use the integer distance-squared test: for each pixel, compute
// dx*dx + dy*dy and check if it falls between INNER_R2 and OUTER_R2
// to draw a 2-pixel-wide ring. This is a brute-force O(n^2) approach
// but n is only 24 pixels, so it's 576 iterations — trivial even on a 486.
//
// The inner symbols (i, !, X, ?) are drawn with hardcoded rectFill calls
// at specific pixel offsets. This is less elegant than using the font
// renderer, but it gives precise control over the glyph appearance at
// this small size where the 8x16 bitmap font would look too blocky.
//
// Drawing 1x1 rects for individual pixels is intentional: it goes
// through the clip rect check in rectFill, so we don't need separate
// bounds checking here.
static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color) {
if (iconType == MB_ICONINFO) {
@ -155,6 +210,20 @@ static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y
// ============================================================
// dvxMessageBox
// ============================================================
//
// Creates and runs a modal message box. The flags parameter is a bitmask:
// low nibble selects button set (MB_OK, MB_YESNO, etc.), next nibble
// selects icon type (MB_ICONINFO, MB_ICONERROR, etc.). This is the same
// flag encoding Windows MessageBox uses, which makes porting code easier.
//
// The dialog is auto-sized to fit the word-wrapped message text plus the
// button row. Non-resizable (maxW/maxH clamped to initial size) because
// resizing a message box serves no purpose.
//
// Button labels use '&' to mark accelerator keys (e.g., "&OK" makes
// Alt+O activate the button). Button IDs are stored in widget->userData
// via intptr_t cast — a common pattern when you need to associate a
// small integer with a widget without allocating a separate struct.
int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags) {
int32_t btnFlags = flags & 0x000F;
@ -310,16 +379,20 @@ int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message,
RectT fullRect = { 0, 0, win->contentW, win->contentH };
win->onPaint(win, &fullRect);
// Set as modal (save previous in case we're stacking modals)
// Save previous modal so stacked modals work correctly. This happens
// when a message box opens from within a file dialog (e.g., overwrite
// confirmation) — the file dialog's modal is pushed and restored
// when the message box closes.
WindowT *prevModal = ctx->modalWindow;
ctx->modalWindow = win;
// Nested event loop
// Nested event loop — blocks here until user clicks a button or closes.
// dvxUpdate handles all input/rendering; sMsgBox.done is set by the
// button onClick callback or the window close callback.
while (!sMsgBox.done && ctx->running) {
dvxUpdate(ctx);
}
// Clean up — restore previous modal
ctx->modalWindow = prevModal;
dvxDestroyWindow(ctx, win);
@ -351,6 +424,15 @@ static void onMsgBoxClose(WindowT *win) {
// ============================================================
// onMsgBoxPaint — custom paint: background + text/icon + widgets
// ============================================================
//
// The message box uses a custom onPaint callback rather than pure widgets
// because the message text area with optional icon doesn't map cleanly to
// the widget model. The paint callback creates a temporary display context
// that points at the window's content buffer (not the screen backbuffer),
// draws the background/text/icon directly, then runs the widget layout
// and paint for the button row. The separator line between the message
// area and buttons uses a highlight-over-shadow pair to create a Motif
// etched-line effect.
static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea) {
(void)dirtyArea;
@ -358,7 +440,9 @@ static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea) {
MsgBoxStateT *state = (MsgBoxStateT *)win->userData;
AppContextT *ctx = state->ctx;
// Set up display context pointing at content buffer
// Create a temporary display context targeting the window's content
// buffer. This is the standard pattern for drawing into a window's
// private buffer rather than the screen backbuffer.
DisplayT cd = ctx->display;
cd.lfb = win->contentBuf;
cd.backBuf = win->contentBuf;
@ -406,6 +490,13 @@ static void onMsgBoxPaint(WindowT *win, RectT *dirtyArea) {
// ============================================================
// wordWrapDraw — draw word-wrapped text
// ============================================================
//
// Simple greedy word-wrap: fill each line with as many characters as fit
// within maxW, breaking at the last space if the line overflows. If a
// single word is longer than maxW, it gets its own line (may be clipped).
// Explicit newlines are honored. This is a fixed-width font, so "max
// chars per line" is just maxW / charWidth — no per-character width
// accumulation needed.
static void wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t maxW, uint32_t fg, uint32_t bg) {
int32_t charW = font->charWidth;
@ -461,6 +552,12 @@ static void wordWrapDraw(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *fo
// ============================================================
// wordWrapHeight — compute height of word-wrapped text
// ============================================================
//
// Duplicates the word-wrap logic from wordWrapDraw but only counts lines
// instead of drawing. This is needed to compute the dialog height before
// creating the window. The duplication is intentional — combining them
// into a single function with a "just measure" flag would add branching
// to the draw path and make both harder to read.
static int32_t wordWrapHeight(const BitmapFontT *font, const char *text, int32_t maxW) {
int32_t charW = font->charWidth;
@ -517,24 +614,31 @@ static int32_t wordWrapHeight(const BitmapFontT *font, const char *text, int32_t
// ============================================================
// File dialog
// ============================================================
//
// File open/save dialog with directory navigation, file type filtering,
// and overwrite/create confirmation. The dialog is built from standard
// widgets (listbox, text inputs, dropdown, buttons) composed in the
// widget system. Directory entries are prefixed with "[brackets]" in the
// listbox to distinguish them from files, following the DOS convention.
// FD_MAX_PATH is 260 to match DOS MAX_PATH (including null terminator)
#define FD_MAX_ENTRIES 512
#define FD_MAX_PATH 260
#define FD_NAME_LEN 64
typedef struct {
AppContextT *ctx;
bool done;
bool accepted;
int32_t flags;
bool done; // set true to break modal loop
bool accepted; // true if user clicked OK/Open/Save
int32_t flags; // FD_SAVE, etc.
char curDir[FD_MAX_PATH];
const FileFilterT *filters;
const FileFilterT *filters; // caller-provided filter list
int32_t filterCount;
int32_t activeFilter;
char *entryNames[FD_MAX_ENTRIES];
int32_t activeFilter; // index into filters[]
char *entryNames[FD_MAX_ENTRIES]; // heap-allocated, freed by fdFreeEntries
bool entryIsDir[FD_MAX_ENTRIES];
int32_t entryCount;
const char *listItems[FD_MAX_ENTRIES];
const char *listItems[FD_MAX_ENTRIES]; // pointers into entryNames, for listbox API
WidgetT *fileList;
WidgetT *pathInput;
WidgetT *nameInput;
@ -548,6 +652,11 @@ static FileDialogStateT sFd;
// ============================================================
// fdFilterMatch — check if filename matches a glob pattern
// ============================================================
//
// Supports only the most common DOS file filter patterns: "*.*", "*",
// and "*.ext". Full glob matching isn't needed because file dialogs
// historically only use extension-based filters. The case-insensitive
// extension compare handles DOS's case-insensitive filesystem behavior.
static bool fdFilterMatch(const char *name, const char *pattern) {
if (!pattern || pattern[0] == '\0') {
@ -603,6 +712,12 @@ static void fdFreeEntries(void) {
// ============================================================
// fdEntryCompare — sort: dirs first, then alphabetical
// ============================================================
//
// Sort comparator for the indirect sort in fdLoadDir. Uses an index
// array rather than sorting the entryNames/entryIsDir arrays directly
// because qsort would need a struct or the comparator would need to
// move both arrays in sync. Indirect sort with an index array is
// simpler and avoids that coordination problem.
static int fdEntryCompare(const void *a, const void *b) {
int32_t ia = *(const int32_t *)a;
@ -620,6 +735,13 @@ static int fdEntryCompare(const void *a, const void *b) {
// ============================================================
// fdLoadDir — read directory contents into state
// ============================================================
//
// Reads the current directory, applies the active file filter, sorts
// (directories first, then alphabetical), and updates the listbox widget.
// Entry names are strdup'd because dirent buffers are reused by readdir.
// The sort is done via an index array to avoid shuffling two parallel
// arrays; after sorting the index array, the actual arrays are rebuilt
// in sorted order with a single memcpy pass.
static void fdLoadDir(void) {
fdFreeEntries();
@ -716,9 +838,14 @@ static void fdLoadDir(void) {
// ============================================================
// fdNavigate — change to a new directory
// ============================================================
//
// Handles both absolute and relative paths. Relative paths are resolved
// against curDir. realpath is used to canonicalize the result (resolve
// ".." components, symlinks) so the path display always shows a clean
// absolute path. The stat check ensures we don't try to navigate into
// a file or nonexistent path.
static void fdNavigate(const char *path) {
// Resolve relative paths
char resolved[FD_MAX_PATH];
if (path[0] == '/' || path[0] == '\\' ||
@ -773,6 +900,12 @@ static bool fdValidateFilename(const char *name) {
// ============================================================
// fdAcceptFile — confirm and accept the selected filename
// ============================================================
//
// Validates the filename (platform-specific rules), then checks for
// confirmation scenarios: save dialog + existing file = "overwrite?",
// open dialog + missing file = "create?". The nested dvxMessageBox calls
// work because the modal system supports stacking (prevModal is saved and
// restored). Returns false if the user cancelled at any confirmation step.
static bool fdAcceptFile(const char *name) {
if (!fdValidateFilename(name)) {
@ -829,6 +962,11 @@ static void fdOnListClick(WidgetT *w) {
// ============================================================
// fdOnListDblClick — file list double-click
// ============================================================
//
// Double-click on a directory navigates into it. Double-click on a file
// accepts it immediately (equivalent to selecting + clicking OK). The
// bracket stripping (removing "[" and "]") is needed because directory
// names in the list are displayed as "[dirname]" for visual distinction.
static void fdOnListDblClick(WidgetT *w) {
int32_t sel = wgtListBoxGetSelected(w);
@ -838,7 +976,6 @@ static void fdOnListDblClick(WidgetT *w) {
}
if (sFd.entryIsDir[sel]) {
// Double-click on directory — navigate into it
const char *display = sFd.entryNames[sel];
char dirName[FD_NAME_LEN];
@ -868,11 +1005,16 @@ static void fdOnListDblClick(WidgetT *w) {
// ============================================================
// fdOnOk — OK button clicked
// ============================================================
//
// OK has three behaviors depending on context:
// 1. If a directory is selected in the list, navigate into it
// 2. If the typed filename resolves to a directory, navigate there
// 3. Otherwise, accept the filename (with overwrite/create confirmation)
// This matches Windows file dialog behavior where Enter/OK on a directory
// navigates rather than accepting.
static void fdOnOk(WidgetT *w) {
(void)w;
// Check if a directory is selected in the list
int32_t sel = wgtListBoxGetSelected(sFd.fileList);
if (sel >= 0 && sel < sFd.entryCount && sFd.entryIsDir[sel]) {
@ -969,6 +1111,18 @@ static void fdOnPathSubmit(WidgetT *w) {
// ============================================================
// dvxFileDialog
// ============================================================
//
// Creates a modal file dialog using the widget system. The layout is:
// - Path input (with Enter-to-navigate)
// - File listbox (single-click selects, double-click opens/accepts)
// - Optional filter dropdown (only shown if filters are provided)
// - Filename input
// - OK/Cancel button row
//
// The filter dropdown uses a static label array because the dropdown
// widget takes const char** and the filter labels need to persist for
// the dialog's lifetime. Static is safe since only one file dialog can
// be active at a time.
bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const char *initialDir, const FileFilterT *filters, int32_t filterCount, char *outPath, int32_t outPathSize) {
memset(&sFd, 0, sizeof(sFd));

View file

@ -1,11 +1,20 @@
// dvxDialog.h — Modal dialogs for DVX GUI
//
// Provides pre-built modal dialog boxes (message box, file dialog) that
// block the caller and run their own event loop via dvxUpdate() until the
// user dismisses them. Modal dialogs set ctx->modalWindow to prevent input
// from reaching other windows during the dialog's lifetime.
//
// The flag encoding uses separate bit fields for button configuration
// (low nibble) and icon type (high nibble) so they can be OR'd together
// in a single int32_t argument, matching the Win16 MessageBox() convention.
#ifndef DVX_DIALOG_H
#define DVX_DIALOG_H
#include "dvxApp.h"
// ============================================================
// Message box button flags
// Message box button flags (low nibble)
// ============================================================
#define MB_OK 0x0000
@ -15,7 +24,7 @@
#define MB_RETRYCANCEL 0x0004
// ============================================================
// Message box icon flags
// Message box icon flags (high nibble, OR with button flags)
// ============================================================
#define MB_ICONINFO 0x0010
@ -33,8 +42,10 @@
#define ID_NO 4
#define ID_RETRY 5
// Show a modal message box and return which button was pressed.
// flags = MB_xxx button flag | MB_ICONxxx icon flag
// Display a modal message box with the specified button and icon combination.
// Blocks the caller by running dvxUpdate() in a loop until a button is
// pressed or the dialog is closed. Returns the ID_xxx value of the button
// that was pressed. The dialog window is automatically destroyed on return.
int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags);
// ============================================================
@ -47,17 +58,23 @@ int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message,
// ============================================================
// File dialog filter
// ============================================================
//
// Filters are displayed in a dropdown at the bottom of the file dialog.
// Pattern matching is case-insensitive and supports only single glob
// patterns (no semicolon-separated lists). This keeps the matching code
// trivial for a DOS filesystem where filenames are short and simple.
typedef struct {
const char *label; // e.g. "Text Files (*.txt)"
const char *pattern; // e.g. "*.txt" (case-insensitive, single pattern)
} FileFilterT;
// Show a modal file open/save dialog. Returns true if the user selected
// a file, false if cancelled. The selected path is written to outPath
// (buffer must be at least outPathSize bytes).
// initialDir may be NULL (defaults to current directory).
// filters/filterCount may be NULL/0 for no filter dropdown.
// Display a modal file open/save dialog. The dialog shows a directory
// listing with navigation (parent directory, drive letters on DOS), a
// filename text input, and an optional filter dropdown. Blocks the caller
// via dvxUpdate() loop. Returns true if the user selected a file (path
// written to outPath), false if cancelled or closed. initialDir may be
// NULL to start in the current working directory.
bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const char *initialDir, const FileFilterT *filters, int32_t filterCount, char *outPath, int32_t outPathSize);
#endif // DVX_DIALOG_H

View file

@ -1,4 +1,53 @@
// dvx_draw.c — Layer 2: Drawing primitives for DVX GUI (optimized)
//
// This is the second layer of the DVX compositor stack, sitting on top
// of dvxVideo (layer 1) and below dvxComp (layer 3). It provides all
// rasterization primitives: filled rects, buffer copies, beveled
// frames, bitmap font text, masked bitmaps (cursors/icons), and
// single-pixel operations.
//
// Every function here draws into the system-RAM backbuffer (d->backBuf),
// never directly to the LFB. The compositor layer is responsible for
// flushing changed regions to the hardware framebuffer via rep movsd.
// This separation means draw operations benefit from CPU cache (the
// backbuffer lives in cacheable system RAM) while LFB writes are
// batched into large sequential bursts.
//
// Performance strategy overview:
//
// The core tension on 486/Pentium is between generality and speed.
// The draw layer resolves this with a two-tier approach:
//
// 1) Span operations (spanFill/spanCopy) are dispatched through
// function pointers in BlitOpsT, set once at init based on bpp.
// The platform implementations use rep stosl/rep movsd inline asm
// for maximum throughput (the 486 executes rep stosl at 1 dword
// per clock after startup; the Pentium pairs it in the U-pipe).
// Using function pointers here costs one indirect call per span
// but avoids a bpp switch in the inner loop of rectFill, which
// would otherwise be a branch per scanline.
//
// 2) Character rendering (drawChar, drawTextN, drawTermRow) uses
// explicit if/else chains on bpp rather than function pointers.
// This is deliberate: the per-pixel work inside glyph rendering
// is a tight bit-test loop where an indirect call per pixel would
// be catastrophic, and the bpp branch is taken once per glyph row
// (hoisted out of the pixel loop). The compiler can also inline
// the pixel store when the bpp is a compile-time constant within
// each branch.
//
// 3) For the most critical glyph paths (unclipped 32bpp and 16bpp),
// the pixel loops are fully unrolled into 8 direct array stores
// with literal bit masks. This eliminates the sGlyphBit[] table
// lookup, the loop counter, and the loop branch — saving ~3 cycles
// per pixel on a 486. The clipped path falls back to the table.
//
// Clip rectangle handling: All draw functions clip against
// d->clipX/Y/W/H (set by setClipRect in layer 1). The clipRect()
// helper is marked static inline so it compiles to straight-line
// compare-and-clamp code at each call site with no function call
// overhead. __builtin_expect hints mark the clipping branches as
// unlikely, helping the branch predictor on Pentium and later.
#include "dvxDraw.h"
#include "platform/dvxPlatform.h"
@ -13,7 +62,13 @@ char accelParse(const char *text);
static inline void clipRect(const DisplayT *d, int32_t *x, int32_t *y, int32_t *w, int32_t *h);
static inline void putPixel(uint8_t *dst, uint32_t color, int32_t bpp);
// Bit lookup tables — avoids per-pixel shift on 486 (40+ cycle savings per shift)
// Bit lookup tables for glyph and mask rendering. On a 486, a variable
// shift (1 << (7 - col)) costs 4 cycles per bit position; a table
// lookup is a fixed 1-cycle load from L1. The 8-entry sGlyphBit table
// maps column index 0..7 to the corresponding bit mask in a 1bpp glyph
// byte (MSB-first, matching standard VGA/bitmap font layout). The
// 16-entry sMaskBit table does the same for 16-pixel-wide cursor/icon
// masks.
static const uint8_t sGlyphBit[8] = {0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01};
static const uint16_t sMaskBit[16] = {0x8000, 0x4000, 0x2000, 0x1000, 0x0800, 0x0400, 0x0200, 0x0100, 0x0080, 0x0040, 0x0020, 0x0010, 0x0008, 0x0004, 0x0002, 0x0001};
@ -21,6 +76,13 @@ static const uint16_t sMaskBit[16] = {0x8000, 0x4000, 0x2000, 0x1000, 0x0800, 0
// ============================================================
// accelParse
// ============================================================
//
// Scans a menu/button label for the & accelerator marker and returns
// the character after it (lowercased). Follows the Windows/Motif
// convention: "&File" means Alt+F activates it, "&&" is a literal &.
// Returns 0 if no accelerator is found. The result is always
// lowercased so the WM can do a single case-insensitive compare
// against incoming Alt+key events.
char accelParse(const char *text) {
if (!text) {
@ -68,6 +130,19 @@ char accelParse(const char *text) {
// ============================================================
// clipRect
// ============================================================
//
// Intersects a rectangle with the display's current clip rect,
// modifying the rect in place. If the rect is fully outside the
// clip region, w or h will be <= 0 and callers bail out.
//
// Marked static inline because this is called on every rectFill,
// rectCopy, and indirectly on every glyph — it must compile to
// straight-line clamp instructions with zero call overhead.
// __builtin_expect(…, 0) marks clipping as unlikely; in the
// common case windows are fully within the clip rect and all
// four branches fall through untaken. On Pentium this keeps the
// branch predictor happy (static not-taken prediction for forward
// branches), and on 486 it at least avoids the taken-branch penalty.
static inline void clipRect(const DisplayT *d, int32_t *x, int32_t *y, int32_t *w, int32_t *h) {
int32_t cx2 = d->clipX + d->clipW;
@ -93,6 +168,28 @@ static inline void clipRect(const DisplayT *d, int32_t *x, int32_t *y, int32_t *
// ============================================================
// drawBevel
// ============================================================
//
// Draws a Motif/DESQview-style beveled rectangular frame. The bevel
// creates the illusion of a raised or sunken 3D surface by drawing
// lighter "highlight" edges on the top and left, and darker "shadow"
// edges on the bottom and right. Swapping highlight and shadow gives
// a sunken appearance (see BEVEL_RAISED/BEVEL_SUNKEN macros in
// dvxTypes.h).
//
// BevelStyleT.width controls the border thickness. DV/X uses 2px
// bevels for most window chrome (matching the original DESQview/X
// and Motif look), 1px for inner borders and scrollbar elements.
//
// The implementation has special-cased fast paths for bw==2 and bw==1
// that emit exact spans via rectFill rather than looping. This
// matters because drawBevel is called for every window frame, button,
// menu, and scrollbar element on every repaint — the loop overhead
// and extra rectFill calls in the general case add up. Each rectFill
// call already handles clipping internally, so the bevels clip
// correctly even when a window is partially off-screen.
//
// face==0 means "don't fill the interior", which is used for frame-only
// bevels where the content area is painted separately by a callback.
void drawBevel(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, const BevelStyleT *style) {
int32_t bw = style->width;
@ -142,6 +239,39 @@ void drawBevel(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w
// ============================================================
// drawChar
// ============================================================
//
// Renders a single fixed-width bitmap font character into the
// backbuffer. Returns the character advance width (always
// font->charWidth) so callers can accumulate cursor position.
//
// Font format: each glyph is charHeight bytes of 1bpp data, MSB-first
// (bit 7 = leftmost pixel). This is the standard VGA/PC BIOS font
// format. We use 8-pixel-wide glyphs exclusively because 8 bits fit
// in one byte per scanline, making the inner loop a single byte load
// plus 8 bit tests — no multi-byte glyph row assembly needed.
//
// The function has six specialized code paths (3 bpp x 2 modes),
// chosen with if/else chains rather than function pointers. On 486
// and Pentium, an indirect call through a function pointer stalls the
// pipeline (no branch target buffer for indirect calls on 486, and
// a mandatory bubble on Pentium). The if/else chain resolves at the
// outer loop level (once per glyph, not per pixel), so the per-pixel
// inner code is branch-free within each path.
//
// Opaque vs transparent mode:
// opaque=true: Fills the entire character cell (bg then fg). Used
// for normal text where the background must overwrite
// whatever was previously in the cell.
// opaque=false: Only writes foreground pixels; background shows
// through. Used for overlay text on existing content.
//
// The "unclipped fast path" (colStart==0, colEnd==cw) avoids the
// sGlyphBit[] table lookup by testing literal bit masks directly.
// This matters because the table lookup involves an indexed load
// (base + index * element_size), while the literal mask is an
// immediate operand in the compare instruction. At 8 pixels per row
// and 14-16 rows per glyph, saving even 1 cycle per pixel adds up
// across a full screen of text (~6400 characters at 80x80).
int32_t drawChar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, char ch, uint32_t fg, uint32_t bg, bool opaque) {
int32_t cw = font->charWidth;
@ -180,12 +310,18 @@ int32_t drawChar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int3
if (x < clipX1) { colStart = clipX1 - x; }
if (x + cw > clipX2) { colEnd = clipX2 - x; }
// Unclipped fast path: full 8-pixel character cell with direct bit
// tests eliminates loop overhead and sGlyphBit[] lookups (Item 4)
// Unclipped fast path: when the character cell is fully within the
// clip rect we can skip per-pixel clip checks and use the fully
// unrolled 8-store sequences below. This is the hot path for all
// text that isn't at the edge of a window.
bool unclipped = (colStart == 0 && colEnd == cw);
if (opaque) {
// Opaque mode: fill entire cell with bg, then overwrite fg pixels
// Opaque mode: every pixel in the cell gets written (fg or bg).
// The unclipped 32bpp and 16bpp paths use branchless ternary
// stores — the compiler emits cmov or conditional-set sequences
// that avoid branch misprediction penalties. Each row is 8
// direct array stores with no loop, no table lookup.
if (unclipped && bpp == 4) {
for (int32_t row = rowStart; row < rowEnd; row++) {
uint32_t *dst32 = (uint32_t *)(d->backBuf + (y + row) * pitch + x * 4);
@ -218,7 +354,11 @@ int32_t drawChar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int3
dst16[7] = (bits & 0x01) ? fg16 : bg16;
}
} else {
// Clipped path or 8bpp: spanFill bg then overwrite fg
// Clipped path or 8bpp: use spanFill for bg (leveraging
// rep stosl), then iterate visible columns with sGlyphBit[]
// table for fg. 8bpp always takes this path because 8-bit
// stores can't be branchlessly ternary'd as efficiently —
// the compiler can't cmov into a byte store.
for (int32_t row = rowStart; row < rowEnd; row++) {
int32_t py = y + row;
uint8_t *dst = d->backBuf + py * pitch + (x + colStart) * bpp;
@ -256,7 +396,11 @@ int32_t drawChar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int3
}
}
} else {
// Transparent mode: only write foreground pixels
// Transparent mode: only fg pixels are written; bg is untouched.
// The "bits == 0" early-out per row is important here: blank
// rows in the glyph (common in the top/bottom padding of most
// characters) skip all pixel work entirely. In opaque mode
// blank rows still need the bg fill so we can't skip them.
if (unclipped && bpp == 4) {
for (int32_t row = rowStart; row < rowEnd; row++) {
uint8_t bits = glyph[row];
@ -343,6 +487,21 @@ int32_t drawChar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int3
// Same idea as drawTermRow but for uniform fg/bg text runs.
// Avoids per-character function call overhead, redundant clip
// calculation, and spanFill startup costs.
//
// The key optimization over calling drawChar() in a loop is the
// bg fill strategy: in opaque mode, instead of calling spanFill
// once per character cell per row (count * charHeight spanFill
// calls), we fill the entire visible span's background in one
// spanFill per scanline (just charHeight calls total). Then we
// overlay only the fg glyph pixels. For an 80-column line this
// reduces spanFill calls from 80*16=1280 to just 16. Each
// spanFill maps to a single rep stosl, so we're also getting
// better write-combine utilization from the larger sequential
// stores.
//
// Horizontal clipping is done at the character level (firstChar/
// lastChar) to avoid iterating invisible characters, with per-pixel
// edge clipping only for the partially visible first and last chars.
void drawTextN(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t count, uint32_t fg, uint32_t bg, bool opaque) {
if (count <= 0) {
@ -492,6 +651,19 @@ void drawTextN(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_
// ============================================================
// drawFocusRect
// ============================================================
//
// Draws a dotted (every-other-pixel) rectangle to indicate keyboard
// focus, matching the Windows/Motif convention. Uses putPixel per
// dot rather than spanFill because the alternating pattern can't be
// expressed as a span fill (which writes uniform color).
//
// The parity calculations on the bottom and right edges ensure the
// dot pattern is visually continuous around corners — the starting
// pixel of each edge is offset so dots don't double up or gap at
// the corner where two edges meet.
//
// This is not performance-critical; focus rects are drawn at most
// once per focused widget per repaint.
void drawFocusRect(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color) {
int32_t bpp = ops->bytesPerPixel;
@ -550,6 +722,10 @@ void drawFocusRect(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32
// ============================================================
// drawHLine
// ============================================================
//
// Thin convenience wrapper — a horizontal line is just a 1px-tall rect.
// Delegates to rectFill which handles clipping and uses spanFill (rep
// stosl) for the actual write.
void drawHLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, uint32_t color) {
rectFill(d, ops, x, y, w, 1, color);
@ -559,6 +735,26 @@ void drawHLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w
// ============================================================
// drawInit
// ============================================================
//
// Wires up the BlitOpsT function pointers to the correct
// platform-specific span operations for the active pixel format.
// Called once during startup after videoInit determines the bpp.
//
// The span ops are the only place where function pointers are used
// in the draw layer. This is a deliberate performance tradeoff:
// spanFill and spanCopy are called per-scanline (not per-pixel),
// so the indirect call overhead (~5 cycles on Pentium for the
// mispredicted first call, then predicted afterward) is amortized
// over an entire row of pixels. The alternative — a switch inside
// rectFill's inner loop — would branch every scanline for no gain.
//
// The platform implementations (dvxPlatformDos.c) use inline asm:
// spanFill8/16/32 -> rep stosl (fills 4 bytes per clock)
// spanCopy8/16/32 -> rep movsd (copies 4 bytes per clock)
// These are the fastest bulk memory operations available on 486/
// Pentium without SSE. The 8-bit and 16-bit variants handle
// alignment preambles to get to dword boundaries, then use
// rep stosl/movsd for the bulk.
void drawInit(BlitOpsT *ops, const DisplayT *d) {
ops->bytesPerPixel = d->format.bytesPerPixel;
@ -588,6 +784,22 @@ void drawInit(BlitOpsT *ops, const DisplayT *d) {
// ============================================================
// drawMaskedBitmap
// ============================================================
//
// Renders a 1-bit masked bitmap (used for mouse cursors and icons).
// The two-plane format mirrors the hardware cursor format used by
// VGA and early SVGA cards:
//
// andMask bit=1, xorData bit=X -> transparent (pixel unchanged)
// andMask bit=0, xorData bit=0 -> bgColor
// andMask bit=0, xorData bit=1 -> fgColor
//
// Each row is a uint16_t (supporting up to 16 pixels wide), stored
// MSB-first. This is sufficient for standard 16x16 mouse cursors.
//
// The colMask optimization pre-computes which bits in each row fall
// within the visible (clipped) columns. For fully transparent rows
// (all visible bits have andMask=1), the entire row is skipped with
// a single bitwise AND + compare — no per-pixel iteration needed.
void drawMaskedBitmap(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, const uint16_t *andMask, const uint16_t *xorData, uint32_t fgColor, uint32_t bgColor) {
int32_t bpp = ops->bytesPerPixel;
@ -669,7 +881,25 @@ void drawMaskedBitmap(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, in
// lineData points to (ch, attr) pairs. palette is a 16-entry
// packed-color table. This avoids per-character function call
// overhead, redundant clip calculation, and spanFill startup
// costs that make drawChar expensive when called 80× per row.
// costs that make drawChar expensive when called 80x per row.
//
// This is the primary rendering function for the terminal emulator.
// The attribute byte uses the standard CGA/VGA format:
// bits 0-3: foreground color (0-15)
// bits 4-6: background color (0-7)
// bit 7: blink flag
//
// Unlike drawTextN (which handles uniform fg/bg), every cell here
// can have a different fg/bg pair, so the bg can't be filled in a
// single bulk pass. Instead each cell is rendered individually,
// always in opaque mode (every pixel gets a write). The bpp branch
// is still hoisted outside the per-pixel loop — the outer loop
// selects the bpp path once, then iterates cells within it.
//
// blinkVisible controls the blink phase: when false, fg is replaced
// with bg for characters that have bit 7 set, effectively hiding them.
// cursorCol specifies which cell (if any) should be drawn with
// inverted fg/bg to show the text cursor.
void drawTermRow(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, int32_t cols, const uint8_t *lineData, const uint32_t *palette, bool blinkVisible, int32_t cursorCol) {
int32_t cw = font->charWidth;
@ -801,6 +1031,19 @@ void drawTermRow(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int3
// ============================================================
// drawText
// ============================================================
//
// Renders a null-terminated string by calling drawChar per character.
// Simpler than drawTextN but slower for long runs because each
// drawChar call independently clips, computes row bounds, and
// dispatches on bpp. Used for short labels and ad-hoc text where
// the call overhead doesn't matter; drawTextN is preferred for
// bulk text (editor buffers, list views, etc.).
//
// The left-of-clip skip avoids calling drawChar for characters that
// are entirely to the left of the visible area. The right-of-clip
// early-out breaks the loop as soon as we've passed the right edge.
// These are both marked unlikely (__builtin_expect) because the
// common case is text fully within the clip rect.
void drawText(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, uint32_t fg, uint32_t bg, bool opaque) {
int32_t cw = font->charWidth;
@ -828,6 +1071,16 @@ void drawText(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t
// ============================================================
// drawTextAccel
// ============================================================
//
// Like drawText but interprets & markers in the string: the character
// following & is drawn with an underline to indicate it's the keyboard
// accelerator (e.g. "&File" draws "File" with F underlined). "&&"
// draws a literal &. This matches the Windows/Motif convention for
// menu and button labels.
//
// The underline is drawn as a 1px horizontal line at the bottom of
// the character cell (y + charHeight - 1), which is the standard
// placement for accelerator underlines.
void drawTextAccel(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, uint32_t fg, uint32_t bg, bool opaque) {
int32_t cw = font->charWidth;
@ -880,6 +1133,17 @@ void drawTextAccel(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, in
// ============================================================
// drawVLine
// ============================================================
//
// Draws a vertical line pixel-by-pixel. Unlike drawHLine (which
// delegates to rectFill → spanFill for a single-row span), a
// vertical line can't use spanFill because each pixel is on a
// different scanline. Instead we advance by d->pitch per pixel
// and write directly, branching on bpp once at the top.
//
// The ops parameter is unused (suppressed with (void)ops) because
// spanFill operates on contiguous horizontal runs and is useless
// for vertical lines. We keep the parameter for API consistency
// with the rest of the draw layer.
void drawVLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t h, uint32_t color) {
(void)ops;
@ -923,6 +1187,12 @@ void drawVLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t h
// ============================================================
// putPixel
// ============================================================
//
// Writes a single pixel at an already-computed buffer address.
// Only used by drawFocusRect for its alternating dot pattern.
// Marked static inline so it compiles to a direct store at the
// call site with no function call overhead. The bpp chain here
// is acceptable because focus rect drawing is infrequent.
static inline void putPixel(uint8_t *dst, uint32_t color, int32_t bpp) {
if (bpp == 2) {
@ -938,6 +1208,23 @@ static inline void putPixel(uint8_t *dst, uint32_t color, int32_t bpp) {
// ============================================================
// rectCopy
// ============================================================
//
// Copies a rectangular region from an arbitrary source buffer into
// the display backbuffer. Used by the compositor to blit per-window
// content buffers (win->contentBuf) into the shared backbuffer during
// the composite pass.
//
// Clipping adjusts both the destination and source positions by the
// same delta so the visible portion maps to the correct source pixels.
// When the source and destination pitches match and equal the row byte
// count, the entire block is copied in a single memcpy (which the
// compiler/libc can optimize to rep movsd). Otherwise it falls back
// to per-row memcpy.
//
// This function does NOT handle overlapping source and destination
// regions (no memmove). That's fine because the source is always a
// per-window content buffer and the destination is the shared
// backbuffer — they never overlap.
void rectCopy(DisplayT *d, const BlitOpsT *ops, int32_t dstX, int32_t dstY, const uint8_t *srcBuf, int32_t srcPitch, int32_t srcX, int32_t srcY, int32_t w, int32_t h) {
int32_t bpp = ops->bytesPerPixel;
@ -977,6 +1264,18 @@ void rectCopy(DisplayT *d, const BlitOpsT *ops, int32_t dstX, int32_t dstY, cons
// ============================================================
// rectFill
// ============================================================
//
// The workhorse fill primitive. Clips to the display clip rect,
// then fills one scanline at a time via the spanFill function
// pointer (which routes to rep stosl on DOS). This is the most
// frequently called function in the draw layer — it backs rectFill
// directly, plus drawHLine, drawBevel interior fills, and the bg
// fill in opaque text rendering.
//
// The clip + early-out pattern (clipRect then check w/h <= 0) is
// the same in every draw function. The __builtin_expect marks the
// zero-size case as unlikely to avoid a taken-branch penalty in the
// common case where the rect is visible after clipping.
void rectFill(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color) {
clipRect(d, &x, &y, &w, &h);
@ -998,6 +1297,12 @@ void rectFill(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w,
// ============================================================
// textWidth
// ============================================================
//
// Returns the pixel width of a null-terminated string. Because all
// fonts are fixed-width, this is just strlen * charWidth — but we
// iterate manually rather than calling strlen to avoid a second pass
// over the string. This is used heavily for layout calculations
// (centering text in buttons, sizing menu popups, etc.).
int32_t textWidth(const BitmapFontT *font, const char *text) {
int32_t w = 0;
@ -1014,6 +1319,12 @@ int32_t textWidth(const BitmapFontT *font, const char *text) {
// ============================================================
// textWidthAccel
// ============================================================
//
// Like textWidth but accounts for & accelerator markers: a single &
// is not rendered (it just marks the next character as the accelerator),
// so it doesn't contribute to width. "&&" renders as one "&" character.
// Used to compute the correct pixel width for menu items and button
// labels that contain accelerator markers.
int32_t textWidthAccel(const BitmapFontT *font, const char *text) {
int32_t w = 0;

View file

@ -1,61 +1,95 @@
// dvx_draw.h — Layer 2: Drawing primitives for DVX GUI
//
// Provides all 2D drawing operations: rectangle fills, bitmap blits, text
// rendering, bevels, lines, and cursor/icon rendering. Every function in
// this layer draws into the display's backbuffer and clips to the display's
// current clip rectangle — no direct LFB writes happen here.
//
// This layer is deliberately stateless beyond the clip rect on DisplayT.
// All drawing context (colors, fonts, bevel styles) is passed explicitly
// to each function. This means the draw layer can be used by any caller
// (compositor, WM, widgets) without worrying about shared mutable state.
//
// Performance strategy: the hot inner loops (span fill, span copy) are
// dispatched through BlitOpsT function pointers that resolve to asm on
// DOS. The text rendering functions (drawTextN, drawTermRow) are optimized
// to batch operations per scanline rather than per glyph, which matters
// when rendering 80-column terminal screens on a 486.
#ifndef DVX_DRAW_H
#define DVX_DRAW_H
#include "dvxTypes.h"
// Initialize blit operations based on pixel format
// Populate a BlitOpsT with the correct span functions for the display's
// pixel depth. Must be called once after videoInit().
void drawInit(BlitOpsT *ops, const DisplayT *d);
// Solid color rectangle fill (clips to display clip rect)
// Fill a rectangle with a solid color. Clips to the display clip rect.
// This is the workhorse for backgrounds, window fills, and clear operations.
void rectFill(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color);
// Copy rectangle from source buffer to backbuffer (clips to display clip rect)
// Copy a rectangle from an arbitrary source buffer into the backbuffer.
// Used to blit per-window content buffers during compositing. srcX/srcY
// specify the origin within the source buffer, allowing sub-rectangle blits.
void rectCopy(DisplayT *d, const BlitOpsT *ops, int32_t dstX, int32_t dstY, const uint8_t *srcBuf, int32_t srcPitch, int32_t srcX, int32_t srcY, int32_t w, int32_t h);
// Draw a beveled frame
// Draw a beveled frame. The bevel is drawn as overlapping horizontal and
// vertical spans — top/left in highlight color, bottom/right in shadow.
// The face color fills the interior if non-zero.
void drawBevel(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, const BevelStyleT *style);
// Draw a single character, returns advance width
// Draw a single character glyph. When opaque is true, the background color
// fills the entire cell; when false, only foreground pixels are drawn
// (transparent background). Returns the advance width (always charWidth).
int32_t drawChar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, char ch, uint32_t fg, uint32_t bg, bool opaque);
// Draw a null-terminated string
// Draw a null-terminated string. Calls drawChar per character.
void drawText(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, uint32_t fg, uint32_t bg, bool opaque);
// Draw exactly 'count' characters from a buffer (not null-terminated).
// Much faster than calling drawChar per character: computes clip once,
// fills background in bulk, then overlays glyph foreground pixels.
// Optimized batch text rendering for a known character count. Computes
// clip bounds once for the entire run, fills the background in a single
// rectFill, then overlays glyph foreground pixels. Significantly faster
// than drawChar-per-character for long runs (e.g. terminal rows, list items).
void drawTextN(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t count, uint32_t fg, uint32_t bg, bool opaque);
// Measure text width in pixels
// Returns the pixel width of a null-terminated string (count * charWidth).
int32_t textWidth(const BitmapFontT *font, const char *text);
// Parse accelerator key from text with & markers (e.g. "E&xit" -> 'x')
// Returns the lowercase accelerator character, or 0 if none found.
// Scan text for an & prefix and return the following character as a
// lowercase accelerator key. "&File" -> 'f', "E&xit" -> 'x'.
// Used by menu and button construction to extract keyboard shortcuts.
char accelParse(const char *text);
// Draw text with & accelerator processing (underlines the accelerator character)
// Draw text with & accelerator markers. The character after & is drawn
// with an underline to indicate the keyboard shortcut. && produces a
// literal &. Used for menu items and button labels.
void drawTextAccel(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, uint32_t fg, uint32_t bg, bool opaque);
// Measure text width in pixels, skipping & markers
// Measure text width excluding & markers (so "&File" measures as 4 chars).
int32_t textWidthAccel(const BitmapFontT *font, const char *text);
// Draw a 1-bit bitmap with mask (for cursors, icons)
// andMask/xorData are arrays of uint16_t, one per row
// Draw a 1-bit AND/XOR masked bitmap. Used for software-rendered mouse
// cursors. See CursorT for the mask encoding conventions.
void drawMaskedBitmap(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, const uint16_t *andMask, const uint16_t *xorData, uint32_t fgColor, uint32_t bgColor);
// Draw a row of terminal character cells (ch/attr pairs with a 16-color palette).
// Renders 'cols' cells starting at (x,y). Much faster than calling drawChar per cell.
// cursorCol: column index to draw inverted (cursor), or -1 for no cursor.
// Render an entire row of terminal character cells (ch/attr byte pairs)
// in a single pass. Each cell's foreground and background colors are
// looked up from a 16-color palette. Attribute bit 7 controls blink:
// when blinkVisible is false, blinking characters are hidden. cursorCol
// specifies which column to draw inverted as the text cursor (-1 for none).
// This function exists because rendering an 80-column terminal row
// character-by-character would be unacceptably slow on target hardware.
void drawTermRow(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, int32_t cols, const uint8_t *lineData, const uint32_t *palette, bool blinkVisible, int32_t cursorCol);
// Dotted focus rectangle (every other pixel)
// Draw a 1px dotted rectangle (alternating pixels). Used for keyboard
// focus indicators on buttons and other focusable widgets, matching the
// Windows 3.x focus rectangle convention.
void drawFocusRect(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color);
// Horizontal line
// Horizontal line (1px tall rectangle fill, but with simpler clipping).
void drawHLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, uint32_t color);
// Vertical line
// Vertical line (1px wide, drawn pixel-by-pixel down each scanline).
void drawVLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t h, uint32_t color);
#endif // DVX_DRAW_H

View file

@ -1,4 +1,28 @@
// dvx_font.h — Embedded VGA bitmap font data (CP437) for DVX GUI
//
// Contains the raw glyph bitmaps for two standard VGA ROM fonts (8x14 and
// 8x16) covering the full IBM Code Page 437 character set (256 glyphs).
// These are byte-for-byte copies of the fonts stored in the VGA BIOS ROM.
//
// Embedding them as static const arrays (rather than reading from the VGA
// ROM at INT 10h) serves two purposes:
// 1. The data is available on non-DOS platforms (Linux/SDL) where no
// VGA BIOS exists.
// 2. On DOS, reading the VGA ROM font requires a real-mode interrupt
// call during init, and the data would need to be copied somewhere
// anyway. Compiling it in is simpler and more portable.
//
// The glyph format is 1 bit per pixel, 8 pixels wide, with MSB = leftmost
// pixel. Each glyph occupies charHeight consecutive bytes (14 or 16).
// The drawing code in dvxDraw.c processes one byte per scanline, testing
// each bit to decide foreground vs. background — the 8-pixel width means
// no bit-shifting across byte boundaries is ever needed.
//
// CP437 includes the full ASCII printable range (32-126) plus box-drawing
// characters (176-223), which are essential for the Motif/DV/X look
// (scrollbar arrows, window close gadget, etc.), as well as international
// characters, math symbols, and the classic smiley/card suit characters
// in positions 1-31.
#ifndef DVX_FONT_H
#define DVX_FONT_H
@ -1051,6 +1075,13 @@ static const uint8_t font8x16[256 * 16] = {
// ============================================================
// Font descriptor structs
// ============================================================
//
// Pre-built BitmapFontT instances ready to pass to dvxInit() or use
// directly with draw functions. The 8x14 font fits more rows on screen
// (34 rows at 480 pixels) while 8x16 matches the standard VGA text mode
// cell height (30 rows at 480). DVX defaults to 8x14 for maximum usable
// content area, but 8x16 is available for applications that want the
// classic DOS text mode look.
static const BitmapFontT dvxFont8x14 = {
.charWidth = 8,

View file

@ -1,4 +1,22 @@
// dvxIcon.c — stb_image implementation for DVX GUI
//
// This file exists solely to instantiate the stb_image implementation.
// stb_image is a single-header library: you #define STB_IMAGE_IMPLEMENTATION
// in exactly one .c file to generate the actual function bodies. Putting
// this in its own translation unit keeps the stb code isolated so it:
// 1. Compiles once, not in every file that #includes stb_image.h
// 2. Can have its own warning suppressions without affecting project code
// 3. Doesn't slow down incremental rebuilds (only recompiles if stb changes)
//
// stb_image was chosen over libpng/libjpeg because:
// - Single header, no external dependencies — critical for DJGPP cross-compile
// - Supports BMP, PNG, JPEG, GIF with one include
// - Public domain license, no linking restrictions
// - Small code footprint suitable for DOS targets
//
// STBI_ONLY_* defines strip out support for unused formats (TGA, PSD, HDR,
// PIC, PNM) to reduce binary size. STBI_NO_SIMD is required because DJGPP
// targets 486/Pentium which lack SSE; on the Linux/SDL build it's harmless.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-function"

View file

@ -1,4 +1,14 @@
// dvxImageWrite.c — stb_image_write implementation for DVX GUI
//
// Companion to dvxIcon.c: instantiates stb_image_write for PNG output
// (used by dvxScreenshot and dvxWindowScreenshot). Same rationale as
// dvxIcon.c for using stb — zero external dependencies, single header,
// public domain. Kept in a separate translation unit from the read side
// so projects that don't need screenshot support can omit this file and
// save the code size.
//
// STBI_WRITE_NO_SIMD disables SSE codepaths for the same reason as
// STBI_NO_SIMD in dvxIcon.c: the DOS target lacks SSE support.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-function"

View file

@ -1,4 +1,20 @@
// dvx_palette.h — 8-bit mode palette definition for DVX GUI
//
// Defines the 256-color palette used in 8-bit (VGA Mode 13h / VESA 8bpp)
// video modes. The palette layout follows the same strategy as the X11
// default colormap and Netscape's web-safe colors:
//
// 0-215: 6x6x6 color cube — 6 levels per channel (0, 51, 102, 153,
// 204, 255), giving 216 uniformly distributed colors. The index
// formula (r*36 + g*6 + b) enables O(1) color lookup without
// searching.
// 216-231: 16-step grey ramp for smooth gradients in window chrome.
// 232-239: Dedicated UI chrome colors (highlight, shadow, active title,
// etc.) that need exact values not available in the color cube.
// 240-255: Reserved for future use (currently black).
//
// This partition gives good general-purpose color reproduction while
// guaranteeing the UI has pixel-perfect colors for its chrome elements.
#ifndef DVX_PALETTE_H
#define DVX_PALETTE_H
@ -95,10 +111,18 @@ static inline void dvxGeneratePalette(uint8_t *pal)
}
}
// Find nearest palette entry for an RGB color (for 8-bit mode)
// Find the nearest palette entry for an RGB color using minimum Euclidean
// distance in RGB space. The two-phase approach avoids a full 256-entry
// linear scan in the common case:
// 1. Fast path: snap to the nearest 6x6x6 color cube entry via integer
// division (O(1), no branching).
// 2. Refinement: linearly scan only the 24 grey ramp and chrome entries
// (indices 216-239) to see if any is closer than the cube match.
// Entries 240-255 are reserved (black) and skipped to avoid false matches.
// This is called by packColor() in 8-bit mode, so it needs to be fast.
static inline uint8_t dvxNearestPalEntry(const uint8_t *pal, uint8_t r, uint8_t g, uint8_t b)
{
// Try the color cube first — fast path
// Snap to nearest cube vertex: +25 rounds to nearest 51-step level
int32_t ri = (r + 25) / 51;
int32_t gi = (g + 25) / 51;
int32_t bi = (b + 25) / 51;

View file

@ -1,4 +1,10 @@
// dvx_types.h — Shared type definitions for DVX GUI
//
// Central type definitions shared across all five layers of the DVX GUI
// stack (video, draw, comp, wm, app). Every header includes this file,
// so it must remain free of implementation details and function definitions.
// The design uses a single-include-tree rooted here to avoid circular
// dependencies between layers.
#ifndef DVX_TYPES_H
#define DVX_TYPES_H
@ -8,6 +14,14 @@
// ============================================================
// Pixel format descriptor
// ============================================================
//
// Describes the pixel encoding for the active VESA video mode.
// Populated once at startup from the VBE mode info block and then
// treated as read-only. Storing shift/mask/bits separately avoids
// repeated bit-scanning when packing colors — packColor() uses these
// fields directly for shift-and-mask arithmetic rather than computing
// them on the fly, which matters when called per-glyph during text
// rendering on a 486.
typedef struct {
int32_t bitsPerPixel; // 8, 15, 16, or 32
@ -26,6 +40,25 @@ typedef struct {
// ============================================================
// Display context
// ============================================================
//
// The single display context passed by pointer through every layer.
// Using one struct instead of globals makes the entire stack reentrant
// in principle and simplifies testing under Linux (where lfb points to
// an SDL surface instead of real VESA memory).
//
// The double-buffer strategy (backBuf in system RAM, lfb is the real
// framebuffer) exists because writes to video memory over the PCI bus
// are dramatically slower than writes to main RAM on 486/Pentium hardware
// — often 10-50x slower for random-access patterns. All drawing goes to
// backBuf, and only dirty rectangles are flushed to lfb via fast aligned
// copies (rep movsd). This is the single most important performance
// decision in the entire compositor.
//
// The clip rectangle is mutable state set before each draw call so that
// the drawing layer doesn't need per-window context — it just clips to
// whatever rectangle is currently set on the display. This avoids an
// extra parameter on every draw function and mirrors how classic windowing
// systems (X11, GDI) handle clipping.
typedef struct {
int32_t width;
@ -44,6 +77,11 @@ typedef struct {
// ============================================================
// Rectangle
// ============================================================
//
// x/y/w/h representation chosen over x1/y1/x2/y2 because most operations
// (clipping, blit sizing, layout) naturally work with origin + extent.
// The compositor, window manager, and widget layout engine all traffic
// in RectT, so keeping one canonical representation avoids conversion bugs.
typedef struct {
int32_t x;
@ -55,6 +93,19 @@ typedef struct {
// ============================================================
// Span operation function pointers
// ============================================================
//
// BlitOpsT is a vtable for the two hot-path memory operations: filling
// a horizontal span with a solid color, and copying a span from one
// buffer to another. The function pointers are resolved once at init
// time based on the active pixel depth (8/16/32 bpp), and on DOS they
// dispatch to hand-written asm inner loops using rep stosl / rep movsd.
// Every rectangle fill, blit, and text draw ultimately bottlenecks
// through these two functions, so even a small speedup here multiplies
// across the entire frame.
//
// Storing bytesPerPixel and pitch alongside the function pointers keeps
// the hot data in one cache line and avoids re-dereferencing the DisplayT
// on every scanline of a fill or copy.
typedef void (*SpanFillFnT)(uint8_t *dst, uint32_t color, int32_t count);
typedef void (*SpanCopyFnT)(uint8_t *dst, const uint8_t *src, int32_t count);
@ -69,6 +120,16 @@ typedef struct {
// ============================================================
// Bevel style
// ============================================================
//
// Bevels are the defining visual element of the Motif/DESQview/X aesthetic.
// Swapping highlight and shadow colors flips between raised and sunken
// appearance — the BEVEL_RAISED and BEVEL_SUNKEN macros encode this
// convention so callers don't get the colors backwards.
//
// A width of 2 pixels is the standard Motif bevel size. Using 1 creates
// a thinner look for scrollbar troughs and other subtle borders.
// face = 0 means "don't fill the interior", which is used when drawing
// a bevel frame around content that's already been painted.
typedef struct {
uint32_t highlight; // lighter color (top/left edges)
@ -85,6 +146,18 @@ typedef struct {
// ============================================================
// Bitmap font
// ============================================================
//
// Fixed-width 8-pixel-wide bitmap fonts only. This simplifies text
// rendering enormously: character positions are pure multiplication
// (x = col * 8), glyph lookup is a single array index, and the 1bpp
// format means each scanline of a glyph is exactly one byte.
//
// Two font sizes are provided (8x14 and 8x16), matching the standard
// VGA ROM fonts and CP437 encoding. Proportional fonts would require
// glyph-width tables, kerning, and per-character positioning — none of
// which is worth the complexity for a DOS-era window manager targeting
// 640x480 screens. The 8-pixel width also aligns nicely with byte
// boundaries, enabling per-scanline glyph rendering without bit shifting.
#define FONT_CHAR_WIDTH 8 // fixed glyph width in pixels
@ -99,6 +172,18 @@ typedef struct {
// ============================================================
// Color scheme
// ============================================================
//
// All UI colors are pre-packed into display pixel format at init time.
// This means every color here is a uint32_t that can be written directly
// to the framebuffer without any per-pixel conversion. The tradeoff is
// that the scheme must be regenerated if the video mode changes, but
// since mode changes require re-init anyway, this is free in practice.
//
// The color set mirrors classic Motif/Windows 3.x conventions:
// windowHighlight/windowShadow form bevel pairs, activeTitleBg gives
// the focused window's title bar its distinctive color, and so on.
// Keeping all colors in one struct makes theme support trivial — just
// swap the struct.
typedef struct {
uint32_t desktop;
@ -124,6 +209,13 @@ typedef struct {
// ============================================================
// Dirty rectangle list
// ============================================================
//
// Fixed-size array rather than a linked list because: (a) allocation
// in a DOS protected-mode environment should be predictable, (b) 128
// rects is more than enough for any realistic frame (window moves,
// menu opens, etc.), and (c) linear scanning for merge candidates is
// cache-friendly at this size. If the list fills up, the compositor
// can merge aggressively or fall back to a full-screen repaint.
#define MAX_DIRTY_RECTS 128
@ -135,6 +227,17 @@ typedef struct {
// ============================================================
// Window chrome constants
// ============================================================
//
// These define the pixel geometry of the window frame (border, title bar,
// menu bar). They're compile-time constants because the DV/X look demands
// a fixed, uniform chrome thickness across all windows — there's no
// per-window customization of frame geometry.
//
// CHROME_TOTAL_TOP/SIDE/BOTTOM are the total chrome insets from the
// outer frame rectangle to the content area. The content area position
// is computed from these constants, so they must be kept in sync with
// the drawing code in wmDrawChrome(). Menu bar height is added
// dynamically only for windows that have menus.
#define CHROME_BORDER_WIDTH 4
#define CHROME_TITLE_HEIGHT 20
@ -149,6 +252,16 @@ typedef struct {
// ============================================================
// Menu types
// ============================================================
//
// Fixed-size arrays with inline char buffers rather than heap-allocated
// strings. This avoids malloc/free churn for menus that are typically
// built once at window creation and never change. The limits (16 items
// per menu, 8 menus per bar, 32-char labels) are generous for a DOS
// application; exceeding them is silently capped.
//
// Cascading submenus are supported by the subMenu pointer on MenuItemT.
// The forward declaration of MenuT is needed because MenuItemT contains
// a pointer to its parent type (circular reference between item and menu).
#define MAX_MENU_ITEMS 16
#define MAX_MENUS 8
@ -174,6 +287,8 @@ typedef struct {
MenuT *subMenu; // child menu for cascading submenus (NULL if leaf item)
} MenuItemT;
// MenuT is a named struct (not anonymous typedef) because MenuItemT
// needs a forward pointer to it for cascading submenus.
struct MenuT {
char label[MAX_MENU_LABEL]; // menu bar label (e.g. "File")
MenuItemT items[MAX_MENU_ITEMS];
@ -183,6 +298,9 @@ struct MenuT {
char accelKey; // lowercase accelerator character, 0 if none
};
// positionsDirty defers the O(n) barX/barW recomputation until the
// menu bar is actually drawn, avoiding redundant work when multiple
// menus are added in sequence during window setup.
typedef struct {
MenuT menus[MAX_MENUS];
int32_t menuCount;
@ -192,6 +310,14 @@ typedef struct {
// ============================================================
// Scrollbar types
// ============================================================
//
// Window-level scrollbars (separate from the widget-internal scrollbar
// drawing code). These are attached to WindowT and managed by the WM
// layer. The thumb size is computed proportionally from pageSize relative
// to the total range (max - min), matching the Windows/Motif convention.
//
// x/y/length are computed by wmUpdateContentRect() and cached here so
// hit-testing and drawing don't need to recompute them each frame.
typedef enum {
ScrollbarVerticalE,
@ -214,6 +340,16 @@ typedef struct {
// ============================================================
// Accelerator table (global hotkeys)
// ============================================================
//
// Per-window accelerator tables for keyboard shortcuts (e.g. Ctrl+S for
// save). The modifier flags intentionally match the BIOS INT 16h shift
// state bits so that the raw keyboard data can be compared directly
// without translation.
//
// normKey and normMods are precomputed at registration time: the key is
// uppercased and shift is stripped from modifiers. This lets the runtime
// match in O(n) with just two integer comparisons per entry, avoiding
// case-folding and modifier normalization on every keystroke.
#define MAX_ACCEL_ENTRIES 32
@ -222,7 +358,8 @@ typedef struct {
#define ACCEL_CTRL 0x04
#define ACCEL_ALT 0x08
// Key codes for non-ASCII keys (scancode | 0x100)
// Non-ASCII keys are encoded as (scancode | 0x100) to distinguish them
// from regular ASCII values in a single int32_t keycode space.
#define KEY_F1 (0x3B | 0x100)
#define KEY_F2 (0x3C | 0x100)
#define KEY_F3 (0x3D | 0x100)
@ -258,6 +395,20 @@ typedef struct {
// ============================================================
// Window
// ============================================================
//
// WindowT is the central object of the window manager. Each window owns
// its own content backbuffer, which persists across frames so that
// applications don't need to repaint on every expose event — only when
// their actual content changes. This is crucial for performance on slow
// hardware: dragging a window across others doesn't force every obscured
// window to repaint.
//
// The content area (contentX/Y/W/H) is computed from the frame dimensions
// minus chrome. This separation lets the compositor blit chrome and content
// independently during dirty-rect repainting.
//
// RESIZE_xxx flags are bitfields so that corner resizes can combine two
// edges (e.g. RESIZE_LEFT | RESIZE_TOP for the top-left corner).
#define MAX_WINDOWS 64
#define MAX_TITLE_LEN 128
@ -271,6 +422,8 @@ typedef struct {
#define MIN_WINDOW_W 80
#define MIN_WINDOW_H 60
// Minimized windows display as icons at the bottom of the screen,
// similar to DESQview/X's original icon bar behavior.
#define ICON_SIZE 64
#define ICON_BORDER 2
#define ICON_TOTAL_SIZE (ICON_SIZE + ICON_BORDER * 2)
@ -297,6 +450,8 @@ typedef struct WindowT {
bool contentDirty; // true when contentBuf has changed since last icon refresh
int32_t maxW; // maximum width (-1 = screen width)
int32_t maxH; // maximum height (-1 = screen height)
// Pre-maximize geometry is saved so wmRestore() can put the window
// back exactly where it was. This matches Windows 3.x behavior.
int32_t preMaxX; // saved position before maximize
int32_t preMaxY;
int32_t preMaxW;
@ -328,7 +483,11 @@ typedef struct WindowT {
// Accelerator table (NULL if none, caller owns allocation)
AccelTableT *accelTable;
// Callbacks
// Callbacks — the application's interface to the window manager.
// Using function pointers rather than a message queue keeps latency
// low (no queuing/dispatching overhead) and matches the callback-driven
// model that DOS programs expect. The widget system installs its own
// handlers here via wgtInitWindow() and dispatches to individual widgets.
void *userData;
void (*onPaint)(struct WindowT *win, RectT *dirtyArea);
void (*onKey)(struct WindowT *win, int32_t key, int32_t mod);
@ -342,6 +501,19 @@ typedef struct WindowT {
// ============================================================
// Window stack
// ============================================================
//
// The window stack is an array of pointers ordered front-to-back: index
// count-1 is the topmost (frontmost) window. This ordering means the
// compositor can iterate back-to-front for painting (painter's algorithm)
// and front-to-back for hit testing (first hit wins).
//
// Using an array of pointers (not embedded structs) lets us reorder
// windows by swapping pointers instead of copying entire WindowT structs,
// which matters because WindowT is large.
//
// Drag/resize/scroll state lives here rather than on individual windows
// because only one interaction can be active at a time system-wide —
// you can't drag two windows simultaneously with a single mouse.
typedef struct {
WindowT *windows[MAX_WINDOWS];
@ -360,6 +532,17 @@ typedef struct {
// ============================================================
// Mouse cursor
// ============================================================
//
// Software-rendered cursor using the classic AND/XOR mask approach from
// the original IBM VGA hardware cursor spec. This two-mask scheme enables
// four pixel states: transparent (AND=1, XOR=0), inverted (AND=1, XOR=1),
// black (AND=0, XOR=0), and white (AND=0, XOR=1). The cursor is drawn
// in software on top of the composited frame because VESA VBE doesn't
// provide a hardware sprite — the cursor is painted into the backbuffer
// and the affected region is flushed to the LFB each frame.
//
// 16 pixels wide using uint16_t rows, one word per scanline. This keeps
// cursor data compact and aligned, and 16x16 is the standard DOS cursor size.
typedef struct {
int32_t width;
@ -373,6 +556,17 @@ typedef struct {
// ============================================================
// Popup state for dropdown menus (with cascading submenu support)
// ============================================================
//
// Only one popup chain can be active at a time (menus are modal).
// Cascading submenus are tracked as a stack of PopupLevelT frames,
// where each level saves the parent menu's state so it can be restored
// when the submenu closes. MAX_SUBMENU_DEPTH of 4 allows File > Recent >
// Category > Item style nesting, which is deeper than any sane DOS UI needs.
//
// The popup's screen coordinates are stored directly rather than computed
// relative to the window, because the popup is painted on top of
// everything during the compositor's overlay pass and needs absolute
// screen positioning.
#define MAX_SUBMENU_DEPTH 4
@ -405,6 +599,12 @@ typedef struct {
// ============================================================
// System menu (control menu / close box menu)
// ============================================================
//
// The system menu appears when clicking the close gadget once (double-click
// closes). This matches classic Windows 3.x/Motif behavior where the control
// menu provides Move, Size, Minimize, Maximize, Restore, and Close. The
// menu items are dynamically enabled/disabled based on window state (e.g.
// Restore is disabled if the window isn't maximized).
typedef enum {
SysMenuRestoreE = 1,
@ -440,6 +640,12 @@ typedef struct {
// ============================================================
// Keyboard move/resize mode
// ============================================================
//
// Supports moving and resizing windows via arrow keys (triggered from
// the system menu's Move/Size commands). The original geometry is saved
// so that Escape can cancel the operation and restore the window to its
// prior position/size. This is the standard Windows 3.x keyboard
// accessibility mechanism for window management.
typedef enum {
KbModeNoneE = 0,

View file

@ -3,6 +3,43 @@
// Platform-independent video utilities. The actual VESA/VBE code
// now lives in dvxPlatformDos.c (or the platform file for whatever
// OS we're targeting).
//
// This is the lowest layer of the 5-layer DVX compositor stack
// (video -> draw -> comp -> wm -> app). It owns the DisplayT context
// that threads through every layer above: screen dimensions, pixel
// format, the backbuffer pointer, and the current clip rectangle.
//
// Design decisions at this layer:
//
// LFB-only (no bank switching): VBE 2.0 LFB gives the CPU a flat
// address window over the entire framebuffer. Bank switching (the
// VBE 1.x fallback) requires an INT 10h or port-I/O call every 64 KB,
// which is lethal to throughput in a compositor that redraws hundreds
// of dirty rects per frame. Requiring LFB eliminates per-scanline
// bank math, allows rep movsd bulk copies, and simplifies every span
// operation in the draw layer. The tradeoff is dropping cards that
// only expose banked modes — acceptable since the target is 486+ with
// a VESA 2.0 BIOS (virtually universal by 1994-95).
//
// System-RAM backbuffer with dirty-rect flushing: Drawing directly
// to the LFB is slow because VESA framebuffers sit behind the PCI/ISA
// bus and every write-combine flush costs dozens of cycles. Reads from
// LFB are catastrophically slow (uncached MMIO). Instead, all draw
// operations target a malloc'd backbuffer in system RAM, which lives
// in the CPU's L1/L2 cache hierarchy. The compositor (layer 3) then
// copies only the dirty rectangles to the LFB with rep movsd, making
// the bus transfer a single sequential burst per rect. This also
// means overdraw from occluded windows never touches the bus at all.
//
// Clip rectangle on DisplayT: Rather than passing a clip rect to
// every draw call (4 extra parameters, extra register pressure on
// i386 calling convention where only a few args go in registers), the
// clip rect is set once on DisplayT before a batch of draw calls. The
// window manager sets it to the current window's content area before
// calling onPaint, and the compositor sets it to each dirty rect during
// the composite pass. This keeps the draw API narrow and the common
// case (many draws within the same clip) free of redundant parameter
// shuffling.
#include "dvxVideo.h"
#include "platform/dvxPlatform.h"
@ -14,12 +51,30 @@
// ============================================================
// packColor
// ============================================================
//
// Converts an 8-bit-per-channel RGB triple into whatever pixel
// representation the current display mode uses. The result is
// always returned as a uint32_t regardless of actual pixel width,
// so callers can store it and pass it through the draw layer without
// caring about the active format.
//
// For 8-bit indexed mode, this falls through to dvxNearestPalEntry
// which searches the 6x6x6 color cube, grey ramp, and chrome slots.
// For direct-color modes (15/16/32-bit), we right-shift each channel
// to discard the low bits that don't fit in the mode's color field,
// then left-shift into position. This approach truncates rather than
// rounds, which is a deliberate simplicity tradeoff — dithering or
// rounding would add per-pixel branches on a 486 for minimal visual
// benefit in a desktop GUI.
uint32_t packColor(const DisplayT *d, uint8_t r, uint8_t g, uint8_t b) {
if (d->format.bitsPerPixel == 8) {
return dvxNearestPalEntry(d->palette, r, g, b);
}
// Shift each channel: first discard the low bits that won't fit in
// the mode's field width (e.g. 8-bit red -> 5-bit red for 565),
// then shift left to the field's position within the pixel word.
uint32_t rv = ((uint32_t)r >> (8 - d->format.redBits)) << d->format.redShift;
uint32_t gv = ((uint32_t)g >> (8 - d->format.greenBits)) << d->format.greenShift;
uint32_t bv = ((uint32_t)b >> (8 - d->format.blueBits)) << d->format.blueShift;
@ -31,6 +86,9 @@ uint32_t packColor(const DisplayT *d, uint8_t r, uint8_t g, uint8_t b) {
// ============================================================
// resetClipRect
// ============================================================
//
// Restores the clip rect to the full screen. Called after the
// compositor finishes flushing dirty rects, and during init.
void resetClipRect(DisplayT *d) {
d->clipX = 0;
@ -43,6 +101,19 @@ void resetClipRect(DisplayT *d) {
// ============================================================
// setClipRect
// ============================================================
//
// Sets the active clip rectangle, clamping to screen bounds.
// The clip rect is stored directly on DisplayT so every draw call
// in layer 2 can read it without an extra parameter. This is the
// central mechanism that makes per-window and per-dirty-rect
// clipping cheap: the WM sets clip to the window's content area
// before calling onPaint, and the compositor narrows it further
// to each dirty rect during the composite pass.
//
// Coordinates are converted to min/max form internally, clamped,
// then stored back as x/y/w/h. If the caller passes a rect that
// lies entirely outside the screen, clipW or clipH will be zero
// and all subsequent draw calls will trivially reject.
void setClipRect(DisplayT *d, int32_t x, int32_t y, int32_t w, int32_t h) {
int32_t x2 = x + w;
@ -63,6 +134,13 @@ void setClipRect(DisplayT *d, int32_t x, int32_t y, int32_t w, int32_t h) {
// ============================================================
// videoInit
// ============================================================
//
// Thin wrapper that delegates to the platform layer. All the heavy
// lifting — VBE enumeration, mode scoring, LFB DPMI mapping,
// backbuffer allocation — lives in dvxPlatformDos.c so this file
// stays portable. The platform layer fills in every field of
// DisplayT (dimensions, pitch, pixel format, lfb pointer,
// backBuf pointer, palette, initial clip rect).
int32_t videoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
return platformVideoInit(d, requestedW, requestedH, preferredBpp);
@ -72,6 +150,10 @@ int32_t videoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t p
// ============================================================
// videoShutdown
// ============================================================
//
// Restores text mode, frees the backbuffer and palette, unmaps the
// LFB, and disables DJGPP near pointers. Must be called before
// exit or the console will be left in graphics mode.
void videoShutdown(DisplayT *d) {
platformVideoShutdown(d);

View file

@ -1,25 +1,48 @@
// dvx_video.h — Layer 1: VESA VBE video backend for DVX GUI
//
// The lowest layer in the DVX stack. Responsible for VESA VBE mode
// negotiation, linear framebuffer (LFB) mapping via DPMI, system RAM
// backbuffer allocation, and pixel format discovery.
//
// LFB-only design: bank switching is deliberately unsupported. Every
// VESA 2.0+ card provides LFB, and the code complexity of managing
// 64K bank windows (with scanlines that straddle bank boundaries) is
// not worth supporting ancient VESA 1.x hardware. If videoInit() can't
// find an LFB-capable mode, it fails immediately.
//
// This layer also owns color packing (RGB -> native pixel format) and
// the display-wide clip rectangle. These live here rather than in the
// draw layer because they depend on pixel format knowledge that only
// the video layer has.
#ifndef DVX_VIDEO_H
#define DVX_VIDEO_H
#include "dvxTypes.h"
// Initialize VESA video mode and map LFB
// Returns 0 on success, -1 on failure (error message printed to stderr)
// Probes VBE for a mode matching the requested resolution and depth,
// enables it, maps the LFB into the DPMI linear address space, and
// allocates a system RAM backbuffer of the same size. preferredBpp is
// a hint — if the exact depth isn't available, the closest match is used.
int32_t videoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
// Shut down video — restore text mode, unmap LFB, free backbuffer
// Restores VGA text mode (INT 10h AH=0, mode 3), unmaps the LFB, and
// frees the backbuffer. Safe to call even if videoInit() failed.
void videoShutdown(DisplayT *d);
// Pack an RGB color into the display's pixel format
// For 15/16/32-bit: returns packed pixel value
// For 8-bit: returns nearest palette index
// Pack an RGB triplet into the display's native pixel format.
// For direct-color modes (15/16/32 bpp): returns a packed pixel value
// using the shift/mask fields from PixelFormatT.
// For 8-bit mode: returns the nearest palette index using Euclidean
// distance in RGB space (fast path through the 6x6x6 color cube, then
// linear scan of the grey ramp and chrome entries).
uint32_t packColor(const DisplayT *d, uint8_t r, uint8_t g, uint8_t b);
// Set the clip rectangle on the display
// Set the clip rectangle on the display. All subsequent draw operations
// will be clipped to this rectangle. The caller is responsible for
// saving and restoring the clip rect around scoped operations.
void setClipRect(DisplayT *d, int32_t x, int32_t y, int32_t w, int32_t h);
// Reset clip rectangle to full display
// Reset clip rectangle to the full display dimensions.
void resetClipRect(DisplayT *d);
#endif // DVX_VIDEO_H

View file

@ -1,4 +1,33 @@
// dvxWidget.h — Widget system for DVX GUI
//
// A retained-mode widget toolkit layered on top of the DVX window manager.
// Widgets form a tree (parent-child via firstChild/lastChild/nextSibling
// pointers) rooted at a per-window VBox container. Layout is automatic:
// the engine measures minimum sizes bottom-up, then allocates space top-down
// using a flexbox-like algorithm with weights for extra-space distribution.
//
// Design decisions and rationale:
//
// - Single WidgetT struct with a tagged union: avoids the overhead of a
// class hierarchy and virtual dispatch (no C++ vtable indirection).
// The wclass pointer provides vtable-style dispatch only for the few
// operations that genuinely differ per widget type (paint, layout, events).
// Type-specific data lives in the `as` union to keep the struct compact.
//
// - Tagged size values (wgtPixels/wgtChars/wgtPercent): encoding the unit
// in the high bits of a single int32_t avoids extra struct fields for
// unit type and lets size hints be passed as plain integers. The 30-bit
// value range (up to ~1 billion) is more than sufficient for pixel counts.
//
// - Tree linkage uses firstChild/lastChild/nextSibling (no prevSibling):
// this halves the pointer overhead per widget and insertion/removal is
// still O(n) in the worst case, which is acceptable given typical tree
// depths of 5-10 nodes.
//
// - Large widget data (AnsiTermDataT, ListViewDataT) is separately
// allocated and stored as a pointer in the union rather than inlined,
// because these structures are hundreds of bytes and would bloat every
// WidgetT even for simple labels and buttons.
#ifndef DVX_WIDGET_H
#define DVX_WIDGET_H
@ -14,9 +43,15 @@ struct WidgetClassT;
// Size specifications
// ============================================================
//
// Tagged size values encode both a unit type and a numeric value.
// Use wgtPixels(), wgtChars(), or wgtPercent() to create them.
// A raw 0 means "auto" (use the widget's natural size).
// Tagged size values encode both a unit type and a numeric value in a
// single int32_t. The top 2 bits select the unit (pixels, character widths,
// or percentage of parent), and the low 30 bits hold the numeric value.
// A raw 0 means "auto" (use the widget's natural/minimum size).
//
// This encoding avoids a separate enum field for the unit type, keeping
// size hints as simple scalar assignments: w->minW = wgtChars(40);
// The wgtResolveSize() function in the layout engine decodes these tagged
// values back into pixel counts using the font metrics and parent dimensions.
#define WGT_SIZE_TYPE_MASK 0xC0000000
#define WGT_SIZE_VAL_MASK 0x3FFFFFFF
@ -39,6 +74,11 @@ static inline int32_t wgtPercent(int32_t v) {
// ============================================================
// Widget type enum
// ============================================================
//
// Used as the index into widgetClassTable[] (in widgetInternal.h) to
// look up the vtable for each widget type. Adding a new widget type
// requires adding an enum value here, a corresponding union member in
// WidgetT, and a WidgetClassT entry in widgetClassTable[].
typedef enum {
WidgetVBoxE,
@ -135,7 +175,19 @@ typedef enum {
// ============================================================
// Large widget data (separately allocated to reduce WidgetT size)
// ============================================================
//
// AnsiTermDataT and ListViewDataT are heap-allocated and stored as
// pointers in the WidgetT union. Inlining them would add hundreds of
// bytes to every WidgetT instance (even labels and buttons), wasting
// memory on the 30+ simple widgets that don't need terminal or listview
// state. The pointer indirection adds one dereference but saves
// significant memory across a typical widget tree.
// AnsiTermDataT — full VT100/ANSI terminal emulator state.
// Implements a subset of DEC VT100 escape sequences sufficient for BBS
// and DOS ANSI art rendering: cursor movement, color attributes (16-color
// with bold-as-bright), scrolling regions, and blink. The parser is a
// simple state machine (normal -> ESC -> CSI) with parameter accumulation.
typedef struct {
uint8_t *cells; // character cells: (ch, attr) pairs, cols*rows*2 bytes
int32_t cols; // columns (default 80)
@ -156,7 +208,10 @@ typedef struct {
// Scrolling region (0-based, inclusive)
int32_t scrollTop; // top row of scroll region
int32_t scrollBot; // bottom row of scroll region
// Scrollback
// Scrollback — circular buffer so old lines age out naturally without
// memmove. Each line is cols*2 bytes (same ch/attr format as cells).
// scrollPos tracks the view offset: when equal to scrollbackCount,
// the user sees the live screen; when less, they're viewing history.
uint8_t *scrollback; // circular buffer of scrollback lines
int32_t scrollbackMax; // max lines in scrollback buffer
int32_t scrollbackCount; // current number of lines stored
@ -168,11 +223,17 @@ typedef struct {
// Cursor blink
bool cursorOn; // current cursor blink phase
clock_t cursorTime; // timestamp of last cursor toggle
// Dirty tracking for fast repaint
// Dirty tracking — a 32-bit bitmask where each bit corresponds to one
// terminal row. Only dirty rows are repainted, which is critical because
// the ANSI terminal can receive data every frame (at 9600+ baud) and
// re-rendering all 25 rows of 80 columns each frame would dominate the
// CPU budget. Limits terminal height to 32 rows, which is fine for the
// 25-row default target.
uint32_t dirtyRows; // bitmask of rows needing repaint
int32_t lastCursorRow; // cursor row at last repaint
int32_t lastCursorCol; // cursor col at last repaint
// Cached packed palette (avoids packColor per repaint)
// Pre-packed 16-color palette avoids calling packColor() (which involves
// shift/mask arithmetic) 80*25 = 2000 times per full repaint.
uint32_t packedPalette[16];
bool paletteValid;
// Selection (line indices in scrollback+screen space)
@ -181,12 +242,21 @@ typedef struct {
int32_t selEndLine;
int32_t selEndCol;
bool selecting;
// Communications interface (all NULL = disconnected)
// Communications interface — abstracted so the terminal can connect to
// different backends (serial port, secLink channel, local pipe) without
// knowing the transport details. When all are NULL, the terminal is in
// offline/disconnected mode (useful for viewing .ANS files).
void *commCtx;
int32_t (*commRead)(void *ctx, uint8_t *buf, int32_t maxLen);
int32_t (*commWrite)(void *ctx, const uint8_t *buf, int32_t len);
} AnsiTermDataT;
// ListViewDataT — multi-column list with sortable headers and optional
// multi-select. cellData is a flat array of strings indexed as
// cellData[row * colCount + col]. The sortIndex is an indirection array
// that maps displayed row numbers to data row numbers, allowing sort
// without rearranging the actual data. resolvedColW[] caches pixel widths
// after resolving tagged column sizes, avoiding re-resolution on every paint.
typedef struct {
const ListViewColT *cols;
int32_t colCount;
@ -217,8 +287,15 @@ typedef struct {
typedef struct WidgetT {
WidgetTypeE type;
// wclass points to the vtable for this widget type. Looked up once at
// creation from widgetClassTable[type]. This avoids a switch on type
// in every paint/layout/event dispatch — the cost is one pointer per
// widget, which is negligible.
const struct WidgetClassT *wclass;
char name[MAX_WIDGET_NAME];
// djb2 hash of the name string, computed at wgtSetName() time.
// wgtFind() compares hashes before strcmp, making name lookups fast
// for the common no-match case in a tree with many unnamed widgets.
uint32_t nameHash; // djb2 hash of name, 0 if unnamed
// Tree linkage
@ -234,17 +311,27 @@ typedef struct WidgetT {
int32_t w;
int32_t h;
// Computed minimum size (set by layout engine)
// Computed minimum size — set bottom-up by calcMinSize during layout.
// These represent the smallest possible size for this widget (including
// its children if it's a container). The layout engine uses these as
// the starting point for space allocation.
int32_t calcMinW;
int32_t calcMinH;
// Size hints (tagged: wgtPixels/wgtChars/wgtPercent, 0 = auto)
// Size hints (tagged: wgtPixels/wgtChars/wgtPercent, 0 = auto).
// These are set by the application and influence the layout engine:
// minW/minH override calcMinW/H if larger, maxW/maxH clamp the final
// size, and prefW/prefH request a specific size (layout may override).
int32_t minW;
int32_t minH;
int32_t maxW; // 0 = no limit
int32_t maxH;
int32_t prefW; // preferred size, 0 = auto
int32_t prefH;
// weight controls how extra space beyond minimum is distributed among
// siblings in a VBox/HBox. weight=0 means fixed size (no stretching),
// weight=100 is the default for flexible widgets. A widget with
// weight=200 gets twice as much extra space as one with weight=100.
int32_t weight; // extra-space distribution (0 = fixed, 100 = normal)
// Container properties
@ -270,7 +357,12 @@ typedef struct WidgetT {
void (*onChange)(struct WidgetT *w);
void (*onDblClick)(struct WidgetT *w);
// Type-specific data
// Type-specific data — tagged union keyed by the `type` field.
// Only the member corresponding to `type` is valid. This is the C
// equivalent of a discriminated union / variant type. Using a union
// instead of separate structs per widget type keeps all widget data
// in a single allocation, which simplifies memory management and
// avoids pointer chasing during layout/paint traversal.
union {
struct {
const char *text;
@ -295,6 +387,11 @@ typedef struct WidgetT {
int32_t index;
} radio;
// Text input has its own edit buffer (not a pointer to external
// storage) so the widget fully owns its text lifecycle. The undo
// buffer holds a single-level snapshot taken before each edit
// operation — Ctrl+Z restores to the snapshot. This is simpler
// than a full undo stack but sufficient for single-line fields.
struct {
char *buf;
int32_t bufSize;
@ -310,6 +407,11 @@ typedef struct WidgetT {
const char *mask; // format mask for InputMaskedE
} textInput;
// Multi-line text editor. desiredCol implements the "sticky column"
// behavior where pressing Up/Down tries to return to the column
// the cursor was at before traversing shorter lines (standard
// text editor UX). cachedLines/cachedMaxLL cache values that
// require full-buffer scans, invalidated to -1 on any text change.
struct {
char *buf;
int32_t bufSize;
@ -404,6 +506,11 @@ typedef struct WidgetT {
const char *title;
} tabPage;
// TreeView uses widget children (WidgetTreeItemE) as its items,
// unlike ListBox which uses a string array. This allows nested
// hierarchies with expand/collapse state per item. The tree is
// rendered by flattening visible items during paint, with TREE_INDENT
// pixels of indentation per nesting level.
struct {
int32_t scrollPos;
int32_t scrollPosH;
@ -488,13 +595,23 @@ typedef struct WidgetT {
// Window integration
// ============================================================
// Set up a window for widgets. Returns the root container (VBox).
// Automatically installs onPaint, onMouse, onKey, and onResize handlers.
// Initialize the widget system for a window. Creates a root VBox container
// that fills the window's content area, and installs callback handlers
// (onPaint, onMouse, onKey, onResize) that dispatch events to the widget
// tree. After this call, the window is fully managed by the widget system
// — the application builds its UI by adding child widgets to the returned
// root container. The window's userData is set to the AppContextT pointer.
WidgetT *wgtInitWindow(struct AppContextT *ctx, WindowT *win);
// ============================================================
// Container creation
// ============================================================
//
// VBox and HBox are the primary layout containers, analogous to CSS
// flexbox with column/row direction. Children are laid out sequentially
// along the main axis (vertical for VBox, horizontal for HBox) with
// spacing between them. Extra space is distributed according to each
// child's weight. Frame is a titled groupbox container with a bevel border.
WidgetT *wgtVBox(WidgetT *parent);
WidgetT *wgtHBox(WidgetT *parent);
@ -514,6 +631,11 @@ WidgetT *wgtMaskedInput(WidgetT *parent, const char *mask);
// ============================================================
// Radio buttons
// ============================================================
//
// Radio buttons must be children of a RadioGroup container. The group
// tracks which child is selected (by index). Clicking a radio button
// automatically deselects the previously selected sibling. This parent-
// tracking approach avoids explicit "radio group ID" parameters.
WidgetT *wgtRadioGroup(WidgetT *parent);
WidgetT *wgtRadio(WidgetT *parent, const char *text);
@ -521,6 +643,11 @@ WidgetT *wgtRadio(WidgetT *parent, const char *text);
// ============================================================
// Spacing and visual dividers
// ============================================================
//
// Spacer is an invisible flexible widget (weight=100) that absorbs
// extra space — useful for pushing subsequent siblings to the end of
// a container (like CSS flex: 1 auto). Separators are thin beveled
// lines for visual grouping.
WidgetT *wgtSpacer(WidgetT *parent);
WidgetT *wgtHSeparator(WidgetT *parent);
@ -702,11 +829,19 @@ uint32_t wgtCanvasGetPixel(const WidgetT *w, int32_t x, int32_t y);
// ============================================================
// ANSI Terminal
// ============================================================
//
// A VT100/ANSI terminal emulator widget. Supports the subset of escape
// sequences needed for DOS BBS programs and ANSI art: cursor positioning,
// SGR color/attribute codes, scrolling regions, and text blink. Pairs
// with the secLink communications layer for remote serial connections
// or can be used standalone for viewing .ANS files.
// Create an ANSI terminal widget (0 for cols/rows = 80x25 default)
WidgetT *wgtAnsiTerm(WidgetT *parent, int32_t cols, int32_t rows);
// Write data through the ANSI parser (for loading .ANS files or feeding data without a connection)
// Write raw data through the ANSI escape sequence parser. Used for
// loading .ANS files or feeding data from a source that isn't using
// the comm interface.
void wgtAnsiTermWrite(WidgetT *w, const uint8_t *data, int32_t len);
// Clear the terminal screen and reset cursor to home
@ -721,23 +856,35 @@ void wgtAnsiTermSetScrollback(WidgetT *w, int32_t maxLines);
// Poll the comm interface for incoming data and process it. Returns bytes processed.
int32_t wgtAnsiTermPoll(WidgetT *w);
// Fast repaint: renders only dirty rows directly into the window's content
// buffer, bypassing the full widget paint pipeline. Returns number of rows
// repainted (0 if nothing was dirty). If outY/outH are non-NULL, they receive
// the content-buffer-relative Y and height of the repainted region.
// Fast-path repaint for the terminal widget. Instead of going through the
// full widget paint pipeline (which would repaint the entire widget), this
// renders only the dirty rows (tracked via the dirtyRows bitmask) directly
// into the window's content buffer. This is essential for responsive terminal
// output — incoming serial data can dirty a few rows per frame, and
// repainting only those rows keeps the cost proportional to the actual
// change rather than the full 80x25 grid. Returns the number of rows
// repainted; outY/outH report the affected region for dirty-rect tracking.
int32_t wgtAnsiTermRepaint(WidgetT *w, int32_t *outY, int32_t *outH);
// ============================================================
// Operations
// ============================================================
// Get the AppContextT from any widget (walks to root)
// Walk from any widget up the tree to the root, then retrieve the
// AppContextT stored in the window's userData. This lets any widget
// access the full application context without passing it through every
// function call.
struct AppContextT *wgtGetContext(const WidgetT *w);
// Mark a widget (and ancestors) for relayout and repaint
// Mark a widget as needing both re-layout (measure + position) and
// repaint. Propagates upward to ancestors since a child's size change
// can affect parent layout. Use this after structural changes (adding/
// removing children, changing text that affects size).
void wgtInvalidate(WidgetT *w);
// Repaint only — skip measure/layout (use for visual-only changes)
// Mark a widget as needing repaint only, without re-layout. Use this
// for visual-only changes that don't affect geometry (e.g. checkbox
// toggle, selection highlight change, cursor blink).
void wgtInvalidatePaint(WidgetT *w);
// Set/get widget text (label, button, textInput, etc.)
@ -792,13 +939,24 @@ void wgtSetDebugLayout(struct AppContextT *ctx, bool enabled);
// Layout (called internally; available for manual trigger)
// ============================================================
// Resolve a tagged size to pixels
// Decode a tagged size value (WGT_SIZE_PIXELS/CHARS/PERCENT) into a
// concrete pixel count. For CHARS, multiplies by charWidth; for PERCENT,
// computes the fraction of parentSize. Returns 0 for a raw 0 input
// (meaning "auto").
int32_t wgtResolveSize(int32_t taggedSize, int32_t parentSize, int32_t charWidth);
// Run layout on the entire widget tree
// Execute the full two-pass layout algorithm on the widget tree:
// Pass 1 (bottom-up): calcMinSize on every widget to compute minimum sizes.
// Pass 2 (top-down): allocate space within availW/availH, distributing
// extra space according to weights and respecting min/max constraints.
// Normally called automatically by the paint handler; exposed here for
// cases where layout must be forced before the next paint (e.g. dvxFitWindow).
void wgtLayout(WidgetT *root, int32_t availW, int32_t availH, const BitmapFontT *font);
// Paint the entire widget tree
// Paint the entire widget tree by depth-first traversal. Each widget's
// clip rect is set to its bounds before calling its paint function.
// Overlays (dropdown popups, tooltips) are painted in a second pass
// after the main tree so they render on top of everything.
void wgtPaint(WidgetT *root, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
#endif // DVX_WIDGET_H

File diff suppressed because it is too large Load diff

View file

@ -1,106 +1,170 @@
// dvx_wm.h — Layer 4: Window manager for DVX GUI
//
// Manages the window lifecycle, Z-order stack, chrome drawing, hit testing,
// and interactive operations (drag, resize, scroll). This layer bridges the
// low-level drawing/compositing layers below with the high-level application
// API above.
//
// Design philosophy: the WM owns window geometry and chrome, but the
// window's content is owned by the application (via callbacks or the widget
// system). This separation means the WM can move, resize, and repaint
// window frames without involving the application at all — only content
// changes trigger application callbacks.
//
// All WM operations that change visible screen state accept a DirtyListT*
// so they can mark affected regions for compositor repaint. This push-based
// dirty tracking avoids the need for a full-screen diff each frame.
#ifndef DVX_WM_H
#define DVX_WM_H
#include "dvxTypes.h"
// Initialize window stack
// Zero the window stack. Must be called before any other wm function.
void wmInit(WindowStackT *stack);
// Create a new window and add it to the stack (raised to top)
// Allocate a new WindowT, initialize its geometry and content buffer, and
// push it to the top of the Z-order stack. The returned window has default
// callbacks (all NULL) — the caller should set onPaint/onKey/etc. before
// the next event loop iteration.
WindowT *wmCreateWindow(WindowStackT *stack, DisplayT *d, const char *title, int32_t x, int32_t y, int32_t w, int32_t h, bool resizable);
// Destroy a window and remove from stack
// Free the window's content buffer and all attached resources (menu bar,
// scrollbars, widget tree), then remove it from the stack and dirty the
// region it occupied.
void wmDestroyWindow(WindowStackT *stack, WindowT *win);
// Raise a window to the top of the stack
// Move window at stack index idx to the top of the Z-order. Dirties both
// the old and new top positions so overlapping windows get repainted.
void wmRaiseWindow(WindowStackT *stack, DirtyListT *dl, int32_t idx);
// Set focus to a window (by stack index)
// Transfer keyboard focus to the window at stack index idx. Unfocuses the
// previously focused window and dirties both title bars (since active/
// inactive title colors differ).
void wmSetFocus(WindowStackT *stack, DirtyListT *dl, int32_t idx);
// Recalculate content area geometry from frame dimensions
// Recompute contentX/Y/W/H from the window's outer frame dimensions,
// accounting for chrome borders, title bar, menu bar, and scrollbars.
// Must be called after any change to frame size or chrome configuration
// (e.g. adding a menu bar or scrollbar).
void wmUpdateContentRect(WindowT *win);
// Reallocate content buffer after resize
// Reallocate the per-window content backbuffer to match the current
// contentW/H. Returns 0 on success, -1 if allocation fails. The old
// buffer contents are lost — the caller should trigger a full repaint
// via onPaint after a successful realloc.
int32_t wmReallocContentBuf(WindowT *win, const DisplayT *d);
// Add a menu bar to a window
// Allocate and attach a menu bar to a window. Adjusts the content area
// to make room for the menu bar (CHROME_MENU_HEIGHT pixels). Only one
// menu bar per window is supported.
MenuBarT *wmAddMenuBar(WindowT *win);
// Add a menu to a menu bar
// Append a dropdown menu to the menu bar. Returns the MenuT to populate
// with items. The label supports & accelerator markers (e.g. "&File").
MenuT *wmAddMenu(MenuBarT *bar, const char *label);
// Add an item to a menu
// Append a clickable item to a menu. The id is passed to the window's
// onMenu callback when the item is selected.
void wmAddMenuItem(MenuT *menu, const char *label, int32_t id);
// Add a checkbox item to a menu
// Add a checkbox-style item. Check state is toggled on click and
// rendered with a checkmark glyph.
void wmAddMenuCheckItem(MenuT *menu, const char *label, int32_t id, bool checked);
// Add a radio item to a menu (radio group = consecutive radio items)
// Add a radio-style item. Radio groups are defined implicitly by
// consecutive radio items in the same menu — selecting one unchecks
// the others in the group.
void wmAddMenuRadioItem(MenuT *menu, const char *label, int32_t id, bool checked);
// Add a separator to a menu
// Insert a horizontal separator line. Separators are not interactive.
void wmAddMenuSeparator(MenuT *menu);
// Add a submenu item (returns the child MenuT to populate, or NULL on failure)
// Create a cascading submenu attached to the parent menu. Returns the
// child MenuT to populate, or NULL if MAX_MENU_ITEMS is exhausted.
// The child MenuT is heap-allocated and freed when the parent window
// is destroyed.
MenuT *wmAddSubMenu(MenuT *parentMenu, const char *label);
// Add a vertical scrollbar to a window
// Attach a vertical scrollbar to the right edge of the window's content
// area. Shrinks contentW by SCROLLBAR_WIDTH pixels to make room. The
// scrollbar's value range and page size determine thumb proportions.
ScrollbarT *wmAddVScrollbar(WindowT *win, int32_t min, int32_t max, int32_t pageSize);
// Add a horizontal scrollbar to a window
// Attach a horizontal scrollbar to the bottom edge. Shrinks contentH
// by SCROLLBAR_WIDTH pixels.
ScrollbarT *wmAddHScrollbar(WindowT *win, int32_t min, int32_t max, int32_t pageSize);
// Draw window chrome (frame, title bar, menu bar) clipped to a dirty rect
// Draw the window frame (outer bevel, title bar with text, close/minimize/
// maximize gadgets, and menu bar if present). clipTo restricts drawing to
// only the intersection with the given dirty rectangle, avoiding redundant
// pixel writes outside the damaged region.
void wmDrawChrome(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, WindowT *win, const RectT *clipTo);
// Draw window content clipped to a dirty rect
// Blit the window's content backbuffer into the display backbuffer,
// clipped to the dirty rect. This is a pure copy (no drawing) — the
// content was already rendered by the application into contentBuf.
void wmDrawContent(DisplayT *d, const BlitOpsT *ops, WindowT *win, const RectT *clipTo);
// Draw scrollbars for a window
// Draw scrollbars (track, arrows, proportional thumb) for a window.
// Drawn after content so scrollbars overlay the content area edge.
void wmDrawScrollbars(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, WindowT *win, const RectT *clipTo);
// Draw minimized window icons at bottom of screen
// Draw icons for all minimized windows along the bottom of the screen.
// Each icon shows a scaled-down preview of the window's content (or a
// default icon) with a beveled border, similar to DESQview/X's icon bar.
void wmDrawMinimizedIcons(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, const WindowStackT *stack, const RectT *clipTo);
// Hit test: which part of which window is at screen position (mx, my)?
// Returns stack index or -1 if no window hit
// Sets *hitPart: 0=content, 1=title, 2=close button, 3=resize edge,
// Determine which window and which part of that window is under the given
// screen coordinates. Iterates the stack front-to-back (highest Z first)
// so the topmost window wins. Returns the stack index, or -1 if no window
// was hit (i.e. the desktop). hitPart identifies the chrome region:
// 0=content, 1=title bar, 2=close button, 3=resize edge,
// 4=menu bar, 5=vscroll, 6=hscroll, 7=minimize, 8=maximize
int32_t wmHitTest(const WindowStackT *stack, int32_t mx, int32_t my, int32_t *hitPart);
// Detect which resize edge(s) are hit (returns RESIZE_xxx flags)
// For a point within a window's border zone, determine which edge(s)
// are being targeted for resize. Returns a bitmask of RESIZE_xxx flags.
// Corner hits combine two edges (e.g. RESIZE_LEFT | RESIZE_TOP).
int32_t wmResizeEdgeHit(const WindowT *win, int32_t mx, int32_t my);
// Handle window drag (call each mouse move during drag)
// Update window position during an active drag. Called on each mouse move
// event while dragWindow is active. Dirties both the old and new window
// positions. The drag offset (mouse position relative to window origin at
// drag start) is applied so the window tracks the mouse smoothly.
void wmDragMove(WindowStackT *stack, DirtyListT *dl, int32_t mouseX, int32_t mouseY);
// Handle window resize (call each mouse move during resize)
// Update window dimensions during an active resize. Enforces MIN_WINDOW_W/H
// and optional maxW/maxH constraints. Reallocates the content buffer if the
// content area size changed, then calls onResize to notify the application.
void wmResizeMove(WindowStackT *stack, DirtyListT *dl, const DisplayT *d, int32_t mouseX, int32_t mouseY);
// Begin dragging a window
// Begin a window drag operation. Records the mouse offset from the window
// origin so the window doesn't jump to the cursor position.
void wmDragBegin(WindowStackT *stack, int32_t idx, int32_t mouseX, int32_t mouseY);
// End dragging
// End the current drag operation. Clears dragWindow state.
void wmDragEnd(WindowStackT *stack);
// Begin resizing a window
// Begin a window resize operation. Records which edge(s) are being dragged
// and the initial mouse position for delta computation.
void wmResizeBegin(WindowStackT *stack, int32_t idx, int32_t edge, int32_t mouseX, int32_t mouseY);
// End resizing
// End the current resize operation. Clears resizeWindow state.
void wmResizeEnd(WindowStackT *stack);
// Set window title
void wmSetTitle(WindowT *win, DirtyListT *dl, const char *title);
// Handle scrollbar click (arrow buttons, trough, or begin thumb drag)
// Handle an initial click on a scrollbar. Determines what was hit (up/down
// arrows, page trough area, or thumb) and either adjusts the value
// immediately (arrows, trough) or begins a thumb drag operation.
void wmScrollbarClick(WindowStackT *stack, DirtyListT *dl, int32_t idx, int32_t orient, int32_t mx, int32_t my);
// Handle ongoing scrollbar thumb drag
// Update the scroll value during an active thumb drag. Maps the mouse
// position along the track to a scroll value proportional to the range.
void wmScrollbarDrag(WindowStackT *stack, DirtyListT *dl, int32_t mx, int32_t my);
// End scrollbar thumb drag
// End an active scrollbar thumb drag.
void wmScrollbarEnd(WindowStackT *stack);
// Maximize a window (saves current geometry, expands to screen/max bounds)
@ -122,10 +186,14 @@ void wmRestoreMinimized(WindowStackT *stack, DirtyListT *dl, WindowT *win);
// Load an icon image for a window (converts to display pixel format)
int32_t wmSetIcon(WindowT *win, const char *path, const DisplayT *d);
// Allocate a standalone menu (for use as a context menu). Free with wmFreeMenu().
// Allocate a heap-resident MenuT for use as a context menu (right-click).
// Unlike menu bar menus (which are embedded in MenuBarT), context menus
// are standalone allocations because they may be shared across windows or
// attached/detached dynamically. Free with wmFreeMenu().
MenuT *wmCreateMenu(void);
// Free a standalone menu allocated with wmCreateMenu()
// Free a standalone menu allocated with wmCreateMenu(). Also frees any
// heap-allocated submenu children recursively.
void wmFreeMenu(MenuT *menu);
#endif // DVX_WM_H

View file

@ -3,6 +3,22 @@
// All OS-specific and CPU-specific code is isolated behind this
// interface. To port DVX to a new platform, implement a new
// dvxPlatformXxx.c against this header.
//
// Currently two implementations exist:
// dvxPlatformDos.c — DJGPP/DPMI: real VESA VBE, INT 33h mouse,
// INT 16h keyboard, rep movsd/stosl asm spans
// dvxPlatformLinux.c — SDL2: software rendering to an SDL window,
// used for development and testing on Linux
//
// The abstraction covers five areas: video mode setup, framebuffer
// flushing, optimized memory spans, mouse input, and keyboard input.
// File system operations are minimal (just filename validation) because
// the C standard library handles most file I/O portably.
//
// Design rule: functions in this header must be stateless or manage their
// own internal state. They must not reference AppContextT or any layer
// above dvxTypes.h. This ensures the platform layer can be compiled and
// tested independently.
#ifndef DVX_PLATFORM_H
#define DVX_PLATFORM_H
@ -11,6 +27,10 @@
// ============================================================
// Keyboard event
// ============================================================
//
// Separating ASCII value from scancode handles the DOS keyboard model
// where extended keys (arrows, F-keys) produce a zero ASCII byte followed
// by a scancode. The platform layer normalizes this into a single struct.
typedef struct {
int32_t ascii; // ASCII value, 0 for extended/function keys
@ -21,40 +41,68 @@ typedef struct {
// System lifecycle
// ============================================================
// One-time platform initialisation (signal handling, etc.)
// One-time platform initialisation. On DOS this installs signal handlers
// for clean shutdown on Ctrl+C/Ctrl+Break. On Linux this initializes SDL.
void platformInit(void);
// Cooperative multitasking yield (give up CPU when idle)
// Cooperative yield — give up the CPU timeslice when the event loop has
// nothing to do. On DOS this calls __dpmi_yield() to be friendly to
// multitaskers (Windows 3.x, OS/2, DESQview). On Linux this calls
// SDL_Delay(1) to avoid busy-spinning at 100% CPU.
void platformYield(void);
// ============================================================
// Video
// ============================================================
// Initialise video mode, map framebuffer, allocate backbuffer.
// Fills in all DisplayT fields on success. Returns 0/-1.
// Probe for a suitable video mode, enable it, map the framebuffer, and
// allocate the system RAM backbuffer. On DOS this involves VBE BIOS calls
// and DPMI physical memory mapping. On Linux this creates an SDL window
// and software surface. Fills in all DisplayT fields on success.
int32_t platformVideoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
// Shut down video — restore text mode, unmap framebuffer, free backbuffer.
// Restore the previous video mode and free all video resources. On DOS
// this restores VGA text mode (mode 3) and frees the DPMI memory mapping.
void platformVideoShutdown(DisplayT *d);
// Enumerate available graphics modes. Calls cb(w, h, bpp, userData) for
// each LFB-capable graphics mode found.
// Enumerate LFB-capable graphics modes. The callback is invoked for each
// available mode. Used by videoInit() to find the best match for the
// requested resolution and depth. On Linux, this reports a fixed set of
// common resolutions since SDL doesn't enumerate modes the same way.
void platformVideoEnumModes(void (*cb)(int32_t w, int32_t h, int32_t bpp, void *userData), void *userData);
// Set palette entries (8-bit mode). pal is R,G,B triplets.
// Program the VGA/VESA DAC palette registers (8-bit mode only). pal
// points to RGB triplets (3 bytes per entry). On Linux this is a no-op
// since the SDL surface is always truecolor.
void platformVideoSetPalette(const uint8_t *pal, int32_t firstEntry, int32_t count);
// ============================================================
// Framebuffer flush
// ============================================================
// Copy a rectangle from d->backBuf to the display surface (d->lfb).
// Copy a rectangle from the system RAM backbuffer (d->backBuf) to the
// display surface (d->lfb). On DOS this copies to real video memory via
// the LFB mapping — the critical path where PCI bus write speed matters.
// On Linux this copies to the SDL surface, then SDL_UpdateRect is called.
// Each scanline is copied as a contiguous block; rep movsd on DOS gives
// near-optimal bus utilization for aligned 32-bit writes.
void platformFlushRect(const DisplayT *d, const RectT *r);
// ============================================================
// Optimised memory operations (span fill / copy)
// ============================================================
//
// These are the innermost loops of the renderer — called once per
// scanline of every rectangle fill, blit, and text draw. On DOS they
// use inline assembly: rep stosl for fills (one instruction fills an
// entire scanline) and rep movsd for copies. On Linux they use memset/
// memcpy which the compiler can auto-vectorize.
//
// Three variants per operation (8/16/32 bpp) because the fill semantics
// differ by depth: 8-bit fills a byte per pixel, 16-bit fills a word
// (must handle odd counts), and 32-bit fills a dword. The copy variants
// differ only in the byte count computation (count * bytesPerPixel).
// drawInit() selects the right function pointers into BlitOpsT at startup.
void platformSpanFill8(uint8_t *dst, uint32_t color, int32_t count);
void platformSpanFill16(uint8_t *dst, uint32_t color, int32_t count);
@ -67,36 +115,52 @@ void platformSpanCopy32(uint8_t *dst, const uint8_t *src, int32_t count);
// Input — Mouse
// ============================================================
// Initialise mouse driver, set movement range, centre cursor.
// Initialize the mouse driver and constrain movement to the screen bounds.
// On DOS this calls INT 33h functions to detect the mouse, set the X/Y
// range, and center the cursor. On Linux this initializes SDL mouse state.
void platformMouseInit(int32_t screenW, int32_t screenH);
// Read current mouse position and button state.
// Poll the current mouse state. Buttons is a bitmask: bit 0 = left,
// bit 1 = right, bit 2 = middle. Polling (rather than event-driven
// callbacks) is the natural model for a cooperative event loop — the
// main loop polls once per frame and compares with the previous state
// to detect press/release edges.
void platformMousePoll(int32_t *mx, int32_t *my, int32_t *buttons);
// ============================================================
// Input — Keyboard
// ============================================================
// Return current modifier flags (BIOS shift-state format:
// bits 0-1 = shift, bit 2 = ctrl, bit 3 = alt).
// Return the current modifier key state in BIOS shift-state format:
// bits 0-1 = either shift, bit 2 = ctrl, bit 3 = alt. On DOS this
// reads the BIOS data area at 0040:0017. On Linux this queries SDL
// modifier state and translates to the same bit format.
int32_t platformKeyboardGetModifiers(void);
// Read the next key from the keyboard buffer.
// Returns true if a key was available, false if the buffer was empty.
// Normalises extended-key markers (e.g. 0xE0 → 0).
// Non-blocking read of the next key from the keyboard buffer. Returns
// true if a key was available. On DOS this uses INT 16h AH=11h (check)
// + AH=10h (read). Extended keys (0xE0 prefix from enhanced keyboard)
// are normalized by zeroing the ASCII byte so the scancode identifies
// them unambiguously.
bool platformKeyboardRead(PlatformKeyEventT *evt);
// Map a scan code to its Alt+letter ASCII character.
// Returns the lowercase letter if the scan code corresponds to an
// Alt+letter/digit combo, or 0 if it does not.
// Translate an Alt+key scancode to its corresponding ASCII character.
// When Alt is held, DOS doesn't provide the ASCII value — only the
// scancode. This function contains a lookup table mapping scancodes
// to their unshifted letter/digit. Returns 0 for scancodes that don't
// map to a printable character (e.g. Alt+F1).
char platformAltScanToChar(int32_t scancode);
// ============================================================
// File system
// ============================================================
// Validate a filename for the current platform.
// Returns NULL if valid, or a human-readable error string if invalid.
// Validate a filename against platform-specific rules. On DOS this
// enforces 8.3 naming (no long filenames), checks for reserved device
// names (CON, PRN, etc.), and rejects characters illegal in FAT filenames.
// On Linux the rules are much more permissive (just no slashes or NUL).
// Returns NULL if the filename is valid, or a human-readable error string
// describing why it's invalid. Used by the file dialog's save-as validation.
const char *platformValidateFilename(const char *name);
#endif // DVX_PLATFORM_H

View file

@ -2,6 +2,29 @@
//
// All BIOS calls, DPMI functions, port I/O, inline assembly, and
// DOS-specific file handling are isolated in this single file.
//
// This file is the ONLY place where DJGPP headers (dpmi.h, go32.h,
// sys/nearptr.h, etc.) appear. Every other DVX module calls through
// the dvxPlatform.h interface, so porting to a new OS (Linux/SDL,
// Win32, or bare-metal ARM) requires replacing only this file and
// nothing else. The abstraction covers five domains:
// 1. VESA VBE video init / mode set / LFB mapping
// 2. Backbuffer-to-LFB flush using rep movsl
// 3. Span fill/copy primitives using inline asm (rep stosl / rep movsl)
// 4. Mouse input via INT 33h driver
// 5. Keyboard input via BIOS INT 16h
//
// Why BIOS INT 16h for keyboard instead of direct port I/O (scancode
// reading from port 0x60): BIOS handles typematic repeat, keyboard
// translation tables, and extended key decoding. Direct port I/O would
// require reimplementing all of that, and the DPMI host already hooks
// IRQ1 to feed the BIOS buffer. The BIOS approach is simpler and more
// portable across emulators (DOSBox, 86Box, PCem all handle it correctly).
//
// Why INT 33h for mouse: same rationale — the mouse driver handles
// PS/2 and serial mice transparently, and every DOS emulator provides
// a compatible driver. Polling via function 03h avoids the complexity
// of installing a real-mode callback for mouse events.
#include "dvxPlatform.h"
#include "../dvxPalette.h"
@ -12,6 +35,7 @@
#include <ctype.h>
#include <signal.h>
// DJGPP-specific headers — this is the ONLY file that includes these
#include <dpmi.h>
#include <go32.h>
#include <pc.h>
@ -30,6 +54,10 @@ static int32_t setVesaMode(uint16_t mode);
// Alt+key scan code to ASCII lookup table (indexed by BIOS scan code).
// INT 16h returns these scan codes with ascii=0 for Alt+key combos.
// Using a 256-byte lookup table instead of a switch or if-chain because
// this is called on every keypress and the table fits in a single cache
// line cluster. The designated initializer syntax leaves all other
// entries as zero, which is the "no mapping" sentinel.
static const char sAltScanToAscii[256] = {
// Alt+letters
[0x10] = 'q', [0x11] = 'w', [0x12] = 'e', [0x13] = 'r',
@ -49,6 +77,25 @@ static const char sAltScanToAscii[256] = {
// ============================================================
// findBestMode
// ============================================================
//
// Enumerates all VESA VBE modes and selects the best match for the
// requested resolution and color depth using a scoring algorithm.
//
// The approach is: call VBE function 0x4F00 to get the controller
// info block (which contains a pointer to the mode list), then call
// VBE function 0x4F01 for each mode to get its attributes. Each mode
// is scored by getModeInfo() and the highest-scoring mode wins.
//
// Why scoring instead of exact-match: real VESA BIOSes vary wildly in
// what modes they expose. Some have 640x480x16 but not x32; some only
// have 800x600. The scoring heuristic picks the closest usable mode
// rather than failing outright if the exact requested mode is absent.
//
// All VBE info block reads use DJGPP's far pointer API (_farpeekb/w/l)
// to access the DPMI transfer buffer (__tb), which lives in the first
// 1MB of address space (conventional memory). VBE BIOS calls use
// real-mode interrupts via __dpmi_int(), so all data must pass through
// the transfer buffer.
static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t preferredBpp, uint16_t *outMode, DisplayT *d) {
__dpmi_regs r;
@ -58,11 +105,14 @@ static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t pref
memset(&bestDisplay, 0, sizeof(bestDisplay));
// Get VBE controller info
// Get VBE controller info — the transfer buffer (__tb) is the DJGPP-
// provided region in conventional memory that real-mode BIOS calls
// can read/write. We split it into seg:off for the INT 10h call.
uint32_t infoSeg = __tb >> 4;
uint32_t infoOff = __tb & 0x0F;
// Write "VBE2" at transfer buffer to request VBE 2.0 info
// Writing "VBE2" tells the BIOS we want VBE 2.0+ extended info.
// Without this, we'd get VBE 1.x info which lacks LFB addresses.
_farpokeb(_dos_ds, __tb + 0, 'V');
_farpokeb(_dos_ds, __tb + 1, 'B');
_farpokeb(_dos_ds, __tb + 2, 'E');
@ -74,12 +124,14 @@ static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t pref
r.x.di = infoOff;
__dpmi_int(0x10, &r);
// VBE functions return 0x004F in AX on success. Any other value
// means the function failed or isn't supported.
if (r.x.ax != 0x004F) {
fprintf(stderr, "VBE: Function 0x4F00 failed (AX=0x%04X)\n", r.x.ax);
return -1;
}
// Verify VBE signature
// On success the BIOS overwrites "VBE2" with "VESA" in the buffer
char sig[5];
for (int32_t i = 0; i < 4; i++) {
sig[i] = _farpeekb(_dos_ds, __tb + i);
@ -91,7 +143,9 @@ static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t pref
return -1;
}
// Check VBE version (need 2.0+)
// VBE 2.0+ is required for LFB (Linear Frame Buffer) support.
// VBE 1.x only supports bank switching, which we explicitly don't
// implement — the complexity isn't worth it for 486+ targets.
uint16_t vbeVersion = _farpeekw(_dos_ds, __tb + 4);
if (vbeVersion < 0x0200) {
fprintf(stderr, "VBE: Version %d.%d too old (need 2.0+)\n",
@ -99,12 +153,15 @@ static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t pref
return -1;
}
// Get mode list pointer (far pointer at offset 14)
// The mode list is a far pointer (seg:off) at offset 14 in the info
// block. It points to a null-terminated (0xFFFF) array of mode numbers
// in conventional memory.
uint16_t modeListOff = _farpeekw(_dos_ds, __tb + 14);
uint16_t modeListSeg = _farpeekw(_dos_ds, __tb + 16);
uint32_t modeListAddr = ((uint32_t)modeListSeg << 4) + modeListOff;
// Walk mode list
// Walk the mode list. Cap at 256 to prevent runaway on corrupt BIOS
// data (real hardware rarely has more than ~50 modes).
for (int32_t i = 0; i < 256; i++) {
uint16_t mode = _farpeekw(_dos_ds, modeListAddr + i * 2);
@ -201,6 +258,27 @@ void platformVideoEnumModes(void (*cb)(int32_t w, int32_t h, int32_t bpp, void *
// ============================================================
// getModeInfo
// ============================================================
//
// Queries VBE mode info (function 0x4F01) for a single mode and
// scores it against the requested parameters. The scoring algorithm:
//
// Base score by bpp: 16-bit=100, 15-bit=90, 32-bit=85, 8-bit=70
// +20 if bpp matches preferredBpp
// +10 if exact resolution match, -10 if oversize
// -1 (rejected) if mode lacks LFB, is text-mode, is below requested
// resolution, or uses an unsupported bpp (e.g. 24-bit)
//
// 16-bit is preferred over 32-bit because it's twice as fast for
// span fill/copy on a 486/Pentium bus (half the bytes). 15-bit scores
// slightly below 16-bit because some VESA BIOSes report 15bpp modes
// as 16bpp with a dead high bit, causing confusion. 8-bit scores
// lowest because palette management adds complexity.
//
// 24-bit is explicitly rejected (not 8/15/16/32) because its 3-byte
// pixels can't use dword-aligned rep stosl fills without masking.
//
// The physical LFB address is temporarily stored in d->lfb as a raw
// integer cast — it will be properly mapped via DPMI in mapLfb() later.
static void getModeInfo(uint16_t mode, DisplayT *d, int32_t *score, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
__dpmi_regs r;
@ -218,15 +296,15 @@ static void getModeInfo(uint16_t mode, DisplayT *d, int32_t *score, int32_t requ
return;
}
// Read mode attributes
// VBE mode attribute word at offset 0:
// bit 7 = LFB available, bit 4 = graphics mode (not text)
// Both are required — we never bank-switch and never want text modes.
uint16_t attr = _farpeekw(_dos_ds, __tb + 0);
// Must have LFB support (bit 7)
if (!(attr & 0x0080)) {
return;
}
// Must be a graphics mode (bit 4)
if (!(attr & 0x0010)) {
return;
}
@ -282,7 +360,10 @@ static void getModeInfo(uint16_t mode, DisplayT *d, int32_t *score, int32_t requ
d->format.bitsPerPixel = bpp;
d->format.bytesPerPixel = (bpp + 7) / 8;
// Read color masks from mode info
// Read the channel mask layout from the VBE mode info block.
// These offsets (31-36) define the bit position and size of each
// color channel. This is essential because the channel layout
// varies: some cards use RGB565, others BGR565, etc.
if (bpp >= 15) {
int32_t redSize = _farpeekb(_dos_ds, __tb + 31);
int32_t redPos = _farpeekb(_dos_ds, __tb + 32);
@ -310,6 +391,33 @@ static void getModeInfo(uint16_t mode, DisplayT *d, int32_t *score, int32_t requ
// ============================================================
// mapLfb
// ============================================================
//
// Maps the video card's physical LFB address into the DPMI linear
// address space, then converts it to a near pointer for direct C
// access.
//
// The mapping process has three steps:
// 1. __dpmi_physical_address_mapping() — asks the DPMI host to
// create a linear address mapping for the physical framebuffer.
// This is necessary because DPMI runs in protected mode with
// paging; physical addresses aren't directly accessible.
// 2. __dpmi_lock_linear_region() — pins the mapped pages so they
// can't be swapped out. The LFB is memory-mapped I/O to the
// video card; paging it would be catastrophic.
// 3. __djgpp_nearptr_enable() — disables DJGPP's default segment
// limit checking so we can use plain C pointers to access the
// LFB address. Without this, all LFB access would require far
// pointer calls (_farpokeb etc.), which are much slower because
// each one involves a segment register load.
//
// Why near pointers: the performance difference is dramatic.
// platformFlushRect() copies thousands of dwords per frame using
// rep movsl — this only works with near pointers. Far pointer access
// would add ~10 cycles per byte and make 60fps impossible on a 486.
//
// The final pointer calculation adds __djgpp_conventional_base, which
// is the offset DJGPP applies to convert linear addresses to near
// pointer addresses (compensating for the DS segment base).
static int32_t mapLfb(DisplayT *d, uint32_t physAddr) {
__dpmi_meminfo info;
@ -323,18 +431,17 @@ static int32_t mapLfb(DisplayT *d, uint32_t physAddr) {
return -1;
}
// Lock the region to prevent paging
__dpmi_meminfo lockInfo;
lockInfo.address = info.address;
lockInfo.size = fbSize;
__dpmi_lock_linear_region(&lockInfo);
// Enable near pointers for direct access
if (__djgpp_nearptr_enable() == 0) {
fprintf(stderr, "VBE: Failed to enable near pointers\n");
return -1;
}
// Convert linear address to near pointer by adding the DS base offset
d->lfb = (uint8_t *)(info.address + __djgpp_conventional_base);
return 0;
@ -357,6 +464,26 @@ char platformAltScanToChar(int32_t scancode) {
// ============================================================
// platformFlushRect
// ============================================================
//
// Copies a dirty rectangle from the system RAM backbuffer to the LFB.
// This is the critical path for display updates — the compositor calls
// it once per dirty rect per frame.
//
// Two code paths:
// 1. Full-width: if the rect spans the entire scanline (rowBytes ==
// pitch), collapse all rows into a single large rep movsl. This
// avoids per-row loop overhead and is the common case for full-
// screen redraws.
// 2. Partial-width: copy each scanline individually with rep movsl,
// advancing src/dst by pitch (not rowBytes) between rows.
//
// rep movsl is used instead of memcpy() because on 486/Pentium, GCC's
// memcpy may not generate the optimal dword-aligned string move, and
// the DJGPP C library's memcpy isn't always tuned for large copies.
// The explicit asm guarantees exactly the instruction sequence we want.
//
// __builtin_expect hints tell GCC to generate branch-free fast paths
// for the common cases (non-zero w/h, no trailing bytes).
void platformFlushRect(const DisplayT *d, const RectT *r) {
int32_t bpp = d->format.bytesPerPixel;
@ -421,7 +548,9 @@ void platformFlushRect(const DisplayT *d, const RectT *r) {
// ============================================================
void platformInit(void) {
// Disable Ctrl+C/Break termination
// Disable Ctrl+C/Break so the user can't accidentally kill the
// GUI while in graphics mode (which would leave the display in
// an unusable state without restoring text mode first).
signal(SIGINT, SIG_IGN);
}
@ -429,6 +558,13 @@ void platformInit(void) {
// ============================================================
// platformKeyboardGetModifiers
// ============================================================
//
// Returns the current modifier key state via INT 16h function 12h
// (enhanced get extended shift flags). The low byte contains:
// bit 0 = right shift, bit 1 = left shift
// bit 2 = ctrl, bit 3 = alt
// The widget system uses these bits for keyboard accelerators
// (Alt+key) and text editing shortcuts (Ctrl+C/V/X).
int32_t platformKeyboardGetModifiers(void) {
__dpmi_regs r;
@ -444,29 +580,43 @@ int32_t platformKeyboardGetModifiers(void) {
// ============================================================
// platformKeyboardRead
// ============================================================
//
// Non-blocking keyboard read using enhanced INT 16h functions.
//
// Uses the "enhanced" functions (10h/11h) rather than the original
// (00h/01h) because the originals can't distinguish between grey
// and numpad arrow keys, and they don't report F11/F12. The enhanced
// functions have been standard since AT-class machines (1984).
//
// The two-step peek-then-read is necessary because function 10h
// (read key) blocks until a key is available — there's no non-blocking
// read in the BIOS API. Function 11h (check key) peeks without
// consuming, letting us poll without blocking the event loop.
bool platformKeyboardRead(PlatformKeyEventT *evt) {
__dpmi_regs r;
// Check if key is available (INT 16h, enhanced function 11h)
// Peek: function 11h sets ZF if buffer is empty
r.x.ax = 0x1100;
__dpmi_int(0x16, &r);
// Zero flag set = no key available
// Test the Zero Flag (bit 6 of the flags register)
if (r.x.flags & 0x40) {
return false;
}
// Read the key (INT 16h, enhanced function 10h)
// Consume: function 10h removes the key from the BIOS buffer.
// AH = scan code, AL = ASCII character (0 for extended keys).
r.x.ax = 0x1000;
__dpmi_int(0x16, &r);
evt->scancode = (r.x.ax >> 8) & 0xFF;
evt->ascii = r.x.ax & 0xFF;
// Enhanced INT 16h returns ascii=0xE0 for grey/extended keys
// (arrows, Home, End, Insert, Delete, etc. on 101-key keyboards).
// Normalize to 0 so all extended key checks work uniformly.
// Enhanced INT 16h uses 0xE0 as the ASCII byte for grey/extended
// keys (arrows, Home, End, Insert, Delete on 101-key keyboards).
// Normalize to 0 so the rest of the codebase can use a single
// "ascii == 0 means extended key, check scancode" convention.
if (evt->ascii == 0xE0) {
evt->ascii = 0;
}
@ -478,30 +628,42 @@ bool platformKeyboardRead(PlatformKeyEventT *evt) {
// ============================================================
// platformMouseInit
// ============================================================
//
// Initializes the INT 33h mouse driver. The mouse driver is a TSR
// (or emulated by the DOS environment) that tracks position and
// buttons independently of the application.
//
// We must set the movement range to match our VESA resolution,
// because the default range may be 640x200 (CGA text mode).
// Without this, mouse coordinates would be wrong or clipped.
//
// The hardware cursor is never shown — DVX composites its own
// software cursor on top of the backbuffer. We only use INT 33h
// for position/button state via polling (function 03h).
void platformMouseInit(int32_t screenW, int32_t screenH) {
__dpmi_regs r;
// Reset mouse driver
// Function 00h: reset driver, detect mouse hardware
memset(&r, 0, sizeof(r));
r.x.ax = 0x0000;
__dpmi_int(0x33, &r);
// Set horizontal range
// Function 07h: set horizontal min/max range
memset(&r, 0, sizeof(r));
r.x.ax = 0x0007;
r.x.cx = 0;
r.x.dx = screenW - 1;
__dpmi_int(0x33, &r);
// Set vertical range
// Function 08h: set vertical min/max range
memset(&r, 0, sizeof(r));
r.x.ax = 0x0008;
r.x.cx = 0;
r.x.dx = screenH - 1;
__dpmi_int(0x33, &r);
// Position cursor at center
// Function 04h: warp cursor to center of screen
memset(&r, 0, sizeof(r));
r.x.ax = 0x0004;
r.x.cx = screenW / 2;
@ -513,6 +675,16 @@ void platformMouseInit(int32_t screenW, int32_t screenH) {
// ============================================================
// platformMousePoll
// ============================================================
//
// Reads current mouse state via INT 33h function 03h.
// Returns: CX=X position, DX=Y position, BX=button state
// (bit 0 = left, bit 1 = right, bit 2 = middle).
//
// Polling is used instead of a callback/event model because the
// DVX event loop already runs at frame rate. Installing a real-mode
// callback for mouse events would add DPMI mode-switch overhead
// on every mickeyed movement, which is wasteful when we only sample
// once per frame anyway.
void platformMousePoll(int32_t *mx, int32_t *my, int32_t *buttons) {
__dpmi_regs r;
@ -530,9 +702,23 @@ void platformMousePoll(int32_t *mx, int32_t *my, int32_t *buttons) {
// ============================================================
// platformSpanCopy8
// ============================================================
//
// Copies 'count' 8-bit pixels from src to dst using dword-aligned
// rep movsl for the bulk transfer.
//
// All span operations (Copy8/16/32, Fill8/16/32) follow the same
// pattern: align to a dword boundary, do the bulk as rep movsl or
// rep stosl, then handle the remainder. This pattern exists because
// on 486/Pentium, misaligned dword moves incur a 3-cycle penalty per
// access. Aligning first ensures the critical rep loop runs at full
// bus speed.
//
// rep movsl moves 4 bytes per iteration with hardware loop decrement,
// which is faster than a C for-loop — the CPU string move pipeline
// optimizes sequential memory access patterns.
void platformSpanCopy8(uint8_t *dst, const uint8_t *src, int32_t count) {
// Align to 4 bytes
// Align dst to a dword boundary with byte copies
while (((uintptr_t)dst & 3) && count > 0) {
*dst++ = *src++;
count--;
@ -560,9 +746,15 @@ void platformSpanCopy8(uint8_t *dst, const uint8_t *src, int32_t count) {
// ============================================================
// platformSpanCopy16
// ============================================================
//
// Copies 'count' 16-bit pixels. Since each pixel is 2 bytes, we
// only need to check bit 1 of the address for dword alignment
// (bit 0 is always clear for 16-bit aligned data). A single
// leading pixel copy brings us to a dword boundary, then rep movsl
// copies pixel pairs as dwords.
void platformSpanCopy16(uint8_t *dst, const uint8_t *src, int32_t count) {
// Handle odd leading pixel for dword alignment
// Copy one pixel to reach dword alignment if needed
if (((uintptr_t)dst & 2) && count > 0) {
*(uint16_t *)dst = *(const uint16_t *)src;
dst += 2;
@ -591,6 +783,9 @@ void platformSpanCopy16(uint8_t *dst, const uint8_t *src, int32_t count) {
// ============================================================
// platformSpanCopy32
// ============================================================
//
// 32-bit pixels are inherently dword-aligned, so no alignment
// preamble is needed — straight to rep movsl.
void platformSpanCopy32(uint8_t *dst, const uint8_t *src, int32_t count) {
__asm__ __volatile__ (
@ -605,6 +800,12 @@ void platformSpanCopy32(uint8_t *dst, const uint8_t *src, int32_t count) {
// ============================================================
// platformSpanFill8
// ============================================================
//
// Fills 'count' 8-bit pixels with a single color value.
// The 8-bit value is replicated into all four bytes of a dword so
// that rep stosl writes 4 identical pixels per iteration. This is
// 4x faster than byte-at-a-time for large fills (window backgrounds,
// screen clears).
void platformSpanFill8(uint8_t *dst, uint32_t color, int32_t count) {
uint8_t c = (uint8_t)color;
@ -639,6 +840,10 @@ void platformSpanFill8(uint8_t *dst, uint32_t color, int32_t count) {
// ============================================================
// platformSpanFill16
// ============================================================
//
// Fills 'count' 16-bit pixels. Two pixels are packed into a dword
// (low half = first pixel, high half = second pixel) so rep stosl
// writes 2 pixels per iteration.
void platformSpanFill16(uint8_t *dst, uint32_t color, int32_t count) {
uint16_t c = (uint16_t)color;
@ -673,6 +878,10 @@ void platformSpanFill16(uint8_t *dst, uint32_t color, int32_t count) {
// ============================================================
// platformSpanFill32
// ============================================================
//
// 32-bit fill is the simplest case — each pixel is already a dword,
// so rep stosl writes exactly one pixel per iteration with no
// alignment or packing concerns.
void platformSpanFill32(uint8_t *dst, uint32_t color, int32_t count) {
__asm__ __volatile__ (
@ -687,6 +896,19 @@ void platformSpanFill32(uint8_t *dst, uint32_t color, int32_t count) {
// ============================================================
// platformValidateFilename — DOS 8.3 filename validation
// ============================================================
//
// Validates that a filename conforms to DOS 8.3 conventions:
// - Base name: 1-8 chars, extension: 0-3 chars, one dot max
// - No spaces or special characters that DOS can't handle
// - Not a reserved device name (CON, PRN, AUX, NUL, COMn, LPTn)
//
// The reserved name check compares the base name only (before the
// dot), case-insensitive, because DOS treats "CON.TXT" the same
// as the CON device — the extension is ignored for device names.
//
// Returns NULL on success, or a human-readable error string on failure.
// On non-DOS platforms, this function would be replaced with one that
// validates for that platform's filesystem rules.
const char *platformValidateFilename(const char *name) {
static const char *reserved[] = {
@ -764,6 +986,20 @@ const char *platformValidateFilename(const char *name) {
// ============================================================
// platformVideoInit
// ============================================================
//
// Complete video initialization sequence:
// 1. findBestMode() — enumerate VESA modes and pick the best match
// 2. setVesaMode() — actually switch to the chosen mode with LFB
// 3. mapLfb() — DPMI-map the physical framebuffer into linear memory
// 4. Allocate system RAM backbuffer (same size as LFB)
// 5. Set up 8-bit palette if needed
// 6. Initialize clip rect to full display
//
// The backbuffer is allocated in system RAM rather than drawing
// directly to the LFB because: (a) reads from the LFB are extremely
// slow on ISA/VLB/PCI (uncached MMIO), so any compositing that reads
// pixels would crawl; (b) the dirty rect system only flushes changed
// regions, so most of the LFB is never touched per frame.
int32_t platformVideoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
uint16_t bestMode;
@ -833,9 +1069,20 @@ int32_t platformVideoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, i
// ============================================================
// platformVideoSetPalette
// ============================================================
//
// Programs the VGA DAC palette registers via direct port I/O.
// Port 0x3C8 = write index, port 0x3C9 = data (auto-increments).
//
// The VGA DAC expects 6-bit values (0-63) but our palette stores
// 8-bit values (0-255), hence the >> 2 shift. This is standard
// VGA behavior dating back to the original IBM VGA in 1987.
//
// Direct port I/O is used instead of VBE function 09h (set palette)
// because the VGA DAC ports are faster (no BIOS call overhead) and
// universally compatible — even VBE 3.0 cards still have the standard
// VGA DAC at ports 0x3C8/0x3C9.
void platformVideoSetPalette(const uint8_t *pal, int32_t firstEntry, int32_t count) {
// Set VGA DAC registers directly via port I/O
outportb(0x3C8, (uint8_t)firstEntry);
for (int32_t i = 0; i < count; i++) {
@ -850,9 +1097,14 @@ void platformVideoSetPalette(const uint8_t *pal, int32_t firstEntry, int32_t cou
// ============================================================
// platformVideoShutdown
// ============================================================
//
// Tears down the graphics mode and restores standard 80x25 text
// mode (BIOS mode 3) so the user gets their DOS prompt back.
// Also frees the backbuffer, palette, and disables near pointers
// (re-enables DJGPP's segment limit checking for safety).
void platformVideoShutdown(DisplayT *d) {
// Restore text mode (mode 3)
// INT 10h function 00h, mode 03h = 80x25 color text
__dpmi_regs r;
memset(&r, 0, sizeof(r));
r.x.ax = 0x0003;
@ -877,6 +1129,12 @@ void platformVideoShutdown(DisplayT *d) {
// ============================================================
// platformYield
// ============================================================
//
// Cooperative yield to the DPMI host. In a multitasking DOS
// environment (Windows 3.x DOS box, OS/2 VDM, or DESQview),
// this gives other tasks a chance to run. Under a single-tasking
// DPMI server (CWSDPMI) it's essentially a no-op, but it doesn't
// hurt. Called once per event loop iteration when idle.
void platformYield(void) {
__dpmi_yield();
@ -886,6 +1144,10 @@ void platformYield(void) {
// ============================================================
// setVesaMode
// ============================================================
//
// Sets a VBE video mode via function 0x4F02. Bit 14 of the mode
// number tells the BIOS to enable the Linear Frame Buffer instead
// of the default banked mode. This is the only mode we support.
static int32_t setVesaMode(uint16_t mode) {
__dpmi_regs r;

View file

@ -1,4 +1,39 @@
// widgetAnsiTerm.c — ANSI BBS terminal emulator widget
//
// Implements a VT100/ANSI-compatible terminal emulator widget designed for
// connecting to BBS systems over the serial link. The terminal uses a
// traditional text-mode cell buffer (character + attribute byte pairs) that
// mirrors the layout of CGA/VGA text mode memory. This representation was
// chosen because:
// 1. It maps directly to the BBS/ANSI art paradigm (CP437 character set,
// 16-color CGA palette, blink attribute)
// 2. Cell-based storage is extremely compact — 2 bytes per cell means an
// 80x25 screen is only 4000 bytes, fitting in L1 cache on a 486
// 3. Dirty-row tracking via a 32-bit bitmask allows sub-millisecond
// incremental repaints without scanning the entire buffer
//
// The ANSI parser is a 3-state machine (NORMAL → ESC → CSI) that handles
// the subset of sequences commonly used by DOS BBS software: cursor movement,
// screen/line erase, scrolling regions, SGR colors, and a few DEC private modes.
// Full VT100 conformance is explicitly NOT a goal — only sequences actually
// emitted by real BBS systems are implemented.
//
// Scrollback is implemented as a circular buffer of row snapshots. Only
// full-screen scroll operations push lines into scrollback; scroll-region
// operations (used by split-screen chat, status bars) do not, matching
// the behavior users expect from DOS terminal programs.
//
// The widget supports two paint paths:
// - Full paint (widgetAnsiTermPaint): used during normal widget repaints
// - Fast repaint (wgtAnsiTermRepaint): bypasses the widget pipeline
// entirely, rendering dirty rows directly into the window's content
// buffer. This is critical for serial communication where ACK turnaround
// time matters — the fewer milliseconds between receiving data and
// displaying it, the higher the effective throughput.
//
// Communication is abstracted through read/write function pointers, allowing
// the terminal to work with raw serial ports, the secLink encrypted channel,
// or any other byte-oriented transport.
#include "widgetInternal.h"
@ -12,6 +47,9 @@
#define ANSI_SB_W 14
#define ANSI_DEFAULT_SCROLLBACK 500
// Three-state ANSI parser: NORMAL processes printable chars and C0 controls,
// ESC waits for the CSI introducer '[' (or standalone ESC sequences like ESC D/M/c),
// CSI accumulates numeric parameters until a final byte (0x40-0x7E) dispatches.
#define PARSE_NORMAL 0
#define PARSE_ESC 1
#define PARSE_CSI 2
@ -19,7 +57,9 @@
// Default attribute: light gray on black
#define ANSI_DEFAULT_ATTR 0x07
// Attribute byte: bit 7 = blink, bits 6-4 = bg (0-7), bits 3-0 = fg (0-15)
// Attribute byte layout matches CGA text mode exactly so that ANSI art
// designed for DOS renders correctly without any translation.
// Bit 7 = blink, bits 6-4 = bg (0-7), bits 3-0 = fg (0-15)
#define ATTR_BLINK_BIT 0x80
#define ATTR_BG_MASK 0x70
#define ATTR_FG_MASK 0x0F
@ -51,7 +91,13 @@ static const uint8_t sCgaPalette[16][3] = {
{255, 255, 255}, // 15: bright white
};
// ANSI SGR color index to CGA color index
// ANSI SGR color index to CGA color index.
// ANSI and CGA use different orderings for the base 8 colors. ANSI follows
// the RGB bit-field convention (0=black, 1=red, 2=green, 3=yellow, 4=blue,
// 5=magenta, 6=cyan, 7=white) while CGA uses (0=black, 1=blue, 2=green,
// 3=cyan, 4=red, 5=magenta, 6=brown, 7=light gray). This table maps from
// ANSI SGR color numbers (30-37 minus 30) to CGA palette indices. The
// high bit (bright) is handled separately via the bold attribute.
static const int32_t sAnsiToCga[8] = { 0, 4, 2, 6, 1, 5, 3, 7 };
@ -89,6 +135,10 @@ static void ansiTermSelectionRange(const WidgetT *w, int32_t *startLine, int32_t
// ============================================================
//
// Copy a screen row into the scrollback circular buffer.
// The circular buffer avoids any need for memmove when the buffer fills —
// the head index simply wraps around, overwriting the oldest entry. This
// is O(1) per row regardless of scrollback size, which matters when BBS
// software rapidly dumps text (e.g. file listings, ANSI art).
static void ansiTermAddToScrollback(WidgetT *w, int32_t screenRow) {
if (!w->as.ansiTerm->scrollback || w->as.ansiTerm->scrollbackMax <= 0) {
@ -118,6 +168,11 @@ static void ansiTermAddToScrollback(WidgetT *w, int32_t screenRow) {
// Build the packed 16-color palette and cache it in the widget.
// Only recomputed when paletteValid is false (first use or
// after a display format change).
//
// Caching avoids calling packColor() 16 times per repaint. Since packColor
// involves shifting and masking per the display's pixel format, and the
// terminal repaints rows frequently during data reception, this saves
// significant overhead on a 486 where each function call costs ~30 cycles.
static void ansiTermBuildPalette(WidgetT *w, const DisplayT *d) {
if (w->as.ansiTerm->paletteValid) {
@ -137,6 +192,8 @@ static void ansiTermBuildPalette(WidgetT *w, const DisplayT *d) {
// ============================================================
static void ansiTermClearSelection(WidgetT *w) {
// Dirty all rows when clearing a selection so the selection highlight
// is erased on the next repaint
if (ansiTermHasSelection(w)) {
w->as.ansiTerm->dirtyRows = 0xFFFFFFFF;
}
@ -166,7 +223,11 @@ static void ansiTermCopySelection(WidgetT *w) {
int32_t cols = w->as.ansiTerm->cols;
// Build text from selected cells (strip trailing spaces per line)
// Build text from selected cells (strip trailing spaces per line).
// Trailing spaces are stripped because BBS text mode fills the entire
// row with spaces — without stripping, pasting would include many
// unwanted trailing blanks. Fixed 4KB buffer is sufficient for typical
// terminal selections (80 cols * 50 rows = 4000 chars max).
char buf[4096];
int32_t pos = 0;
@ -208,6 +269,10 @@ static void ansiTermCopySelection(WidgetT *w) {
// ============================================================
//
// Mark rows dirty that are touched by a cell range [startCell, startCell+count).
// Uses a 32-bit bitmask — one bit per row — which limits tracking to the first
// 32 rows. This is fine because standard terminal sizes are 24-25 rows, and
// bitmask operations are single-cycle on the target CPU. The bitmask approach
// is much cheaper than maintaining a dirty rect list for per-row tracking.
static void ansiTermDirtyRange(WidgetT *w, int32_t startCell, int32_t count) {
int32_t cols = w->as.ansiTerm->cols;
@ -235,6 +300,10 @@ static void ansiTermDirtyRow(WidgetT *w, int32_t row) {
// ansiTermDeleteLines
// ============================================================
// Delete lines within the scroll region by shifting rows up with memmove.
// The vacated lines at the bottom are filled with the current attribute.
// This is the CSI 'M' (DL) handler. Used by BBS software for scroll-region
// tricks (e.g., split-screen chat windows).
static void ansiTermDeleteLines(WidgetT *w, int32_t count) {
int32_t cols = w->as.ansiTerm->cols;
int32_t bot = w->as.ansiTerm->scrollBot;
@ -274,11 +343,19 @@ static void ansiTermDeleteLines(WidgetT *w, int32_t count) {
// ansiTermDispatchCsi
// ============================================================
// Central CSI dispatcher. After the parser accumulates parameters in the CSI
// state, the final byte triggers dispatch here. Only sequences commonly used
// by BBS software are implemented — exotic VT220+ sequences are silently ignored.
// DEC private modes (ESC[?...) are handled separately since they use a different
// parameter namespace than standard ECMA-48 sequences.
static void ansiTermDispatchCsi(WidgetT *w, uint8_t cmd) {
int32_t *p = w->as.ansiTerm->params;
int32_t n = w->as.ansiTerm->paramCount;
// DEC private modes (ESC[?...)
// Mode 6: origin mode (cursor addressing relative to scroll region)
// Mode 7: auto-wrap at right margin
// Mode 25: cursor visibility (DECTCEM)
if (w->as.ansiTerm->csiPrivate) {
int32_t mode = (n >= 1) ? p[0] : 0;
@ -395,7 +472,10 @@ static void ansiTermDispatchCsi(WidgetT *w, uint8_t cmd) {
case 'c': // DA - device attributes
{
if (w->as.ansiTerm->commWrite) {
// Respond as VT100 with advanced video option
// Respond as VT100 with advanced video option (AVO).
// Many BBS door games query DA to detect terminal capabilities.
// Claiming VT100+AVO is the safest response — it tells the remote
// side we support ANSI color without implying VT220+ features.
const uint8_t reply[] = "\033[?1;2c";
w->as.ansiTerm->commWrite(w->as.ansiTerm->commCtx, reply, 7);
}
@ -529,6 +609,9 @@ static void ansiTermDispatchCsi(WidgetT *w, uint8_t cmd) {
case 'Z': // CBT - back tab
{
// Move cursor back to the previous tab stop (every 8 columns).
// Uses integer math: ((col-1)/8)*8 rounds down to the nearest
// multiple of 8 strictly before the current position.
int32_t col = w->as.ansiTerm->cursorCol;
if (col > 0) {
@ -549,12 +632,16 @@ static void ansiTermDispatchCsi(WidgetT *w, uint8_t cmd) {
if (w->as.ansiTerm->commWrite) {
if (mode == 6) {
// CPR — cursor position report: ESC[row;colR (1-based)
// CPR — cursor position report: ESC[row;colR (1-based).
// BBS software uses this for screen-size detection and
// to synchronize cursor positioning in door games.
char reply[16];
int32_t len = snprintf(reply, sizeof(reply), "\033[%ld;%ldR", (long)(w->as.ansiTerm->cursorRow + 1), (long)(w->as.ansiTerm->cursorCol + 1));
w->as.ansiTerm->commWrite(w->as.ansiTerm->commCtx, (const uint8_t *)reply, len);
} else if (mode == 255) {
// Screen size report
// Custom extension: screen size report (non-standard).
// Allows the remote side to query our terminal dimensions
// without relying on NAWS or other Telnet extensions.
char reply[16];
int32_t len = snprintf(reply, sizeof(reply), "\033[%ld;%ldR", (long)w->as.ansiTerm->rows, (long)w->as.ansiTerm->cols);
w->as.ansiTerm->commWrite(w->as.ansiTerm->commCtx, (const uint8_t *)reply, len);
@ -566,6 +653,10 @@ static void ansiTermDispatchCsi(WidgetT *w, uint8_t cmd) {
case 'r': // DECSTBM - set scrolling region
{
// Defines the top and bottom rows of the scroll region. Lines
// outside this region are not affected by scroll operations. This
// is heavily used by BBS split-screen chat (status line at top,
// chat scrolling below) and door game scoreboards.
int32_t rows = w->as.ansiTerm->rows;
int32_t top = (n >= 1 && p[0]) ? p[0] - 1 : 0;
int32_t bot = (n >= 2 && p[1]) ? p[1] - 1 : rows - 1;
@ -613,6 +704,13 @@ static void ansiTermDispatchCsi(WidgetT *w, uint8_t cmd) {
// ansiTermEraseDisplay
// ============================================================
// Erase Display (ED): mode 0 = cursor to end, 1 = start to cursor, 2 = all.
// Mode 2 pushes all visible lines to scrollback before clearing, preserving
// content that was on screen — this is what users expect when a BBS sends
// a clear-screen sequence (they can scroll back to see previous content).
// The wasAtBottom check ensures auto-scroll tracking: if the user was
// already viewing the latest content, they stay at the bottom after new
// scrollback lines are added.
static void ansiTermEraseDisplay(WidgetT *w, int32_t mode) {
int32_t cols = w->as.ansiTerm->cols;
int32_t rows = w->as.ansiTerm->rows;
@ -669,6 +767,10 @@ static void ansiTermEraseLine(WidgetT *w, int32_t mode) {
// ============================================================
//
// Fill a range of cells with space + current attribute.
// Uses the current attribute (not default) so that erasing respects the
// currently active background color — this is correct per ECMA-48 and
// matches what BBS software expects (e.g., colored backgrounds that
// persist after a line erase).
static void ansiTermFillCells(WidgetT *w, int32_t start, int32_t count) {
uint8_t *cells = w->as.ansiTerm->cells;
@ -701,6 +803,11 @@ static void ansiTermFillCells(WidgetT *w, int32_t start, int32_t count) {
// combined scrollback+screen view.
// lineIndex < scrollbackCount → scrollback line
// lineIndex >= scrollbackCount → screen line
//
// The unified line index simplifies paint and selection code — they don't
// need to know whether a given line is in scrollback or on screen. The
// circular buffer index computation uses modular arithmetic to map from
// logical scrollback line number to physical buffer position.
static const uint8_t *ansiTermGetLine(WidgetT *w, int32_t lineIndex) {
int32_t cols = w->as.ansiTerm->cols;
@ -742,6 +849,9 @@ static bool ansiTermHasSelection(const WidgetT *w) {
// ansiTermInsertLines
// ============================================================
// Insert blank lines at the cursor position within the scroll region by
// shifting rows down with memmove. This is the CSI 'L' (IL) handler,
// the inverse of ansiTermDeleteLines.
static void ansiTermInsertLines(WidgetT *w, int32_t count) {
int32_t cols = w->as.ansiTerm->cols;
int32_t bot = w->as.ansiTerm->scrollBot;
@ -799,6 +909,16 @@ static void ansiTermNewline(WidgetT *w) {
// ============================================================
//
// Feed one byte through the ANSI parser state machine.
// This is the hot path for data reception — called once per byte received
// from the serial link. The state machine is kept as simple as possible
// (no tables, no indirect calls) because this runs on every incoming byte
// and branch prediction on a 486/Pentium benefits from straightforward
// if/switch chains.
//
// In PARSE_NORMAL, C0 control characters (CR, LF, BS, TAB, FF, BEL, ESC)
// are handled first. All other bytes — including CP437 graphic characters
// in the 0x01-0x1F range that aren't C0 controls, plus 0x80-0xFF — are
// treated as printable and placed at the cursor via ansiTermPutChar.
static void ansiTermProcessByte(WidgetT *w, uint8_t ch) {
switch (w->as.ansiTerm->parseState) {
@ -814,6 +934,8 @@ static void ansiTermProcessByte(WidgetT *w, uint8_t ch) {
w->as.ansiTerm->cursorCol--;
}
} else if (ch == '\t') {
// Advance to next 8-column tab stop using bit-mask trick:
// (col+8) & ~7 rounds up to the next multiple of 8
w->as.ansiTerm->cursorCol = (w->as.ansiTerm->cursorCol + 8) & ~7;
if (w->as.ansiTerm->cursorCol >= w->as.ansiTerm->cols) {
@ -867,6 +989,11 @@ static void ansiTermProcessByte(WidgetT *w, uint8_t ch) {
break;
case PARSE_CSI:
// CSI parameter accumulation. Parameters are separated by ';'.
// Digits are accumulated into the current parameter slot using
// decimal shift (p*10 + digit). The '?' prefix marks DEC private
// mode sequences which have separate semantics from standard CSI.
// Final bytes (0x40-0x7E) terminate the sequence and dispatch.
if (ch == '?') {
w->as.ansiTerm->csiPrivate = true;
} else if (ch >= '0' && ch <= '9') {
@ -901,6 +1028,17 @@ static void ansiTermProcessByte(WidgetT *w, uint8_t ch) {
// ============================================================
//
// Handle SGR (Select Graphic Rendition) escape sequence.
// Maps ANSI color codes to the CGA attribute byte. The attribute byte
// is packed as (bg<<4)|fg, matching CGA text mode. Bold (code 1) sets
// the high bit of the foreground (giving bright colors), while blink
// (code 5) sets bit 3 of the background nibble (the CGA blink/bright-bg bit).
//
// Multiple SGR codes in a single sequence are processed left-to-right,
// each modifying the running attribute. This handles sequences like
// ESC[1;33;44m (bold yellow on blue) correctly.
//
// Codes 90-97/100-107 (bright colors) are non-standard but widely used
// by BBS software as an alternative to bold+color for explicit bright colors.
static void ansiTermProcessSgr(WidgetT *w) {
if (w->as.ansiTerm->paramCount == 0) {
@ -977,7 +1115,9 @@ static void ansiTermPasteToComm(WidgetT *w) {
return;
}
// Transmit clipboard contents, converting \n to \r for the terminal
// Transmit clipboard contents, converting \n to \r for the terminal.
// Terminals expect CR as the line-ending character; the remote system
// will echo back CR+LF if needed.
for (int32_t i = 0; i < clipLen; i++) {
uint8_t ch = (uint8_t)clip[i];
@ -1056,6 +1196,11 @@ static void ansiTermScrollDown(WidgetT *w) {
//
// Scroll the screen up by one line. The top line is pushed into
// the scrollback buffer before being discarded.
//
// Scrollback capture only occurs for full-screen scrolls (top=0,
// bot=last row). When a sub-region is scrolling (e.g., a BBS
// split-screen chat window), those lines are NOT added to scrollback
// because they represent transient UI content, not conversation history.
static void ansiTermScrollUp(WidgetT *w) {
int32_t cols = w->as.ansiTerm->cols;
@ -1121,6 +1266,11 @@ static void ansiTermSelectionRange(const WidgetT *w, int32_t *startLine, int32_t
// wgtAnsiTerm
// ============================================================
// Create a new ANSI terminal widget. The cell buffer is allocated as a
// flat array of (char, attr) pairs — rows * cols * 2 bytes. The separate
// heap allocation for AnsiTermDataT (via calloc) keeps the WidgetT union
// small; terminal state is substantial (~100+ bytes of fields plus the
// cell and scrollback buffers) so it's pointed to rather than inlined.
WidgetT *wgtAnsiTerm(WidgetT *parent, int32_t cols, int32_t rows) {
if (!parent) {
return NULL;
@ -1281,6 +1431,18 @@ void wgtAnsiTermSetScrollback(WidgetT *w, int32_t maxLines) {
// wgtAnsiTermPoll
// ============================================================
// Poll the terminal for incoming data and update timers. This should be called
// from the application's main loop at a reasonable frequency. It handles three
// things:
// 1. Text blink timer (500ms) — toggles visibility of cells with the blink
// attribute. Only rows containing blink cells are dirtied, avoiding
// unnecessary repaints of static content.
// 2. Cursor blink timer (250ms) — faster than text blink to feel responsive.
// Only the cursor's row is dirtied.
// 3. Comm read — pulls up to 256 bytes from the transport and feeds them
// through the ANSI parser. The 256-byte chunk size balances between
// responsiveness (smaller = more frequent repaints) and throughput
// (larger = fewer function call overhead per byte).
int32_t wgtAnsiTermPoll(WidgetT *w) {
VALIDATE_WIDGET(w, WidgetAnsiTermE, 0);
@ -1346,6 +1508,17 @@ int32_t wgtAnsiTermPoll(WidgetT *w) {
// no relayout, no other widgets). This keeps ACK turnaround fast
// for the serial link.
// Fast repaint: renders dirty rows directly into the window's content buffer,
// completely bypassing the normal widget paint pipeline (widgetOnPaint →
// widgetPaintOne → full tree walk). Returns the number of rows repainted
// and optionally reports the vertical extent via outY/outH so the caller
// can issue a minimal compositor dirty rect.
//
// This exists because the normal paint path clears the entire content area
// and repaints all widgets, which is far too expensive to do on every
// incoming serial byte. With fast repaint, the path from data reception
// to LFB flush is: poll → write → dirtyRows → repaint → compositor dirty,
// keeping the round-trip under 1ms on a Pentium.
int32_t wgtAnsiTermRepaint(WidgetT *w, int32_t *outY, int32_t *outH) {
if (!w || w->type != WidgetAnsiTermE || !w->window) {
return 0;
@ -1382,7 +1555,10 @@ int32_t wgtAnsiTermRepaint(WidgetT *w, int32_t *outY, int32_t *outH) {
return 0;
}
// Set up display context pointing at the content buffer
// Set up a temporary DisplayT pointing at the window's content buffer
// instead of the backbuffer. This lets us reuse all the drawing
// primitives (drawTermRow, etc.) while rendering directly into the
// window's private content bitmap.
DisplayT cd = ctx->display;
cd.backBuf = win->contentBuf;
cd.width = win->contentW;
@ -1449,6 +1625,10 @@ int32_t wgtAnsiTermRepaint(WidgetT *w, int32_t *outY, int32_t *outH) {
// wgtAnsiTermSetComm
// ============================================================
// Attach communication callbacks to the terminal. The read/write function
// pointers are transport-agnostic — the terminal doesn't care whether
// data comes from a raw UART, the secLink encrypted channel, or a
// socket-based proxy. The ctx pointer is passed through opaquely.
void wgtAnsiTermSetComm(WidgetT *w, void *ctx, int32_t (*readFn)(void *, uint8_t *, int32_t), int32_t (*writeFn)(void *, const uint8_t *, int32_t)) {
VALIDATE_WIDGET_VOID(w, WidgetAnsiTermE);
@ -1462,6 +1642,11 @@ void wgtAnsiTermSetComm(WidgetT *w, void *ctx, int32_t (*readFn)(void *, uint8_t
// wgtAnsiTermWrite
// ============================================================
// Write raw bytes into the terminal for parsing and display. Any active
// selection is cleared first since the screen content is changing and the
// selection coordinates would become stale. Each byte is fed through the
// ANSI parser individually — the parser maintains state between calls so
// multi-byte sequences split across writes are handled correctly.
void wgtAnsiTermWrite(WidgetT *w, const uint8_t *data, int32_t len) {
if (!w || w->type != WidgetAnsiTermE || !data || len <= 0) {
return;
@ -1491,6 +1676,10 @@ void widgetAnsiTermDestroy(WidgetT *w) {
// widgetAnsiTermCalcMinSize
// ============================================================
// Min size = exact pixel dimensions needed for the grid plus border and
// scrollbar. The terminal is not designed to be resizable — the grid
// dimensions (cols x rows) are fixed at creation time, matching the BBS
// convention of 80x25 or similar fixed screen sizes.
void widgetAnsiTermCalcMinSize(WidgetT *w, const BitmapFontT *font) {
w->calcMinW = w->as.ansiTerm->cols * font->charWidth + ANSI_BORDER * 2 + ANSI_SB_W;
w->calcMinH = w->as.ansiTerm->rows * font->charHeight + ANSI_BORDER * 2;
@ -1502,7 +1691,11 @@ void widgetAnsiTermCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// ============================================================
//
// Overlay selection highlighting for a single terminal row.
// Inverts fg/bg for selected cells.
// Selected cells are redrawn with fg/bg swapped (fg becomes bg and vice versa),
// which is the traditional terminal selection highlight method. This avoids
// needing a separate "selection color" and works regardless of the underlying
// cell colors. The swap is done in palette-index space (not pixel space) so
// it's just a few index lookups, keeping the per-cell cost minimal.
static void ansiTermPaintSelRow(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t screenRow, int32_t baseX, int32_t baseY) {
if (!ansiTermHasSelection(w)) {
@ -1552,6 +1745,14 @@ static void ansiTermPaintSelRow(WidgetT *w, DisplayT *d, const BlitOpsT *ops, co
// Translate keyboard input to ANSI escape sequences and send
// via the comm interface. Does nothing if commWrite is NULL.
// Key handling for the terminal. Keyboard input is translated to ANSI
// escape sequences and transmitted via the comm interface. The key codes
// use the BIOS INT 16h convention: extended keys have bit 0x100 set, with
// the scan code in the low byte. This matches the dvxApp keyboard layer.
//
// Ctrl+C has dual behavior: copy selection if one exists, otherwise send
// the ^C control character to the remote (for breaking running programs).
// Ctrl+V pastes clipboard contents to the terminal as keystrokes.
void widgetAnsiTermOnKey(WidgetT *w, int32_t key, int32_t mod) {
// Ctrl+C: copy if selection exists, otherwise send ^C
if (key == 0x03 && (mod & KEY_MOD_CTRL)) {
@ -1660,6 +1861,12 @@ void widgetAnsiTermOnKey(WidgetT *w, int32_t key, int32_t mod) {
//
// Handle mouse clicks: scrollbar interaction and focus.
// Mouse handling: the text area (left of the scrollbar) supports click-to-select
// with single-click (anchor), double-click (word), and triple-click (line).
// The scrollbar area uses direct hit testing on up/down arrow buttons and
// page-up/page-down regions, with proportional thumb positioning.
// Selection drag is handled externally by the widget event dispatcher via
// sDragTextSelect — on mouse-down we set the anchor and enable drag mode.
void widgetAnsiTermOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
AppContextT *actx = (AppContextT *)root->userData;
const BitmapFontT *font = &actx->font;
@ -1803,6 +2010,18 @@ void widgetAnsiTermOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy)
// widgetAnsiTermPaint
// ============================================================
// Full paint: renders the complete terminal widget including border, all text
// rows, selection overlay, and scrollbar. This is called through the normal
// widget paint pipeline (e.g., on window expose or full invalidation).
// For incremental updates during data reception, wgtAnsiTermRepaint is used
// instead — it's much faster since it only repaints dirty rows and skips
// the border/scrollbar.
//
// The terminal renders its own scrollbar rather than using the shared
// widgetDrawScrollbarV because the scrollbar's total/visible/position
// semantics are different — the terminal scrollbar represents scrollback
// lines (historical content above the screen), not a viewport over a
// virtual content area.
void widgetAnsiTermPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
// Draw sunken bevel border
BevelStyleT bevel;

View file

@ -1,4 +1,22 @@
// widgetBox.c — VBox, HBox, and Frame container widgets
//
// VBox and HBox are the primary layout containers. They have no visual
// representation of their own — they exist purely to arrange children
// vertically or horizontally. The actual layout algorithm lives in
// widgetLayout.c (widgetCalcMinSizeBox / widgetLayoutBox) which handles
// weight-based space distribution, spacing, padding, and alignment.
//
// VBox and HBox are distinguished by a flag (WCLASS_HORIZ_CONTAINER) in
// the class table rather than having separate code. This keeps the layout
// engine unified — the same algorithm works in both orientations by
// swapping which axis is "major" vs "minor".
//
// Frame is a labeled grouping box with a Motif-style beveled border.
// It acts as a VBox for layout purposes (children stack vertically inside
// the frame's padded interior). The title text sits centered vertically
// on the top border line, with a small background-filled gap to visually
// "break" the border behind the title — this is the classic Win3.1/Motif
// group box appearance.
#include "widgetInternal.h"
@ -7,6 +25,17 @@
// widgetFramePaint
// ============================================================
// Paint the frame border and optional title. The border is offset down by
// half the font height so the title text can sit centered on the top edge.
// This creates the illusion of the title "interrupting" the border — a
// background-colored rectangle is drawn behind the title to erase the
// border pixels, then the title is drawn on top.
//
// Three border styles are supported:
// FrameFlatE — single-pixel solid color outline
// FrameInE — Motif "groove" (inset bevel: shadow-then-highlight)
// FrameOutE — Motif "ridge" (outset bevel: highlight-then-shadow)
// The groove/ridge are each two nested 1px bevels with swapped colors.
void widgetFramePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
int32_t fb = widgetFrameBorderWidth(w);
int32_t boxY = w->y + font->charHeight / 2;
@ -69,6 +98,9 @@ void widgetFramePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitmap
// wgtFrame
// ============================================================
// Create a Frame container. The title string supports accelerator keys
// (prefixed with '&') which are parsed by accelParse. The title pointer
// is stored directly (not copied) — the caller must ensure it remains valid.
WidgetT *wgtFrame(WidgetT *parent, const char *title) {
WidgetT *w = widgetAlloc(parent, WidgetFrameE);

View file

@ -1,4 +1,23 @@
// widgetButton.c — Button widget
//
// Standard push button with text label, Motif-style 2px beveled border,
// and press animation. The button uses a two-phase press model:
// - Mouse press: sets pressed=true and stores the widget in sPressedButton.
// The event dispatcher tracks mouse movement — if the mouse leaves the
// button bounds, pressed is cleared (visual feedback), and if it re-enters,
// pressed is re-set. The onClick callback fires only on mouse-up while
// still inside the button. This gives the user a chance to cancel by
// dragging away, matching standard GUI behavior.
// - Keyboard press (Space/Enter): sets pressed=true and stores in
// sKeyPressedBtn. The onClick fires on key-up. This uses a different
// global than mouse press because key and mouse can't happen simultaneously.
//
// The press animation shifts text and focus rect by BUTTON_PRESS_OFFSET (1px)
// down and right, and swaps the bevel highlight/shadow colors to create the
// illusion of the button being pushed "into" the screen.
//
// Text supports accelerator keys via '&' prefix (e.g., "&OK" underlines 'O'
// and Alt+O activates the button).
#include "widgetInternal.h"
@ -43,6 +62,9 @@ void widgetButtonSetText(WidgetT *w, const char *text) {
// widgetButtonCalcMinSize
// ============================================================
// Min size includes horizontal and vertical padding around the text. The text
// width is computed by textWidthAccel which excludes the '&' accelerator prefix
// character from the measurement.
void widgetButtonCalcMinSize(WidgetT *w, const BitmapFontT *font) {
w->calcMinW = textWidthAccel(font, w->as.button.text) + BUTTON_PAD_H * 2;
w->calcMinH = font->charHeight + BUTTON_PAD_V * 2;
@ -82,6 +104,12 @@ void widgetButtonOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
// widgetButtonPaint
// ============================================================
// Paint: draws the beveled border, centered text, and optional focus rect.
// When pressed, the bevel colors swap (highlight↔shadow) creating the sunken
// appearance, and the text shifts by BUTTON_PRESS_OFFSET pixels. Disabled
// buttons use the "embossed" text technique (highlight color at +1,+1, then
// shadow color at 0,0) to create a chiseled/etched look — this is the
// standard Windows 3.1 disabled control appearance.
void widgetButtonPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
uint32_t bgFace = w->bgColor ? w->bgColor : colors->buttonFace;

View file

@ -1,4 +1,25 @@
// widgetCanvas.c — Drawable canvas widget (freehand draw, PNG save/load)
//
// The canvas widget provides a pixel buffer in the display's native pixel
// format that applications can draw into directly. It stores pixels in
// display format (not always RGB) to avoid per-pixel conversion on every
// repaint — the paint function just does a straight rectCopy blit from
// the canvas buffer to the display. This is critical on a 486 where
// per-pixel format conversion during repaint would be prohibitively slow.
//
// The tradeoff is that load/save operations must convert between RGB and
// the display format, but those are one-time costs at I/O time rather
// than per-frame costs.
//
// Drawing operations (dot, line, rect, circle) operate directly on the
// canvas buffer using canvasPutPixel for pixel-level writes. The dot
// primitive draws a filled circle using the pen size, and line uses
// Bresenham's algorithm placing dots along the path. This gives smooth
// freehand drawing with variable pen widths.
//
// Canvas coordinates are independent of widget position — (0,0) is the
// top-left of the canvas content, not the widget. Mouse events translate
// widget-space coordinates to canvas-space by subtracting the border offset.
#include "widgetInternal.h"
#include "../thirdparty/stb_image.h"
@ -17,11 +38,14 @@ static void canvasUnpackColor(const DisplayT *d, uint32_t pixel, uint8_t *r, uin
// ============================================================
// canvasPutPixel
// canvasGetPixel / canvasPutPixel
// ============================================================
//
// Write a single pixel of the given color at dst, respecting the display's
// bytes-per-pixel depth.
// Read/write a single pixel at the given address, respecting the display's
// bytes-per-pixel depth (1=8-bit palette, 2=16-bit hicolor, 4=32-bit truecolor).
// These are inline because they're called per-pixel in tight loops (circle fill,
// line draw) — the function call overhead would dominate on a 486. The bpp
// branch is predictable since it doesn't change within a single draw operation.
static inline uint32_t canvasGetPixel(const uint8_t *src, int32_t bpp) {
if (bpp == 1) {
@ -71,7 +95,11 @@ static void canvasDrawDot(WidgetT *w, int32_t cx, int32_t cy) {
return;
}
// Filled circle via per-row horizontal span
// Filled circle via per-row horizontal span. For each row (dy offset from
// center), compute the horizontal extent using the circle equation
// dx^2 + dy^2 <= r^2. The horizontal half-span is floor(sqrt(r^2 - dy^2)).
// This approach is faster than checking each pixel individually because
// the inner loop just fills a horizontal run — no per-pixel distance check.
int32_t r2 = rad * rad;
for (int32_t dy = -rad; dy <= rad; dy++) {
@ -86,7 +114,11 @@ static void canvasDrawDot(WidgetT *w, int32_t cx, int32_t cy) {
int32_t rem = r2 - dy2;
int32_t hspan = 0;
// Integer sqrt via Newton's method
// Integer sqrt via Newton's method (Babylonian method). 8 iterations
// is more than enough to converge for any radius that fits in int32_t.
// Using integer sqrt avoids pulling in the FPU which may not be
// present on 486SX systems, and avoids the float-to-int conversion
// overhead even on systems with an FPU.
if (rem > 0) {
hspan = rad;
@ -127,6 +159,10 @@ static void canvasDrawDot(WidgetT *w, int32_t cx, int32_t cy) {
// ============================================================
//
// Bresenham line from (x0,y0) to (x1,y1), placing dots along the path.
// Each point on the line gets a full pen dot (canvasDrawDot), which means
// lines with large pen sizes are smooth rather than aliased. Bresenham was
// chosen over DDA because it's pure integer arithmetic — no floating point
// needed, which matters on 486SX (no FPU).
static void canvasDrawLine(WidgetT *w, int32_t x0, int32_t y0, int32_t x1, int32_t y1) {
int32_t dx = x1 - x0;
@ -166,6 +202,10 @@ static void canvasDrawLine(WidgetT *w, int32_t x0, int32_t y0, int32_t x1, int32
// ============================================================
//
// Reverse of packColor — extract RGB from a display-format pixel.
// Only used during PNG save (wgtCanvasSave) to convert the canvas's
// native-format pixels back to RGB for stb_image_write. The bit-shift
// approach works for 15/16/24/32-bit modes; 8-bit paletted mode uses
// a direct palette table lookup instead.
static void canvasUnpackColor(const DisplayT *d, uint32_t pixel, uint8_t *r, uint8_t *g, uint8_t *b) {
if (d->format.bitsPerPixel == 8) {
@ -192,6 +232,12 @@ static void canvasUnpackColor(const DisplayT *d, uint32_t pixel, uint8_t *r, uin
// wgtCanvas
// ============================================================
// Create a canvas widget with the specified dimensions. The canvas buffer
// is allocated in the display's native pixel format by walking up the widget
// tree to find the AppContextT (which holds the display format info). This
// tree-walk pattern is necessary because the widget doesn't have direct access
// to the display — only the root widget's userData points to the AppContextT.
// The buffer is initialized to white using spanFill for performance.
WidgetT *wgtCanvas(WidgetT *parent, int32_t w, int32_t h) {
if (!parent || w <= 0 || h <= 0) {
return NULL;
@ -470,6 +516,10 @@ void wgtCanvasFillRect(WidgetT *w, int32_t x, int32_t y, int32_t width, int32_t
}
AppContextT *ctx = (AppContextT *)root->userData;
// Use the optimized spanFill (which uses rep stosl on x86) when available,
// falling back to per-pixel writes if the AppContextT can't be reached.
// The spanFill path is ~4x faster for 32-bit modes because it writes
// 4 bytes per iteration instead of going through the bpp switch.
if (ctx) {
for (int32_t py = y0; py < y1; py++) {
ctx->blitOps.spanFill(data + py * pitch + x0 * bpp, color, fillW);
@ -508,6 +558,12 @@ uint32_t wgtCanvasGetPixel(const WidgetT *w, int32_t x, int32_t y) {
// wgtCanvasLoad
// ============================================================
// Load an image file into the canvas, replacing the current content.
// Uses stb_image for decoding (supports BMP, PNG, JPEG, GIF, etc.).
// The loaded RGB pixels are converted to the display's native pixel format
// during load so that subsequent repaints are just a memcpy. The old buffer
// is freed and replaced with the new one — canvas dimensions change to match
// the loaded image.
int32_t wgtCanvasLoad(WidgetT *w, const char *path) {
if (!w || w->type != WidgetCanvasE || !path) {
return -1;
@ -573,6 +629,9 @@ int32_t wgtCanvasLoad(WidgetT *w, const char *path) {
// wgtCanvasSave
// ============================================================
// Save the canvas content to a PNG file. Since the canvas stores pixels in
// the display's native format (which varies per video mode), we must convert
// back to RGB before writing. This is the inverse of the load conversion.
int32_t wgtCanvasSave(WidgetT *w, const char *path) {
if (!w || w->type != WidgetCanvasE || !path || !w->as.canvas.data) {
return -1;
@ -687,6 +746,10 @@ void widgetCanvasDestroy(WidgetT *w) {
// widgetCanvasCalcMinSize
// ============================================================
// The canvas requests exactly its pixel dimensions plus the sunken bevel
// border. The font parameter is unused since the canvas has no text content.
// The canvas is not designed to scale — it reports its exact size as the
// minimum, and the layout engine should respect that.
void widgetCanvasCalcMinSize(WidgetT *w, const BitmapFontT *font) {
(void)font;
w->calcMinW = w->as.canvas.canvasW + CANVAS_BORDER * 2;
@ -698,6 +761,11 @@ void widgetCanvasCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetCanvasOnMouse
// ============================================================
// Mouse handler: translates widget-space coordinates to canvas-space (subtracting
// border offset) and invokes the application's mouse callback. The sDrawingCanvas
// global tracks whether this is a drag (mouse was already down on this canvas)
// vs a new click, so the callback can distinguish between starting a new stroke
// and continuing one.
void widgetCanvasOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
(void)root;
@ -727,6 +795,10 @@ void widgetCanvasOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
// widgetCanvasPaint
// ============================================================
// Paint: draws a sunken bevel border then blits the canvas buffer. Because
// the canvas stores pixels in the display's native format, rectCopy is a
// straight memcpy per scanline — no per-pixel conversion needed. This makes
// repaint essentially free relative to the display bandwidth.
void widgetCanvasPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
(void)font;

View file

@ -1,4 +1,16 @@
// widgetCheckbox.c — Checkbox widget
//
// Classic checkbox: a small box with a sunken bevel (1px) on the left, a text
// label to the right. The check mark is drawn as two diagonal lines forming an
// "X" pattern rather than a traditional checkmark glyph — this is simpler to
// render with drawHLine primitives and matches the DV/X aesthetic.
//
// State management is simple: a boolean 'checked' flag toggles on each click
// or Space/Enter keypress. The onChange callback fires after each toggle so
// the application can respond immediately.
//
// Focus is shown via a dotted rectangle around the label text (not the box),
// matching the Win3.1 convention where the focus indicator wraps the label.
#include "widgetInternal.h"
@ -43,6 +55,9 @@ void widgetCheckboxSetText(WidgetT *w, const char *text) {
// widgetCheckboxCalcMinSize
// ============================================================
// Min width = box + gap + text. Min height = whichever is taller (the box or
// the font). This ensures the box and text are always vertically centered
// relative to each other regardless of font size.
void widgetCheckboxCalcMinSize(WidgetT *w, const BitmapFontT *font) {
w->calcMinW = CHECKBOX_BOX_SIZE + CHECKBOX_GAP +
textWidthAccel(font, w->as.checkbox.text);
@ -103,7 +118,11 @@ void widgetCheckboxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
bevel.width = 1;
drawBevel(d, ops, w->x, boxY, CHECKBOX_BOX_SIZE, CHECKBOX_BOX_SIZE, &bevel);
// Draw check mark if checked
// Draw check mark if checked. The check is an X pattern drawn as
// two diagonal lines of single pixels. Using 1-pixel drawHLine calls
// instead of a real line drawing algorithm avoids Bresenham overhead
// for what's always a small fixed-size glyph (6x6 pixels). The 3px
// inset from the box edge keeps the mark visually centered.
if (w->as.checkbox.checked) {
int32_t cx = w->x + 3;
int32_t cy = boxY + 3;

View file

@ -1,10 +1,49 @@
// widgetClass.c — Widget class vtable definitions
//
// This file implements a C vtable pattern for the widget type system.
// Each widget type has a static const WidgetClassT struct that defines
// its behavior — paint, layout, mouse handling, keyboard handling, etc.
// A master table (widgetClassTable[]) maps WidgetTypeE enum values to
// their class definitions, enabling O(1) dispatch.
//
// Why a vtable approach instead of switch statements:
// 1. Adding a new widget type is purely additive — define a new class
// struct and add one entry to the table. No existing switch
// statements need modification, reducing the risk of forgetting
// a case.
// 2. NULL function pointers indicate "no behavior" naturally. A box
// container has no paint function because the framework paints
// its children directly. A label has no onKey handler because
// it's not interactive. This is cleaner than empty case blocks.
// 3. The flags field combines multiple boolean properties into a
// single bitmask, avoiding per-type if-chains in hot paths
// like hit testing and layout.
//
// Class flags:
// WCLASS_FOCUSABLE — widget can receive keyboard focus (Tab order)
// WCLASS_BOX_CONTAINER — uses the generic box layout engine (VBox/HBox)
// WCLASS_HORIZ_CONTAINER — lays out children horizontally (HBox variant)
// WCLASS_PAINTS_CHILDREN — widget handles its own child painting (TabControl,
// TreeView, ScrollPane, Splitter). The default paint
// walker skips children for these widgets.
// WCLASS_NO_HIT_RECURSE — hit testing stops at this widget instead of
// recursing into children. Used by widgets that
// manage their own internal click regions (TreeView,
// ScrollPane, ListView, Splitter).
#include "widgetInternal.h"
// ============================================================
// Per-type class definitions
// ============================================================
//
// Containers (VBox, HBox, RadioGroup, TabPage, StatusBar, Toolbar)
// typically have NULL for most function pointers because their
// behavior is handled generically by the box layout engine and
// the default child-walking paint logic.
//
// Leaf widgets (Button, Label, TextInput, etc.) override paint,
// calcMinSize, and usually onMouse/onKey for interactivity.
static const WidgetClassT sClassVBox = {
.flags = WCLASS_BOX_CONTAINER,
@ -97,6 +136,9 @@ static const WidgetClassT sClassRadio = {
.setText = widgetRadioSetText
};
// TextInput and TextArea have destroy functions because they dynamically
// allocate their text buffer and undo buffer. Most other widgets store
// all data inline in the WidgetT union and need no cleanup.
static const WidgetClassT sClassTextInput = {
.flags = WCLASS_FOCUSABLE,
.paint = widgetTextInputPaint,
@ -175,6 +217,9 @@ static const WidgetClassT sClassFrame = {
.setText = NULL
};
// Dropdown and ComboBox use paintOverlay to draw their popup lists
// on top of the entire widget tree. This is rendered in a separate
// pass after the main paint, so the popup can overlap sibling widgets.
static const WidgetClassT sClassDropdown = {
.flags = WCLASS_FOCUSABLE,
.paint = widgetDropdownPaint,
@ -227,6 +272,9 @@ static const WidgetClassT sClassSlider = {
.setText = NULL
};
// TabControl has both PAINTS_CHILDREN and a custom layout function
// because it needs to show only the active tab page's children and
// position them inside the tab content area (below the tab strip).
static const WidgetClassT sClassTabControl = {
.flags = WCLASS_FOCUSABLE | WCLASS_PAINTS_CHILDREN,
.paint = widgetTabControlPaint,
@ -279,6 +327,13 @@ static const WidgetClassT sClassToolbar = {
.setText = NULL
};
// TreeView uses all three special flags:
// PAINTS_CHILDREN — it renders tree items itself (with indentation,
// expand/collapse buttons, and selection highlighting)
// NO_HIT_RECURSE — mouse clicks go to the TreeView widget, which
// figures out which tree item was clicked based on scroll position
// and item Y coordinates, rather than letting the hit tester
// recurse into child TreeItem widgets
static const WidgetClassT sClassTreeView = {
.flags = WCLASS_FOCUSABLE | WCLASS_PAINTS_CHILDREN | WCLASS_NO_HIT_RECURSE,
.paint = widgetTreeViewPaint,
@ -292,6 +347,10 @@ static const WidgetClassT sClassTreeView = {
.setText = NULL
};
// TreeItem has no paint/mouse/key handlers — it's a data-only node.
// The TreeView parent widget handles all rendering and interaction.
// TreeItem exists as a WidgetT so it can participate in the tree
// structure (parent/child/sibling links) for hierarchical data.
static const WidgetClassT sClassTreeItem = {
.flags = 0,
.paint = NULL,
@ -370,6 +429,9 @@ static const WidgetClassT sClassAnsiTerm = {
.setText = NULL
};
// ScrollPane, Splitter: PAINTS_CHILDREN because they clip/position
// children in a custom way; NO_HIT_RECURSE because they manage their
// own scrollbar/divider hit regions.
static const WidgetClassT sClassScrollPane = {
.flags = WCLASS_PAINTS_CHILDREN | WCLASS_NO_HIT_RECURSE,
.paint = widgetScrollPanePaint,
@ -412,6 +474,18 @@ static const WidgetClassT sClassSpinner = {
// ============================================================
// Class table — indexed by WidgetTypeE
// ============================================================
//
// This array is the central dispatch table for the widget system.
// Indexed by the WidgetTypeE enum, it provides O(1) lookup of any
// widget type's class definition. Every WidgetT stores a pointer
// to its class (w->wclass) set at allocation time, so per-widget
// dispatch doesn't even need to index this table — it's a direct
// pointer dereference through the vtable.
//
// Using C99 designated initializers ensures the array slots match
// the enum values even if the enum is reordered. If a new enum
// value is added without a table entry, it will be NULL, which
// callers check for before dispatching.
const WidgetClassT *widgetClassTable[] = {
[WidgetVBoxE] = &sClassVBox,

View file

@ -1,4 +1,24 @@
// widgetComboBox.c — ComboBox widget (editable text + dropdown list)
//
// Combines a single-line text input with a dropdown list. The text area
// supports full editing (cursor movement, selection, undo, clipboard) via
// the shared widgetTextEditOnKey helper, while the dropdown button opens
// a popup list overlay.
//
// This is a "combo" box in the Windows sense: the user can either type a
// value or select from the list. When an item is selected from the list,
// its text is copied into the edit buffer. The edit buffer is independently
// allocated (malloc'd) so the user can modify the text after selecting.
//
// The popup list is painted as an overlay (widgetComboBoxPaintPopup) that
// renders on top of all other widgets. Popup visibility is coordinated
// through the sOpenPopup global — only one popup can be open at a time.
// The sClosedPopup mechanism prevents click-to-close from immediately
// reopening the popup when the close click lands on the dropdown button.
//
// Text selection supports single-click (cursor placement + drag start),
// double-click (word select), and triple-click (select all). Drag-select
// is tracked via the sDragTextSelect global.
#include "widgetInternal.h"
@ -7,6 +27,11 @@
// wgtComboBox
// ============================================================
// Create a combo box. maxLen controls the edit buffer size (0 = default 256).
// Two buffers are allocated: the edit buffer and an undo buffer (for Ctrl+Z
// single-level undo). Selection indices start at -1 (nothing selected).
// Default weight=100 makes combo boxes expand to fill available space in
// a layout container, which is the typical desired behavior.
WidgetT *wgtComboBox(WidgetT *parent, int32_t maxLen) {
WidgetT *w = widgetAlloc(parent, WidgetComboBoxE);
@ -56,7 +81,9 @@ void wgtComboBoxSetItems(WidgetT *w, const char **items, int32_t count) {
w->as.comboBox.items = items;
w->as.comboBox.itemCount = count;
// Cache max item strlen to avoid recomputing in calcMinSize
// Cache max item string length so calcMinSize doesn't need to re-scan
// the entire item array on every layout pass. Items are stored as
// external pointers (not copied) — the caller owns the string data.
int32_t maxLen = 0;
for (int32_t i = 0; i < count; i++) {
@ -73,6 +100,8 @@ void wgtComboBoxSetItems(WidgetT *w, const char **items, int32_t count) {
w->as.comboBox.selectedIdx = -1;
}
// wgtInvalidate (not wgtInvalidatePaint) triggers a full relayout because
// changing items may change the widget's minimum width
wgtInvalidate(w);
}
@ -141,6 +170,12 @@ const char *widgetComboBoxGetText(const WidgetT *w) {
// widgetComboBoxOnKey
// ============================================================
// Key handling has two modes: when the popup is open, Up/Down navigate the list
// and Enter confirms the selection. When closed, keys go to the text editor
// (via widgetTextEditOnKey) except Down-arrow which opens the popup. This split
// behavior is necessary because the same widget must serve as both a text input
// and a list selector depending on popup state.
// Key codes: 0x48|0x100 = Up, 0x50|0x100 = Down (BIOS scan codes with extended bit).
void widgetComboBoxOnKey(WidgetT *w, int32_t key, int32_t mod) {
if (w->as.comboBox.open) {
if (key == (0x48 | 0x100)) {
@ -304,6 +339,12 @@ void widgetComboBoxOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
// widgetComboBoxPaint
// ============================================================
// Paint: two regions side-by-side — a sunken text area (left) and a raised
// dropdown button (right). The text area renders the edit buffer with optional
// selection highlighting (up to 3 text runs: pre-selection, selection,
// post-selection). The dropdown button has a small triangular arrow glyph
// drawn as horizontal lines of decreasing width. When the popup is open,
// the button bevel is inverted (sunken) to show it's active.
void widgetComboBoxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->enabled ? (w->fgColor ? w->fgColor : colors->contentFg) : colors->windowShadow;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
@ -338,7 +379,11 @@ void widgetComboBoxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
selHi = w->as.comboBox.selStart < w->as.comboBox.selEnd ? w->as.comboBox.selEnd : w->as.comboBox.selStart;
}
// Draw up to 3 runs: before selection, selection, after selection
// Draw up to 3 text runs: before selection (normal colors), selection
// (highlight colors), after selection (normal colors). This avoids
// drawing the entire line twice (once normal, once selected) which
// would be wasteful on slow hardware. The visible selection range
// is clamped to the scrolled viewport.
int32_t visSelLo = selLo - off;
int32_t visSelHi = selHi - off;

View file

@ -1,31 +1,67 @@
// widgetCore.c — Core widget infrastructure (alloc, tree ops, helpers)
//
// This file provides the foundation for the widget tree: allocation,
// parent-child linking, focus management, hit testing, and shared
// utility functions used across multiple widget types.
//
// Widgets form a tree using intrusive linked lists (firstChild/lastChild/
// nextSibling pointers inside each WidgetT). This is a singly-linked
// child list with a tail pointer for O(1) append. The tree is owned
// by its root, which is attached to a WindowT. Destroying the root
// recursively destroys all descendants.
//
// Memory allocation is plain malloc/free rather than an arena or pool.
// The widget count per window is typically small (tens to low hundreds),
// so the allocation overhead is negligible on target hardware. An arena
// approach was considered but rejected because widgets can be individually
// created and destroyed at runtime (dialog dynamics, tree item insertion),
// which doesn't map cleanly to an arena pattern.
#include "widgetInternal.h"
// ============================================================
// Global state for drag and popup tracking
// ============================================================
//
// These module-level pointers track ongoing UI interactions that span
// multiple mouse events (drags, popups, button presses). They are global
// rather than per-window because the DOS GUI is single-threaded and only
// one interaction can be active at a time.
//
// Each pointer is set when an interaction begins (e.g. mouse-down on a
// slider) and cleared when it ends (mouse-up). The event dispatcher in
// widgetEvent.c checks these before normal hit testing — active drags
// take priority over everything else.
//
// All of these must be NULLed when the pointed-to widget is destroyed,
// otherwise dangling pointers would cause crashes. widgetDestroyChildren()
// and wgtDestroy() handle this cleanup.
bool sDebugLayout = false;
WidgetT *sFocusedWidget = NULL;
WidgetT *sOpenPopup = NULL;
WidgetT *sPressedButton = NULL;
WidgetT *sDragSlider = NULL;
WidgetT *sDrawingCanvas = NULL;
WidgetT *sDragTextSelect = NULL;
int32_t sDragOffset = 0;
WidgetT *sResizeListView = NULL;
int32_t sResizeCol = -1;
int32_t sResizeStartX = 0;
int32_t sResizeOrigW = 0;
WidgetT *sDragSplitter = NULL;
int32_t sDragSplitStart = 0;
WidgetT *sDragReorder = NULL;
WidgetT *sFocusedWidget = NULL; // currently focused widget (O(1) access, avoids tree walk)
WidgetT *sOpenPopup = NULL; // dropdown/combobox with open popup list
WidgetT *sPressedButton = NULL; // button being held down (tracks mouse in/out)
WidgetT *sDragSlider = NULL; // slider being dragged
WidgetT *sDrawingCanvas = NULL; // canvas receiving paint strokes
WidgetT *sDragTextSelect = NULL; // text widget in drag-select mode
int32_t sDragOffset = 0; // pixel offset from drag start to thumb center
WidgetT *sResizeListView = NULL; // ListView undergoing column resize
int32_t sResizeCol = -1; // which column is being resized
int32_t sResizeStartX = 0; // mouse X at resize start
int32_t sResizeOrigW = 0; // column width at resize start
WidgetT *sDragSplitter = NULL; // splitter being dragged
int32_t sDragSplitStart = 0; // mouse offset from splitter edge at drag start
WidgetT *sDragReorder = NULL; // list/tree widget in drag-reorder mode
// ============================================================
// widgetAddChild
// ============================================================
//
// Appends a child to the end of the parent's child list. O(1)
// thanks to the lastChild tail pointer. The child list is singly-
// linked (nextSibling), which saves 4 bytes per widget vs doubly-
// linked and is sufficient because child removal is infrequent.
void widgetAddChild(WidgetT *parent, WidgetT *child) {
child->parent = parent;
@ -44,6 +80,18 @@ void widgetAddChild(WidgetT *parent, WidgetT *child) {
// ============================================================
// widgetAlloc
// ============================================================
//
// Allocates and zero-initializes a new widget, links it to its
// class vtable via widgetClassTable[], and optionally adds it as
// a child of the given parent.
//
// The memset to 0 is intentional — it establishes sane defaults
// for all fields: NULL pointers, zero coordinates, no focus,
// no accel key, etc. Only visible and enabled default to true.
//
// The window pointer is inherited from the parent so that any
// widget in the tree can find its owning window without walking
// to the root.
WidgetT *widgetAlloc(WidgetT *parent, WidgetTypeE type) {
WidgetT *w = (WidgetT *)malloc(sizeof(WidgetT));
@ -104,6 +152,17 @@ int32_t widgetCountVisibleChildren(const WidgetT *w) {
// ============================================================
// widgetDestroyChildren
// ============================================================
//
// Recursively destroys all descendants of a widget. Processes
// children depth-first (destroy grandchildren before the child
// itself) so that per-widget destroy callbacks see a consistent
// tree state.
//
// Critically, this function clears all global state pointers that
// reference destroyed widgets. Without this, any pending drag or
// focus state would become a dangling pointer. Each global is
// checked individually rather than cleared unconditionally to
// avoid disrupting unrelated ongoing interactions.
void widgetDestroyChildren(WidgetT *w) {
WidgetT *child = w->firstChild;
@ -155,7 +214,17 @@ void widgetDestroyChildren(WidgetT *w) {
// widgetDropdownPopupRect
// ============================================================
//
// Calculate the rectangle for a dropdown/combobox popup list.
// Calculates the screen rectangle for a dropdown/combobox popup list.
// Shared between Dropdown and ComboBox since they have identical
// popup positioning logic.
//
// The popup tries to open below the widget first. If there isn't
// enough room (popup would extend past the content area bottom),
// it flips to open above instead. This ensures the popup is always
// visible, even for dropdowns near the bottom of a window.
//
// Popup height is capped at DROPDOWN_MAX_VISIBLE items to prevent
// huge popups from dominating the screen.
void widgetDropdownPopupRect(WidgetT *w, const BitmapFontT *font, int32_t contentH, int32_t *popX, int32_t *popY, int32_t *popW, int32_t *popH) {
int32_t itemCount = 0;
@ -196,6 +265,15 @@ void widgetDropdownPopupRect(WidgetT *w, const BitmapFontT *font, int32_t conten
// ============================================================
// widgetFindByAccel
// ============================================================
//
// Finds a widget with the given Alt+key accelerator. Recurses the
// tree depth-first, respecting visibility and enabled state.
//
// Special case for TabPage widgets: even if the tab page itself is
// not visible (inactive tab), its accelKey is still checked. This
// allows Alt+key to switch to a different tab. However, children
// of invisible tab pages are NOT searched — their accelerators
// should not be active when the tab is hidden.
WidgetT *widgetFindByAccel(WidgetT *root, char key) {
if (!root || !root->enabled) {
@ -232,9 +310,15 @@ WidgetT *widgetFindByAccel(WidgetT *root, char key) {
// widgetFindNextFocusable
// ============================================================
//
// Depth-first walk of the widget tree. Returns the first focusable
// widget found after 'after'. If 'after' is NULL, returns the first
// focusable widget. Wraps around to the beginning if needed.
// Implements Tab-order navigation: finds the next focusable widget
// after 'after' in depth-first tree order. The two-pass approach
// (search from 'after' to end, then wrap to start) ensures circular
// tabbing — Tab on the last focusable widget wraps to the first.
//
// The pastAfter flag tracks whether we've passed the 'after' widget
// during traversal. Once past it, the next focusable widget is the
// answer. This avoids collecting all focusable widgets into an array
// just to find the next one — the common case returns quickly.
static WidgetT *findNextFocusableImpl(WidgetT *w, WidgetT *after, bool *pastAfter) {
if (!w->visible || !w->enabled) {
@ -280,8 +364,17 @@ WidgetT *widgetFindNextFocusable(WidgetT *root, WidgetT *after) {
// widgetFindPrevFocusable
// ============================================================
//
// Depth-first walk collecting all visible+enabled focusable widgets,
// then returns the one before 'before'. Wraps around if needed.
// Shift+Tab navigation: finds the previous focusable widget.
// Unlike findNextFocusable which can short-circuit during traversal,
// finding the PREVIOUS widget requires knowing the full order.
// So this collects all focusable widgets into an array, finds the
// target's index, and returns index-1 (with wraparound).
//
// The explicit stack-based DFS (rather than recursion) is used here
// because we need to push children in reverse order to get the same
// left-to-right depth-first ordering as the recursive version.
// Fixed-size arrays (128 widgets, 64 stack depth) are adequate for
// any reasonable dialog layout and avoid dynamic allocation.
WidgetT *widgetFindPrevFocusable(WidgetT *root, WidgetT *before) {
WidgetT *list[128];
@ -362,6 +455,22 @@ int32_t widgetFrameBorderWidth(const WidgetT *w) {
// ============================================================
// widgetHitTest
// ============================================================
//
// Recursive hit testing: finds the deepest (most specific) widget
// under the given coordinates. Returns the widget itself if no
// child is hit, or NULL if the point is outside this widget.
//
// Children are iterated front-to-back (first to last in the linked
// list), but the LAST match wins. This gives later siblings higher
// Z-order, which matches the painting order (later children paint
// on top of earlier ones). This is important for overlapping widgets,
// though in practice the layout engine rarely produces overlap.
//
// Widgets with WCLASS_NO_HIT_RECURSE stop the recursion — the parent
// widget handles all mouse events for its children. This is used by
// TreeView, ScrollPane, ListView, and Splitter, which need to manage
// their own internal regions (scrollbars, column headers, tree
// expand buttons) that don't correspond to child widgets.
WidgetT *widgetHitTest(WidgetT *w, int32_t x, int32_t y) {
if (!w->visible) {
@ -427,8 +536,16 @@ bool widgetIsHorizContainer(WidgetTypeE type) {
// widgetNavigateIndex
// ============================================================
//
// Shared Up/Down/Home/End/PgUp/PgDn handler for list-like widgets.
// Returns the new index, or -1 if the key is not a navigation key.
// Shared keyboard navigation for list-like widgets (ListBox, Dropdown,
// ListView, etc.). Encapsulates the Up/Down/Home/End/PgUp/PgDn logic
// so each widget doesn't have to reimplement index clamping.
//
// Key values use the 0x100 flag to mark extended scan codes (arrow
// keys, Home, End, etc.) — this is the DVX convention for passing
// scan codes through the same int32_t channel as ASCII values.
//
// Returns -1 for unrecognized keys so callers can check whether the
// key was consumed.
int32_t widgetNavigateIndex(int32_t key, int32_t current, int32_t count, int32_t pageSize) {
if (key == (0x50 | 0x100)) {
@ -516,6 +633,15 @@ void widgetPaintPopupList(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *f
// ============================================================
// widgetScrollbarThumb
// ============================================================
//
// Calculates thumb position and size for a scrollbar track.
// Used by both the WM-level scrollbars and widget-internal scrollbars
// (ListBox, TreeView, etc.) to maintain consistent scrollbar behavior.
//
// The thumb size is proportional to visibleSize/totalSize — a larger
// visible area means a larger thumb, giving visual feedback about how
// much content is scrollable. SB_MIN_THUMB prevents the thumb from
// becoming too small to grab with a mouse.
void widgetScrollbarThumb(int32_t trackLen, int32_t totalSize, int32_t visibleSize, int32_t scrollPos, int32_t *thumbPos, int32_t *thumbSize) {
*thumbSize = (trackLen * visibleSize) / totalSize;
@ -541,6 +667,11 @@ void widgetScrollbarThumb(int32_t trackLen, int32_t totalSize, int32_t visibleSi
// ============================================================
// widgetRemoveChild
// ============================================================
//
// Unlinks a child from its parent's child list. O(n) in the number
// of children because the singly-linked list requires walking to
// find the predecessor. This is acceptable because child removal
// is infrequent (widget destruction, tree item reordering).
void widgetRemoveChild(WidgetT *parent, WidgetT *child) {
WidgetT *prev = NULL;

View file

@ -1,4 +1,22 @@
// widgetDropdown.c — Dropdown (select) widget
//
// A non-editable dropdown list (HTML <select> equivalent). Unlike ComboBox,
// the user cannot type a custom value — they can only choose from the
// predefined items. This simplifies the widget considerably: no text buffer,
// no undo, no cursor, no text editing key handling.
//
// The visual layout is identical to ComboBox (sunken display area + raised
// dropdown button), but the display area shows the selected item's text
// as read-only. A focus rect is drawn inside the display area when focused
// (since there's no cursor to indicate focus).
//
// Keyboard behavior when closed: Up arrow decrements selection immediately
// (inline cycling), Down/Space/Enter opens the popup. This matches the
// Win3.1 dropdown behavior where arrow keys cycle through values without
// opening the full list.
//
// Popup overlay painting and hit testing are shared with ComboBox via
// widgetDropdownPopupRect and widgetPaintPopupList in widgetCore.c.
#include "widgetInternal.h"
@ -108,6 +126,11 @@ void widgetDropdownCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetDropdownOnKey
// ============================================================
// Key handling: when popup is open, Up/Down navigate the hover index and
// Enter/Space confirms selection. When closed, Down/Space/Enter opens the
// popup, while Up decrements the selection inline (without opening). This
// two-mode behavior matches the standard Windows combo box UX where quick
// arrow-key cycling doesn't require the popup to be visible.
void widgetDropdownOnKey(WidgetT *w, int32_t key, int32_t mod) {
(void)mod;
@ -171,6 +194,12 @@ void widgetDropdownOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetDropdownOnMouse
// ============================================================
// Mouse click toggles the popup open/closed. The sClosedPopup guard prevents
// a re-open race condition: when a click outside the popup closes it, the
// widget event dispatcher first closes the popup (setting sClosedPopup),
// then dispatches the click to whatever widget is under the cursor. If that
// happens to be this dropdown's body, we'd immediately re-open without this
// check. The sClosedPopup reference is cleared on the next event cycle.
void widgetDropdownOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
(void)root;
(void)vx;
@ -225,7 +254,9 @@ void widgetDropdownPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
btnBevel.width = 2;
drawBevel(d, ops, w->x + textAreaW, w->y, DROPDOWN_BTN_WIDTH, w->h, &btnBevel);
// Down arrow in button
// Down arrow glyph in button — a small filled triangle drawn as horizontal
// lines of decreasing width (7, 5, 3, 1 pixels). This creates a 4-pixel
// tall downward-pointing triangle centered in the button.
uint32_t arrowFg = w->enabled ? colors->contentFg : colors->windowShadow;
int32_t arrowX = w->x + textAreaW + DROPDOWN_BTN_WIDTH / 2;
int32_t arrowY = w->y + w->h / 2 - 1;
@ -244,6 +275,11 @@ void widgetDropdownPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
// widgetDropdownPaintPopup
// ============================================================
// Paint the popup overlay list. Delegates to widgetDropdownPopupRect (which
// computes position, handling screen-edge flip to ensure the popup stays
// on-screen) and widgetPaintPopupList (which renders the bordered, scrollable
// item list with hover highlighting). These shared helpers are also used by
// ComboBox, keeping the popup rendering consistent between both widgets.
void widgetDropdownPaintPopup(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
int32_t popX;
int32_t popY;

View file

@ -1,9 +1,28 @@
// widgetEvent.c — Window event handlers for widget system
//
// This file routes window-level events (mouse, keyboard, paint, resize,
// scroll) into the widget tree. It serves as the bridge between the
// window manager (dvxWm) and the widget system.
//
// Event handling follows a priority system for mouse events:
// 1. Active drag/interaction states (slider drag, button press tracking,
// text selection, canvas drawing, column resize, drag-reorder,
// splitter drag) are checked first and handled directly.
// 2. Open popups (dropdown/combobox lists) intercept clicks next.
// 3. Normal hit testing routes clicks to the target widget.
//
// This priority ordering ensures that ongoing interactions complete
// correctly even if the mouse moves outside the originating widget.
// For example, dragging a slider and moving the mouse above the slider
// still adjusts the value, because sDragSlider captures the event
// before hit testing runs.
#include "widgetInternal.h"
// Widget whose popup was just closed by click-outside — prevents
// immediate re-open on the same click.
// immediate re-open on the same click. Without this, clicking the
// dropdown button to close its popup would immediately hit-test the
// button again and re-open the popup in the same event.
WidgetT *sClosedPopup = NULL;
@ -11,9 +30,26 @@ WidgetT *sClosedPopup = NULL;
// widgetManageScrollbars
// ============================================================
//
// Checks whether the widget tree's minimum size exceeds the
// window content area. Adds or removes WM scrollbars as needed,
// then relayouts the widget tree at the virtual content size.
// Manages automatic scrollbar addition/removal for widget-based windows.
// Called on every invalidation to ensure scrollbars match the current
// widget tree's minimum size requirements.
//
// The algorithm:
// 1. Measure the full widget tree to get its minimum size.
// 2. Remove all existing scrollbars to measure the full available area.
// 3. Compare min size vs available area to decide if scrollbars are needed.
// 4. Account for scrollbar interaction: adding a vertical scrollbar
// reduces horizontal space, which may require a horizontal scrollbar
// (and vice versa). This mutual dependency is handled with a single
// extra check rather than iterating to convergence.
// 5. Preserve scroll positions across scrollbar recreation.
// 6. Layout at the virtual content size (max of available and minimum).
//
// The virtual content size concept is key: if the widget tree needs
// 800px but only 600px is available, the tree is laid out at 800px
// and the window scrolls to show the visible portion. This means
// widget positions can be negative (scrolled above the viewport)
// or extend past the window edge (scrolled below).
void widgetManageScrollbars(WindowT *win, AppContextT *ctx) {
WidgetT *root = win->widgetRoot;
@ -106,6 +142,15 @@ void widgetManageScrollbars(WindowT *win, AppContextT *ctx) {
// ============================================================
// widgetOnKey
// ============================================================
//
// Keyboard event dispatch. Unlike mouse events which use hit testing,
// keyboard events go directly to the focused widget (sFocusedWidget).
// The cached pointer avoids an O(n) tree walk to find the focused
// widget on every keypress.
//
// There is no keyboard event bubbling — if the focused widget doesn't
// handle a key, it's simply dropped. Accelerators (Alt+key) are
// handled at a higher level in the app event loop, not here.
void widgetOnKey(WindowT *win, int32_t key, int32_t mod) {
WidgetT *root = win->widgetRoot;
@ -136,6 +181,20 @@ void widgetOnKey(WindowT *win, int32_t key, int32_t mod) {
// ============================================================
// widgetOnMouse
// ============================================================
//
// Main mouse event handler. This is the most complex event handler
// because it must manage multiple overlapping interaction states.
//
// The function is structured as a series of early-return checks:
// each active interaction (drag, press, popup) is checked in priority
// order. If the interaction handles the event, it returns immediately.
// Only if no interaction is active does the event fall through to
// normal hit testing.
//
// Coordinates (x, y) are in content-buffer space — the window manager
// has already subtracted the window chrome offset. Widget positions
// are also in content-buffer space (set during layout), so no
// coordinate transform is needed for hit testing.
void widgetOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) {
WidgetT *root = win->widgetRoot;
@ -465,18 +524,21 @@ void widgetOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) {
return;
}
// Clear focus from previously focused widget (O(1) instead of tree walk)
// Clear focus from the previously focused widget. This is done via
// the cached sFocusedWidget pointer rather than walking the tree to
// find the focused widget — an O(1) operation vs O(n).
if (sFocusedWidget) {
sFocusedWidget->focused = false;
sFocusedWidget = NULL;
}
// Dispatch to per-widget mouse handler via vtable
// Dispatch to the hit widget's mouse handler via vtable. The handler
// is responsible for setting hit->focused=true if it wants focus.
if (hit->enabled && hit->wclass && hit->wclass->onMouse) {
hit->wclass->onMouse(hit, root, vx, vy);
}
// Cache the newly focused widget
// Update the cached focus pointer for O(1) access in widgetOnKey
if (hit->focused) {
sFocusedWidget = hit;
}
@ -488,6 +550,25 @@ void widgetOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) {
// ============================================================
// widgetOnPaint
// ============================================================
//
// Paints the entire widget tree into the window's content buffer.
// Called whenever the window needs a redraw (invalidation, scroll,
// resize).
//
// Sets up a temporary DisplayT context that points at the window's
// content buffer instead of the screen backbuffer. This means all
// draw operations (drawText, rectFill, drawBevel, etc.) write
// directly into the per-window content buffer, which the compositor
// later blits to the screen backbuffer.
//
// Scroll offset is applied by shifting the root widget's position
// to negative coordinates (-scrollX, -scrollY). This elegantly makes
// scrolling work without any special scroll handling in individual
// widgets — their positions are simply offset, and the clip rect
// on the DisplayT limits drawing to the visible area.
//
// The conditional re-layout avoids redundant layout passes when only
// the paint is needed (e.g. cursor blink, selection change).
void widgetOnPaint(WindowT *win, RectT *dirtyArea) {
(void)dirtyArea;
@ -544,6 +625,10 @@ void widgetOnPaint(WindowT *win, RectT *dirtyArea) {
// ============================================================
// widgetOnResize
// ============================================================
//
// Called when the window is resized. Triggers a full scrollbar
// re-evaluation and relayout, since the available content area
// changed and scrollbars may need to be added or removed.
void widgetOnResize(WindowT *win, int32_t newW, int32_t newH) {
(void)newW;
@ -567,6 +652,12 @@ void widgetOnResize(WindowT *win, int32_t newW, int32_t newH) {
// ============================================================
// widgetOnScroll
// ============================================================
//
// Called by the WM when a window scrollbar value changes (user dragged
// the thumb, clicked the track, or used arrow buttons). Triggers a
// full repaint so the widget tree is redrawn at the new scroll offset.
// The actual scroll offset is read from win->vScroll/hScroll in the
// paint handler, so the orient and value parameters aren't directly used.
void widgetOnScroll(WindowT *win, ScrollbarOrientE orient, int32_t value) {
(void)orient;
@ -592,6 +683,20 @@ void widgetOnScroll(WindowT *win, ScrollbarOrientE orient, int32_t value) {
// ============================================================
// widgetReorderDrop — finalize drag-reorder on mouse release
// ============================================================
//
// Completes a drag-reorder operation for ListBox, ListView, or TreeView.
// Moves the dragged item from its original position to the drop position
// by shifting intermediate elements. This is an in-place array rotation
// for ListBox/ListView (O(n) element moves) and a linked-list splice
// for TreeView.
//
// For ListBox and ListView, the item array, selection bits, and sort
// indices are all shifted together to maintain consistency. The selected
// index is updated to follow the moved item.
//
// For TreeView, the operation is a tree node re-parenting: unlink the
// dragged item from its old parent's child list, then insert it before
// or after the drop target in the target's parent's child list.
void widgetReorderDrop(WidgetT *w) {
if (w->type == WidgetListBoxE) {
@ -801,6 +906,18 @@ void widgetReorderDrop(WidgetT *w) {
// ============================================================
// widgetReorderUpdate — update drop position during drag
// ============================================================
//
// Tracks the mouse during a drag-reorder, updating the drop indicator
// position and auto-scrolling when the mouse is near the widget's edges.
//
// The drop position is computed from the mouse Y relative to item
// boundaries: if the mouse is in the top half of an item, the drop
// indicator goes before that item; if in the bottom half, it goes
// after. This gives intuitive "insert between items" behavior.
//
// Auto-scrolling happens when the mouse is within one row-height of
// the top or bottom edge, allowing the user to drag items to positions
// not currently visible.
void widgetReorderUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y) {
(void)x;

View file

@ -1,4 +1,20 @@
// widgetImage.c — Image widget (displays bitmap, responds to clicks)
//
// Displays a bitmap image, optionally responding to clicks. The image data
// must be in the display's native pixel format (pre-converted). Two creation
// paths are provided:
// - wgtImage: from raw pixel data already in display format (takes ownership)
// - wgtImageFromFile: loads from file via stb_image and converts to display
// format during load
//
// The widget supports a simple press effect (1px offset on click) and fires
// onClick immediately on mouse-down. Unlike Button which has press/release
// tracking, Image fires instantly — this is intentional for image-based
// click targets where visual press feedback is less important than
// responsiveness.
//
// No border or bevel is drawn — the image fills its widget bounds with
// centering if the widget is larger than the image.
#include "widgetInternal.h"
#include "../thirdparty/stb_image.h"
@ -33,6 +49,13 @@ WidgetT *wgtImage(WidgetT *parent, uint8_t *data, int32_t w, int32_t h, int32_t
// Load an image from a file (BMP, PNG, JPEG, GIF), convert to
// display pixel format, and create an image widget.
// Load an image from disk and create an Image widget. Uses stb_image for
// decoding (any format it supports: PNG, BMP, JPEG, GIF, etc.). The RGB
// pixels are converted to the display's native pixel format during load
// using packColor, then the raw RGB data is freed. The per-pixel bpp switch
// is duplicated here rather than using canvasPutPixel because this function
// is in a different compilation unit and inlining across units isn't guaranteed
// on DJGPP.
WidgetT *wgtImageFromFile(WidgetT *parent, const char *path) {
if (!parent || !path) {
return NULL;
@ -129,6 +152,11 @@ void widgetImageCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetImageOnMouse
// ============================================================
// Mouse click: briefly sets pressed=true (for the 1px offset effect),
// invalidates to show the press, fires onClick, then immediately clears
// pressed. The press is purely visual — there's no release tracking like
// Button has, since Image clicks are meant for instant actions (e.g.,
// clicking a logo or icon area).
void widgetImageOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
(void)root;
(void)vx;

View file

@ -1,4 +1,20 @@
// widgetImageButton.c — Image button widget (button with image instead of text)
//
// Combines a Button's beveled border and press behavior with an Image's
// bitmap rendering. The image is centered within the button bounds and
// shifts by 1px on press, just like text in a regular button.
//
// Uses the same two-phase press model as Button: mouse press stores in
// sPressedButton, keyboard press (Space/Enter) stores in sKeyPressedBtn,
// and the event dispatcher handles release/cancel. The onClick callback
// fires on release, not press.
//
// The widget takes ownership of the image data buffer — if widget creation
// fails, the data is freed to prevent leaks.
//
// The 4px added to min size (widgetImageButtonCalcMinSize) accounts for
// the 2px bevel on each side — no extra padding is added beyond that,
// keeping image buttons compact for toolbar use.
#include "widgetInternal.h"

View file

@ -1,4 +1,16 @@
// widgetInternal.h — Shared internal header for widget implementation files
//
// This header is included only by widget implementation .c files (in the
// widgets/ directory), never by application code. It exposes the vtable
// system, shared module-level state, and internal helper functions that
// widget implementations need but the public API should not expose.
//
// The widget system is split across multiple .c files by concern:
// widgetCore.c — allocation, tree ops, focus management, shared helpers
// widgetLayout.c — measure + layout algorithms for box containers
// widgetEvent.c — mouse/keyboard dispatch, scrollbar management
// widgetOps.c — paint dispatch, overlay rendering
// widget*.c — per-type paint, event, and layout implementations
#ifndef WIDGET_INTERNAL_H
#define WIDGET_INTERNAL_H
@ -15,6 +27,25 @@
// ============================================================
// Widget class vtable
// ============================================================
//
// Each widget type has a WidgetClassT entry in widgetClassTable[], indexed
// by WidgetTypeE. This is a C implementation of virtual dispatch: instead
// of a switch(type) in every operation, the code calls w->wclass->paint()
// etc. The vtable pointer is set once at widget creation and never changes.
//
// Flags encode static properties that the framework needs without calling
// into the vtable:
// FOCUSABLE — can receive keyboard focus (Tab navigation)
// BOX_CONTAINER — uses the generic VBox/HBox layout algorithm
// HORIZ_CONTAINER — lays out children horizontally (vs. default vertical)
// PAINTS_CHILDREN — the widget's paint function handles child rendering
// (e.g. TabControl only paints the active tab page)
// NO_HIT_RECURSE — hit testing stops at this widget and doesn't recurse
// into children (e.g. ListBox handles its own items)
//
// NULL function pointers are valid and mean "no-op" for that operation.
// paintOverlay is used by widgets that need to draw outside their bounds
// (dropdown popup lists), rendered in a separate pass after all widgets.
#define WCLASS_FOCUSABLE 0x0001
#define WCLASS_BOX_CONTAINER 0x0002
@ -35,11 +66,18 @@ typedef struct WidgetClassT {
void (*setText)(WidgetT *w, const char *text);
} WidgetClassT;
// Global vtable array — one entry per WidgetTypeE value. Defined in
// widgetCore.c. Must stay in sync with the WidgetTypeE enum order.
extern const WidgetClassT *widgetClassTable[];
// ============================================================
// Validation macros
// ============================================================
//
// Defensive type-checking at the top of public widget API functions.
// Since WidgetT is a tagged union, calling a ListBox function with a
// Button widget would access the wrong union member and corrupt state.
// These macros provide a consistent early-return guard pattern.
#define VALIDATE_WIDGET(w, wtype, retval) \
do { if (!(w) || (w)->type != (wtype)) { return (retval); } } while (0)
@ -51,11 +89,16 @@ extern const WidgetClassT *widgetClassTable[];
// Constants
// ============================================================
// Modifier flags (BIOS INT 16h shift state bits)
// Modifier flags (BIOS INT 16h shift state bits). Duplicated from
// dvxTypes.h ACCEL_xxx constants because widget code references them
// frequently and the KEY_MOD_ prefix reads better in event handlers.
#define KEY_MOD_SHIFT 0x03
#define KEY_MOD_CTRL 0x04
#define KEY_MOD_ALT 0x08
// Layout and geometry constants. These define the visual metrics of
// each widget type and are tuned for readability at 640x480 with 8px
// wide fonts. Changing these affects every instance of the widget type.
#define DEFAULT_SPACING 4
#define DEFAULT_PADDING 4
#define SEPARATOR_THICKNESS 2
@ -82,6 +125,8 @@ extern const WidgetClassT *widgetClassTable[];
#define TREE_EXPAND_SIZE 9
#define TREE_ICON_GAP 4
#define TREE_BORDER 2
// WGT_SB_W is the widget-internal scrollbar width (slightly narrower than
// the window-level SCROLLBAR_WIDTH) to fit within widget content areas.
#define WGT_SB_W 14
#define TREE_MIN_ROWS 4
#define SB_MIN_THUMB 14
@ -100,8 +145,12 @@ static inline int32_t clampInt(int32_t val, int32_t lo, int32_t hi) {
return val;
}
// Classic Windows 3.1 embossed (etched) text for disabled widgets:
// Draw text at +1,+1 in highlight, then at 0,0 in shadow.
// Classic Windows 3.1 embossed (etched) text for disabled widgets.
// The illusion works by drawing the text twice: first offset +1,+1 in
// the highlight color (creating a light "shadow" behind the text), then
// at the original position in the shadow color. The result is text that
// appears chiseled into the surface — the universal visual indicator for
// "greyed out" in Motif and Windows 3.x era GUIs.
static inline void drawTextEmbossed(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, const ColorSchemeT *colors) {
drawText(d, ops, font, x + 1, y + 1, text, colors->windowHighlight, 0, false);
drawText(d, ops, font, x, y, text, colors->windowShadow, 0, false);
@ -115,51 +164,89 @@ static inline void drawTextAccelEmbossed(DisplayT *d, const BlitOpsT *ops, const
// ============================================================
// Shared state (defined in widgetCore.c)
// ============================================================
//
// Module-level globals for tracking active interactive operations.
// Only one of these can be active at a time (e.g. you can't drag a
// slider and resize a listview column simultaneously). Using globals
// rather than per-widget state avoids bloating every widget with fields
// that are only relevant during a single drag operation. The tradeoff
// is that these are truly global — but since the GUI is single-threaded
// and only one mouse can exist, this is safe.
extern bool sDebugLayout;
extern WidgetT *sClosedPopup;
extern WidgetT *sFocusedWidget;
extern WidgetT *sKeyPressedBtn;
extern WidgetT *sOpenPopup;
extern WidgetT *sPressedButton;
extern WidgetT *sDragSlider;
extern WidgetT *sDrawingCanvas;
extern WidgetT *sDragTextSelect;
extern int32_t sDragOffset;
extern WidgetT *sResizeListView;
extern int32_t sResizeCol;
extern int32_t sResizeStartX;
extern int32_t sResizeOrigW;
extern WidgetT *sDragSplitter;
extern int32_t sDragSplitStart;
extern WidgetT *sDragReorder;
extern WidgetT *sClosedPopup; // popup that was just closed (prevents immediate reopen)
extern WidgetT *sFocusedWidget; // currently focused widget across all windows
extern WidgetT *sKeyPressedBtn; // button being held via keyboard (Space/Enter)
extern WidgetT *sOpenPopup; // dropdown/combobox with open popup list
extern WidgetT *sPressedButton; // button/imagebutton being held via mouse
extern WidgetT *sDragSlider; // slider being dragged
extern WidgetT *sDrawingCanvas; // canvas receiving drag events
extern WidgetT *sDragTextSelect; // text widget with active mouse selection drag
extern int32_t sDragOffset; // mouse offset within thumb/handle at drag start
extern WidgetT *sResizeListView; // listview whose column is being resized
extern int32_t sResizeCol; // which column is being resized
extern int32_t sResizeStartX; // mouse X at start of column resize
extern int32_t sResizeOrigW; // original column width at start of resize
extern WidgetT *sDragSplitter; // splitter being dragged
extern int32_t sDragSplitStart; // mouse position at start of splitter drag
extern WidgetT *sDragReorder; // listbox/treeview item being drag-reordered
// ============================================================
// Core functions (widgetCore.c)
// ============================================================
// Tree manipulation
void widgetAddChild(WidgetT *parent, WidgetT *child);
WidgetT *widgetAlloc(WidgetT *parent, WidgetTypeE type);
void widgetClearFocus(WidgetT *root);
int32_t widgetCountVisibleChildren(const WidgetT *w);
void widgetRemoveChild(WidgetT *parent, WidgetT *child);
void widgetDestroyChildren(WidgetT *w);
void widgetDropdownPopupRect(WidgetT *w, const BitmapFontT *font, int32_t contentH, int32_t *popX, int32_t *popY, int32_t *popW, int32_t *popH);
WidgetT *widgetFindByAccel(WidgetT *root, char key);
// Allocate a new widget, set its type and vtable, and add it as a child.
WidgetT *widgetAlloc(WidgetT *parent, WidgetTypeE type);
// Focus management — Tab/Shift-Tab navigation walks the tree in creation
// order, skipping non-focusable and hidden widgets.
void widgetClearFocus(WidgetT *root);
WidgetT *widgetFindNextFocusable(WidgetT *root, WidgetT *after);
WidgetT *widgetFindPrevFocusable(WidgetT *root, WidgetT *before);
// Alt+key accelerator lookup — searches the tree for a widget whose
// accelKey matches, used for keyboard navigation of labeled controls.
WidgetT *widgetFindByAccel(WidgetT *root, char key);
// Utility queries
int32_t widgetCountVisibleChildren(const WidgetT *w);
int32_t widgetFrameBorderWidth(const WidgetT *w);
WidgetT *widgetHitTest(WidgetT *w, int32_t x, int32_t y);
bool widgetIsFocusable(WidgetTypeE type);
bool widgetIsBoxContainer(WidgetTypeE type);
bool widgetIsHorizContainer(WidgetTypeE type);
// Hit testing — find the deepest widget containing the point (x,y).
// Respects WCLASS_NO_HIT_RECURSE to stop at list/tree widgets.
WidgetT *widgetHitTest(WidgetT *w, int32_t x, int32_t y);
// Translate arrow/PgUp/PgDn/Home/End keys into list index changes.
// Shared by ListBox, ListView, Dropdown, and TreeView.
int32_t widgetNavigateIndex(int32_t key, int32_t current, int32_t count, int32_t pageSize);
// Compute popup position for a dropdown/combobox, clamped to screen bounds.
void widgetDropdownPopupRect(WidgetT *w, const BitmapFontT *font, int32_t contentH, int32_t *popX, int32_t *popY, int32_t *popW, int32_t *popH);
// Paint a generic popup item list (used by dropdown, combobox, and popup menus).
void widgetPaintPopupList(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t popX, int32_t popY, int32_t popW, int32_t popH, const char **items, int32_t itemCount, int32_t hoverIdx, int32_t scrollPos);
void widgetRemoveChild(WidgetT *parent, WidgetT *child);
// Compute scrollbar thumb position and size for a given track length,
// content size, visible size, and scroll position. Used by both widget-
// internal scrollbars and the window-level scrollbar drawing code.
void widgetScrollbarThumb(int32_t trackLen, int32_t totalSize, int32_t visibleSize, int32_t scrollPos, int32_t *thumbPos, int32_t *thumbSize);
// ============================================================
// Shared scrollbar functions (widgetScrollbar.c)
// ============================================================
//
// Widget-internal scrollbar rendering and hit testing, shared across
// ListBox, ListView, TreeView, TextArea, ScrollPane, and AnsiTerm.
// These operate on abstract track coordinates (position + length) so
// the same code handles both horizontal and vertical scrollbars.
typedef enum {
ScrollHitNoneE,
@ -177,6 +264,19 @@ ScrollHitE widgetScrollbarHitTest(int32_t sbLen, int32_t relPos, int32_t totalSi
// ============================================================
// Layout functions (widgetLayout.c)
// ============================================================
//
// The layout algorithm is a two-pass flexbox-like system:
//
// calcMinSize (bottom-up): each leaf widget reports its minimum size
// (e.g. button = text width + padding). Containers sum their children's
// minimums plus spacing along the main axis, and take the maximum of
// children's minimums on the cross axis.
//
// layout (top-down): starting from the available space (window content
// area), each container distributes space to children. Children that fit
// at their minimum get their minimum. Remaining space is distributed
// proportionally by weight to flexible children. This happens recursively
// down the tree.
void widgetCalcMinSizeBox(WidgetT *w, const BitmapFontT *font);
void widgetCalcMinSizeTree(WidgetT *w, const BitmapFontT *font);
@ -186,8 +286,16 @@ void widgetLayoutChildren(WidgetT *w, const BitmapFontT *font);
// ============================================================
// Event functions (widgetEvent.c)
// ============================================================
//
// These are the WindowT callback implementations installed by wgtInitWindow().
// They bridge the window manager's callback interface to the widget tree's
// event dispatch. Each one locates the appropriate widget (via hit-test for
// mouse events, or the focused widget for keyboard events) and calls the
// widget's vtable handler.
// Sync window-level scrollbar ranges to match the widget tree's overflow.
void widgetManageScrollbars(WindowT *win, AppContextT *ctx);
void widgetOnKey(WindowT *win, int32_t key, int32_t mod);
void widgetOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons);
void widgetOnPaint(WindowT *win, RectT *dirtyArea);
@ -310,11 +418,23 @@ void widgetTextInputDestroy(WidgetT *w);
// ============================================================
// Per-widget mouse/key functions
// ============================================================
//
// Shared text editing helpers used by TextInput, TextArea, ComboBox, and
// Spinner. The widgetTextEditOnKey function implements the common subset
// of single-line text editing behavior (cursor movement, selection,
// copy/paste, undo) shared across all text-editing widgets, so changes
// to editing behavior only need to happen in one place.
// Clear text selections in all widgets except the given one (ensures only
// one text selection is active at a time across the entire GUI).
void clearOtherSelections(WidgetT *except);
void clipboardCopy(const char *text, int32_t len);
const char *clipboardGet(int32_t *outLen);
bool isWordChar(char c);
// Detect double/triple clicks based on timing and position proximity.
// Returns 1 for single, 2 for double, 3 for triple click.
int32_t multiClickDetect(int32_t vx, int32_t vy);
void widgetAnsiTermOnKey(WidgetT *w, int32_t key, int32_t mod);
void widgetAnsiTermOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
@ -351,6 +471,11 @@ void widgetTabControlOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
void widgetTextAreaOnKey(WidgetT *w, int32_t key, int32_t mod);
void widgetTextAreaOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
void widgetTextDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
// Shared single-line text editing logic. Takes pointers to all the edit
// state fields so the same code works for TextInput, ComboBox, and Spinner
// (which store these fields in different union members). This avoids
// duplicating the full editing logic (cursor movement, word selection,
// clipboard, undo) three times.
void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursor, int32_t *pScrollOff, int32_t *pSelStart, int32_t *pSelEnd, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor);
void widgetTextInputOnKey(WidgetT *w, int32_t key, int32_t mod);
void widgetTextInputOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
@ -359,9 +484,15 @@ int32_t wordStart(const char *buf, int32_t pos);
void widgetTreeViewOnKey(WidgetT *w, int32_t key, int32_t mod);
void widgetTreeViewOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
// Drag-reorder helpers (dispatch to ListBox/ListView/TreeView)
// Drag-reorder helpers — generic drag-and-drop reordering shared by
// ListBox, ListView, and TreeView. The update function tracks the mouse
// position and computes the drop target, the drop function commits the
// reorder by relinking the item in the data structure.
void widgetReorderUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y);
void widgetReorderDrop(WidgetT *w);
// Iterate to the next visible item in a tree view (skipping collapsed
// subtrees). Used for keyboard navigation and rendering.
WidgetT *widgetTreeViewNextVisible(WidgetT *item, WidgetT *treeView);
#endif // WIDGET_INTERNAL_H

View file

@ -1,4 +1,19 @@
// widgetLabel.c — Label widget
//
// Static text display — the simplest widget. Not focusable, not interactive.
// Supports accelerator keys via '&' prefix: when the user presses Alt+key,
// focus moves to the next focusable widget after this label. This follows
// the Win3.1 convention where labels act as keyboard shortcuts for adjacent
// controls (e.g., a label "&Name:" before a text input).
//
// The text pointer is stored directly (not copied) — the caller must ensure
// the string remains valid for the widget's lifetime, or use widgetLabelSetText
// to update it. This avoids unnecessary allocations for the common case of
// literal string labels.
//
// Background is transparent by default (bgColor == 0 means use the parent's
// content background color from the color scheme). The +1 in calcMinH adds a
// pixel of breathing room below the text baseline.
#include "widgetInternal.h"

View file

@ -1,4 +1,30 @@
// widgetLayout.c — Layout engine (measure + arrange)
//
// Implements a two-pass layout algorithm inspired by CSS flexbox:
// Pass 1 (Measure): bottom-up calculation of each widget's minimum
// size. Leaf widgets report their intrinsic size (text width, etc.)
// and containers sum their children's minimums plus padding/spacing.
// Pass 2 (Arrange): top-down assignment of actual positions and sizes.
// The root gets the full available area, then each container divides
// its space among children based on their minimums and weight values.
//
// Why two passes instead of one:
// Containers can't assign sizes until they know all children's minimums
// (to calculate extra space for weight distribution). And children can't
// know their minimums until their subtrees are measured. So measure must
// be bottom-up before arrange can be top-down.
//
// Why flexbox-like rather than constraint-based:
// Flexbox maps naturally to the VBox/HBox container model — it's simple
// to understand, implement, and debug. Constraint-based layout (like
// Cassowary) would add significant complexity for little benefit in a
// DOS GUI where most layouts are linear stacks of widgets.
//
// Size hints use a tagged integer encoding (see dvxWidget.h):
// High 2 bits encode the unit type (pixels, characters, percent),
// low 30 bits encode the numeric value. This packs three distinct
// unit types into a single int32_t without needing a separate struct.
// The wgtResolveSize() function decodes the tag and converts to pixels.
#include "widgetInternal.h"
@ -6,6 +32,15 @@
// ============================================================
// widgetCalcMinSizeBox
// ============================================================
//
// Measure pass for box containers (VBox, HBox, RadioGroup, StatusBar,
// Toolbar, Frame, TabPage). Recursively measures all visible children,
// then computes this container's minimum size as:
// main axis: sum of children minimums + gaps + padding
// cross axis: max of children minimums + padding
//
// "Main axis" is vertical for VBox, horizontal for HBox. The horiz
// flag flips the calculation so VBox and HBox share the same code.
void widgetCalcMinSizeBox(WidgetT *w, const BitmapFontT *font) {
bool horiz = widgetIsHorizContainer(w->type);
@ -23,7 +58,9 @@ void widgetCalcMinSizeBox(WidgetT *w, const BitmapFontT *font) {
gap = DEFAULT_SPACING;
}
// Frame: box starts at y + charHeight/2 so the title sits on the top line
// Frame reserves extra vertical space at the top so the frame title
// text can sit centered on the top border line (like Windows group boxes).
// Without this, the first child would overlap the title.
int32_t frameExtraTop = 0;
if (w->type == WidgetFrameE) {
@ -31,7 +68,8 @@ void widgetCalcMinSizeBox(WidgetT *w, const BitmapFontT *font) {
pad = DEFAULT_PADDING;
}
// Toolbar and StatusBar use tighter padding
// Toolbar and StatusBar use minimal padding to pack controls tightly,
// matching the compact feel of classic Windows/Motif toolbars.
if (w->type == WidgetToolbarE || w->type == WidgetStatusBarE) {
pad = TOOLBAR_PAD;
gap = TOOLBAR_GAP;
@ -85,7 +123,17 @@ void widgetCalcMinSizeBox(WidgetT *w, const BitmapFontT *font) {
// widgetCalcMinSizeTree
// ============================================================
//
// Top-level measure dispatcher. Recurses through the widget tree.
// Top-level measure dispatcher. Routes to the appropriate measure
// function based on widget type:
// - Box containers use the generic widgetCalcMinSizeBox()
// - Widgets with custom layout (TabControl, TreeView, ScrollPane,
// Splitter) provide their own calcMinSize via the vtable
// - Widgets without calcMinSize get 0x0 (they rely on size hints)
//
// After the type-specific measure, explicit minW/minH size hints
// are applied as a floor. This allows "this widget should be at
// least N pixels/chars wide" without changing the widget's natural
// size calculation. Hints only increase the minimum, never shrink it.
void widgetCalcMinSizeTree(WidgetT *w, const BitmapFontT *font) {
if (widgetIsBoxContainer(w->type)) {
@ -97,7 +145,10 @@ void widgetCalcMinSizeTree(WidgetT *w, const BitmapFontT *font) {
w->calcMinH = 0;
}
// Apply size hints (override calculated minimum)
// Size hints act as a floor: if the app specified wgtPixels(200) as
// minW, the widget will be at least 200px wide even if its intrinsic
// content is smaller. This is resolved at measure time (not arrange)
// so parent containers see the correct minimum when distributing space.
if (w->minW) {
int32_t hintW = wgtResolveSize(w->minW, 0, font->charWidth);
@ -119,6 +170,27 @@ void widgetCalcMinSizeTree(WidgetT *w, const BitmapFontT *font) {
// ============================================================
// widgetLayoutBox
// ============================================================
//
// Arrange pass for box containers. This is the core of the flexbox-
// like layout algorithm, working in two sub-passes:
//
// Sub-pass 1: Sum all children's minimum sizes and total weight.
// "Extra space" = available main-axis size - total minimum sizes.
//
// Sub-pass 2: Assign each child's position and size.
// - Each child gets at least its minimum size.
// - Extra space is distributed proportionally by weight (like CSS
// flex-grow). A child with weight=100 gets twice as much extra
// as one with weight=50.
// - If total weight is 0 (all children are fixed-size), extra space
// is distributed according to the container's alignment: start
// (default), center, or end.
// - maxW/maxH constraints cap a child's final size.
// - Cross-axis size is the full available cross dimension (minus
// any maxW/maxH constraint on that axis).
//
// After positioning each child, widgetLayoutChildren() recurses to
// lay out the child's own subtree.
void widgetLayoutBox(WidgetT *w, const BitmapFontT *font) {
bool horiz = widgetIsHorizContainer(w->type);
@ -167,7 +239,9 @@ void widgetLayoutBox(WidgetT *w, const BitmapFontT *font) {
int32_t availMain = horiz ? (innerW - totalGap) : (innerH - totalGap);
int32_t availCross = horiz ? innerH : innerW;
// First pass: sum minimum sizes and total weight
// Sub-pass 1: accumulate children's minimum sizes and weights.
// totalMin is the space needed if every child gets exactly its minimum.
// totalWeight determines how to split any leftover space.
int32_t totalMin = 0;
int32_t totalWeight = 0;
@ -187,7 +261,9 @@ void widgetLayoutBox(WidgetT *w, const BitmapFontT *font) {
extraSpace = 0;
}
// Compute alignment offset for main axis
// Alignment only applies when no children have weight (all fixed-size).
// When weights exist, the extra space is consumed by weighted children
// and alignment has no effect.
int32_t alignOffset = 0;
if (totalWeight == 0 && extraSpace > 0) {
@ -269,7 +345,11 @@ void widgetLayoutBox(WidgetT *w, const BitmapFontT *font) {
// widgetLayoutChildren
// ============================================================
//
// Top-level layout dispatcher.
// Top-level layout dispatcher. Mirrors the measure dispatcher
// in widgetCalcMinSizeTree(): box containers use the generic
// algorithm, widgets with custom layout (TabControl, TreeView,
// ScrollPane, Splitter) use their vtable entry, and leaf widgets
// do nothing (they have no children to lay out).
void widgetLayoutChildren(WidgetT *w, const BitmapFontT *font) {
if (widgetIsBoxContainer(w->type)) {
@ -283,6 +363,14 @@ void widgetLayoutChildren(WidgetT *w, const BitmapFontT *font) {
// ============================================================
// wgtLayout
// ============================================================
//
// Public entry point: runs both passes on the entire widget tree.
// The root widget is positioned at (0,0) and given the full available
// area, then the arrange pass distributes space to its children.
//
// This is called from widgetManageScrollbars() and widgetOnPaint(),
// which may pass a virtual content size larger than the physical
// window if scrolling is needed.
void wgtLayout(WidgetT *root, int32_t availW, int32_t availH, const BitmapFontT *font) {
if (!root) {
@ -305,6 +393,21 @@ void wgtLayout(WidgetT *root, int32_t availW, int32_t availH, const BitmapFontT
// ============================================================
// wgtResolveSize
// ============================================================
//
// Decodes a tagged size value into an actual pixel count.
//
// The tagged integer format uses the high 2 bits as a type tag:
// 00 = pixels (value is used directly)
// 01 = characters (value * charWidth, for text-relative sizing)
// 10 = percent (value% of parentSize, for responsive layouts)
//
// This encoding allows a single int32_t field to represent any of
// three unit types without needing a separate struct or enum.
// The tradeoff is that pixel values are limited to 30 bits (~1 billion),
// which is far more than any supported display resolution.
//
// A value of 0 means "auto" (use intrinsic/calculated size) — the
// caller checks for 0 before calling this function.
int32_t wgtResolveSize(int32_t taggedSize, int32_t parentSize, int32_t charWidth) {
if (taggedSize == 0) {

View file

@ -1,4 +1,31 @@
// widgetListBox.c — ListBox widget (single and multi-select)
//
// Scrollable list of text items with single-select or multi-select modes.
// Items are stored as external string pointers (not copied), with a vertical
// scrollbar appearing when the item count exceeds visible rows.
//
// Multi-select uses a parallel selBits array (one byte per item, 0 or 1)
// rather than a bitfield. Using a full byte per item wastes some memory but
// makes individual item toggle/test trivial without shift/mask operations,
// which matters when the selection code runs on every click and keyboard event.
//
// The selection model follows Windows explorer conventions:
// - Plain click: select one item, clear others, set anchor
// - Ctrl+click: toggle one item, update anchor
// - Shift+click: range select from anchor to clicked item
// - Shift+arrow: range select from anchor to cursor
// - Space: toggle current item (multi-select)
// - Ctrl+A: select all (multi-select)
//
// The "anchor" concept is key: it's the starting point for shift-select
// ranges. It's updated on non-shift clicks but stays fixed during shift
// operations, allowing the user to extend/shrink the selection by
// shift-clicking different endpoints.
//
// Drag-reorder support allows items to be rearranged by dragging. When
// enabled, a mouse-down initiates a drag (tracked via sDragReorder global),
// and a 2px horizontal line indicator shows the insertion point. The actual
// reordering is handled by the application's onReorder callback.
#include "widgetInternal.h"
@ -22,6 +49,9 @@ static void selectRange(WidgetT *w, int32_t from, int32_t to);
// allocSelBits
// ============================================================
// Allocate (or re-allocate) the selection bits array. Only allocated in
// multi-select mode — in single-select, selectedIdx alone tracks the
// selection. calloc initializes all bits to 0 (nothing selected).
static void allocSelBits(WidgetT *w) {
if (w->as.listBox.selBits) {
free(w->as.listBox.selBits);
@ -40,6 +70,9 @@ static void allocSelBits(WidgetT *w) {
// ensureScrollVisible
// ============================================================
// Adjust scroll position so the item at idx is within the visible viewport.
// If the item is above the viewport, scroll up to it. If below, scroll down
// to show it at the bottom. If already visible, do nothing.
static void ensureScrollVisible(WidgetT *w, int32_t idx) {
if (idx < 0) {
return;
@ -66,6 +99,8 @@ static void ensureScrollVisible(WidgetT *w, int32_t idx) {
// selectRange
// ============================================================
// Set selection bits for all items in the range [from, to] (inclusive,
// order-independent). Used for shift-click and shift-arrow range selection.
static void selectRange(WidgetT *w, int32_t from, int32_t to) {
if (!w->as.listBox.selBits) {
return;
@ -292,6 +327,10 @@ void wgtListBoxSetSelected(WidgetT *w, int32_t idx) {
// widgetListBoxCalcMinSize
// ============================================================
// Min size accounts for the widest item, a scrollbar, padding, and border.
// Height is based on LISTBOX_MIN_ROWS (4 rows) so the listbox has a usable
// minimum height even when empty. The 8-character minimum width prevents
// the listbox from collapsing too narrow when items are short or absent.
void widgetListBoxCalcMinSize(WidgetT *w, const BitmapFontT *font) {
int32_t maxItemW = w->as.listBox.maxItemLen * font->charWidth;
int32_t minW = font->charWidth * 8;
@ -309,6 +348,10 @@ void widgetListBoxCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetListBoxOnKey
// ============================================================
// Key handling: delegates to widgetNavigateIndex for cursor movement
// (Up, Down, Home, End, PgUp, PgDn) which returns the new index after
// navigation. This shared helper ensures consistent keyboard navigation
// across ListBox, ListView, and other scrollable widgets.
void widgetListBoxOnKey(WidgetT *w, int32_t key, int32_t mod) {
if (!w || w->type != WidgetListBoxE || w->as.listBox.itemCount == 0) {
return;
@ -384,6 +427,11 @@ void widgetListBoxOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetListBoxOnMouse
// ============================================================
// Mouse handling: first checks if the click is on the scrollbar (right edge),
// then falls through to item click handling. The scrollbar hit-test uses
// widgetScrollbarHitTest which divides the scrollbar into arrow buttons, page
// regions, and thumb based on the click position. Item clicks are translated
// from pixel coordinates to item index using integer division by charHeight.
void widgetListBoxOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
@ -490,6 +538,13 @@ void widgetListBoxOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
// widgetListBoxPaint
// ============================================================
// Paint: sunken bevel border, then iterate visible rows drawing text.
// Selected items get highlight background (full row width) with contrasting
// text color. In multi-select mode, the cursor item (selectedIdx) gets a
// dotted focus rect overlay — this is separate from the selection highlight
// so the user can see which item the keyboard cursor is on even when multiple
// items are selected. The scrollbar is drawn only when needed (item count
// exceeds visible rows), and the content width is reduced accordingly.
void widgetListBoxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
@ -551,7 +606,9 @@ void widgetListBoxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitm
}
}
// Draw drag-reorder insertion indicator
// Draw drag-reorder insertion indicator: a 2px horizontal line at the
// drop position. The line is drawn between items (not on top of them)
// so it's clear where the dragged item will be inserted.
if (w->as.listBox.reorderable && w->as.listBox.dragIdx >= 0 && w->as.listBox.dropIdx >= 0) {
int32_t drop = w->as.listBox.dropIdx;
int32_t lineY = innerY + (drop - scrollPos) * font->charHeight;

View file

@ -1,4 +1,51 @@
// widgetListView.c — ListView (multi-column list) widget
//
// A multi-column list with clickable/sortable column headers, horizontal
// and vertical scrolling, column resize by dragging header borders, and
// multi-select support. This is the most complex widget in the toolkit.
//
// Data model: cell data is stored as a flat array of const char* pointers
// in row-major order (cellData[row * colCount + col]). This external data
// model (pointers, not copies) keeps memory usage minimal and avoids
// copying strings that the application already owns.
//
// ListView state is heap-allocated separately (ListViewDataT*) rather than
// inlined in the widget union because the state is too large for a union
// member — it includes per-column resolved widths, sort index, selection
// bits, and numerous scroll/drag state fields.
//
// Sort: clicking a column header toggles sort direction. Sorting uses an
// indirection array (sortIndex) rather than rearranging the cellData. This
// is critical because:
// 1. The application owns cellData and may not expect it to be mutated
// 2. Multi-select selBits are indexed by data row, not display row —
// rearranging data would invalidate all selection state
// 3. The indirection allows O(1) display↔data row mapping
//
// Insertion sort is used because it's stable (preserves original order for
// equal keys) and has good performance for the typical case of partially-sorted
// data. For the row counts typical in a DOS GUI (hundreds, not millions),
// insertion sort's O(n^2) worst case is acceptable and the constant factors
// are lower than quicksort.
//
// Column widths support four sizing modes via a tagged integer:
// - 0: auto-size (scan all data for widest string)
// - positive small: character count (resolved via font->charWidth)
// - positive large: pixel width
// - negative: percentage of parent width
// The resolution is done lazily in resolveColumnWidths, which is called on
// first paint or mouse event after data changes.
//
// Column resize: dragging a column header border (3px hot zone) resizes the
// column. The drag is tracked via sResizeListView/sResizeCol/sResizeStartX/
// sResizeOrigW globals. A minimum column width (LISTVIEW_MIN_COL_W) prevents
// columns from collapsing to zero.
//
// Horizontal scrolling is needed when total column width exceeds the widget's
// inner width. Both headers and data rows are offset by scrollPosH. The
// horizontal scrollbar appears automatically when needed, and its presence
// may trigger the vertical scrollbar to appear (and vice versa) — this
// two-pass logic handles the interdependency correctly.
#include "widgetInternal.h"
@ -24,6 +71,8 @@ static void resolveColumnWidths(WidgetT *w, const BitmapFontT *font);
// allocListViewSelBits
// ============================================================
// Same allocation strategy as ListBox's selBits — one byte per row,
// only allocated when multiSelect is enabled.
static void allocListViewSelBits(WidgetT *w) {
if (w->as.listView->selBits) {
free(w->as.listView->selBits);
@ -46,6 +95,12 @@ static void allocListViewSelBits(WidgetT *w) {
// (stable) on the sort column. If no sort is active, frees
// the index so paint uses natural order.
// Build the sort indirection array. Rather than rearranging cellData (which
// the application owns and indexes into), we maintain a separate array that
// maps display-row → data-row. Paint and keyboard navigation work in display-row
// space, then map through sortIndex to get the data row for cell lookups and
// selection operations. This decoupling is essential because selBits are
// indexed by data row — sorting must not invalidate selection state.
static void listViewBuildSortIndex(WidgetT *w) {
int32_t rowCount = w->as.listView->rowCount;
int32_t sortCol = w->as.listView->sortCol;
@ -79,7 +134,13 @@ static void listViewBuildSortIndex(WidgetT *w) {
idx[i] = i;
}
// Insertion sort — stable, O(n^2) but fine for typical row counts
// Insertion sort — stable (equal elements keep their original order),
// O(n^2) worst case but with very low constant factors. For typical
// ListView row counts (10s to low 100s), this beats quicksort because
// there's no recursion overhead, no stack usage, and the inner loop
// is a simple pointer chase. On a 486, the predictable memory access
// pattern also helps since insertion sort is cache-friendly (sequential
// access to adjacent elements).
bool ascending = (w->as.listView->sortDir == ListViewSortAscE);
for (int32_t i = 1; i < rowCount; i++) {
@ -126,6 +187,14 @@ static void listViewBuildSortIndex(WidgetT *w) {
// to actual pixel widths. Auto-sized columns (width==0) scan
// cellData for the widest string in that column.
// Resolve tagged column widths to actual pixel values. Column widths in the
// ListViewColT definition can be specified as:
// 0 = auto (scan data for widest string, include header)
// positive = passed to wgtResolveSize (handles px, chars, or percent)
//
// Auto-sizing scans all rows for the widest string in that column, which
// is O(rows * cols) but only runs once per data change (totalColW is reset
// to 0 when data changes, triggering recalculation on next paint/mouse).
static void resolveColumnWidths(WidgetT *w, const BitmapFontT *font) {
int32_t colCount = w->as.listView->colCount;
int32_t parentW = w->w - LISTVIEW_BORDER * 2;
@ -174,6 +243,10 @@ static void resolveColumnWidths(WidgetT *w, const BitmapFontT *font) {
// Returns true if (vx, vy) is on a column header border (resize zone).
// Coordinates are in widget/virtual space (scroll-adjusted).
// Test if a point is on a column header border (within 3px). Used by the
// cursor-shape logic to show a resize cursor when hovering over column borders.
// The 3px zone on each side of the border line makes it easy to grab even
// on low-resolution displays where pixel-precise clicking is difficult.
bool widgetListViewColBorderHit(const WidgetT *w, int32_t vx, int32_t vy) {
if (!w || w->type != WidgetListViewE || w->as.listView->colCount == 0) {
return false;
@ -208,6 +281,9 @@ bool widgetListViewColBorderHit(const WidgetT *w, int32_t vx, int32_t vy) {
// widgetListViewDestroy
// ============================================================
// Free all heap-allocated ListView state. The sortIndex and selBits are
// optional (may be NULL if no sort or single-select), so we check before
// freeing. The ListViewDataT struct itself is always freed.
void widgetListViewDestroy(WidgetT *w) {
if (w->as.listView->sortIndex) {
free(w->as.listView->sortIndex);
@ -226,6 +302,11 @@ void widgetListViewDestroy(WidgetT *w) {
// wgtListView
// ============================================================
// Create a ListView widget. The ListViewDataT is heap-allocated separately
// because it's too large for the widget union (it contains fixed-size arrays
// for resolved column widths, plus numerous state fields for scrolling, sorting,
// selection, and drag-reorder). Default weight=100 makes the ListView expand
// to fill available space, which is the typical desired behavior.
WidgetT *wgtListView(WidgetT *parent) {
WidgetT *w = widgetAlloc(parent, WidgetListViewE);
@ -400,6 +481,11 @@ void wgtListViewSetItemSelected(WidgetT *w, int32_t idx, bool selected) {
// wgtListViewSetReorderable
// ============================================================
// Enable/disable drag-reorder. When reorderable is enabled, sorting is
// disabled because the two features are fundamentally incompatible: if the
// user manually arranges items, then sorts by a column, the manual order
// is lost. By clearing sortCol and freeing sortIndex, we ensure the display
// shows the natural data order which the user's drag operations will modify.
void wgtListViewSetReorderable(WidgetT *w, bool reorderable) {
VALIDATE_WIDGET_VOID(w, WidgetListViewE);
@ -487,6 +573,18 @@ void widgetListViewCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetListViewOnKey
// ============================================================
// Key handling must account for the sort indirection layer. Navigation happens
// in display-row space (what the user sees on screen), but selection and data
// access happen in data-row space (the original cellData indices). This means:
// 1. Convert selectedIdx (data row) to display row via linear search in sortIndex
// 2. Navigate in display space (widgetNavigateIndex)
// 3. Convert back to data row via sortIndex[displayRow]
// 4. Update selectedIdx and selBits using data-row indices
//
// Shift-select for sorted views is particularly tricky: the range must be
// computed in display-row space (what the user sees as contiguous) but applied
// to the data-row selBits. This means the selected data rows may not be
// contiguous in the original data order.
void widgetListViewOnKey(WidgetT *w, int32_t key, int32_t mod) {
if (!w || w->type != WidgetListViewE || w->as.listView->rowCount == 0) {
return;
@ -627,6 +725,18 @@ void widgetListViewOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetListViewOnMouse
// ============================================================
// Mouse handling is the most complex part of ListView. It handles four
// distinct hit regions (checked in priority order):
// 1. Vertical scrollbar (right edge, if visible)
// 2. Horizontal scrollbar (bottom edge, if visible)
// 3. Dead corner (when both scrollbars present — no action)
// 4. Column headers: resize drag (border +-3px) or sort toggle (click)
// 5. Data rows: item selection with Ctrl/Shift modifiers, double-click,
// and drag-reorder initiation
//
// The scrollbar visibility calculation is repeated here (matching paint)
// because scrollbar presence depends on content/widget dimensions which
// may have changed since the last paint.
void widgetListViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
hit->focused = true;
@ -646,6 +756,10 @@ void widgetListViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy)
bool needVSb = (hit->as.listView->rowCount > visibleRows);
bool needHSb = false;
// Scrollbar interdependency: adding a vertical scrollbar reduces innerW,
// which may cause columns to overflow and require a horizontal scrollbar.
// Adding a horizontal scrollbar reduces innerH, which may require a
// vertical scrollbar. This two-pass check handles the mutual dependency.
if (needVSb) {
innerW -= WGT_SB_W;
}
@ -772,7 +886,10 @@ void widgetListViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy)
colX += cw;
}
// Not on a border — check for sort click (disabled when reorderable)
// Not on a border — check for sort click (disabled when reorderable
// since sorting conflicts with manual ordering). Clicking a column
// toggles between ascending/descending; clicking a different column
// starts ascending.
if (!hit->as.listView->reorderable) {
colX = hit->x + LISTVIEW_BORDER - hit->as.listView->scrollPosH;
@ -898,6 +1015,24 @@ void widgetListViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy)
// widgetListViewPaint
// ============================================================
// Paint: the most involved paint function in the widget toolkit. Renders
// in layers:
// 1. Outer sunken bevel border
// 2. Column headers (clipped to content width, scrolled by scrollPosH):
// each header is a raised bevel button with centered text and optional
// sort indicator (up/down triangle)
// 3. Data rows (clipped to data area, scrolled both H and V):
// background fill, then per-cell text rendering with alignment, then
// selection highlight, then cursor focus rect in multi-select mode
// 4. Drag-reorder insertion line (if active)
// 5. Scrollbars (V and/or H, with dead corner fill)
// 6. Outer focus rect
//
// Clip rectangles are saved/restored around the header and data sections to
// prevent rendering from bleeding into the scrollbar or border areas. This
// is cheaper than per-cell clipping and handles horizontal scroll correctly
// (partially visible columns at the edges are clipped by the display's clip
// rect rather than by the draw calls).
void widgetListViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
@ -998,7 +1133,10 @@ void widgetListViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
}
}
// Sort indicator
// Sort indicator: a small filled triangle (4px tall) in the header.
// Up triangle for ascending, down triangle for descending. Drawn as
// horizontal lines of increasing/decreasing width, same technique as
// the dropdown arrow glyph.
if (c == w->as.listView->sortCol && w->as.listView->sortDir != ListViewSortNoneE) {
int32_t cx = hdrX + cw - LISTVIEW_SORT_W / 2 - LISTVIEW_PAD;
int32_t cy = baseY + headerH / 2;
@ -1041,7 +1179,10 @@ void widgetListViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
int32_t scrollPos = w->as.listView->scrollPos;
int32_t *sortIdx = w->as.listView->sortIndex;
// Fill entire data area background first
// Fill entire data area background first. This is done as a single
// rectFill rather than per-row because it's cheaper to fill once and
// then overdraw selected rows than to compute and fill each unselected
// row's background separately.
rectFill(d, ops, baseX, dataY, innerW, innerH, bg);
bool multi = w->as.listView->multiSelect;
@ -1089,6 +1230,12 @@ void widgetListViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
int32_t tx = cellX + LISTVIEW_PAD;
// Column alignment: left (default), right, or center.
// Right/center alignment compute the available width and
// offset the text start position accordingly. This is done
// per-cell rather than using a general-purpose aligned draw
// function to keep the inner loop simple and avoid extra
// function call overhead per cell.
if (w->as.listView->cols[c].align == ListViewAlignRightE) {
int32_t renderedW = cellLen * font->charWidth;
int32_t availW = cw - LISTVIEW_PAD * 2;
@ -1139,7 +1286,10 @@ void widgetListViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
int32_t sbY = w->y + w->h - LISTVIEW_BORDER - WGT_SB_W;
widgetDrawScrollbarH(d, ops, colors, sbX, sbY, innerW, totalColW, innerW, w->as.listView->scrollPosH);
// Fill dead corner when both scrollbars present
// Fill the dead corner when both scrollbars are present. This is the
// small square at the intersection of the two scrollbars (bottom-right)
// that doesn't belong to either. Without filling it, stale content
// from previous paints would show through.
if (needVSb) {
rectFill(d, ops, sbX + innerW, sbY, WGT_SB_W, WGT_SB_W, colors->windowFace);
}

View file

@ -1,4 +1,14 @@
// widgetOps.c — Paint dispatcher and public widget operations
//
// This file contains two categories of functions:
// 1. The paint dispatcher (widgetPaintOne, widgetPaintOverlays, wgtPaint)
// which walks the widget tree and calls per-type paint functions.
// 2. Public operations (wgtSetText, wgtSetEnabled, wgtSetVisible,
// wgtFind, wgtDestroy, etc.) that form the widget system's public API.
//
// The paint dispatcher and the public operations are in the same file
// because they share the same invalidation infrastructure (wgtInvalidate
// and wgtInvalidatePaint).
#include "widgetInternal.h"
@ -7,8 +17,12 @@
// debugContainerBorder
// ============================================================
//
// Draw a 1px border in a garish neon color derived from the widget
// pointer so every container gets a distinct, ugly color.
// Draws a 1px border in a neon color derived from the widget pointer.
// The Knuth multiplicative hash (2654435761) distributes pointer values
// across the palette evenly so adjacent containers get different colors.
// This is only active when sDebugLayout is true (toggled via
// wgtSetDebugLayout), used during development to visualize container
// boundaries and diagnose layout issues.
static void debugContainerBorder(WidgetT *w, DisplayT *d, const BlitOpsT *ops) {
static const uint8_t palette[][3] = {
@ -41,8 +55,17 @@ static void debugContainerBorder(WidgetT *w, DisplayT *d, const BlitOpsT *ops) {
// widgetPaintOne
// ============================================================
//
// Paint a single widget and its children. Dispatches to per-widget
// paint functions defined in their respective files.
// Recursive paint walker. For each visible widget:
// 1. Call the widget's paint function (if any) via vtable.
// 2. If the widget has WCLASS_PAINTS_CHILDREN, stop recursion —
// the widget's paint function already handled its children
// (e.g. TabControl only paints the active tab's children).
// 3. Otherwise, recurse into children (default child painting).
// 4. Draw debug borders on top if debug layout is enabled.
//
// The paint order is parent-before-children, which means parent
// backgrounds are drawn first and children paint on top. This is
// the standard painter's algorithm for nested UI elements.
void widgetPaintOne(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
if (!w->visible) {
@ -80,7 +103,12 @@ void widgetPaintOne(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFo
// ============================================================
//
// Paints popup overlays (open dropdowns/comboboxes) on top of
// the widget tree. Called after the main paint pass.
// the widget tree. Called AFTER the main paint pass so popups
// always render above all other widgets regardless of tree position.
//
// Only one popup can be open at a time (tracked by sOpenPopup).
// The tree-root ownership check prevents a popup from one window
// being painted into a different window's content buffer.
void widgetPaintOverlays(WidgetT *root, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
if (!sOpenPopup) {
@ -107,6 +135,18 @@ void widgetPaintOverlays(WidgetT *root, DisplayT *d, const BlitOpsT *ops, const
// ============================================================
// wgtDestroy
// ============================================================
//
// Destroys a widget and its entire subtree. The order is:
// 1. Unlink from parent (so the parent doesn't reference freed memory)
// 2. Recursively destroy all children (depth-first)
// 3. Call the widget's own destroy callback (free buffers, etc.)
// 4. Clear any global state that references this widget
// 5. Clear the window's root pointer if this was the root
// 6. Free the widget memory
//
// This ordering ensures that per-widget destroy callbacks can still
// access the widget's data (step 3 comes after child cleanup but
// before the widget itself is freed).
void wgtDestroy(WidgetT *w) {
if (!w) {
@ -152,6 +192,12 @@ void wgtDestroy(WidgetT *w) {
// ============================================================
// widgetHashName — djb2 hash for widget name lookup
// ============================================================
//
// The djb2 hash function (Dan Bernstein) is used for fast name-based
// widget lookup. The hash is stored in each widget's nameHash field
// so that wgtFind() can reject non-matches without calling strcmp()
// on every node. In a tree of 100 widgets, this typically reduces
// the search from 100 strcmp calls to 1-2.
static uint32_t widgetHashName(const char *s) {
uint32_t h = 5381;
@ -168,6 +214,11 @@ static uint32_t widgetHashName(const char *s) {
// ============================================================
// wgtFindByHash — recursive search with hash fast-reject
// ============================================================
//
// Depth-first search by name with hash pre-screening. The hash
// comparison (integer ==) is much cheaper than strcmp and eliminates
// most non-matches. A full strcmp is only done when hashes match,
// which should be the target widget or a rare collision.
static WidgetT *wgtFindByHash(WidgetT *root, const char *name, uint32_t hash) {
if (root->nameHash == hash && root->name[0] && strcmp(root->name, name) == 0) {
@ -219,6 +270,13 @@ void wgtSetName(WidgetT *w, const char *name) {
// ============================================================
// wgtGetContext
// ============================================================
//
// Retrieves the AppContextT from any widget by walking up to the root.
// The root widget stores the context in its userData field (set during
// wgtInitWindow). This is the only way to get the AppContextT from
// deep inside the widget tree without passing it as a parameter
// through every function call. The walk is O(depth) but widget trees
// are shallow (typically 3-6 levels deep).
AppContextT *wgtGetContext(const WidgetT *w) {
if (!w) {
@ -238,6 +296,11 @@ AppContextT *wgtGetContext(const WidgetT *w) {
// ============================================================
// wgtGetText
// ============================================================
//
// Polymorphic text getter — dispatches through the vtable to the
// appropriate getText implementation for the widget's type. Returns
// an empty string (not NULL) if the widget has no text or no getText
// handler, so callers don't need NULL checks.
const char *wgtGetText(const WidgetT *w) {
if (!w) {
@ -255,6 +318,15 @@ const char *wgtGetText(const WidgetT *w) {
// ============================================================
// wgtInitWindow
// ============================================================
//
// Sets up a window for widget-based content. Creates a root VBox
// container and installs the four window callbacks (onPaint, onMouse,
// onKey, onResize) that bridge WM events into the widget system.
//
// The root widget's userData points to the AppContextT, which is
// the bridge back to the display, font, colors, and blitOps needed
// for painting. This avoids threading the context through every
// widget function — any widget can retrieve it via wgtGetContext().
WidgetT *wgtInitWindow(AppContextT *ctx, WindowT *win) {
WidgetT *root = widgetAlloc(NULL, WidgetVBoxE);
@ -279,6 +351,19 @@ WidgetT *wgtInitWindow(AppContextT *ctx, WindowT *win) {
// ============================================================
// wgtInvalidate
// ============================================================
//
// Full invalidation: re-measures the widget tree, manages scrollbars,
// re-lays out, repaints, and dirties the window on screen.
//
// This is the "something structural changed" path — use when widget
// sizes may have changed (text changed, children added/removed,
// visibility toggled). If only visual state changed (cursor blink,
// selection highlight), use wgtInvalidatePaint() instead to skip
// the expensive measure/layout passes.
//
// The widgetOnPaint check ensures that custom paint handlers (used
// by some dialog implementations) aren't bypassed by the scrollbar
// management code.
void wgtInvalidate(WidgetT *w) {
if (!w || !w->window) {
@ -392,6 +477,10 @@ void wgtSetEnabled(WidgetT *w, bool enabled) {
// ============================================================
// wgtSetText
// ============================================================
//
// Polymorphic text setter. Dispatches to the type-specific setText
// via vtable, then does a full invalidation because changing text
// can change the widget's minimum size (triggering relayout).
void wgtSetText(WidgetT *w, const char *text) {
if (!w) {

View file

@ -1,4 +1,18 @@
// widgetProgressBar.c — ProgressBar widget
//
// A non-interactive fill-bar widget for displaying bounded progress.
// Supports both horizontal and vertical orientations via separate
// constructors rather than a runtime flag, because orientation is
// typically fixed at UI construction time and the two constructors
// read more clearly at the call site.
//
// Rendering: sunken 2px bevel border (inverted highlight/shadow to
// look recessed) with a solid fill rect proportional to value/maxValue.
// Uses integer division for fill size -- no floating point, which would
// be prohibitively expensive on a 486 without an FPU.
//
// The widget has no mouse or keyboard handlers -- it is purely display.
// Value is controlled externally through the set/get API.
#include "widgetInternal.h"
@ -72,6 +86,10 @@ void wgtProgressBarSetValue(WidgetT *w, int32_t value) {
// widgetProgressBarCalcMinSize
// ============================================================
// Min size is based on font metrics to keep the bar proportional to
// surrounding text. The cross-axis uses charHeight+4 (room for the
// bar plus 2px bevel on each side). The main axis uses 12 char widths
// so the bar is wide enough to show meaningful progress granularity.
void widgetProgressBarCalcMinSize(WidgetT *w, const BitmapFontT *font) {
if (w->as.progressBar.vertical) {
w->calcMinW = font->charHeight + 4;
@ -87,12 +105,21 @@ void widgetProgressBarCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetProgressBarPaint
// ============================================================
// Paint uses a two-pass approach: first the sunken bevel border, then
// the fill rect. The fill color defaults to activeTitleBg (the window
// title bar color) to provide strong visual contrast inside the
// recessed trough -- matching classic Win3.1/Motif progress bar style.
// When disabled, the fill becomes windowShadow to look grayed out.
//
// Vertical bars fill from bottom-up (natural "filling" metaphor),
// which requires computing the Y offset as (innerH - fillH).
void widgetProgressBarPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
(void)font;
uint32_t fg = w->enabled ? (w->fgColor ? w->fgColor : colors->activeTitleBg) : colors->windowShadow;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
// Sunken border
// Sunken border -- highlight/shadow are swapped vs raised bevel
// to create the "pressed in" trough appearance
BevelStyleT bevel;
bevel.highlight = colors->windowShadow;
bevel.shadow = colors->windowHighlight;

View file

@ -1,4 +1,25 @@
// widgetRadio.c — RadioGroup and Radio button widgets
//
// Two-level architecture: RadioGroupE is an invisible container that
// holds the selection state (selectedIdx), while RadioE children are
// the visible buttons. This separation means the group tracks which
// index is selected while each radio button only knows its own index,
// keeping the per-button state minimal.
//
// Selection state is stored as an integer index on the parent group
// rather than a pointer, because indices survive widget reordering
// and are trivially serializable. The index is auto-assigned at
// construction time based on sibling order.
//
// Rendering: diamond-shaped indicator (Motif-style) using scanline
// horizontal lines rather than a circle algorithm. This avoids any
// need for anti-aliasing or trigonometry -- purely integer H/V lines.
// The selection dot inside uses a hardcoded width table for a 6-row
// filled diamond, sized to look crisp at the fixed 12px box size.
//
// Keyboard: Space/Enter select the current radio. Arrow keys move
// focus AND selection together (standard radio group behavior --
// focus and selection are coupled, unlike checkboxes).
#include "widgetInternal.h"
@ -7,6 +28,9 @@
// wgtRadio
// ============================================================
// Auto-index is computed by counting RadioE siblings before this one.
// This means radio buttons must be added in order and not reordered
// after construction -- which is the normal pattern for radio groups.
WidgetT *wgtRadio(WidgetT *parent, const char *text) {
WidgetT *w = widgetAlloc(parent, WidgetRadioE);
@ -68,6 +92,9 @@ void widgetRadioSetText(WidgetT *w, const char *text) {
// widgetRadioCalcMinSize
// ============================================================
// Shares CHECKBOX_BOX_SIZE/GAP constants with the checkbox widget
// so radio buttons and checkboxes align when placed in the same
// column, and the indicator + label layout is visually consistent.
void widgetRadioCalcMinSize(WidgetT *w, const BitmapFontT *font) {
w->calcMinW = CHECKBOX_BOX_SIZE + CHECKBOX_GAP +
textWidthAccel(font, w->as.radio.text);
@ -79,6 +106,15 @@ void widgetRadioCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetRadioOnKey
// ============================================================
// Arrow keys move focus AND selection simultaneously -- this is the
// standard radio group behavior where navigating with arrows also
// commits the selection. This differs from checkboxes where arrows
// just move focus and Space toggles. The coupling is deliberate:
// radio groups represent a single mutually-exclusive choice, so
// "looking at" an option means "choosing" it.
//
// Key codes use DOS BIOS scancode convention: high byte 0x01 flag
// ORed with the scancode. 0x50=Down, 0x4D=Right, 0x48=Up, 0x4B=Left.
void widgetRadioOnKey(WidgetT *w, int32_t key, int32_t mod) {
(void)mod;
@ -170,6 +206,16 @@ void widgetRadioOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
// widgetRadioPaint
// ============================================================
// The diamond radio indicator is rendered in three passes:
// 1. Interior fill -- scanline-based diamond fill for the background
// 2. Border -- upper-left edges in shadow, lower-right in highlight
// (sunken appearance, same lighting model as bevels)
// 3. Selection dot -- smaller filled diamond from a hardcoded 6-row
// width table. The static const avoids recomputation per paint.
//
// This approach avoids floating point entirely. Each scanline's
// left/right extent is computed with simple integer distance from
// the midpoint, making the diamond perfectly symmetric at any size.
void widgetRadioPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;

View file

@ -1,4 +1,31 @@
// widgetScrollPane.c — ScrollPane container widget
//
// A clipping container that allows its children (laid out as a
// vertical box) to overflow and be scrolled into view. This is the
// general-purpose scrollable container -- unlike TreeView or ListBox
// which have item-specific scrolling, ScrollPane can wrap any
// collection of arbitrary child widgets.
//
// Architecture: ScrollPane is both a layout container and a paint
// container (WCLASS_PAINTS_CHILDREN flag). It lays out children at
// their virtual positions (offset by scroll position), then during
// paint, sets a clip rect to the inner content area before painting
// children. This means children are positioned at coordinates that
// may be outside the visible area -- the clip rect handles hiding
// the overflow. This is simpler and more efficient than per-widget
// visibility culling for the small widget counts typical on 486 DOS.
//
// Scrollbar visibility uses a two-pass determination: first check if
// V scrollbar is needed, then check H (accounting for the space the
// V scrollbar consumed), then re-check V in case H scrollbar's
// appearance reduced available height. This handles the mutual
// dependency where adding one scrollbar may trigger the other.
//
// The scroll pane has its own copies of the scrollbar drawing routines
// (drawSPHScrollbar, drawSPVScrollbar) rather than using the shared
// widgetDrawScrollbarH/V because it uses its own SP_SB_W constant.
// This is a minor duplication tradeoff for allowing different scrollbar
// widths in different contexts.
#include "widgetInternal.h"
@ -126,6 +153,12 @@ static void drawSPVScrollbar(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const
// spCalcNeeds — determine scrollbar needs and inner dimensions
// ============================================================
// Central sizing function called by layout, paint, and mouse handlers.
// Computes the total content min size from children, then determines
// which scrollbars are needed and adjusts inner dimensions accordingly.
// The two-pass scrollbar dependency resolution handles the case where
// adding a V scrollbar shrinks the width enough to need an H scrollbar,
// which in turn shrinks the height enough to need a V scrollbar.
static void spCalcNeeds(WidgetT *w, const BitmapFontT *font, int32_t *contentMinW, int32_t *contentMinH, int32_t *innerW, int32_t *innerH, bool *needVSb, bool *needHSb) {
// Measure children
int32_t totalMinW = 0;
@ -190,6 +223,11 @@ static void spCalcNeeds(WidgetT *w, const BitmapFontT *font, int32_t *contentMin
// widgetScrollPaneCalcMinSize
// ============================================================
// The scroll pane reports a deliberately small min size (just enough
// for the scrollbar chrome) because its whole purpose is to contain
// content that doesn't fit. However, children still need their min
// sizes computed so spCalcNeeds can determine scrollbar visibility
// and the layout pass can distribute space correctly.
void widgetScrollPaneCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// Recursively measure children so they have valid calcMinW/H
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
@ -206,6 +244,18 @@ void widgetScrollPaneCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetScrollPaneLayout
// ============================================================
// Layout is a vertical box layout offset by the scroll position.
// Children are positioned at their "virtual" coordinates (baseX/baseY
// incorporate the negative scroll offset), so they may have negative
// or very large Y values. The paint pass clips to the visible area.
// This means child coordinates are always absolute screen coords,
// keeping the draw path simple -- no coordinate translation needed
// at paint time.
//
// Extra space distribution uses the same weight-based algorithm as
// the generic box layout: each child gets a share of surplus space
// proportional to its weight/totalWeight ratio. This allows stretch
// children inside the scrollable area.
void widgetScrollPaneLayout(WidgetT *w, const BitmapFontT *font) {
int32_t contentMinW;
int32_t contentMinH;
@ -311,6 +361,11 @@ void widgetScrollPaneLayout(WidgetT *w, const BitmapFontT *font) {
// widgetScrollPaneOnKey
// ============================================================
// Keyboard scrolling uses font metrics for step sizes: charHeight for
// vertical line scroll, charWidth for horizontal, and the full inner
// height for page scroll. This makes scroll distance proportional to
// content size, which feels natural. The early return for unhandled
// keys avoids unnecessary invalidation.
void widgetScrollPaneOnKey(WidgetT *w, int32_t key, int32_t mod) {
(void)mod;
@ -377,6 +432,15 @@ void widgetScrollPaneOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetScrollPaneOnMouse
// ============================================================
// Mouse handling has priority order: V scrollbar > H scrollbar > dead
// corner > child content. The dead corner (where H and V scrollbars
// meet) is explicitly handled to prevent clicks from falling through
// to content behind it. Content clicks do recursive hit-testing into
// children and forward the mouse event, handling focus management
// along the way. This is necessary because scroll pane has
// WCLASS_NO_HIT_RECURSE -- the generic hit-test doesn't descend
// into scroll pane children since their coordinates may be outside
// the pane's visible bounds.
void widgetScrollPaneOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
@ -528,6 +592,12 @@ void widgetScrollPaneOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy
// widgetScrollPanePaint
// ============================================================
// Paint saves and restores the clip rect around child painting.
// Children are painted with a clip rect that excludes the scrollbar
// area, so children that extend past the visible content area are
// automatically clipped. Scrollbars are painted after restoring the
// clip rect so they're always fully visible. The dead corner (when
// both scrollbars are present) is filled with windowFace color.
void widgetScrollPanePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
@ -604,6 +674,9 @@ void widgetScrollPanePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const B
// wgtScrollPane
// ============================================================
// Default weight=100 so the scroll pane stretches to fill available
// space in its parent container. Without this, a scroll pane in a
// vertical box would collapse to its minimal size.
WidgetT *wgtScrollPane(WidgetT *parent) {
WidgetT *w = widgetAlloc(parent, WidgetScrollPaneE);

View file

@ -1,4 +1,24 @@
// widgetScrollbar.c — Shared scrollbar painting and hit-testing
//
// These are not widgets themselves -- they are stateless rendering and
// hit-testing utilities shared by ScrollPane, TreeView, TextArea,
// ListBox, and ListView. Each owning widget stores its own scroll
// position; these functions are purely geometric.
//
// The scrollbar model uses three parts: two arrow buttons (one at each
// end) and a proportional thumb in the track between them. Thumb size
// is proportional to (visibleSize / totalSize), clamped to SB_MIN_THUMB
// to remain grabbable even when content is very large. Thumb position
// maps linearly from scrollPos to track position.
//
// Arrow triangles are drawn with simple loop-based scanlines (4 rows),
// producing 7-pixel-wide arrow glyphs. This avoids any font or bitmap
// dependency for the scrollbar chrome.
//
// The minimum scrollbar length guard (sbW < WGT_SB_W * 3) ensures
// there is at least room for both arrow buttons plus a minimal track.
// If the container is too small, the scrollbar is simply not drawn
// rather than rendering a corrupted mess.
#include "widgetInternal.h"
@ -119,6 +139,12 @@ void widgetDrawScrollbarV(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *
// widgetScrollbarHitTest
// ============================================================
// Axis-agnostic hit test. The caller converts (vx,vy) into a 1D
// position along the scrollbar axis (relPos) and the scrollbar
// length (sbLen). Returns which zone was hit: arrow buttons,
// page-up/page-down trough regions, or the thumb itself.
// This factoring lets all scrollbar-owning widgets share the same
// logic without duplicating per-axis code.
ScrollHitE widgetScrollbarHitTest(int32_t sbLen, int32_t relPos, int32_t totalSize, int32_t visibleSize, int32_t scrollPos) {
if (relPos < WGT_SB_W) {
return ScrollHitArrowDecE;

View file

@ -1,4 +1,21 @@
// widgetSeparator.c — Separator widget (horizontal and vertical)
//
// A purely decorative widget that draws a 2px etched line (shadow +
// highlight pair) to visually divide groups of widgets. The etched
// line technique (dark line followed by light line offset by 1px)
// creates a subtle 3D groove that matches the Motif/Win3.1 aesthetic.
//
// Separate constructors (wgtHSeparator/wgtVSeparator) rather than a
// runtime orientation parameter because separators are always fixed
// orientation at construction time.
//
// The min size in the cross-axis is SEPARATOR_THICKNESS (2px), while
// the main axis min is 0 -- the separator stretches to fill the
// available width/height in its parent layout. This makes separators
// work correctly in both HBox and VBox containers without explicit
// sizing.
//
// No mouse/key handlers -- purely decorative, non-focusable.
#include "widgetInternal.h"

View file

@ -1,4 +1,24 @@
// widgetSlider.c — Slider (trackbar) widget
//
// A continuous-value selector with a draggable thumb on a groove track.
// Supports both horizontal and vertical orientation. The value maps
// linearly to thumb position using integer arithmetic only.
//
// Rendering: thin sunken groove for the track, raised 2px-bevel thumb,
// and a center tick line on the thumb for grip feedback. The groove
// uses reversed highlight/shadow (same as progress bar trough) for
// the recessed look. The thumb uses the standard raised bevel.
//
// Interaction model: clicking on the track jumps the thumb to that
// position (instant seek), while clicking on the thumb starts a drag
// via the global sDragSlider/sDragOffset state. The drag offset stores
// where within the thumb the user grabbed, so the thumb doesn't snap
// to the cursor center during drag -- this feels much more natural.
//
// Keyboard: arrow keys increment/decrement by a computed step that
// scales with range (step = range/100 for ranges > 100, else 1).
// Home/End jump to min/max. This gives ~100 keyboard steps regardless
// of the actual value range.
#include "widgetInternal.h"
@ -7,6 +27,7 @@
// wgtSlider
// ============================================================
// Default weight=100 so the slider stretches in its parent layout.
WidgetT *wgtSlider(WidgetT *parent, int32_t minVal, int32_t maxVal) {
WidgetT *w = widgetAlloc(parent, WidgetSliderE);
@ -57,6 +78,9 @@ void wgtSliderSetValue(WidgetT *w, int32_t value) {
// widgetSliderCalcMinSize
// ============================================================
// Min size: 5 thumb-widths along the main axis gives enough room for
// the thumb to travel meaningfully. Cross-axis is thumb width + 4px
// margin so the groove has visual breathing room around the thumb.
void widgetSliderCalcMinSize(WidgetT *w, const BitmapFontT *font) {
(void)font;
@ -124,6 +148,15 @@ void widgetSliderOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetSliderOnMouse
// ============================================================
// Mouse click distinguishes between thumb hit (start drag) and track
// hit (jump to position). The thumb hit area is the full SLIDER_THUMB_W
// pixel range at the current thumb position. The jump-to-position
// calculation centers the thumb at the click point by subtracting
// half the thumb width before converting pixel position to value.
//
// Drag state is stored in globals (sDragSlider, sDragOffset) rather
// than per-widget because only one slider can be dragged at a time.
// The event loop checks sDragSlider on mouse-move to continue the drag.
void widgetSliderOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
(void)root;
hit->focused = true;
@ -189,6 +222,14 @@ void widgetSliderOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
// widgetSliderPaint
// ============================================================
// Paint: groove track centered in the cross-axis, thumb at value
// position. The thumb position formula is:
// thumbPos = ((value - minValue) * thumbRange) / range
// where thumbRange = widgetSize - SLIDER_THUMB_W.
// This maps value linearly to pixel position using integer math only.
// The groove is thin (SLIDER_TRACK_H = 4px) with a reversed bevel
// for the recessed look, while the thumb is the full widget height
// with a raised bevel for the 3D grab handle appearance.
void widgetSliderPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
(void)font;
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;

View file

@ -1,4 +1,16 @@
// widgetSpacer.c — Spacer widget (invisible stretching element)
//
// A zero-sized invisible widget with weight=100, used purely for
// layout control. It absorbs leftover space in box layouts, pushing
// sibling widgets apart. Common use: place a spacer between buttons
// in a toolbar to right-align some buttons, or between a label and
// a control to create elastic spacing.
//
// Because calcMinSize returns 0x0, the spacer takes no space when
// there is none to spare, but greedily absorbs extra space via its
// weight. This is the simplest possible layout primitive -- no paint,
// no mouse, no keyboard, no state. The entire widget is effectively
// just a weight value attached to a position in the sibling list.
#include "widgetInternal.h"

View file

@ -1,4 +1,33 @@
// widgetSpinner.c — Spinner (numeric up/down) widget
//
// A hybrid widget combining a single-line text editor with up/down
// arrow buttons for numeric value entry. The user can either click
// the arrows, use Up/Down keys, or type a number directly.
//
// Design: the widget has two modes -- display mode (showing the
// formatted value) and edit mode (allowing free-form text input).
// Edit mode is entered on the first text-modifying keystroke and
// committed on Enter or when arrows are clicked. Escape cancels
// the edit and reverts to the pre-edit value. This two-mode design
// keeps the display clean (always showing a properly formatted
// number) while still allowing direct keyboard entry.
//
// The text editing delegates to widgetTextEditOnKey() -- the same
// shared single-line editing logic used by TextInput. This gives
// the spinner cursor movement, selection, cut/copy/paste, and undo
// for free. Input validation filters non-digit characters before
// they reach the editor, and only allows minus at position 0.
//
// Undo uses a single-level swap buffer (same as TextInput): the
// current state is copied to undoBuf before each mutation, and
// Ctrl+Z swaps current<->undo. This is simpler and cheaper than
// a multi-level undo stack for the small buffers involved.
//
// Rendering: sunken border enclosing the text area + two stacked
// raised-bevel arrow buttons on the right. The buttons extend to
// the widget's right edge (including the border width) so they
// look like they're part of the border chrome. The up/down buttons
// split the widget height evenly.
#include "widgetInternal.h"
@ -59,6 +88,10 @@ static void spinnerCommitEdit(WidgetT *w) {
// spinnerFormat
// ============================================================
// Format always places the cursor at the end and resets scroll/selection.
// This is called after any value change to synchronize the text buffer
// with the numeric value. The cursor-at-end position matches user
// expectation after arrow-key increment/decrement.
static void spinnerFormat(WidgetT *w) {
w->as.spinner.len = snprintf(w->as.spinner.buf, sizeof(w->as.spinner.buf), "%d", (int)w->as.spinner.value);
w->as.spinner.cursorPos = w->as.spinner.len;
@ -72,6 +105,10 @@ static void spinnerFormat(WidgetT *w) {
// spinnerStartEdit
// ============================================================
// Entering edit mode snapshots the buffer for undo so the user can
// revert to the pre-edit formatted value. The snapshot is only taken
// on the transition to editing, not on every keystroke, so repeated
// typing within one edit session can be undone all at once.
static void spinnerStartEdit(WidgetT *w) {
if (!w->as.spinner.editing) {
w->as.spinner.editing = true;
@ -107,6 +144,14 @@ const char *widgetSpinnerGetText(const WidgetT *w) {
// widgetSpinnerOnKey
// ============================================================
// Key handling has two distinct paths: navigation keys (Up/Down/PgUp/
// PgDn) always commit any pending edit first, then adjust the numeric
// value directly. Text keys enter edit mode and are forwarded to the
// shared text editor. This split ensures arrow-key nudging always
// operates on the committed value, not on partially typed text.
//
// Page Up/Down use step*10 for coarser adjustment, matching the
// convention used by Windows spin controls.
void widgetSpinnerOnKey(WidgetT *w, int32_t key, int32_t mod) {
int32_t step = w->as.spinner.step;
@ -229,6 +274,14 @@ void widgetSpinnerOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetSpinnerOnMouse
// ============================================================
// Mouse click regions: button area (right side) vs text area (left side).
// Button area is split vertically at the midpoint -- top half increments,
// bottom half decrements. Clicking a button commits any pending edit
// before adjusting the value, same as arrow keys.
//
// Text area clicks compute cursor position from pixel offset using the
// fixed-width font. Double-click selects all text (select-word doesn't
// make sense for numbers), entering edit mode to allow replacement.
void widgetSpinnerOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
hit->focused = true;
@ -291,6 +344,17 @@ void widgetSpinnerOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
// widgetSpinnerPaint
// ============================================================
// Paint uses the same 3-run text rendering approach as TextInput:
// before-selection, selection (highlighted), after-selection. This
// avoids overdraw and gives correct selection highlighting with only
// one pass over the visible text. The scroll offset ensures the
// cursor is always visible even when the number is wider than the
// text area.
//
// The two buttons (up/down) extend SPINNER_BORDER pixels past the
// button area into the widget's right border so they visually merge
// with the outer bevel -- this is why btnW is btnW + SPINNER_BORDER
// in the drawBevel calls.
void widgetSpinnerPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->enabled ? (w->fgColor ? w->fgColor : colors->contentFg) : colors->windowShadow;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;

View file

@ -1,4 +1,31 @@
// widgetSplitter.c — Splitter (draggable divider between two panes)
//
// A container that divides its area between exactly two child widgets
// with a draggable divider bar between them. The divider position
// (dividerPos) is stored as a pixel offset from the leading edge
// (left for vertical, top for horizontal).
//
// Architecture: the splitter is a special container (has its own
// layout function) that manually positions its two children and the
// divider bar. It does NOT use the generic box layout. Children are
// clipped to their respective panes during painting to prevent
// overflow into the other pane.
//
// The divider bar (SPLITTER_BAR_W = 5px) is drawn as a raised bevel
// with a "gripper" pattern -- small embossed 2x2 bumps arranged
// in a line centered on the bar. This provides visual feedback that
// the bar is draggable, following the Win3.1/Motif convention.
//
// Drag state: divider dragging stores the clicked splitter widget
// and the mouse offset within the bar in globals (sDragSplitter,
// sDragSplitStart). The event loop handles mouse-move during drag
// by computing the new divider position from mouse coordinates and
// calling widgetSplitterClampPos to enforce minimum pane sizes.
//
// Minimum pane sizes come from children's calcMinW/H, with a floor
// of SPLITTER_MIN_PANE (20px) to prevent panes from collapsing to
// nothing. The clamp also ensures the divider can't be dragged past
// where the second pane would violate its minimum.
#include "widgetInternal.h"
@ -15,6 +42,10 @@ static WidgetT *spSecondChild(WidgetT *w);
// spFirstChild — get first visible child
// ============================================================
// These helpers skip invisible children so the splitter works
// correctly even if one pane is hidden. The splitter only cares
// about the first two visible children; any additional children
// are ignored (though this shouldn't happen in practice).
static WidgetT *spFirstChild(WidgetT *w) {
for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
if (c->visible) {
@ -109,6 +140,12 @@ void widgetSplitterCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetSplitterLayout
// ============================================================
// Layout assigns each child its full pane area, then recurses into
// child layouts. The first child gets [0, dividerPos) and the second
// gets [dividerPos + SPLITTER_BAR_W, end). The divider position is
// clamped before use to respect minimum pane sizes, even if the
// user hasn't dragged yet (dividerPos=0 from construction gets
// clamped to minFirst).
void widgetSplitterLayout(WidgetT *w, const BitmapFontT *font) {
WidgetT *c1 = spFirstChild(w);
WidgetT *c2 = spSecondChild(w);
@ -171,6 +208,12 @@ void widgetSplitterLayout(WidgetT *w, const BitmapFontT *font) {
// widgetSplitterOnMouse
// ============================================================
// Mouse handling first checks if the click is on the divider bar.
// If so, it starts a drag. If not, it recursively hit-tests into
// children and forwards the event. This manual hit-test forwarding
// is needed because the splitter has WCLASS_NO_HIT_RECURSE (the
// generic hit-test would find the splitter but not recurse into its
// clipped children).
void widgetSplitterOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
int32_t pos = hit->as.splitter.dividerPos;
@ -229,6 +272,16 @@ void widgetSplitterOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy)
// widgetSplitterPaint
// ============================================================
// Paint clips each child to its pane area, preventing overflow across
// the divider. The clip rect is saved/restored around each child's
// paint. The divider bar is painted last (on top) so it's always
// visible even if a child overflows.
//
// The gripper bumps use the classic embossed-dot technique: a
// highlight pixel at (x,y) and a shadow pixel at (x+1,y+1) create
// a tiny raised bump. 11 bumps spaced 3px apart center vertically
// (or horizontally) on the bar. This is purely decorative but
// provides the expected visual affordance for "draggable".
void widgetSplitterPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
int32_t pos = w->as.splitter.dividerPos;

View file

@ -1,4 +1,20 @@
// widgetStatusBar.c — StatusBar widget
//
// A horizontal container that draws a sunken border around each visible
// child to create the classic segmented status bar appearance. Children
// are typically labels or other simple widgets, laid out by the generic
// horizontal box layout. The status bar itself has no special layout
// logic -- it's a standard HBox with tight padding/spacing and the
// extra per-child sunken border decorations.
//
// The border drawing is done as an overlay on top of children rather
// than as part of the child's own paint, because it wraps around
// the child's allocated area (extending 1px past each edge). This
// keeps the sunken-panel effect consistent regardless of the child
// widget type.
//
// No mouse/key handlers -- the status bar is purely display. Children
// that are interactive (e.g., a clickable label) handle their own events.
#include "widgetInternal.h"
@ -7,6 +23,8 @@
// wgtStatusBar
// ============================================================
// Tight 2px padding and spacing keeps the status bar compact, using
// minimal vertical space at the bottom of a window.
WidgetT *wgtStatusBar(WidgetT *parent) {
WidgetT *w = widgetAlloc(parent, WidgetStatusBarE);
@ -23,6 +41,9 @@ WidgetT *wgtStatusBar(WidgetT *parent) {
// widgetStatusBarPaint
// ============================================================
// Draws a 1px sunken bevel (reversed highlight/shadow) around each
// child. The bevel.face=0 with width=1 means only the border lines
// are drawn, not a filled interior -- the child paints its own content.
void widgetStatusBarPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
(void)font;

View file

@ -1,4 +1,34 @@
// widgetTabControl.c — TabControl and TabPage widgets
//
// Two-level architecture: TabControlE is the container holding
// selection state and rendering the tab header strip. TabPageE
// children act as invisible sub-containers, each holding the content
// widgets for that page. Only the active page's children are visible
// and receive layout.
//
// Tab header rendering: each tab is a manually-drawn chrome piece
// (not a button widget) with top/left/right edges in highlight/shadow.
// The active tab is 2px taller than inactive tabs, extending down to
// overlap the content panel's top border -- this creates the classic
// "folder tab" illusion where the active tab appears connected to the
// panel below it. The panel's top border is erased under the active
// tab to complete the effect.
//
// Scrolling tab headers: when the total tab header width exceeds the
// available space, left/right arrow buttons appear and the header area
// becomes a clipped scrolling region. The scrollOffset tracks how many
// pixels the tab strip has scrolled. tabEnsureVisible() auto-scrolls
// to keep the active tab visible after keyboard navigation.
//
// Tab switching closes any open dropdown/combobox popup before
// switching, because the popup's owning widget may be on the
// now-hidden page and would become orphaned visually.
//
// Layout: all tab pages are positioned at the same content area
// coordinates, but only the active page has visible=true. This means
// widgetLayoutChildren is only called for the active page, saving
// layout computation for hidden pages. When switching tabs, the old
// page becomes invisible and the new page becomes visible + relaid out.
#include "widgetInternal.h"
@ -175,6 +205,10 @@ WidgetT *wgtTabPage(WidgetT *parent, const char *title) {
// widgetTabControlCalcMinSize
// ============================================================
// Min size: tab header height + the maximum min size across ALL pages
// (not just the active one). This ensures the tab control reserves
// enough space for the largest page, preventing resize flicker when
// switching tabs. Children are recursively measured.
void widgetTabControlCalcMinSize(WidgetT *w, const BitmapFontT *font) {
int32_t tabH = font->charHeight + TAB_PAD_V * 2;
int32_t maxPageW = 0;
@ -239,6 +273,10 @@ void widgetTabControlLayout(WidgetT *w, const BitmapFontT *font) {
// widgetTabControlOnKey
// ============================================================
// Keyboard navigation: Left/Right cycle through tabs with wrapping
// (modular arithmetic). Home/End jump to first/last tab. The tab
// control only handles these keys when it has focus -- if a child
// widget inside the active page has focus, keys go there instead.
void widgetTabControlOnKey(WidgetT *w, int32_t key, int32_t mod) {
(void)mod;
@ -285,6 +323,11 @@ void widgetTabControlOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetTabControlOnMouse
// ============================================================
// Mouse clicks in the tab header area walk the tab list computing
// accumulated X positions to find which tab was clicked. Only clicks
// in the header strip (top tabH pixels) are handled here -- clicks
// on the content area go through normal child hit-testing. Scroll
// arrow clicks adjust scrollOffset by 4 character widths at a time.
void widgetTabControlOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
hit->focused = true;
AppContextT *ctx = (AppContextT *)root->userData;
@ -360,6 +403,21 @@ void widgetTabControlOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy
// widgetTabControlPaint
// ============================================================
// Paint order: content panel first (raised bevel below the tab strip),
// then scroll arrows if needed, then tab headers in a clipped region,
// then the active page's children. Tab headers are painted with a clip
// rect so partially-scrolled tabs at the edges are cleanly truncated.
//
// The active tab is drawn 2px taller (extending from w->y instead of
// w->y+2) and erases the panel's top border beneath it (2px of
// contentBg), creating the visual connection between tab and panel.
// Inactive tabs sit 2px lower and draw a bottom border to separate
// them from the panel.
//
// Only the active page's children are painted (WCLASS_PAINTS_CHILDREN
// flag means the generic paint won't descend into tab control children).
// This is critical for performance on 486 -- we skip painting all
// hidden pages entirely.
void widgetTabControlPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
int32_t tabH = font->charHeight + TAB_PAD_V * 2;
bool scroll = tabNeedScroll(w, font);

View file

@ -1,4 +1,63 @@
// widgetTextInput.c — TextInput and TextArea widgets
//
// This file implements three text editing widgets plus shared
// infrastructure:
//
// 1. TextInput — single-line text field with scroll, selection,
// undo, password masking, and masked input (e.g., phone/SSN).
// 2. TextArea — multi-line text editor with row/col cursor,
// dual-axis scrolling, and full selection/clipboard support.
// 3. Shared infrastructure — clipboard, multi-click detection,
// word boundary logic, cross-widget selection clearing, and
// the single-line text editing engine (widgetTextEditOnKey).
//
// All text editing is done in-place in fixed-size char buffers
// allocated at widget creation. No dynamic resizing -- this keeps
// memory management simple and predictable on DOS where heap
// fragmentation is a real concern.
//
// The single-line editing engine (widgetTextEditOnKey) is factored
// out as a separate function that takes buffer/cursor/selection
// pointers as parameters, so it can be shared between TextInput,
// Spinner, and ComboBox without code duplication. Each widget
// passes its own state fields.
//
// Undo is single-level swap: before each mutation, the current
// buffer is copied to undoBuf. Ctrl+Z swaps current<->undo, so
// a second Ctrl+Z is "redo". This is simpler than a multi-level
// undo stack and sufficient for typical DOS text entry.
//
// Selection model: selStart/selEnd (single-line) or selAnchor/
// selCursor (multi-line) form a directed range. selStart is where
// the user began selecting, selEnd is where they stopped. The
// "low" end for deletion/copy is always min(start, end). The
// -1 sentinel means "no selection".
//
// Clipboard is a simple static buffer (4KB). This is a process-wide
// clipboard, not per-widget and not OS-integrated (DOS has no
// clipboard API). Text cut/copied from any widget is available to
// paste in any other widget.
//
// Multi-click detection uses clock() timestamps with a 500ms
// threshold and 4px spatial tolerance. Double-click selects word,
// triple-click selects line (TextArea) or all (TextInput).
//
// Cross-widget selection clearing: when a widget gains selection,
// clearOtherSelections() deselects any other widget that had an
// active selection. This prevents the confusing visual state of
// multiple selected ranges across different widgets. The tracking
// uses sLastSelectedWidget to achieve O(1) clearing rather than
// walking the entire widget tree.
//
// Masked input: a special TextInput mode where the buffer is
// pre-filled from a mask pattern (e.g., "###-##-####" for SSN).
// '#' accepts digits, 'A' accepts letters, '*' accepts any
// printable char. Literal characters in the mask are fixed and the
// cursor skips over them. This provides constrained input without
// needing a separate widget type.
//
// Password mode: renders bullets instead of characters (CP437 0xF9)
// and blocks copy/cut operations for security.
#include "widgetInternal.h"
@ -126,6 +185,9 @@ int32_t wordEnd(const char *buf, int32_t len, int32_t pos) {
// Track the widget that last had an active selection so we can
// clear it in O(1) instead of walking every widget in every window.
// This is critical for performance on 486 -- a brute-force walk
// across all widgets in all windows on every focus change would be
// prohibitive.
static WidgetT *sLastSelectedWidget = NULL;
@ -181,6 +243,12 @@ static bool clearSelectionOnWidget(WidgetT *w) {
}
// Clears selection on the previously-selected widget (if different
// from the newly-focused one). Validates that the previous widget's
// window is still in the window stack before touching it -- the
// window may have been closed since sLastSelectedWidget was set.
// If the previous widget was in a different window, that window
// gets a full repaint to clear the stale selection highlight.
void clearOtherSelections(WidgetT *except) {
if (!except || !except->window || !except->window->widgetRoot) {
return;
@ -330,6 +398,11 @@ static int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos) {
// wgtTextArea
// ============================================================
// TextArea allocates heap buffers for content and undo. maxLen controls
// the buffer size; 0 defaults to 256 bytes. The undo buffer is the
// same size as the content buffer to support full-buffer undo. Line
// count and max line length are cached (invalidated by textAreaDirtyCache)
// to avoid re-scanning the entire buffer on every paint/layout call.
WidgetT *wgtTextArea(WidgetT *parent, int32_t maxLen) {
WidgetT *w = widgetAlloc(parent, WidgetTextAreaE);
@ -364,6 +437,10 @@ WidgetT *wgtTextArea(WidgetT *parent, int32_t maxLen) {
// wgtTextInput
// ============================================================
// TextInput also heap-allocates buffers. The weight=100 default makes
// text inputs stretch to fill available width, which is the expected
// behavior in form layouts. Selection uses a start/end pair rather
// than anchor/cursor -- for single-line, the concepts are equivalent.
WidgetT *wgtTextInput(WidgetT *parent, int32_t maxLen) {
WidgetT *w = widgetAlloc(parent, WidgetTextInputE);
@ -397,6 +474,11 @@ WidgetT *wgtPasswordInput(WidgetT *parent, int32_t maxLen) {
}
// Masked input pre-fills the buffer from the mask pattern: literal
// characters are placed directly, editable slots get '_' placeholder.
// The cursor starts at the first editable slot. The mask string is
// NOT copied -- it must remain valid for the widget's lifetime (caller
// typically passes a string literal).
WidgetT *wgtMaskedInput(WidgetT *parent, const char *mask) {
if (!mask) {
return NULL;
@ -483,6 +565,14 @@ static int32_t maskPrevSlot(const char *mask, int32_t pos) {
}
// Masked input key handling is entirely separate from the shared
// text editor because the editing semantics are fundamentally different:
// the buffer length is fixed (it's always maskLen), characters can
// only be placed in slot positions, and backspace clears a slot rather
// than removing a character. The cursor skips over literal characters
// when moving left/right. Cut clears slots to '_' rather than removing
// text. Paste fills consecutive slots, skipping non-matching clipboard
// characters.
static void maskedInputOnKey(WidgetT *w, int32_t key, int32_t mod) {
char *buf = w->as.textInput.buf;
const char *mask = w->as.textInput.mask;
@ -787,6 +877,10 @@ static int32_t textAreaCountLines(const char *buf, int32_t len) {
}
// Cached line count -- sentinel value -1 means "dirty, recompute".
// This avoids O(N) buffer scans on every paint frame. The cache is
// invalidated (set to -1) by textAreaDirtyCache() after any buffer
// mutation. The same pattern is used for max line length.
static int32_t textAreaGetLineCount(WidgetT *w) {
if (w->as.textArea.cachedLines < 0) {
w->as.textArea.cachedLines = textAreaCountLines(w->as.textArea.buf, w->as.textArea.len);
@ -946,6 +1040,23 @@ const char *widgetTextAreaGetText(const WidgetT *w) {
// widgetTextAreaOnKey
// ============================================================
// TextArea key handling is inline (not using widgetTextEditOnKey)
// because multi-line editing has fundamentally different cursor
// semantics: row/col instead of linear offset, desiredCol for
// vertical movement (so moving down from a long line to a short
// line remembers the original column), and Enter inserts newlines.
//
// The SEL_BEGIN/SEL_END/HAS_SEL macros factor out the repetitive
// selection-start/selection-extend pattern: SEL_BEGIN initializes
// the anchor at the current offset if Shift is held and no selection
// exists yet. SEL_END updates the selection cursor to the new
// position (or clears selection if Shift isn't held). This keeps
// the per-key handler code manageable despite the large number of
// key combinations.
//
// textAreaEnsureVisible() is called after every cursor movement to
// auto-scroll the viewport. It adjusts scrollRow/scrollCol so the
// cursor is within the visible range.
void widgetTextAreaOnKey(WidgetT *w, int32_t key, int32_t mod) {
if (!w->as.textArea.buf) {
return;
@ -1473,6 +1584,14 @@ void widgetTextAreaOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetTextAreaOnMouse
// ============================================================
// Mouse handling: scrollbar clicks (both V and H), then content area
// clicks. Content clicks convert pixel coordinates to row/col using
// font metrics and scroll offset. Multi-click: double-click selects
// word, triple-click selects entire line. Single click starts a
// drag-select (sets sDragTextSelect which the event loop monitors
// on mouse-move to extend the selection). The drag-select global
// is cleared on double/triple click since the selection is already
// complete.
void widgetTextAreaOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
w->focused = true;
clearOtherSelections(w);
@ -1668,6 +1787,15 @@ void widgetTextAreaOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
// widgetTextDragUpdate — update selection during mouse drag
// ============================================================
// Called by the event loop on mouse-move while sDragTextSelect is set.
// Extends the selection from the anchor to the current mouse position.
// Handles auto-scroll: when the mouse is past the widget edges, the
// scroll offset is nudged by one unit per event, creating a smooth
// scroll-while-dragging effect. This function handles TextInput,
// TextArea, ComboBox, and AnsiTerm -- all widgets that support
// drag-selection. The type switch is slightly ugly but avoids
// needing a virtual method for what is essentially the same operation
// with different field names.
void widgetTextDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
AppContextT *ctx = (AppContextT *)root->userData;
const BitmapFontT *font = &ctx->font;
@ -1821,6 +1949,23 @@ void widgetTextDragUpdate(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
// widgetTextAreaPaint
// ============================================================
// TextArea paint uses an optimized incremental line-offset approach:
// instead of calling textAreaLineStart() for each visible row (which
// would re-scan from the buffer start each time), we compute the
// starting offset of the first visible line once, then advance it
// line by line. This makes the paint cost O(visRows * maxLineLen)
// rather than O(visRows * totalLines).
//
// Each line is drawn in up to 3 runs (before-selection, selection,
// after-selection) to avoid overdraw. Selection highlighting that
// extends past the end of a line (the newline itself is "selected")
// is rendered as a highlight-colored rectFill past the text.
//
// Scrollbars are drawn inline (not using the shared scrollbar
// functions) because TextArea scrolling is in rows/cols (logical
// units) rather than pixels, so the thumb calculation parameters
// differ. The dead corner between scrollbars is filled with
// windowFace color.
void widgetTextAreaPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
@ -2138,6 +2283,11 @@ const char *widgetTextInputGetText(const WidgetT *w) {
// widgetTextInputOnKey
// ============================================================
// TextInput key handling delegates to the shared widgetTextEditOnKey
// engine, passing pointers to its state fields. Masked input mode
// gets its own handler since the editing semantics are completely
// different. Password mode blocks copy/cut at this level before
// reaching the shared engine.
void widgetTextInputOnKey(WidgetT *w, int32_t key, int32_t mod) {
if (!w->as.textInput.buf) {
return;
@ -2170,6 +2320,12 @@ void widgetTextInputOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetTextInputOnMouse
// ============================================================
// Mouse handling for single-line input. Cursor position is computed
// from pixel offset using the fixed-width font (relX / charWidth +
// scrollOff). Multi-click: double-click selects word (using
// wordStart/wordEnd), triple-click selects all. Single click starts
// drag-select by setting both selStart and selEnd to the click
// position and registering sDragTextSelect.
void widgetTextInputOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
w->focused = true;
clearOtherSelections(w);
@ -2222,6 +2378,15 @@ void widgetTextInputOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
// widgetTextInputPaint
// ============================================================
// TextInput paint: sunken 2px bevel, then text with optional selection
// highlighting, then cursor line. Text is drawn from a display buffer
// that may be either the actual text or bullets (password mode, using
// CP437 character 0xF9 which renders as a small centered dot).
//
// The 3-run approach (before/during/after selection) draws text in a
// single pass without overdraw. The scroll offset ensures only the
// visible portion of the text is drawn. The cursor is a 1px-wide
// vertical line drawn at the character boundary.
void widgetTextInputPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t fg = w->enabled ? (w->fgColor ? w->fgColor : colors->contentFg) : colors->windowShadow;
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
@ -2326,6 +2491,24 @@ void widgetTextInputSetText(WidgetT *w, const char *text) {
// widgetTextEditOnKey — shared single-line text editing logic
// ============================================================
// This is the core single-line text editing engine, parameterized by
// pointer to allow reuse across TextInput, Spinner, and ComboBox.
// All buffer manipulation (insert, delete, cursor movement, selection,
// clipboard, undo) is handled here. The caller passes pointers to
// its own state fields so this function can modify them directly.
//
// Key mapping follows DOS conventions for extended keys (scancode |
// 0x100): 0x4B=Left, 0x4D=Right, 0x47=Home, 0x4F=End, 0x53=Delete,
// 0x73=Ctrl+Left, 0x74=Ctrl+Right. Control characters: 1=Ctrl+A,
// 3=Ctrl+C, 22=Ctrl+V, 24=Ctrl+X, 26=Ctrl+Z.
//
// The "goto adjustScroll" pattern consolidates the scroll-offset
// adjustment and repaint that most key handlers need, reducing
// code duplication. The scroll offset keeps the cursor visible
// within the visible character range.
//
// Single-line paste strips newlines from clipboard content, which
// is important when pasting from TextArea to TextInput.
void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursor, int32_t *pScrollOff, int32_t *pSelStart, int32_t *pSelEnd, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor) {
bool shift = (mod & KEY_MOD_SHIFT) != 0;
bool hasSel = (pSelStart && pSelEnd && *pSelStart >= 0 && *pSelEnd >= 0 && *pSelStart != *pSelEnd);

View file

@ -1,4 +1,19 @@
// widgetToolbar.c — Toolbar widget
//
// A horizontal container with a raised 1px bevel background, used to
// hold buttons, separators, spacers, and other control widgets in a
// strip at the top of a window. Like StatusBar, the toolbar is a
// standard HBox layout with custom paint -- child widgets position
// themselves via the generic horizontal box algorithm.
//
// The 1px raised bevel (vs the 2px used for window chrome) keeps the
// toolbar visually distinct but lightweight. The bevel's bottom shadow
// line serves as the separator between the toolbar and the content
// area below it.
//
// Tight 2px padding and spacing match the StatusBar for visual
// consistency between top and bottom chrome bars. Children (typically
// ImageButtons or small Buttons) handle their own mouse/key events.
#include "widgetInternal.h"
@ -23,6 +38,9 @@ WidgetT *wgtToolbar(WidgetT *parent) {
// widgetToolbarPaint
// ============================================================
// Raised bevel with windowFace fill. The 1px bevel width is
// intentionally thinner than window chrome (2px) to keep the
// toolbar from looking too heavy.
void widgetToolbarPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
(void)font;

View file

@ -1,4 +1,51 @@
// widgetTreeView.c — TreeView and TreeItem widgets
//
// A hierarchical list with expand/collapse nodes, scrolling, multi-select,
// and drag-reorder support.
//
// Architecture: TreeViewE is the container/viewport widget. TreeItemE
// children are the actual data nodes, forming a tree via the standard
// widget parent/child/sibling links. This reuses the existing widget
// tree structure as the data model -- no separate tree data structure
// needed. Each TreeItemE can have child TreeItemEs for nesting.
//
// Traversal: the tree is navigated in depth-first order using
// nextVisibleItem/prevVisibleItem. "Visible" means the item's parent
// chain is fully expanded -- collapsed subtrees are skipped. These
// traversal functions are O(depth) worst case, which is fine for the
// moderate tree depths typical in a DOS GUI.
//
// Selection model: single-select uses selectedItem pointer on the
// TreeViewE. Multi-select adds per-item 'selected' booleans and an
// anchorItem for Shift+click range selection. The cursor (selectedItem)
// and selection are separate concepts in multi-select mode -- the
// cursor is the "current" item for keyboard navigation, while the
// selected set is the highlighted items. Space toggles individual
// items, Shift+arrow extends range from anchor.
//
// Scrolling: dual-axis with automatic scrollbar appearance using the
// same two-pass mutual-dependency resolution as ScrollPane. Vertical
// scroll is in pixels (not items), allowing smooth scrolling. The
// treeCalcScrollbarNeeds function centralizes the dimension/scrollbar
// computation shared by layout, paint, and mouse handlers.
//
// Rendering: items are painted recursively with depth-based indent.
// The expand/collapse icon is a 9x9 box with +/- inside (Windows
// Explorer style). Only items within the visible clip region are
// actually drawn -- items outside clipTop/clipBottom are skipped
// (though their Y positions are still accumulated for correct
// positioning of subsequent items).
//
// Drag-reorder: when enabled, mouse-down on an item sets dragItem.
// Mouse-move (handled in widgetEvent.c) updates dropTarget/dropAfter
// to show an insertion line. Mouse-up (widgetReorderDrop) performs
// the actual node reparenting via widgetRemoveChild/widgetAddChild.
//
// Performance note: calcTreeItemsHeight and calcTreeItemsMaxWidth
// walk the full visible tree on every call. For trees with hundreds
// of items this could be optimized with caching (as TextArea does
// for line count). In practice, DOS-era tree views are small enough
// that the linear scan is fast on a Pentium.
#include "widgetInternal.h"
@ -25,6 +72,9 @@ static int32_t treeItemYPosHelper(WidgetT *parent, WidgetT *target, int32_t *cur
// calcTreeItemsHeight
// ============================================================
// Recursively sums the pixel height of all visible (expanded) items.
// Each item is one charHeight row. Only descends into expanded nodes,
// so collapsed subtrees contribute zero height.
static int32_t calcTreeItemsHeight(WidgetT *parent, const BitmapFontT *font) {
int32_t totalH = 0;
@ -48,6 +98,10 @@ static int32_t calcTreeItemsHeight(WidgetT *parent, const BitmapFontT *font) {
// calcTreeItemsMaxWidth
// ============================================================
// Finds the widest visible item accounting for depth-based indent.
// Used to determine if a horizontal scrollbar is needed. The text
// width uses strlen * charWidth (monospace font assumption) rather
// than a proportional measurement function.
static int32_t calcTreeItemsMaxWidth(WidgetT *parent, const BitmapFontT *font, int32_t depth) {
int32_t maxW = 0;
@ -143,6 +197,11 @@ static void layoutTreeItems(WidgetT *parent, const BitmapFontT *font, int32_t x,
// Return the next visible tree item after the given item
// (depth-first order: children first, then siblings, then uncle).
// Depth-first forward traversal: try children first (if expanded),
// then next sibling, then walk up to find an uncle. This gives the
// standard tree traversal order matching what the user sees on screen.
// The treeView parameter is the traversal boundary -- we stop walking
// up when we reach it.
static WidgetT *nextVisibleItem(WidgetT *item, WidgetT *treeView) {
// If expanded with children, descend
if (item->as.treeItem.expanded) {
@ -206,6 +265,20 @@ static void paintReorderIndicator(WidgetT *w, DisplayT *d, const BlitOpsT *ops,
// paintTreeItems
// ============================================================
// Recursive item painting with visibility culling. Items whose Y
// range falls entirely outside [clipTop, clipBottom) are skipped --
// their Y is still accumulated so subsequent items position correctly,
// but no draw calls are made. This is the key performance optimization
// for large trees: only visible items incur draw cost.
//
// Each visible item draws: optional selection highlight background,
// expand/collapse icon (if has children), and text label. The expand
// icon is a 9x9 bordered box with a horizontal line (minus) when
// expanded, or horizontal + vertical lines (plus) when collapsed.
// This matches the Windows 95/NT Explorer style.
//
// In multi-select mode, a focus rect is drawn around the cursor item
// (selectedItem) to distinguish it from selected-but-not-cursor items.
static void paintTreeItems(WidgetT *parent, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t baseX, int32_t *itemY, int32_t depth, int32_t clipTop, int32_t clipBottom, WidgetT *treeView) {
bool multi = treeView->as.treeView.multiSelect;
@ -309,6 +382,10 @@ static void paintTreeItems(WidgetT *parent, DisplayT *d, const BlitOpsT *ops, co
// (depth-first order: last visible descendant of previous sibling,
// or parent).
// Depth-first backward traversal: find previous sibling, then descend
// to its last visible descendant (the deepest last-child chain). If
// no previous sibling, go to parent. This is the inverse of
// nextVisibleItem and produces the correct "up arrow" traversal order.
static WidgetT *prevVisibleItem(WidgetT *item, WidgetT *treeView) {
// Find previous sibling
WidgetT *prevSib = NULL;
@ -358,6 +435,12 @@ static WidgetT *prevVisibleItem(WidgetT *item, WidgetT *treeView) {
// Select all visible items between 'from' and 'to' (inclusive).
// Direction is auto-detected.
// Selects all visible items between 'from' and 'to' inclusive. The
// direction is auto-detected by walking forward from 'from' -- if
// 'to' is found, that's the forward direction; otherwise we swap.
// This handles both Shift+Down and Shift+Up range selection with
// the same code. The forward walk is O(N) in the worst case but
// range selection in trees is inherently O(N) anyway.
static void selectRange(WidgetT *treeView, WidgetT *from, WidgetT *to) {
if (!from || !to) {
return;
@ -681,6 +764,10 @@ void wgtTreeItemSetSelected(WidgetT *w, bool selected) {
// widgetTreeViewCalcMinSize
// ============================================================
// Min size: wide enough for one indent level + expand icon + 6 chars
// of text + border + vertical scrollbar. Tall enough for TREE_MIN_ROWS
// (4) items. This ensures the tree is usable even when space is tight,
// while the weight=100 default allows it to grow to fill available space.
void widgetTreeViewCalcMinSize(WidgetT *w, const BitmapFontT *font) {
int32_t minContentW = TREE_INDENT + TREE_EXPAND_SIZE + TREE_ICON_GAP + 6 * font->charWidth;
@ -693,6 +780,20 @@ void widgetTreeViewCalcMinSize(WidgetT *w, const BitmapFontT *font) {
// widgetTreeViewOnKey
// ============================================================
// Keyboard navigation follows Windows Explorer conventions:
// - Up/Down: move cursor to prev/next visible item
// - Right: expand collapsed node, or move to first child if expanded
// - Left: collapse expanded node, or move to parent if collapsed
// - Enter: toggle expand/collapse for parents, onClick for leaves
// - Space (multi-select): toggle selection of cursor item
//
// After cursor movement, the view auto-scrolls to keep the cursor
// visible. Multi-select range extension (Shift+arrow) clears existing
// selections and selects the range from anchor to cursor.
//
// The Right/Left expand/collapse behavior provides efficient keyboard
// tree navigation: Right drills in, Left backs out, without needing
// separate expand/collapse keys.
void widgetTreeViewOnKey(WidgetT *w, int32_t key, int32_t mod) {
VALIDATE_WIDGET_VOID(w, WidgetTreeViewE);
@ -848,6 +949,11 @@ void widgetTreeViewOnKey(WidgetT *w, int32_t key, int32_t mod) {
// widgetTreeViewLayout
// ============================================================
// Layout assigns (x, y, w, h) to all visible tree items at their
// scroll-adjusted screen coordinates. Item width extends to the
// full inner width (or total content width if wider), so selection
// highlight bars span the full visible width. Auto-selects the first
// item if nothing is selected yet, providing a reasonable default.
void widgetTreeViewLayout(WidgetT *w, const BitmapFontT *font) {
// Auto-select first item if nothing is selected
if (!w->as.treeView.selectedItem) {
@ -881,6 +987,24 @@ void widgetTreeViewLayout(WidgetT *w, const BitmapFontT *font) {
// widgetTreeViewOnMouse
// ============================================================
// Mouse handling priority: V scrollbar > H scrollbar > dead corner >
// tree item. Item clicks are resolved by treeItemAtY which walks the
// visible tree converting Y coordinates to items. The item's depth
// is computed by walking up the parent chain to determine the expand
// icon's X position and check if the click landed on it.
//
// Multi-select mouse behavior:
// - Plain click: select only this item (clear others), set anchor
// - Ctrl+click: toggle item, update anchor
// - Shift+click: range-select from anchor to clicked item
//
// Collapsing a node re-checks scroll bounds because content height
// may have decreased, and the current scroll position may now be
// past the new maximum.
//
// Drag-reorder initiation: plain click (no modifier, not on expand
// icon) sets dragItem and sDragReorder. The actual reorder drag
// tracking happens in widgetEvent.c during mouse-move.
void widgetTreeViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy) {
hit->focused = true;
AppContextT *ctx = (AppContextT *)root->userData;
@ -1091,6 +1215,11 @@ void widgetTreeViewOnMouse(WidgetT *hit, WidgetT *root, int32_t vx, int32_t vy)
// widgetTreeViewPaint
// ============================================================
// Paint: sunken border, then clipped content area for items +
// reorder indicator, then scrollbars outside the clip rect. The clip
// rect excludes the scrollbar area so items don't paint over scrollbars.
// Both scroll axes are applied to baseX/itemY so the tree content
// shifts correctly in both dimensions.
void widgetTreeViewPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;

View file

@ -15,6 +15,11 @@
// Module state
// ============================================================
// Fixed-size app slot table. Using a static array (not dynamic) because:
// 1. The number of concurrent apps is bounded by available DOS memory
// 2. App IDs are slot indices, so a fixed array gives O(1) lookup
// 3. No fragmentation concerns from repeated malloc/free cycles
// Slot 0 is reserved (represents the shell itself); apps use slots 1..31.
static ShellAppT sApps[SHELL_MAX_APPS];
int32_t sCurrentAppId = 0;
@ -38,6 +43,9 @@ void shellTerminateAllApps(AppContextT *ctx);
// Static helpers
// ============================================================
// Find the first free slot, starting at 1 (slot 0 is the shell).
// Returns the slot index which also serves as the app's unique ID.
// This linear scan is fine for 32 slots.
static int32_t allocSlot(void) {
for (int32_t i = 1; i < SHELL_MAX_APPS; i++) {
if (sApps[i].state == AppStateFreeE) {
@ -49,6 +57,15 @@ static int32_t allocSlot(void) {
}
// Task entry point for main-loop apps. Runs in its own cooperative task.
// Sets sCurrentAppId before calling the app's entry point so that any
// GUI resources created during the app's lifetime are tagged with the
// correct owner. When the app's main loop returns (normal exit), the
// state is set to Terminating so the shell's main loop will reap it.
//
// If the app crashes (signal), the signal handler longjmps to main and
// this function never completes — the task is killed externally via
// shellForceKillApp + tsKill.
static void appTaskWrapper(void *arg) {
ShellAppT *app = (ShellAppT *)arg;
@ -80,12 +97,19 @@ void shellAppInit(void) {
}
// Forcible kill — no shutdown hook is called. Used for crashed apps
// (where running more app code would be unsafe) and for "End Task".
// Cleanup order matters: windows first (removes them from the compositor),
// then the task (frees the stack), then the DXE handle (unmaps the code).
// If we closed the DXE first, destroying windows could call into unmapped
// callback code and crash the shell.
void shellForceKillApp(AppContextT *ctx, ShellAppT *app) {
if (!app || app->state == AppStateFreeE) {
return;
}
// Destroy all windows belonging to this app
// Destroy all windows belonging to this app. Walk backwards because
// dvxDestroyWindow removes the window from the stack, shifting indices.
for (int32_t i = ctx->stack.count - 1; i >= 0; i--) {
if (ctx->stack.windows[i]->appId == app->appId) {
dvxDestroyWindow(ctx, ctx->stack.windows[i]);
@ -123,6 +147,11 @@ ShellAppT *shellGetApp(int32_t appId) {
}
// Load a DXE app: dlopen the module, resolve symbols, set up context, launch.
// DXE3 is DJGPP's dynamic linking system — similar to dlopen/dlsym on Unix.
// Each .app file is a DXE3 shared object that exports _appDescriptor and
// _appMain (and optionally _appShutdown). The leading underscore is the
// COFF symbol convention; DJGPP's dlsym expects it.
int32_t shellLoadApp(AppContextT *ctx, const char *path) {
// Allocate a slot
int32_t id = allocSlot();
@ -182,7 +211,10 @@ int32_t shellLoadApp(AppContextT *ctx, const char *path) {
app->dxeCtx.shellCtx = ctx;
app->dxeCtx.appId = id;
// Derive app directory from path (everything up to last '/' or '\')
// Derive app directory from path (everything up to last '/' or '\').
// This lets apps load resources relative to their own location rather
// than the shell's working directory. Handles both Unix and DOS path
// separators because DJGPP accepts either.
snprintf(app->dxeCtx.appDir, sizeof(app->dxeCtx.appDir), "%s", path);
char *lastSlash = strrchr(app->dxeCtx.appDir, '/');
@ -199,7 +231,9 @@ int32_t shellLoadApp(AppContextT *ctx, const char *path) {
app->dxeCtx.appDir[1] = '\0';
}
// Launch
// Launch. Set sCurrentAppId before any app code runs so that window
// creation wrappers stamp the correct owner. Reset to 0 afterward so
// shell-initiated operations (e.g., message boxes) aren't misattributed.
sCurrentAppId = id;
if (desc->hasMainLoop) {
@ -218,7 +252,10 @@ int32_t shellLoadApp(AppContextT *ctx, const char *path) {
app->mainTaskId = (uint32_t)taskId;
} else {
// Callback-only: call entry directly in task 0
// Callback-only: call entry directly in task 0 (the shell).
// The app creates its windows and returns. From this point on,
// the app lives entirely through event callbacks dispatched by
// the shell's dvxUpdate loop. No separate task or stack needed.
app->entryFn(&app->dxeCtx);
}
@ -230,6 +267,11 @@ int32_t shellLoadApp(AppContextT *ctx, const char *path) {
}
// Graceful reap — called from shellReapApps when an app has reached
// the Terminating state. Unlike forceKill, this calls the app's
// shutdown hook (if provided) giving it a chance to save state, close
// files, etc. The sCurrentAppId is set during the shutdown call so
// any final resource operations are attributed correctly.
void shellReapApp(AppContextT *ctx, ShellAppT *app) {
if (!app || app->state == AppStateFreeE) {
return;
@ -267,6 +309,11 @@ void shellReapApp(AppContextT *ctx, ShellAppT *app) {
}
// Called every frame from the shell's main loop. Scans for apps that have
// entered the Terminating state (either their task returned or their last
// window was closed) and cleans them up. The deferred-reap design avoids
// destroying resources in the middle of a callback chain — the app marks
// itself for termination, and cleanup happens at a safe top-level point.
bool shellReapApps(AppContextT *ctx) {
bool reaped = false;

View file

@ -1,4 +1,24 @@
// shellApp.h — DVX Shell application lifecycle types and API
//
// The shell supports two kinds of DXE apps:
//
// 1. Callback-only apps (hasMainLoop = false):
// appMain() is called directly in the shell's task 0. It creates
// windows, registers event callbacks, and returns immediately. The
// app then lives entirely through GUI callbacks driven by the shell's
// main loop. This is simpler and cheaper (no extra stack/task) and
// is the right choice for apps that are purely event-driven (dialogs,
// simple tools). Lifecycle ends when the last window is closed.
//
// 2. Main-loop apps (hasMainLoop = true):
// A dedicated cooperative task is created for the app. appMain() runs
// in that task and can do its own polling/processing loop, calling
// tsYield() to share CPU. This is needed for apps that have continuous
// work (terminal emulators, games, long computations). Lifecycle ends
// when appMain() returns or the task is killed.
//
// Both types use the same DXE interface (appDescriptor + appMain), so
// app authors choose by setting hasMainLoop in their descriptor.
#ifndef SHELL_APP_H
#define SHELL_APP_H
@ -14,8 +34,13 @@
// ============================================================
#define SHELL_APP_NAME_MAX 64
// Fixed array size for app slots. A small fixed limit avoids dynamic
// allocation complexity and matches the practical limit of a DOS desktop.
// Slot 0 is reserved for the shell itself, so 31 app slots are usable.
#define SHELL_MAX_APPS 32
// Every DXE app exports a global AppDescriptorT named "appDescriptor".
// The shell reads it at load time to determine how to launch the app.
typedef struct {
char name[SHELL_APP_NAME_MAX];
bool hasMainLoop;
@ -27,6 +52,12 @@ typedef struct {
// App context (passed to appMain)
// ============================================================
// Passed as the sole argument to appMain(). Gives the app access to
// the shell's GUI context (for creating windows, drawing, etc.) and
// its own identity. appDir is derived from the .app file path at load
// time so the app can find its own resources (icons, data files) via
// relative paths — important because the working directory is shared
// by all apps and can't be changed per-app in DOS.
typedef struct {
AppContextT *shellCtx; // the shell's GUI context
int32_t appId; // this app's ID
@ -37,6 +68,11 @@ typedef struct {
// Per-app state
// ============================================================
// State machine: Free -> Loaded -> Running -> Terminating -> Free
// LoadedE is transient (only during shellLoadApp before entry is called).
// TerminatingE means the app's task has exited but cleanup (window
// destruction, DXE unload) hasn't happened yet — the shell's main loop
// reaps these each frame via shellReapApps().
typedef enum {
AppStateFreeE, // slot available
AppStateLoadedE, // DXE loaded, not yet started
@ -61,7 +97,13 @@ typedef struct {
// Shell global state
// ============================================================
// Current app ID for resource tracking (0 = shell)
// Current app ID for resource tracking (0 = shell).
// Set before calling any app code (entry, shutdown, callbacks) so that
// wrapper functions (shellWrapCreateWindow, etc.) can stamp newly created
// resources with the correct owner. This is the mechanism that lets the
// shell know which windows belong to which app, enabling per-app cleanup
// on crash or termination. It's a global rather than a thread-local
// because cooperative multitasking means only one task runs at a time.
extern int32_t sCurrentAppId;
// ============================================================
@ -72,15 +114,25 @@ extern int32_t sCurrentAppId;
void shellAppInit(void);
// Load and start an app from a DXE file. Returns app ID (>= 1) or -1 on error.
// For callback-only apps, appMain runs synchronously and returns before
// shellLoadApp returns. For main-loop apps, a task is created and the
// app starts running on the next tsYield.
int32_t shellLoadApp(AppContextT *ctx, const char *path);
// Reap finished callback-only apps (call each frame from main loop)
// Reap finished apps (call each frame from main loop). Returns true if
// any apps were reaped, so the caller can notify the desktop to refresh.
// For callback-only apps, termination is triggered by shellWrapDestroyWindow
// when the last window closes. For main-loop apps, termination happens
// when appMain returns (via appTaskWrapper marking AppStateTerminatingE).
bool shellReapApps(AppContextT *ctx);
// Gracefully shut down a single app
// Gracefully shut down a single app — calls shutdownFn if present,
// destroys windows, kills task, closes DXE handle.
void shellReapApp(AppContextT *ctx, ShellAppT *app);
// Forcibly kill an app (Task Manager "End Task")
// Forcibly kill an app (Task Manager "End Task"). Skips shutdownFn —
// used when the app is hung or has crashed and cannot be trusted to
// run its own cleanup code.
void shellForceKillApp(AppContextT *ctx, ShellAppT *app);
// Terminate all running apps (shell shutdown)
@ -114,7 +166,10 @@ void shellExportInit(void);
#define SHELL_DESKTOP_APP "apps/progman/progman.app"
// Register a callback for app state changes (load, reap, crash).
// The desktop app calls this during appMain to receive notifications.
// The desktop app (Program Manager) calls this during appMain to receive
// notifications so it can refresh its task list / window list display.
// Only one callback is supported — the desktop is always loaded first
// and is the only consumer.
void shellRegisterDesktopUpdate(void (*updateFn)(void));
#endif // SHELL_APP_H

View file

@ -2,6 +2,30 @@
//
// Exports all dvx*/wgt*/ts* symbols that DXE apps need. A few functions
// are wrapped for resource tracking (window ownership via appId).
//
// DXE3 is DJGPP's dynamic linking mechanism. Unlike ELF shared libraries,
// DXE modules have no implicit access to the host's symbol table. Every
// function or variable the DXE needs must be explicitly listed in an
// export table registered via dlregsym() BEFORE any dlopen() call. If a
// symbol is missing, dlopen() returns NULL with a "symbol not found" error.
//
// This file is essentially the ABI contract between the shell and apps.
// Three categories of exports:
//
// 1. Wrapped functions: dvxCreateWindow, dvxCreateWindowCentered,
// dvxDestroyWindow. These are intercepted to stamp win->appId for
// resource ownership tracking. The DXE sees them under their original
// names — the app code calls dvxCreateWindow() normally and gets our
// wrapper transparently.
//
// 2. Direct exports: all other dvx/wgt/wm/ts functions. These are safe
// to call without shell-side interception.
//
// 3. libc functions: DXE modules are statically linked against DJGPP's
// libc, but DJGPP's DXE3 loader requires explicit re-export of any
// libc symbols the module references. Without these entries, the DXE
// would fail to load with unresolved symbol errors. This is a DXE3
// design limitation — there's no automatic fallback to the host's libc.
#include "shellApp.h"
#include "dvxApp.h"
@ -34,6 +58,11 @@ static void shellWrapDestroyWindow(AppContextT *ctx, WindowT *win);
// Wrapper: dvxCreateWindow — stamps win->appId
// ============================================================
// The wrapper calls the real dvxCreateWindow, then tags the result with
// sCurrentAppId. This is how the shell knows which app owns which window,
// enabling per-app window cleanup on crash/termination. The app never
// sees the difference — the wrapper has the same signature and is
// exported under the same name as the original function.
static WindowT *shellWrapCreateWindow(AppContextT *ctx, const char *title, int32_t x, int32_t y, int32_t w, int32_t h, bool resizable) {
WindowT *win = dvxCreateWindow(ctx, title, x, y, w, h, resizable);
@ -64,6 +93,12 @@ static WindowT *shellWrapCreateWindowCentered(AppContextT *ctx, const char *titl
// Wrapper: dvxDestroyWindow — checks for last-window reap
// ============================================================
// Beyond just destroying the window, this wrapper implements the lifecycle
// rule for callback-only apps: when their last window closes, they're done.
// Main-loop apps manage their own lifetime (their task returns from
// appMain), so this check only applies to callback-only apps.
// The appId is captured before destruction because the window struct is
// freed by dvxDestroyWindow.
static void shellWrapDestroyWindow(AppContextT *ctx, WindowT *win) {
int32_t appId = win->appId;
@ -96,11 +131,14 @@ static void shellWrapDestroyWindow(AppContextT *ctx, WindowT *win) {
// Export table
// ============================================================
// We use extern_asm to get the actual addresses for wrapped functions
// since we export under the original names but point to our wrappers.
// DXE_EXPORT_TABLE generates a DXE symbol table array. DXE_EXPORT(fn)
// expands to { "_fn", (void *)fn } — the underscore prefix matches COFF
// symbol naming. For wrapped functions we use raw entries with explicit
// names so the DXE sees "_dvxCreateWindow" but gets our wrapper's address.
DXE_EXPORT_TABLE(shellExportTable)
// Wrapped functions (exported under original names)
// Wrapped functions (exported under original names, but pointing to
// our wrappers that add resource tracking)
{ "_dvxCreateWindow", (void *)shellWrapCreateWindow },
{ "_dvxDestroyWindow", (void *)shellWrapDestroyWindow },
@ -331,7 +369,10 @@ DXE_EXPORT_TABLE(shellExportTable)
DXE_EXPORT(wgtLayout)
DXE_EXPORT(wgtPaint)
// taskswitch.h
// taskswitch.h — only yield and query functions are exported.
// tsCreate/tsKill/etc. are NOT exported because apps should not
// manipulate the task system directly — the shell manages task
// lifecycle through shellLoadApp/shellForceKillApp.
DXE_EXPORT(tsYield)
DXE_EXPORT(tsCurrentId)
DXE_EXPORT(tsActiveCount)
@ -349,6 +390,12 @@ DXE_EXPORT_TABLE(shellExportTable)
DXE_EXPORT(shellRunningAppCount)
DXE_EXPORT(shellRegisterDesktopUpdate)
// libc exports below. DXE3 modules are compiled as relocatable objects,
// not fully linked executables. Any libc function the DXE calls must be
// re-exported here so the DXE3 loader can resolve the reference at
// dlopen time. Forgetting an entry produces a cryptic "unresolved
// symbol" error at load time — no lazy binding fallback exists.
// libc — memory
DXE_EXPORT(malloc)
DXE_EXPORT(free)
@ -419,6 +466,9 @@ DXE_EXPORT_END
// shellRegisterExports
// ============================================================
// dlregsym registers our export table with DJGPP's DXE3 runtime.
// Must be called once before any dlopen — subsequent dlopen calls
// will search this table to resolve DXE symbol references.
static void shellRegisterExports(void) {
dlregsym(shellExportTable);
}

View file

@ -3,6 +3,26 @@
// Initializes the GUI, task system, DXE export table, and loads
// the desktop app. Runs the cooperative main loop, yielding to
// app tasks and reaping terminated apps each frame.
//
// The main loop design (dvxUpdate + tsYield + reap + notify):
// Each iteration does four things:
// 1. dvxUpdate: processes input events, dispatches callbacks, composites
// dirty rects, flushes to the LFB. This is the shell's primary job.
// 2. tsYield: gives CPU time to app tasks. Without this, main-loop apps
// would never run because the shell task would monopolize the CPU.
// 3. shellReapApps: cleans up any apps that terminated during this frame
// (either their task returned or their last window was closed).
// 4. desktopUpdate: notifies the desktop app (Program Manager) if any
// apps were reaped, so it can refresh its task list.
//
// Crash recovery uses setjmp/longjmp:
// The shell installs signal handlers for SIGSEGV, SIGFPE, SIGILL. If a
// crash occurs in an app task, the handler longjmps back to the setjmp
// point in main(). This works because longjmp restores the main task's
// stack frame regardless of which task was running. tsRecoverToMain()
// then fixes the scheduler's bookkeeping, and the crashed app is killed.
// This gives the shell Windows 3.1-style fault tolerance — one bad app
// doesn't take down the whole system.
#include "shellApp.h"
#include "dvxDialog.h"
@ -13,6 +33,8 @@
#include <signal.h>
#include <stdio.h>
#include <string.h>
// DJGPP-specific: provides __djgpp_exception_state_ptr for accessing
// CPU register state at the point of the exception
#include <sys/exceptn.h>
// ============================================================
@ -20,7 +42,11 @@
// ============================================================
static AppContextT sCtx;
// setjmp buffer for crash recovery. The crash handler longjmps here to
// return control to the shell's main loop after an app crashes.
static jmp_buf sCrashJmp;
// Volatile because it's written from a signal handler context. Tells
// the recovery code which signal fired (for logging/diagnostics).
static volatile int sCrashSignal = 0;
static FILE *sLogFile = NULL;
static void (*sDesktopUpdateFn)(void) = NULL;
@ -40,6 +66,16 @@ static void logVideoMode(int32_t w, int32_t h, int32_t bpp, void *userData);
// crashHandler — catch page faults and other fatal signals
// ============================================================
// Signal handler for fatal exceptions. DJGPP uses System V signal
// semantics where the handler is reset to SIG_DFL after each delivery,
// so we must re-install it before doing anything else.
//
// The longjmp is the key to crash recovery: it unwinds whatever stack
// we're on (potentially a crashed app's task stack) and restores the
// main task's stack frame to the setjmp point in main(). This is safe
// because cooperative switching means the main task's stack is always
// intact — it was cleanly suspended at a yield point. The crashed
// task's stack is abandoned (and later freed by tsKill).
static void crashHandler(int sig) {
logCrash(sig);
@ -66,6 +102,12 @@ static void desktopUpdate(void) {
// idleYield — called when no dirty rects need compositing
// ============================================================
// Registered as sCtx.idleCallback. dvxUpdate calls this when it has
// processed all pending events and there are no dirty rects to composite.
// Instead of busy-spinning, we yield to app tasks — this is where most
// of the CPU time for main-loop apps comes from when the UI is idle.
// The tsActiveCount > 1 check avoids the overhead of a tsYield call
// (which would do a scheduler scan) when the shell is the only task.
static void idleYield(void *ctx) {
(void)ctx;
@ -90,6 +132,11 @@ static void installCrashHandler(void) {
// logCrash — write exception details to the log
// ============================================================
// Dump as much diagnostic info as possible before longjmp destroys the
// crash context. This runs inside the signal handler, so only
// async-signal-safe functions should be used — but since we're in
// DJGPP (single-threaded DOS), reentrancy isn't a practical concern
// and vfprintf/fflush are safe to call here.
static void logCrash(int sig) {
const char *sigName = "UNKNOWN";
@ -118,7 +165,10 @@ static void logCrash(int sig) {
shellLog("Crashed in shell (task 0)");
}
// Dump CPU registers from exception state
// __djgpp_exception_state_ptr is a DJGPP extension that captures the
// full CPU register state at the point of the exception. This gives
// us the faulting EIP, stack pointer, and all GPRs — invaluable for
// post-mortem debugging of app crashes from the log file.
jmp_buf *estate = __djgpp_exception_state_ptr;
if (estate) {
@ -203,7 +253,10 @@ int main(void) {
return 1;
}
// Shell task (task 0) gets high priority for responsive UI
// Shell task (task 0) gets high priority so the UI remains responsive
// even when app tasks are CPU-hungry. With HIGH priority (11 credits
// per epoch) vs app tasks at NORMAL (6 credits), the shell gets
// roughly twice as many scheduling turns as any single app.
tsSetPriority(0, TS_PRIORITY_HIGH);
// Register DXE export table
@ -212,7 +265,10 @@ int main(void) {
// Initialize app slot table
shellAppInit();
// Set up idle callback for cooperative yielding
// Set up idle callback for cooperative yielding. When dvxUpdate has
// no work to do (no input events, no dirty rects), it calls this
// instead of busy-looping. This is the main mechanism for giving
// app tasks CPU time during quiet periods.
sCtx.idleCallback = idleYield;
sCtx.idleCtx = &sCtx;
@ -231,16 +287,23 @@ int main(void) {
return 1;
}
// Install crash handler after everything is initialized
// Install crash handler after everything is initialized — if
// initialization itself crashes, we want the default DJGPP behavior
// (abort with register dump) rather than our recovery path, because
// the system isn't in a recoverable state yet.
installCrashHandler();
shellLog("DVX Shell ready.");
// Set recovery point for crash handler
// Set recovery point for crash handler. setjmp returns 0 on initial
// call (falls through to the main loop). On a crash, longjmp makes
// setjmp return non-zero, entering this recovery block. The recovery
// code runs on the main task's stack (restored by longjmp) so it's
// safe to call any shell function.
if (setjmp(sCrashJmp) != 0) {
// Returned here from crash handler via longjmp.
// If the crash was in a non-main task, the task switcher still
// thinks that task is running. Fix it before doing anything else.
// The task switcher's currentIdx still points to the crashed task.
// Fix it before doing anything else so the scheduler is consistent.
tsRecoverToMain();
shellLog("Recovering from crash, killing app %ld", (long)sCurrentAppId);
@ -262,7 +325,12 @@ int main(void) {
desktopUpdate();
}
// Main loop
// Main loop — runs until dvxQuit() sets sCtx.running = false.
// Two yield points per iteration: one explicit (below) and one via
// the idle callback inside dvxUpdate. The explicit yield here ensures
// app tasks get CPU time even during busy frames (lots of repaints).
// Without it, a flurry of mouse-move events could starve app tasks
// because dvxUpdate would keep finding work to do and never call idle.
while (sCtx.running) {
dvxUpdate(&sCtx);
@ -271,7 +339,9 @@ int main(void) {
tsYield();
}
// Reap terminated apps and notify desktop if anything changed
// Reap terminated apps and notify desktop if anything changed.
// This is the safe point for cleanup — we're at the top of the
// main loop, not inside any callback or compositor operation.
if (shellReapApps(&sCtx)) {
desktopUpdate();
}
@ -279,7 +349,9 @@ int main(void) {
shellLog("DVX Shell shutting down...");
// Clean shutdown: terminate all apps
// Clean shutdown: terminate all apps first (destroys windows, kills
// tasks, closes DXE handles), then tear down the task system and GUI
// in reverse initialization order.
shellTerminateAllApps(&sCtx);
tsShutdown();

View file

@ -3,11 +3,18 @@
// Frame format (before byte stuffing):
// [0x7E] [SEQ] [TYPE] [LEN] [PAYLOAD...] [CRC_LO] [CRC_HI]
//
// Byte stuffing:
// 0x7E -> 0x7D 0x5E
// 0x7D -> 0x7D 0x5D
// The leading 0x7E is the frame flag. A trailing 0x7E closes the frame and
// also serves as the flag for the next frame (back-to-back). SEQ is an 8-bit
// sequence number that wraps naturally. TYPE identifies DATA/ACK/NAK/RST.
// LEN is the payload byte count (0-255). CRC covers SEQ through PAYLOAD.
//
// CRC-16-CCITT over SEQ+TYPE+LEN+PAYLOAD
// Byte stuffing (transparency):
// 0x7E -> 0x7D 0x5E (flag byte escaped)
// 0x7D -> 0x7D 0x5D (escape byte itself escaped)
// XOR with 0x20 is the standard HDLC transparency method.
//
// CRC-16-CCITT (polynomial 0x1021) over SEQ+TYPE+LEN+PAYLOAD.
// Initial value 0xFFFF. Stored little-endian in the frame.
#include <stdint.h>
#include <stdbool.h>
@ -47,20 +54,29 @@
// Receive buffer must hold at least one max-size stuffed frame
#define RX_BUF_SIZE (MAX_STUFFED_SIZE + 64)
// Retransmit timeout in milliseconds
// Retransmit timeout. 500ms is conservative for a local serial link (RTT
// is < 1ms) but accounts for the remote side being busy. On a real BBS
// connection through the proxy, the round-trip includes TCP latency.
#define RETRANSMIT_TIMEOUT_MS 500
// Receive state machine
#define RX_STATE_HUNT 0 // scanning for FLAG_BYTE
#define RX_STATE_ACTIVE 1 // receiving frame data
#define RX_STATE_ESCAPE 2 // next byte is escaped
// Receive state machine: three states for HDLC deframing.
// HUNT: discarding bytes until a flag (0x7E) is seen — sync acquisition.
// ACTIVE: accumulating frame bytes, watching for flag (end of frame) or
// escape (next byte is XOR'd).
// ESCAPE: the previous byte was 0x7D; XOR this byte with 0x20 to recover
// the original value.
#define RX_STATE_HUNT 0
#define RX_STATE_ACTIVE 1
#define RX_STATE_ESCAPE 2
// ========================================================================
// Types
// ========================================================================
// Transmit window slot
// Transmit window slot: retains a copy of sent data so we can retransmit
// on NAK or timeout without the caller keeping its buffer alive. The timer
// tracks when this slot was last (re)transmitted for timeout detection.
typedef struct {
uint8_t data[PKT_MAX_PAYLOAD];
int len;
@ -68,20 +84,23 @@ typedef struct {
clock_t timer;
} TxSlotT;
// Connection state
// Connection state. txSlots is a circular window indexed by [0..txCount-1],
// where slot 0 is the oldest unacked frame (sequence txAckSeq) and
// slot txCount-1 is the newest. When an ACK advances txAckSeq, we shift
// slots down (implicitly, by incrementing txAckSeq and decrementing txCount).
struct PktConnS {
int com;
int windowSize;
PktRecvCallbackT callback;
void *callbackCtx;
// Transmit state
// Transmit state (Go-Back-N sender)
uint8_t txNextSeq; // next sequence number to assign
uint8_t txAckSeq; // oldest unacknowledged sequence
TxSlotT txSlots[PKT_MAX_WINDOW];
int txCount; // number of slots in use
// Receive state
// Receive state (Go-Back-N receiver: only accepts in-order frames)
uint8_t rxExpectSeq; // next expected sequence number
uint8_t rxState; // RX_STATE_*
uint8_t rxFrame[MAX_FRAME_SIZE];
@ -150,6 +169,10 @@ static int txSlotIndex(PktConnT *conn, uint8_t seq);
// CRC computation
// ========================================================================
// Table-driven CRC-16-CCITT. Processing one byte per iteration with a
// 256-entry table is ~10x faster than bit-by-bit on a 486. The table
// costs 512 bytes of .rodata — a worthwhile trade for a function called
// on every frame received and transmitted.
static uint16_t crcCalc(const uint8_t *data, int len) {
uint16_t crc = 0xFFFF;
@ -165,6 +188,10 @@ static uint16_t crcCalc(const uint8_t *data, int len) {
// Frame transmission
// ========================================================================
// Build a raw frame, compute CRC, byte-stuff it, and transmit.
// The stuffed buffer can be up to 2x the raw size (every byte might need
// escaping) plus the flags. This is stack-allocated because frames are small
// and we're not in a deeply recursive call path.
static void sendFrame(PktConnT *conn, uint8_t seq, uint8_t type, const uint8_t *payload, int len) {
uint8_t raw[MAX_FRAME_SIZE];
uint8_t stuffed[MAX_STUFFED_SIZE];
@ -229,6 +256,10 @@ static void sendRst(PktConnT *conn) {
// Sequence number helpers
// ========================================================================
// Check if a sequence number falls within a window starting at base.
// Works correctly with 8-bit wrap-around because unsigned subtraction
// wraps mod 256 — if seq is "ahead" of base by less than size, diff
// will be a small positive number.
static int seqInWindow(uint8_t seq, uint8_t base, int size) {
uint8_t diff = seq - base;
return diff < (uint8_t)size;
@ -248,6 +279,18 @@ static int txSlotIndex(PktConnT *conn, uint8_t seq) {
// Frame processing
// ========================================================================
// Handle a complete, de-stuffed frame. CRC is verified first; on failure,
// we NAK to request retransmission of the frame we actually expected.
//
// For DATA frames: Go-Back-N receiver logic — only accept if seq matches
// rxExpectSeq (strictly in-order). Out-of-order frames within the window
// trigger a NAK; duplicates and out-of-window frames are silently dropped.
//
// For ACK frames: cumulative acknowledgement. The ACK carries the next
// expected sequence number, so we free all slots up to that point.
//
// For NAK frames: the receiver wants us to retransmit from a specific
// sequence. Go-Back-N retransmits that frame AND all subsequent ones.
static void processFrame(PktConnT *conn, const uint8_t *frame, int len) {
uint8_t seq;
uint8_t type;
@ -333,6 +376,12 @@ static void processFrame(PktConnT *conn, const uint8_t *frame, int len) {
// Receive byte processing (state machine)
// ========================================================================
// Feed one byte from the serial port into the HDLC deframing state machine.
// The flag byte (0x7E) serves double duty: it ends the current frame AND
// starts the next one. This means back-to-back frames share a single flag
// byte, saving bandwidth. A frame is only processed if it meets the minimum
// size requirement (header + CRC), so spurious flags between frames are
// harmless (they just produce zero-length "frames" that are discarded).
static void rxProcessByte(PktConnT *conn, uint8_t byte) {
switch (conn->rxState) {
case RX_STATE_HUNT:
@ -379,6 +428,11 @@ static void rxProcessByte(PktConnT *conn, uint8_t byte) {
// Retransmit check
// ========================================================================
// Timer-based retransmission for slots that haven't been ACK'd within the
// timeout. This handles the case where an ACK or NAK was lost — without
// this, the connection would stall forever. Each slot is retransmitted
// independently and its timer is reset, creating exponential backoff
// behavior naturally (each retransmit resets the timer).
static void retransmitCheck(PktConnT *conn) {
clock_t now = clock();
clock_t timeout = (clock_t)RETRANSMIT_TIMEOUT_MS * CLOCKS_PER_SEC / 1000;
@ -450,6 +504,12 @@ PktConnT *pktOpen(int com, int windowSize, PktRecvCallbackT callback, void *call
}
// Main polling function — must be called frequently (each iteration of the
// app's main loop or event loop). It drains the serial port's RX buffer,
// feeds bytes through the deframing state machine, and checks for
// retransmit timeouts. The callback is invoked synchronously for each
// complete, verified, in-order data frame, so the caller should be prepared
// for re-entrant calls to pktSend from within the callback.
int pktPoll(PktConnT *conn) {
char buf[128];
int nRead;
@ -500,6 +560,11 @@ int pktReset(PktConnT *conn) {
}
// Send a data packet. If block=true and the window is full, polls in a
// tight loop until space opens up (an ACK arrives). The data is copied
// into a retransmit slot before sending so the caller can reuse its buffer
// immediately. The window slot tracks the sequence number and timestamp
// for retransmission.
int pktSend(PktConnT *conn, const uint8_t *data, int len, bool block) {
TxSlotT *slot;

View file

@ -1,5 +1,28 @@
// Packetized serial transport with HDLC-style framing and sliding window
// Provides reliable, ordered delivery over an unreliable serial link.
//
// Why HDLC framing:
// HDLC's flag-byte + byte-stuffing scheme is the simplest way to delimit
// variable-length frames on a raw byte stream. The 0x7E flag byte marks
// frame boundaries; byte-stuffing (escaping 0x7E and 0x7D within data)
// ensures the flag is unambiguous. This is proven, lightweight, and
// requires zero buffering at the framing layer. Alternative approaches
// like length-prefixed framing are fragile on noisy links because a
// corrupted length field desynchronizes the receiver permanently.
//
// Why Go-Back-N ARQ:
// Go-Back-N is simpler than Selective Repeat (no out-of-order reassembly
// buffer needed on the receiver) and works well for the low bandwidth-delay
// product of a serial link. On a 115200 bps local serial connection, the
// round-trip time is negligible, so the window rarely fills. GBN's
// retransmit-all-from-NAK behavior wastes bandwidth on lossy links, but
// serial links are nearly lossless — the CRC check is primarily a safety
// net for electrical noise, not a routine error recovery mechanism.
//
// CRC-16-CCITT:
// Standard polynomial used in HDLC/X.25. Table-driven for speed
// (256-entry lookup table trades 512 bytes of ROM for ~10x faster CRC
// than the bit-by-bit method, important on a 486).
#ifndef PACKET_H
#define PACKET_H
@ -7,13 +30,16 @@
#include <stdint.h>
#include <stdbool.h>
// Maximum payload per packet (excluding header/CRC)
// Maximum payload per packet (excluding header/CRC). 255 bytes fits in a
// single LEN byte and keeps total frame size manageable for serial buffers.
#define PKT_MAX_PAYLOAD 255
// Default sliding window size (1-8)
// Default sliding window size (1-8). 4 allows up to 4 unacknowledged
// frames in flight, providing good throughput without excessive buffering.
#define PKT_DEFAULT_WINDOW 4
// Maximum window size
// Maximum window size. Limited to 8 because sequence numbers are 8-bit
// and we need seq space > 2*window to avoid ambiguity (255 > 2*8).
#define PKT_MAX_WINDOW 8
// Error codes

View file

@ -1,8 +1,25 @@
// SecLink proxy — bridges an 86Box serial connection to a telnet BBS
//
// Architecture:
// 86Box (DOS terminal) ←→ TCP ←→ proxy ←→ TCP ←→ BBS
// secLink protocol plain telnet
//
// The proxy runs on Linux and sits between two TCP connections:
// 1. Left side: 86Box connects via its serial-over-TCP feature. The proxy
// speaks the full secLink protocol (packet framing, DH handshake,
// XTEA encryption) over this connection.
// 2. Right side: plain telnet to a BBS. The proxy handles telnet IAC
// negotiation and strips control sequences before forwarding clean
// data to the DOS side.
//
// This design keeps all crypto complexity on the proxy side (Linux has
// /dev/urandom for good entropy) and inside the DOS app. The BBS sees
// a normal telnet client and doesn't know encryption is involved.
//
// The socket shim (sockShim.c) makes the TCP socket look like a COM port
// to the secLink stack, allowing full code reuse of the packet and
// security layers without modification.
//
// Usage: proxy [listen_port] [bbs_host] [bbs_port]
// Defaults: 2323 10.1.0.244 2023
@ -34,7 +51,9 @@
#define CHANNEL_TERMINAL 0
#define POLL_TIMEOUT_MS 10
// Telnet protocol bytes
// Telnet protocol bytes (RFC 854). The proxy must handle these because BBS
// servers send telnet negotiation sequences that would corrupt the terminal
// display if passed through raw.
#define TEL_IAC 255
#define TEL_DONT 254
#define TEL_DO 253
@ -49,7 +68,9 @@
#define TELOPT_TTYPE 24
#define TELOPT_NAWS 31
// Telnet parser states
// Telnet parser states. The parser is a simple state machine that strips
// IAC sequences from the BBS data stream. It persists across read() calls
// (sTelState is static) because a sequence can span TCP segment boundaries.
#define TS_DATA 0
#define TS_IAC 1
#define TS_WILL 2
@ -151,12 +172,14 @@ static int createListenSocket(int port) {
}
// secLink receive callback — called when decrypted data arrives from the DOS
// terminal. Before the BBS is connected, we watch for an ENTER keypress as
// a "ready" signal so the user can see the terminal is working before we
// connect to the BBS (which would immediately start sending ANSI art).
static void onRecvFromDos(void *ctx, const uint8_t *data, int len, uint8_t channel) {
int bbsFd = *(int *)ctx;
(void)channel;
// Check for ENTER before BBS is connected
if (!sGotEnter) {
for (int i = 0; i < len; i++) {
if (data[i] == '\r' || data[i] == '\n') {
@ -179,6 +202,9 @@ static void onRecvFromDos(void *ctx, const uint8_t *data, int len, uint8_t chann
}
// On Linux, /dev/urandom provides high-quality entropy without blocking.
// This is vastly better than the PIT-based entropy on DOS — one of the
// advantages of running the proxy on the host OS.
static void seedRng(void) {
uint8_t entropy[32];
FILE *f;
@ -216,8 +242,13 @@ static void telnetRespond(int bbsFd, uint8_t cmd, uint8_t opt) {
}
// Filter telnet IAC sequences from BBS data.
// Handles negotiation by responding appropriately.
// Filter telnet IAC sequences from BBS data and handle negotiation.
// We accept ECHO and SGA (Suppress Go Ahead) because most BBSes require them
// for character-at-a-time mode. We accept TTYPE and NAWS for terminal type
// and window size negotiation. Everything else is refused. Subnegotiations
// (SB...SE) are consumed silently — we don't actually respond with terminal
// type or window size data, but accepting the option prevents the BBS from
// falling back to line mode.
// Returns the number of clean data bytes written to 'out'.
static int telnetFilter(int bbsFd, const uint8_t *in, int inLen, uint8_t *out) {
int outLen = 0;
@ -351,6 +382,8 @@ int main(int argc, char *argv[]) {
bbsPort = atoi(argv[3]);
}
// Ignore SIGPIPE so write() to a closed socket returns EPIPE instead of
// killing the process. Essential for any TCP server/proxy.
signal(SIGPIPE, SIG_IGN);
struct sigaction sa;
sa.sa_handler = sigHandler;
@ -387,7 +420,9 @@ int main(int argc, char *argv[]) {
printf("86Box connected.\n");
sClientFd = clientFd;
// Associate socket with COM0 for the secLink stack
// Map the TCP socket to "COM0" so the secLink stack (which calls rs232Read/
// rs232Write) transparently operates over the TCP connection instead of
// a real UART. The sockShim intercepts rs232 calls and redirects to socket I/O.
sockShimSetFd(0, clientFd);
// Seed RNG from /dev/urandom and open secLink
@ -442,7 +477,10 @@ int main(int argc, char *argv[]) {
int flags = fcntl(bbsFd, F_GETFL, 0);
fcntl(bbsFd, F_SETFL, flags | O_NONBLOCK);
// Main proxy loop
// Main proxy loop: poll both sockets with a short timeout so secLinkPoll
// runs frequently enough for ACK processing and retransmits. Data flows:
// 86Box -> secLink (decrypt) -> callback -> write to BBS
// BBS -> read -> telnet filter -> secLink (encrypt) -> 86Box
fds[0].fd = clientFd;
fds[0].events = POLLIN;
fds[1].fd = bbsFd;

View file

@ -1,4 +1,13 @@
// Socket shim — rs232-compatible API over TCP sockets
//
// Maps up to 4 "COM ports" to TCP socket file descriptors. Reads use
// MSG_DONTWAIT (non-blocking) to match rs232Read's non-blocking semantics.
// Writes are blocking (loop until all bytes sent) to match rs232Write's
// guarantee of complete delivery.
//
// The shim does NOT close sockets in rs232Close — socket lifecycle is
// managed by the proxy's main() function. This avoids double-close bugs
// when secLinkClose calls rs232Close but the proxy still needs the fd.
#include "sockShim.h"
#include <sys/socket.h>
@ -32,6 +41,8 @@ int rs232Close(int com) {
}
// Serial parameters are ignored — TCP handles framing, flow control, and
// error correction. We just validate that a socket has been assigned.
int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake) {
(void)bps;
(void)dataBits;
@ -51,6 +62,10 @@ int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int
}
// Non-blocking read matching rs232Read semantics:
// > 0: bytes read
// 0: no data available (EAGAIN)
// -1: error or connection closed (maps to PKT_ERR_DISCONNECTED in pktPoll)
int rs232Read(int com, char *data, int len) {
if (com < 0 || com >= MAX_PORTS || sFds[com] < 0) {
return -1;
@ -64,6 +79,7 @@ int rs232Read(int com, char *data, int len) {
return -1;
}
if (n == 0) {
// TCP FIN — peer closed connection
return -1;
}
@ -71,6 +87,9 @@ int rs232Read(int com, char *data, int len) {
}
// Blocking write: loops until all bytes are sent. MSG_NOSIGNAL prevents
// SIGPIPE on a closed connection (returns EPIPE instead). This matches
// rs232Write's blocking-until-complete behavior.
int rs232Write(int com, const char *data, int len) {
if (com < 0 || com >= MAX_PORTS || sFds[com] < 0) {
return RS232_ERR_NOT_OPEN;

View file

@ -1,10 +1,21 @@
// Socket shim — provides rs232-compatible API backed by TCP sockets
// Used by the Linux proxy to reuse the packet and secLink layers.
//
// Design: the packet and secLink layers call rs232Read/rs232Write assuming
// a real UART underneath. By providing a socket-backed implementation with
// the same function signatures, we can reuse the entire protocol stack
// on Linux without any #ifdefs in the packet or secLink code.
//
// The #define RS232_H below is intentional: it prevents the real rs232.h
// from being included if someone includes both headers. The shim only
// implements the subset of rs232 functions that packet and secLink use
// (open, close, read, write). Hardware-specific functions (IRQ, FIFO,
// flow control) are not needed and not provided.
#ifndef SOCKSHIM_H
#define SOCKSHIM_H
// Block the real rs232.h from being included
// Prevent the real rs232.h from being included alongside this shim
#define RS232_H
#include <stdint.h>

View file

@ -14,11 +14,16 @@
// Defines and macros
// ========================================================================
// Port I/O helpers (outportb returns void in DJGPP, but macros need the value)
// Port I/O helpers that return the written value. outportb/outportw are void
// in DJGPP, but our macros (UART_WRITE_IER, etc.) use comma expressions to
// cache the written value in the port state struct for later reads without
// hitting the hardware again.
#define OUTP(a, b) (outportb((a), (b)), (b))
#define OUTPW(a, w) (outportw((a), (w)), (w))
// Buffer sizes (power of 2)
// Buffer sizes must be power of 2 so (index & MASK) wraps correctly.
// 2048 bytes balances memory usage (8KB per port for both buffers) against
// burst tolerance at 115200 bps.
#define RX_BUFFER_BITS 11 // 2048
#define TX_BUFFER_BITS 11 // 2048
#define RX_BUFFER_SIZE (1L << RX_BUFFER_BITS)
@ -36,7 +41,10 @@
#define TX_HIGH_WATER (TX_BUFFER_SIZE * TX_HIGH_PERCENT / 100UL)
#define TX_LOW_WATER (TX_BUFFER_SIZE * TX_LOW_PERCENT / 100UL)
// RX buffer operations
// Ring buffer macros. These advance head/tail BEFORE accessing the slot
// (pre-increment style). The +1-before-mask pattern means slot 0 is never
// used and the full/empty distinction works: head==tail means empty,
// (head+1)&mask==tail means full (one slot wasted to distinguish states).
#define RX_READ(C) (C)->rxBuff[(C)->rxTail = ((C)->rxTail + 1) & RX_BUFFER_MASK]
#define RX_WRITE(C, D) (C)->rxBuff[(C)->rxHead = ((C)->rxHead + 1) & RX_BUFFER_MASK] = (D)
#define RX_INIT(C) (C)->rxHead = (C)->rxTail = 0
@ -306,6 +314,20 @@ static void removeIrqHandler(int irq);
// Interrupt service routine
// ========================================================================
// Single shared ISR for all COM ports. ISR sharing is necessary because
// COM1/COM3 typically share IRQ4 and COM2/COM4 share IRQ3. Having one ISR
// that polls all ports avoids the complexity of per-IRQ handlers.
//
// The ISR design follows a careful protocol:
// 1. Mask all COM IRQs on the PIC to prevent re-entry
// 2. STI to allow higher-priority interrupts (timer, keyboard) through
// 3. Loop over all open ports, draining each UART's pending interrupts
// 4. CLI, send EOI to PIC, re-enable COM IRQs, STI before IRET
//
// This "mask-then-STI" pattern is standard for slow device ISRs on PC
// hardware — it prevents the same IRQ from re-entering while still allowing
// the timer tick and keyboard to function during potentially long UART
// processing.
static void comGeneralIsr(void) {
Rs232StateT *comMin = &sComPorts[0];
Rs232StateT *comMax = &sComPorts[COM_MAX];
@ -313,9 +335,6 @@ static void comGeneralIsr(void) {
uint8_t data;
uint8_t slaveTriggered = 0;
Rs232StateT *com;
// Disable IRQs for all open COM ports, then re-enable CPU interrupts
// so other faster devices can be serviced
for (com = comMin; com <= comMax; com++) {
if (com->isOpen) {
if (com->irq > 7) {
@ -385,6 +404,9 @@ static void comGeneralIsr(void) {
break;
case IIR_TX_HOLD_EMPTY: {
// Drain up to FIFO_SIZE bytes from TX ring buffer to UART.
// Writing more than one byte is only safe on 16550A+ (has FIFO);
// on 8250/16450 FIFO_SIZE effectively limits to 1 byte anyway.
int cnt;
for (cnt = 0; cnt < FIFO_SIZE && com->txFlowOn && !TX_EMPTY(com); cnt++) {
UART_WRITE_DATA(com, TX_READ(com));
@ -414,8 +436,9 @@ static void comGeneralIsr(void) {
}
}
// Must explicitly STI before IRET because IRET won't always
// restore interrupts in a virtual environment
// Must explicitly STI before IRET because under DPMI (and some
// virtualizers like DOSBox/86Box), IRET's implicit IF restore from
// the flags on the stack doesn't always work as expected.
asm("STI");
}
@ -424,11 +447,20 @@ static void comGeneralIsr(void) {
// DPMI utility functions
// ========================================================================
// These functions wrap DJGPP's DPMI interface for installing protected-mode
// interrupt handlers. Under DPMI, the ISR code and data must be locked in
// physical memory to prevent page faults during interrupt handling — a page
// fault inside an ISR would be fatal. The IRET wrapper is also allocated
// by DPMI to handle the real-mode-to-protected-mode transition.
static void dpmiGetPvect(int vector, _go32_dpmi_seginfo *info) {
_go32_dpmi_get_protected_mode_interrupt_vector(vector, info);
}
// Lock the ISR code and its data (sComPorts array) in physical memory.
// The IRET wrapper handles the stack frame that DPMI uses to dispatch
// hardware interrupts to protected-mode code.
static int dpmiLockMemory(void) {
unsigned long dataAddr;
unsigned long codeAddr;
@ -488,6 +520,12 @@ static void dpmiUnlockMemory(void) {
// IRQ management
// ========================================================================
// Auto-detect which IRQ a UART is wired to by generating a TX hold empty
// interrupt and reading the PIC's IRR (Interrupt Request Register) to see
// which line went high. Uses a double-check pattern: enable interrupt,
// read IRR, disable, read IRR again, mask out persistent bits, then
// enable once more to confirm. Falls back to the default IRQ if detection
// fails (e.g., on virtualized hardware that doesn't model IRR accurately).
static int findIrq(int comport) {
Rs232StateT *com = &sComPorts[comport];
uint8_t imrM = PIC_READ_IMR(PIC_MASTER);
@ -564,6 +602,9 @@ static int findIrq(int comport) {
}
// Release this port's IRQ. If another port shares the same IRQ line,
// the handler stays installed — only the last user removes it. This is
// critical for COM1/COM3 IRQ sharing.
static void freeIrq(int comport) {
Rs232StateT *com = &sComPorts[comport];
Rs232StateT *comMin = &sComPorts[0];
@ -956,6 +997,11 @@ int rs232GetTxBuffered(int com) {
}
// Detect UART type by probing hardware features. The detection sequence:
// 1. Write/read scratch register — 8250 doesn't have one
// 2. Enable FIFO and check IIR bits 7:6 — distinguishes 16450/16550/16550A
// This matters because only 16550A has a reliable 16-byte FIFO; the
// original 16550 FIFO is buggy and should not be enabled.
int rs232GetUartType(int com) {
Rs232StateT *port = &sComPorts[com];
uint8_t scratch;
@ -1016,7 +1062,10 @@ int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int
RX_INIT(port);
TX_INIT(port);
// Read COM base address from BIOS data area (0040:0000-0040:0007)
// Read COM base address from BIOS data area at 0040:0000. The BIOS
// stores up to 4 COM port base addresses as 16-bit words at 40:00-40:07.
// This is more reliable than hardcoding 0x3F8/0x2F8 because BIOS setup
// may have remapped ports or detected them in a different order.
if (rs232SetBase(com, _farpeekw(_dos_ds, 0x400 + (com << 1))) != RS232_SUCCESS) {
return RS232_ERR_NO_UART;
}
@ -1369,6 +1418,12 @@ int rs232SetStop(int com, int stopBits) {
}
// Blocking polled write: busy-waits on LSR_TX_HOLD_EMPTY for each byte.
// This bypasses the TX ring buffer entirely and writes directly to the UART.
// Used by the packet layer for frame transmission where we want guaranteed
// delivery order and don't want ISR-driven TX reordering complications.
// The trade-off is CPU burn during the wait, but at 115200 bps each byte
// takes only ~87us, and frame sizes are small (< 520 bytes worst case).
int rs232Write(int com, const char *data, int len) {
Rs232StateT *port = &sComPorts[com];
int i;
@ -1398,6 +1453,10 @@ int rs232Write(int com, const char *data, int len) {
}
// Non-blocking buffered write: pushes data into the TX ring buffer and
// enables the TX_HOLD_EMPTY interrupt so the ISR drains it asynchronously.
// Returns the number of bytes actually buffered (may be less than len if
// the buffer is full). This is the ISR-driven counterpart to rs232Write.
int rs232WriteBuf(int com, const char *data, int len) {
Rs232StateT *port = &sComPorts[com];
int i;

View file

@ -3,6 +3,25 @@
//
// ISR-driven UART communication with ring buffers and flow control.
// Supports up to 4 simultaneous COM ports with auto-detected IRQ.
//
// Architecture:
// A single ISR (comGeneralIsr) handles all open COM ports. When any
// UART interrupts, the ISR loops over all open ports draining their
// FIFOs into per-port ring buffers. The application reads from these
// buffers at its leisure. This decouples the UART's byte-at-a-time
// pace from application-level processing.
//
// Ring buffers are power-of-2 sized (2048 bytes) so head/tail index
// wrapping is a single AND, not a modulo — critical for ISR speed.
//
// Flow control (XON/XOFF, RTS/CTS, DTR/DSR) operates entirely within
// the ISR using watermark thresholds. When the RX buffer is 80% full,
// we signal the remote to stop; at 20%, we allow it to resume. This
// prevents buffer overflow without application involvement.
//
// The ISR and its data structures are locked in memory via DPMI to
// prevent page faults during interrupt handling — a requirement for
// any ISR running under a DPMI host (DOS extender, Windows, etc.).
#ifndef RS232_H
#define RS232_H
@ -22,7 +41,11 @@
#define RS232_HANDSHAKE_RTSCTS 2
#define RS232_HANDSHAKE_DTRDSR 3
// UART Types
// UART Types (detected by probing scratch register and FIFO capability)
// 8250: No scratch register, no FIFO (original IBM PC)
// 16450: Has scratch register, no FIFO
// 16550: Has FIFO but it's buggy (rare)
// 16550A: Has working 16-byte FIFO (most common in 486+ era hardware)
#define RS232_UART_UNKNOWN 0
#define RS232_UART_8250 1
#define RS232_UART_16450 2
@ -77,6 +100,9 @@ int rs232GetTxBuffered(int com);
int rs232GetUartType(int com);
// Read/write
// rs232Read: non-blocking drain from RX ring buffer, returns bytes read
// rs232Write: blocking polled write directly to UART THR (bypasses TX buffer)
// rs232WriteBuf: non-blocking write to TX ring buffer, ISR drains to UART
int rs232Read(int com, char *data, int len);
int rs232Write(int com, const char *data, int len);
int rs232WriteBuf(int com, const char *data, int len);

View file

@ -6,9 +6,17 @@
// 3. Derive separate TX/RX cipher keys based on public key ordering
// 4. Transition to READY — all subsequent encrypted packets use the keys
//
// The handshake uses the packet layer's reliable delivery, so lost packets
// are automatically retransmitted. Both sides can send their public key
// simultaneously — there's no initiator/responder distinction.
//
// Directionality: the side with the lexicographically lower public key
// uses master XOR 0xAA for TX and master XOR 0x55 for RX. The other
// side uses the reverse. This prevents CTR counter collisions.
// side uses the reverse. This is critical: if both sides used the same
// key and counter for CTR mode, they'd produce identical keystreams,
// and XOR'ing two ciphertexts would reveal the XOR of the plaintexts.
// The XOR-derived directional keys ensure each direction has a unique
// keystream even though both start their counters at zero.
//
// Channel header byte: bit 7 = encrypted, bits 6..0 = channel (0-127)
@ -68,6 +76,11 @@ static void internalRecv(void *ctx, const uint8_t *data, int len);
// Static functions (alphabetical)
// ========================================================================
// Called when we've received the remote's public key. Computes the DH
// shared secret, derives directional cipher keys, and transitions to READY.
// After this, the DH context (containing the private key) is destroyed
// immediately — forward secrecy principle: even if the long-term state is
// compromised later, past session keys can't be recovered.
static void completeHandshake(SecLinkT *link) {
uint8_t masterKey[SEC_XTEA_KEY_SIZE];
uint8_t txKey[SEC_XTEA_KEY_SIZE];
@ -98,6 +111,12 @@ static void completeHandshake(SecLinkT *link) {
}
// Internal packet-layer callback. Routes incoming packets based on state:
// - During handshake: expects a 128-byte DH public key
// - When ready: strips the channel header, decrypts if flagged, and
// forwards plaintext to the user callback
// The channel header is always one byte, so the minimum valid data packet
// is 1 byte (header only, zero-length payload).
static void internalRecv(void *ctx, const uint8_t *data, int len) {
SecLinkT *link = (SecLinkT *)ctx;
@ -287,8 +306,10 @@ int secLinkSend(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, b
return SECLINK_ERR_PARAM;
}
// For non-blocking sends, check window space BEFORE encrypting
// to avoid advancing the cipher counter on a failed send
// CRITICAL: check window space BEFORE encrypting. If we encrypted first
// and then the send failed, the cipher counter would have advanced but
// the data wouldn't have been sent, permanently desynchronizing the
// TX cipher state from the remote's RX cipher state.
if (!block && !pktCanSend(link->pkt)) {
return SECLINK_ERR_SEND;
}
@ -316,6 +337,11 @@ int secLinkSend(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, b
}
// Convenience function for sending large buffers. Splits the data into
// SECLINK_MAX_PAYLOAD (254 byte) chunks and sends each one, blocking until
// the send window has room. The receiver sees multiple packets on the same
// channel and must reassemble if needed. Always blocking because the caller
// expects the entire buffer to be sent when this returns.
int secLinkSendBuf(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, bool encrypt) {
int offset = 0;
int rc;

View file

@ -1,5 +1,11 @@
// Secure serial link — convenience wrapper tying rs232 + packet + security
//
// This is the top-level API for the serial/networking stack. It composes
// three layers into one:
// rs232 — ISR-driven UART I/O with ring buffers
// packet — HDLC framing + CRC-16 + Go-Back-N ARQ (reliable delivery)
// security — DH key exchange + XTEA-CTR encryption
//
// Usage:
// 1. secLinkOpen() — opens COM port, sets up packet framing
// 2. secLinkHandshake() — DH key exchange (blocks until both sides complete)
@ -7,9 +13,17 @@
// 4. secLinkPoll() — receive, decrypt if needed, deliver to callback
// 5. secLinkClose() — tear everything down
//
// Channel multiplexing:
// Each packet carries a one-byte header: bit 7 = encrypted flag,
// bits 6..0 = channel number (0-127). The callback receives plaintext
// regardless of whether encryption was used.
// bits 6..0 = channel number (0-127). This allows multiple logical streams
// (e.g., terminal data, file transfer, control messages) over a single
// serial link without needing separate framing or sequencing per stream.
// The callback receives plaintext regardless of whether encryption was used.
//
// Mixed clear/encrypted traffic:
// Unencrypted packets can be sent before or after the handshake. This
// allows a startup protocol (e.g., version negotiation) before keys are
// exchanged. Encrypted packets require a completed handshake.
#ifndef SECLINK_H
#define SECLINK_H

View file

@ -80,9 +80,13 @@ static const BigNumT sDhPrime = { .w = {
// Generator g = 2
static const BigNumT sDhGenerator = { .w = { 2 } };
// Montgomery constants (computed lazily)
static BigNumT sDhR2; // R^2 mod p
static uint32_t sDhM0Inv; // -p[0]^(-1) mod 2^32
// Montgomery constants (computed lazily on first DH operation).
// These are expensive to compute (~2048 shift-and-subtract operations for R2)
// but only need to be done once since we always use the same prime.
// R = 2^1024 in Montgomery arithmetic; R^2 mod p is the conversion factor.
// m0inv = -p[0]^(-1) mod 2^32 is the Montgomery reduction constant.
static BigNumT sDhR2;
static uint32_t sDhM0Inv;
static bool sDhInited = false;
// RNG state
@ -185,6 +189,18 @@ static void bnFromBytes(BigNumT *a, const uint8_t *buf) {
}
// Modular exponentiation using Montgomery multiplication.
//
// Montgomery multiplication replaces expensive modular reduction (division
// by a 1024-bit number) with cheaper additions and right-shifts, at the
// cost of converting operands to/from "Montgomery form" (multiply by R mod m).
// For exponentiation where we do hundreds of multiplications with the same
// modulus, the conversion cost is amortized and the net speedup is ~3-5x
// over schoolbook multiply-then-reduce on a 486.
//
// Uses left-to-right binary (square-and-multiply) scanning of the exponent.
// For a 256-bit private exponent, this is ~256 squarings + ~128 multiplies
// on average (half the bits are 1).
static void bnModExp(BigNumT *result, const BigNumT *base, const BigNumT *exp, const BigNumT *mod, uint32_t m0inv, const BigNumT *r2) {
BigNumT montBase;
BigNumT montResult;
@ -224,6 +240,19 @@ static void bnModExp(BigNumT *result, const BigNumT *base, const BigNumT *exp, c
}
// Montgomery multiplication: computes (a * b * R^(-1)) mod m without division.
//
// The algorithm processes one word of 'a' per outer iteration (32 words for
// 1024-bit numbers). For each word:
// 1. Accumulate a[i] * b into the temporary product t
// 2. Compute the Montgomery reduction factor u = t[0] * m0inv (mod 2^32)
// 3. Add u * mod to t and shift right by 32 bits (the division by 2^32)
//
// The shift-and-reduce avoids explicit modular reduction. After all 32
// iterations, the result is in [0, 2m), so a single conditional subtraction
// brings it into [0, m). This is the CIOS (Coarsely Integrated Operand
// Scanning) variant, which is cache-friendly because it accesses 'b' and
// 'mod' sequentially in the inner loops.
static void bnMontMul(BigNumT *result, const BigNumT *a, const BigNumT *b, const BigNumT *mod, uint32_t m0inv) {
uint32_t t[BN_WORDS + 1];
uint32_t u;
@ -319,9 +348,12 @@ static void bnToBytes(uint8_t *buf, const BigNumT *a) {
// Helper functions (alphabetical)
// ========================================================================
// Compute -m0^(-1) mod 2^32 using Newton's method for modular inverse.
// Starting from x=1 (which is always a valid initial approximation for
// odd m0), each iteration doubles the number of correct bits. After 5
// iterations we have 32 correct bits (1->2->4->8->16->32). This is the
// standard approach for computing the Montgomery constant.
static uint32_t computeM0Inv(uint32_t m0) {
// Newton's method: compute m0^(-1) mod 2^32
// Converges quadratically: 1 → 2 → 4 → 8 → 16 → 32 correct bits
uint32_t x = 1;
for (int i = 0; i < 5; i++) {
@ -333,9 +365,11 @@ static uint32_t computeM0Inv(uint32_t m0) {
}
// Compute R^2 mod m where R = 2^1024. This is the Montgomery domain
// conversion factor. We compute it by repeated doubling (shift left by 1)
// with modular reduction, which is simple but takes 2048 iterations.
// Only done once at initialization time.
static void computeR2(BigNumT *r2, const BigNumT *m) {
// Compute R^2 mod m where R = 2^1024
// Method: start with 1, double 2048 times, reduce mod m each step
bnSet(r2, 1);
for (int i = 0; i < 2 * BN_BITS; i++) {
@ -358,8 +392,10 @@ static void dhInit(void) {
}
// Volatile pointer prevents the compiler from optimizing away the zeroing
// as a dead store. Critical for clearing key material — without volatile,
// the compiler sees that ptr is about to be freed and removes the memset.
static void secureZero(void *ptr, int len) {
// Volatile prevents the compiler from optimizing away the zeroing
volatile uint8_t *p = (volatile uint8_t *)ptr;
for (int i = 0; i < len; i++) {
@ -368,6 +404,11 @@ static void secureZero(void *ptr, int len) {
}
// XTEA block cipher: encrypts an 8-byte block in-place. The Feistel network
// uses 32 rounds (vs TEA's 32 or 64). Each round mixes the halves using
// shifts, adds, and XORs — no S-boxes, no lookup tables, no key schedule.
// The delta constant (golden ratio * 2^32) ensures each round uses a
// different effective key, preventing slide attacks.
static void xteaEncryptBlock(uint32_t v[2], const uint32_t key[4]) {
uint32_t v0 = v[0];
uint32_t v1 = v[1];
@ -388,8 +429,12 @@ static void xteaEncryptBlock(uint32_t v[2], const uint32_t key[4]) {
// RNG functions (alphabetical)
// ========================================================================
// Mix additional entropy into the RNG state. XOR-folding into the key is
// simple and cannot reduce entropy (XOR with random data is a bijection).
// The re-mix step (encrypting the key with itself) diffuses the new entropy
// across all key bits so that even a single byte of good entropy improves
// the entire key state.
void secRngAddEntropy(const uint8_t *data, int len) {
// XOR additional entropy into the key
for (int i = 0; i < len; i++) {
((uint8_t *)sRng.key)[i % 16] ^= data[i];
}
@ -409,8 +454,12 @@ void secRngAddEntropy(const uint8_t *data, int len) {
}
// Generate pseudorandom bytes using XTEA-CTR DRBG. Each call encrypts
// the monotonically increasing counter with the RNG key, producing 8 bytes
// of keystream per block. The counter never repeats (64-bit space), so
// the output is a pseudorandom stream as long as the key has sufficient
// entropy. Auto-seeds from hardware entropy on first use as a safety net.
void secRngBytes(uint8_t *buf, int len) {
// Auto-seed from hardware if never seeded
if (!sRng.seeded) {
uint8_t entropy[16];
int got = secRngGatherEntropy(entropy, sizeof(entropy));
@ -440,10 +489,15 @@ void secRngBytes(uint8_t *buf, int len) {
}
// Gather hardware entropy from the PIT (Programmable Interval Timer) and
// BIOS tick count. The PIT runs at 1.193182 MHz, so its LSBs change rapidly
// and provide ~10 bits of entropy per read (depending on timing jitter).
// The BIOS tick at 18.2 Hz adds a few more bits. Two PIT readings with
// the intervening code execution provide some jitter. Total: roughly 20
// bits of real entropy — not enough alone, but sufficient to seed the DRBG
// when supplemented by user interaction timing.
int secRngGatherEntropy(uint8_t *buf, int len) {
int out = 0;
// Read PIT channel 0 counter (1.193 MHz, ~10 bits of entropy in LSBs)
outportb(0x43, 0x00);
uint8_t pitLo = inportb(0x40);
uint8_t pitHi = inportb(0x40);
@ -469,6 +523,9 @@ int secRngGatherEntropy(uint8_t *buf, int len) {
}
// Initialize the RNG from entropy. The 64-byte discard at the end is
// standard DRBG practice — it advances the state past any weak initial
// output that might leak information about the seed material.
void secRngSeed(const uint8_t *entropy, int len) {
memset(&sRng, 0, sizeof(sRng));
@ -494,6 +551,11 @@ void secRngSeed(const uint8_t *entropy, int len) {
// DH functions (alphabetical)
// ========================================================================
// Compute the shared secret from the remote side's public key.
// Validates that the remote key is in [2, p-2] to prevent small-subgroup
// attacks (keys of 0, 1, or p-1 would produce trivially guessable secrets).
// The shared secret is remote^private mod p, which both sides compute
// independently and arrive at the same value (the DH property).
int secDhComputeSecret(SecDhT *dh, const uint8_t *remotePub, int len) {
BigNumT remote;
BigNumT two;
@ -534,6 +596,12 @@ SecDhT *secDhCreate(void) {
}
// Derive a symmetric key from the 128-byte shared secret by XOR-folding.
// This is a simple key derivation function: each byte of the secret is
// XOR'd into the output key at position (i % keyLen). For a 16-byte XTEA
// key, each output byte is the XOR of 8 secret bytes, providing good
// mixing. A proper KDF (HKDF, etc.) would be better but adds complexity
// and code size for marginal benefit in this use case.
int secDhDeriveKey(SecDhT *dh, uint8_t *key, int keyLen) {
uint8_t secretBytes[BN_BYTES];
@ -560,6 +628,8 @@ int secDhDeriveKey(SecDhT *dh, uint8_t *key, int keyLen) {
}
// secureZero the entire struct before freeing to prevent the private key
// from lingering in freed memory where it could be read by a later malloc.
void secDhDestroy(SecDhT *dh) {
if (dh) {
secureZero(dh, sizeof(SecDhT));
@ -568,6 +638,11 @@ void secDhDestroy(SecDhT *dh) {
}
// Generate a DH keypair: random 256-bit private key, then compute
// public = g^private mod p. The private key is only 256 bits (not 1024)
// to keep exponentiation fast on 486-class hardware. With Montgomery
// multiplication, this takes ~256 squarings + ~128 multiplies, each
// operating on 32-word (1024-bit) numbers.
int secDhGenerateKeys(SecDhT *dh) {
if (!dh) {
return SEC_ERR_PARAM;
@ -632,6 +707,15 @@ SecCipherT *secCipherCreate(const uint8_t *key) {
}
// CTR-mode encryption/decryption. The counter is encrypted to produce
// an 8-byte keystream block, which is XOR'd with the data. The counter
// increments after each block. Because XOR is its own inverse, the same
// function handles both encryption and decryption.
//
// IMPORTANT: the counter is internal state that advances with each call.
// The secLink layer ensures that TX and RX use separate cipher instances
// with separate counters, and that the same counter value is never reused
// with the same key (which would be catastrophic for CTR mode security).
void secCipherCrypt(SecCipherT *c, uint8_t *data, int len) {
uint32_t block[2];
uint8_t *keystream;

View file

@ -1,6 +1,29 @@
// Security library: Diffie-Hellman key exchange + XTEA-CTR cipher
// Targets 486-class hardware with 1024-bit DH (256-bit private exponent)
// and XTEA in CTR mode for symmetric encryption.
//
// Why XTEA instead of AES/DES:
// XTEA requires zero lookup tables, no key schedule, and compiles to
// ~20 instructions per round (shifts, adds, XORs). This makes it ideal
// for a 486 where cache is tiny (8KB) and AES's 4KB S-boxes would
// thrash it. DES is similarly table-heavy and also has a complex key
// schedule. XTEA has no library dependencies — the entire cipher fits
// in a dozen lines of C. At 32 rounds, XTEA provides 128-bit security
// with negligible per-byte cost on even the slowest target hardware.
//
// Why 1024-bit DH with 256-bit private exponent:
// RFC 2409 Group 2 provides interoperability and a well-audited safe
// prime. 256-bit private exponents (vs full 1024-bit) reduce the modular
// exponentiation from ~1024 squarings+multiplies to ~256, making key
// generation feasible on a 486 in seconds rather than minutes. The
// security reduction is negligible (Pollard's rho on 256-bit exponent
// is ~2^128 operations, same as XTEA's key strength).
//
// RNG:
// XTEA-CTR DRBG seeded from PIT timer jitter and BIOS tick count.
// Hardware entropy is weak (~20 bits from PIT), so callers should
// supplement with user-generated entropy (keyboard timing, etc.)
// before generating DH keys.
#ifndef SECURITY_H
#define SECURITY_H
@ -38,7 +61,11 @@ void secDhDestroy(SecDhT *dh);
int secDhGenerateKeys(SecDhT *dh);
int secDhGetPublicKey(SecDhT *dh, uint8_t *buf, int *len);
// XTEA cipher in CTR mode (encrypt and decrypt are the same operation)
// XTEA cipher in CTR mode. CTR mode turns a block cipher into a stream
// cipher: encrypt an incrementing counter with the key to produce a
// keystream, then XOR with plaintext. Encrypt and decrypt are identical
// (XOR is its own inverse). The counter must never repeat with the same
// key, which is why secLink derives separate TX/RX keys.
SecCipherT *secCipherCreate(const uint8_t *key);
void secCipherCrypt(SecCipherT *c, uint8_t *data, int len);
void secCipherDestroy(SecCipherT *c);

View file

@ -2,6 +2,11 @@
//
// Shows priority scheduling, round-robin, pausing/resuming, dynamic
// priority changes, and slot reuse after task termination.
//
// This is a standalone test harness that runs on the host (Linux x86_64)
// or DJGPP target to verify the task switcher without the GUI stack.
// Each phase exercises a different scheduler feature and prints output
// so you can verify the scheduling order visually.
#include <stdio.h>
#include "taskswitch.h"
@ -65,6 +70,8 @@ int main(void) {
// Phase 1: all tasks run, but high-priority tasks get more turns per round.
// Low (priority 0) gets 1 turn, normal (5) gets 6, high (10) gets 11.
// In the output you'll see "high" appearing much more frequently than
// "low", demonstrating the weighted fair-share scheduling.
printf("--- Phase 1: Priority scheduling ---\n");
for (int32_t i = 0; i < 6; i++) {
printf("[main] yield %d\n", (int)i);
@ -102,7 +109,11 @@ int main(void) {
tsYield();
}
// Phase 5: dynamic creation with slot reuse
// Phase 5: dynamic creation with slot reuse.
// After all tasks from phases 1-4 have terminated, their slots are free.
// Creating new tasks should recycle those slots (IDs 1-4) rather than
// growing the array. Each "wave" creates 3 tasks, lets them finish,
// then creates 3 more — the IDs should repeat across waves.
printf("\n--- Phase 5: Slot reuse ---\n");
printf("[main] active before: %u\n", (unsigned)tsActiveCount());

View file

@ -6,6 +6,20 @@
//
// Task storage is a stb_ds dynamic array that grows as needed.
// Terminated task slots are recycled by tsCreate().
//
// Why inline asm instead of setjmp/longjmp for context switching:
// setjmp/longjmp only save callee-saved registers and don't give us
// control over the stack pointer in a portable way. We need to set up
// a brand-new stack for each task and jump into a trampoline — setjmp
// can't bootstrap a fresh stack. The asm approach also avoids ABI
// differences in jmp_buf layout across DJGPP versions.
//
// Why stb_ds dynamic array instead of a linked list:
// Tasks are indexed by integer ID for O(1) lookup (tsGetState, tsKill,
// etc.). A linked list would require O(n) traversal for every ID-based
// operation. The array also has better cache locality during the
// scheduler's linear scan. The downside (holes after termination) is
// mitigated by slot recycling in findFreeSlot().
#define STB_DS_IMPLEMENTATION
#include "thirdparty/stb_ds.h"
@ -18,6 +32,15 @@
// Internal types
// ============================================================================
// Only callee-saved registers need to be in the context struct because
// the C calling convention guarantees the caller has already saved
// everything else. The compiler treats contextSwitch() as a normal
// function call, so caller-saved regs (eax/ecx/edx on i386,
// rax/rcx/rdx/r8-r11 on x86_64) are spilled by the compiler before
// the call. This minimizes context size and switch overhead.
//
// Field order is critical: the asm uses hardcoded byte offsets into
// this struct. Reordering fields will silently corrupt context switches.
#if defined(__x86_64__)
// Saved CPU context for x86_64 (field order matches asm byte offsets)
typedef struct {
@ -42,7 +65,11 @@ typedef struct {
} TaskContextT;
#endif
// Task control block
// Task control block — one per task slot. The 'allocated' flag tracks
// whether the slot is live or recyclable, separate from the state enum,
// because we need to distinguish "never used" from "terminated and reaped".
// The 'isMain' flag protects task 0 from kill/pause — destroying the
// main task would orphan all other tasks with no scheduler to resume them.
typedef struct {
char name[TS_NAME_MAX];
TaskContextT context;
@ -61,7 +88,12 @@ typedef struct {
// Module state
// ============================================================================
static TaskBlockT *tasks = NULL; // stb_ds dynamic array
// stb_ds dynamic array of task control blocks. Slot 0 is always the main
// task. Slots 1..N are app tasks. Terminated slots have allocated=false
// and are reused by findFreeSlot() to prevent unbounded growth.
static TaskBlockT *tasks = NULL;
// Index of the currently executing task. Updated only by tsYield, tsExit,
// tsPause (self-pause), and tsRecoverToMain.
static uint32_t currentIdx = 0;
static bool initialized = false;
@ -101,6 +133,24 @@ void tsYield(void);
// callee-saved registers and the stack pointer. The return address is
// captured as a local label so that when another task switches back to us,
// execution resumes right after the save point.
//
// The mechanism:
// 1. Save all callee-saved regs + esp/rsp into *save
// 2. Capture the address of local label "1:" as the saved EIP/RIP
// 3. Load all regs + esp/rsp from *restore
// 4. Jump to the restored EIP/RIP (which is label "1:" in the other task)
//
// For a newly created task, the restored EIP points to taskTrampoline
// (set up in tsCreate) rather than label "1:", so the first switch into
// a task bootstraps it into its entry function.
//
// noinline is critical: if the compiler inlines this, the callee-saved
// register assumptions break because the enclosing function may use
// different register allocation. The asm clobber list tells GCC which
// registers we destroy so it spills them before the call.
//
// The "memory" clobber acts as a compiler fence, ensuring all memory
// writes are flushed before the switch and re-read after resumption.
#if defined(__x86_64__)
// x86_64: save rbx, r12-r15, rbp, rsp, rip.
// Inputs via GCC constraints: %rdi = save ptr, %rsi = restore ptr.
@ -114,9 +164,11 @@ static void __attribute__((noinline)) contextSwitch(TaskContextT *save, TaskCont
"movq %%r15, 32(%%rdi)\n\t"
"movq %%rbp, 40(%%rdi)\n\t"
"movq %%rsp, 48(%%rdi)\n\t"
// RIP-relative lea captures the resume point address
"leaq 1f(%%rip), %%rax\n\t"
"movq %%rax, 56(%%rdi)\n\t"
// Restore new context
// Restore new context — once rsp is swapped we're on the other
// task's stack. The jmp completes the switch.
"movq 0(%%rsi), %%rbx\n\t"
"movq 8(%%rsi), %%r12\n\t"
"movq 16(%%rsi), %%r13\n\t"
@ -126,6 +178,8 @@ static void __attribute__((noinline)) contextSwitch(TaskContextT *save, TaskCont
"movq 48(%%rsi), %%rsp\n\t"
"movq 56(%%rsi), %%rax\n\t"
"jmp *%%rax\n\t"
// Resume point: when someone switches back to us, execution
// continues here as if contextSwitch() just returned normally.
"1:\n\t"
:
: "D" (save), "S" (restore)
@ -143,6 +197,7 @@ static void __attribute__((noinline)) contextSwitch(TaskContextT *save, TaskCont
"movl %%edi, 8(%%eax)\n\t"
"movl %%ebp, 12(%%eax)\n\t"
"movl %%esp, 16(%%eax)\n\t"
// i386 can't do RIP-relative lea, so use an absolute label address
"movl $1f, 20(%%eax)\n\t"
// Restore new context
"movl 0(%%edx), %%ebx\n\t"
@ -163,6 +218,8 @@ static void __attribute__((noinline)) contextSwitch(TaskContextT *save, TaskCont
// Find a free (terminated or unallocated) slot in the task array.
// Returns the index, or -1 if no free slot exists.
// Starts at 1 because slot 0 is always the main task and cannot be reused.
// Linear scan is fine — SHELL_MAX_APPS caps the practical limit at ~32 tasks.
static int32_t findFreeSlot(void) {
ptrdiff_t count = arrlen(tasks);
for (ptrdiff_t i = 1; i < count; i++) {
@ -179,6 +236,20 @@ static int32_t findFreeSlot(void) {
// per scheduling turn. When no ready task has credits left, every
// ready task is refilled. This guarantees all tasks run while giving
// higher-priority tasks proportionally more turns.
//
// Algorithm (variant of Linux 2.4's goodness() scheduler):
// 1. Scan forward from currentIdx looking for a ready task with credits > 0
// 2. If found, decrement its credits and select it
// 3. If no task has credits, refill ALL ready tasks (one "epoch")
// 4. Scan again after refill
//
// The round-robin scan starts at (currentIdx + 1) and wraps, ensuring
// fairness among tasks with equal priority — no task gets picked twice
// in a row unless it's the only ready task.
//
// If no ready tasks exist at all (everything paused/terminated), return
// currentIdx so the caller stays on the current task (always task 0 in
// practice, since task 0 is the shell's main loop and never pauses).
static uint32_t scheduleNext(void) {
uint32_t count = (uint32_t)arrlen(tasks);
@ -191,7 +262,7 @@ static uint32_t scheduleNext(void) {
}
}
// All credits exhausted -- refill every ready task
// All credits exhausted — start a new epoch by refilling every ready task
bool anyReady = false;
for (uint32_t i = 0; i < count; i++) {
if (tasks[i].allocated && tasks[i].state == TaskStateReady) {
@ -217,8 +288,13 @@ static uint32_t scheduleNext(void) {
}
// Entry point for every new task. Calls the user-supplied function and
// then terminates the task when it returns.
// Entry point for every new task. The first context switch into a new task
// jumps here (via the EIP/RIP set up in tsCreate). This is a trampoline
// rather than calling entry directly because we need to call tsExit() when
// the entry function returns — if we just set EIP to the entry function,
// it would return to a garbage address (the dummy 0 on the stack).
// The trampoline ensures clean task termination even if the app forgets
// to call tsExit() explicitly.
static void taskTrampoline(void) {
TaskBlockT *task = &tasks[currentIdx];
task->entry(task->arg);
@ -253,7 +329,9 @@ int32_t tsCreate(const char *name, TaskEntryT entry, void *arg, uint32_t stackSi
stackSize = TS_DEFAULT_STACK_SIZE;
}
// Reuse a terminated/free slot, or append a new one
// Reuse a terminated/free slot, or append a new one.
// Recycling avoids unbounded array growth when apps are repeatedly
// launched and terminated over the lifetime of the shell.
int32_t id = findFreeSlot();
if (id < 0) {
TaskBlockT blank = {0};
@ -283,7 +361,13 @@ int32_t tsCreate(const char *name, TaskEntryT entry, void *arg, uint32_t stackSi
task->isMain = false;
task->allocated = true;
// Set up initial stack (grows downward, 16-byte aligned)
// Set up initial stack (grows downward, 16-byte aligned).
// The ABI requires 16-byte stack alignment at function entry. We align
// the top, then push a dummy return address (0) to simulate a CALL
// instruction — this keeps the stack aligned for the trampoline.
// The dummy address is never used because taskTrampoline calls tsExit()
// which switches away without returning, but it satisfies debuggers
// and ABI checkers that expect a return address at the bottom of each frame.
uintptr_t top = (uintptr_t)(task->stack + stackSize);
top &= ~(uintptr_t)0xF;
top -= sizeof(uintptr_t);
@ -316,6 +400,11 @@ uint32_t tsCurrentId(void) {
}
// Self-termination. Frees resources and switches to the next task.
// This function never returns — the terminated task's context is abandoned.
// We save to tasks[prev].context even though we'll never restore it because
// contextSwitch always writes to the save pointer; the data is harmless
// and will be overwritten when the slot is recycled.
void tsExit(void) {
if (!initialized || tasks[currentIdx].isMain) {
return;
@ -323,7 +412,9 @@ void tsExit(void) {
tasks[currentIdx].state = TaskStateTerminated;
// Free the stack immediately
// Free the stack immediately — safe because we're about to switch
// away and never return. The context switch itself doesn't touch
// the old stack after swapping ESP/RSP.
free(tasks[currentIdx].stack);
tasks[currentIdx].stack = NULL;
tasks[currentIdx].allocated = false;
@ -372,6 +463,11 @@ TaskStateE tsGetState(uint32_t taskId) {
}
// Register the calling context as task 0 (main). No stack is allocated
// because the main task uses the process stack. The main task's context
// struct is filled in lazily by contextSwitch on the first tsYield() —
// until then, the saved EIP/ESP are zero, which is fine because we
// never restore task 0 from a cold start.
int32_t tsInit(void) {
if (initialized) {
return TS_ERR_PARAM;
@ -395,6 +491,19 @@ int32_t tsInit(void) {
}
// Forcibly terminate another task. This is safe in a cooperative system
// because the target is guaranteed to be suspended at a yield point — it
// cannot be in the middle of a critical section. The stack is freed and
// the slot is recycled immediately.
//
// Cannot kill self (use tsExit instead) — killing self would free the
// stack we're currently executing on. Cannot kill main (task 0) because
// the shell's main loop must always be runnable for crash recovery.
//
// The shell uses this for two purposes:
// 1. shellForceKillApp: "End Task" from the task manager
// 2. Crash recovery: after a signal handler longjmps to main, the
// crashed task's slot is cleaned up via tsKill
int32_t tsKill(uint32_t taskId) {
if (!initialized || taskId >= (uint32_t)arrlen(tasks)) {
return TS_ERR_PARAM;
@ -437,7 +546,10 @@ int32_t tsPause(uint32_t taskId) {
tasks[taskId].state = TaskStatePaused;
// If we paused ourselves, yield immediately
// If we paused ourselves, must yield immediately — a paused task
// won't be selected by scheduleNext, so staying on CPU would deadlock.
// If pausing another task, no yield needed; it will simply be skipped
// the next time the scheduler scans.
if (taskId == currentIdx) {
uint32_t next = scheduleNext();
if (next != currentIdx) {
@ -454,6 +566,17 @@ int32_t tsPause(uint32_t taskId) {
}
// Emergency recovery after a crash in an app task. When a signal handler
// fires (e.g., SIGSEGV), DJGPP's signal dispatch saves the exception
// state and calls our handler. The handler does longjmp back to the
// shell's setjmp point in main(), which restores the main task's stack.
// However, the task switcher's currentIdx still points to the crashed
// app task. This function fixes the bookkeeping so the scheduler treats
// task 0 as the running task again.
//
// The crashed task's slot is NOT freed here — its stack is corrupt and
// the caller (shellMain's crash recovery) must call shellForceKillApp
// to clean it up properly (destroying windows, closing DXE, etc.).
void tsRecoverToMain(void) {
if (!initialized) {
return;
@ -475,6 +598,9 @@ int32_t tsResume(uint32_t taskId) {
return TS_ERR_STATE;
}
// Transition from Paused back to Ready and refill credits immediately.
// Without the refill, a resumed task might have 0 credits and would have
// to wait for the next epoch to run, making resume feel sluggish.
tasks[taskId].state = TaskStateReady;
tasks[taskId].credits = tasks[taskId].priority + 1;
return TS_OK;
@ -515,6 +641,13 @@ void tsShutdown(void) {
}
// The core cooperative yield. Called explicitly by app code (or implicitly
// via the shell's idle callback and main loop). If no other task is ready,
// returns immediately — no context switch overhead when running solo.
//
// The state transition: current task moves Running -> Ready (still
// schedulable), next task moves Ready -> Running. The previous task will
// resume here when someone else yields and the scheduler picks it again.
void tsYield(void) {
if (!initialized) {
return;
@ -527,6 +660,8 @@ void tsYield(void) {
uint32_t prev = currentIdx;
// Only transition to Ready if still Running — a task that paused itself
// will already be in Paused state when tsYield is called from tsPause.
if (tasks[prev].state == TaskStateRunning) {
tasks[prev].state = TaskStateReady;
}

View file

@ -1,10 +1,25 @@
// taskswitch.h -- Cooperative task switching library for DJGPP
//
// Cooperative (non-preemptive) multitasking for DOS protected mode (DJGPP/DPMI).
//
// Why cooperative instead of preemptive:
// 1. DOS is single-threaded — there is no kernel scheduler to preempt us.
// 2. DPMI provides no thread or timer-based preemption primitives.
// 3. The DVX GUI event model is inherently single-threaded: one compositor,
// one input queue, one window stack. Preemption would require locking
// around every GUI call, adding latency and complexity for no benefit.
// 4. Cooperative switching lets each task yield at safe points, avoiding
// the need for synchronization primitives entirely.
//
// Credit-based cooperative multitasking with task pausing.
// Each task receives (priority + 1) credits per scheduling round.
// Tasks run round-robin, consuming one credit per turn. When all
// credits are spent, every ready task is refilled. Higher-priority
// tasks run proportionally more often but never starve lower ones.
//
// This is a weighted fair-share scheduler: a priority-10 task gets 11
// turns per round while a priority-0 task gets 1, but the low-priority
// task is guaranteed to run eventually — no starvation is possible.
#ifndef TASKSWITCH_H
#define TASKSWITCH_H
@ -20,16 +35,22 @@
#define TS_ERR_NOMEM (-4)
#define TS_ERR_STATE (-5)
// Defaults
// 32KB default stack — generous for callback-style app tasks that spend
// most of their time in the shell's GUI code. Apps needing deeper recursion
// or large stack allocations can request more via AppDescriptorT.stackSize.
#define TS_DEFAULT_STACK_SIZE 32768
#define TS_NAME_MAX 32
// Priority levels
// Priority levels — the credit count is (priority + 1), so LOW gets 1 turn
// per round, NORMAL gets 6, HIGH gets 11. The shell's main task runs at
// HIGH to keep UI responsive; app tasks default to NORMAL.
#define TS_PRIORITY_LOW 0
#define TS_PRIORITY_NORMAL 5
#define TS_PRIORITY_HIGH 10
// Task states
// Task states — only Ready tasks participate in scheduling. The Running
// state is cosmetic (marks the currently executing task). Paused tasks
// are skipped until explicitly resumed. Terminated slots are recycled.
typedef enum {
TaskStateReady = 0,
TaskStateRunning = 1,
@ -37,11 +58,14 @@ typedef enum {
TaskStateTerminated = 3
} TaskStateE;
// Task entry function signature
// Task entry function signature — the void* arg lets the caller pass
// arbitrary context (e.g., a ShellAppT* for the DVX shell's app wrapper)
typedef void (*TaskEntryT)(void *arg);
// Initialize the task system. The calling context becomes task 0 (main).
// The task array grows dynamically as tasks are created.
// Task 0 is special: it cannot be killed or paused, and the crash recovery
// path (tsRecoverToMain) always returns control here. No separate stack is
// allocated for task 0 — it uses the process stack directly.
int32_t tsInit(void);
// Shut down the task system and free all resources.
@ -49,19 +73,25 @@ void tsShutdown(void);
// Create a new task. Pass 0 for stackSize to use TS_DEFAULT_STACK_SIZE.
// Returns the task ID (>= 0) on success, or a negative error code.
// Terminated task slots are recycled to avoid unbounded array growth.
int32_t tsCreate(const char *name, TaskEntryT entry, void *arg, uint32_t stackSize, int32_t priority);
// Yield CPU to the next eligible ready task (credit-based round-robin).
// This is the sole mechanism for task switching — every app must call
// this (or a GUI function that calls it) periodically, or it will
// monopolize the CPU.
void tsYield(void);
// Pause a task. Cannot pause the main task (id 0).
// If a task pauses itself, an implicit yield occurs.
int32_t tsPause(uint32_t taskId);
// Resume a paused task.
// Resume a paused task. Credits are refilled so the task gets a fair
// share of CPU time immediately rather than waiting for the next round.
int32_t tsResume(uint32_t taskId);
// Set a task's scheduling priority.
// Set a task's scheduling priority. Also refills credits so the change
// takes effect immediately rather than at the next credit refill epoch.
int32_t tsSetPriority(uint32_t taskId, int32_t priority);
// Get a task's priority, or TS_ERR_PARAM on invalid id.
@ -77,14 +107,24 @@ uint32_t tsCurrentId(void);
const char *tsGetName(uint32_t taskId);
// Terminate the calling task. Must not be called from the main task.
// The stack is freed immediately and the slot is marked for reuse.
// Internally performs a context switch to the next ready task — this
// function never returns to the caller.
void tsExit(void);
// Forcibly terminate another task. Cannot kill main task (id 0) or self.
// Safe to call only because we're cooperative: the target task is
// guaranteed to be suspended at a yield point, so its stack can be freed
// without worry about it executing concurrently. In a preemptive system
// this would be undefined behavior.
int32_t tsKill(uint32_t taskId);
// Crash recovery: force scheduler back to main task (id 0).
// Call after longjmp from a signal handler that fired in a non-main task.
// The crashed task is NOT cleaned up — call tsKill() afterward.
// This exists because longjmp unwinds the crashed task's stack but the
// scheduler's currentIdx still points to it. We must fix the bookkeeping
// before doing anything else.
void tsRecoverToMain(void);
// Get the number of non-terminated tasks.

View file

@ -1,7 +1,23 @@
// termdemo.c — SecLink terminal emulator demo
//
// Uses DVX GUI ANSI terminal widget with SecLink encrypted serial link
// to provide a BBS terminal over a secured serial connection.
// A standalone DVX GUI application (NOT a DXE app — this has its own main())
// that combines the ANSI terminal widget with the SecLink encrypted serial
// stack to create a BBS terminal client.
//
// Unlike the DXE apps (progman, notepad, clock, dvxdemo) which run inside
// the DVX Shell, this is a freestanding program that initializes the GUI
// directly and manages its own event loop. It demonstrates how to use the
// DVX widget system outside the shell framework.
//
// Data flow:
// User keystrokes -> terminal widget -> commWrite -> secLink (encrypt) -> UART
// UART -> secLink (decrypt) -> onRecv -> ring buffer -> commRead -> terminal widget
//
// The ring buffer decouples the secLink receive callback (which fires during
// secLinkPoll) from the terminal widget's read cycle (which fires during
// dvxUpdate). This is necessary because the callback can fire at any time
// during polling, but the terminal widget expects to read data synchronously
// during its paint cycle.
//
// Usage: termdemo [com_port] [baud_rate]
// com_port — 1-4 (default 1)
@ -29,14 +45,17 @@
// Types
// ============================================================
// Bridges secLink receive callback and terminal widget comm interface
// Bridges the secLink receive callback (asynchronous, fires during poll)
// with the terminal widget's comm interface (synchronous, called during
// widget paint). The ring buffer allows these two timing domains to
// communicate without blocking.
typedef struct {
SecLinkT *link;
AppContextT *app;
WidgetT *term;
uint8_t recvBuf[RECV_BUF_SIZE];
int32_t recvHead;
int32_t recvTail;
int32_t recvHead; // written by onRecv callback
int32_t recvTail; // read by commRead during widget paint
} TermContextT;
// ============================================================
@ -254,7 +273,10 @@ int main(int argc, char *argv[]) {
wgtAnsiTermSetScrollback(term, 1000);
tc.term = term;
// Connect terminal widget to secLink via comm interface
// The comm interface is the glue between the terminal widget and the
// secLink transport. The widget calls commRead during its paint cycle
// to get new data to display, and commWrite when the user types to send
// keystrokes over the encrypted link.
wgtAnsiTermSetComm(term, &tc, commRead, commWrite);
// Status bar showing connection info
@ -266,11 +288,17 @@ int main(int argc, char *argv[]) {
// Fit window to widget tree
dvxFitWindow(&ctx, win);
// Poll serial during idle instead of yielding CPU
// Register an idle callback so the GUI event loop polls secLink during
// idle time (when there are no mouse/keyboard events to process). This
// ensures incoming data is processed even when the user isn't interacting
// with the terminal. Without this, data would only arrive during the
// fixed-rate dvxUpdate calls.
ctx.idleCallback = idlePoll;
ctx.idleCtx = &tc;
// Main loop — poll serial, dvxUpdate handles terminal widget automatically
// Main loop: poll secLink for incoming data, then dvxUpdate processes
// input events, repaints dirty widgets (including the terminal, which
// calls commRead to consume the ring buffer), and flushes to the LFB.
while (ctx.running) {
secLinkPoll(tc.link);