- C++ 60%
- Clean 23.4%
- Roff 11.1%
- C 3.3%
- CMake 1.1%
- Other 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| .vscode | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| CMakeLists.txt | ||
| Dockerfile | ||
| nginx.conf | ||
| README.md | ||
| shell.html | ||
LYNX Desktop Shell
A retro desktop shell originally written in Turbo C++ using the Borland Graphics Interface (BGI), ported to SDL2 for Linux, with an upcoming WASM build target.
Project Structure
lynx/
├── CMakeLists.txt # Build system (CMake)
├── src/
│ │
│ │ ── Core Shim Layer (SDL2 abstraction) ──
│ ├── bgi.h # Unified BGI/conio/DOS compatibility header (305 lines)
│ ├── bgi.cpp # SDL2 implementation of all BGI/conio/DOS functions (1161 lines)
│ ├── graphics.h # Thin wrapper: #include "bgi.h"
│ ├── dos.h # Thin wrapper: #include "bgi.h"
│ ├── conio.h # Thin wrapper: #include "bgi.h"
│ ├── mouse.h # Mouse API header (includes <SDL_mouse.h>)
│ ├── mouse.cpp # Mouse implementation (rewritten for SDL2, 78 lines)
│ │
│ │ ── Application Infrastructure ──
│ ├── lynx.h # Master app header: constants, grinit(), inline helpers
│ ├── lynx_api.h # Cross-module function declarations
│ ├── grph.h # grph_settings class (save/restore graphics state)
│ ├── cookie.h # proxy class: read/write settings to lynx.ini
│ ├── box.h # Text-mode message box and input box (conio shim)
│ ├── button.h # button class (GUI button widget using BGI shim)
│ ├── icon.h # icon class (reads system.icl, draws with BGI shim)
│ ├── windows.h # msgbox() and input_box() (graphical dialog widgets)
│ ├── cal_eval.h # Calculator expression evaluator interface
│ │
│ │ ── Application Source Files ──
│ ├── lynx.cpp # Main desktop shell entry point (160 lines)
│ ├── calculat.cpp # Calculator application (183 lines)
│ ├── clock.cpp # Analog clock application (61 lines)
│ ├── grafiti.cpp # Paint/graphics editor (736 lines)
│ ├── intro.cpp # Intro animation (189 lines)
│ ├── stellar.cpp # "Stellar Evade" text-mode game (88 lines)
│ ├── tic.cpp # Tic-Tac-Toe game logic (313 lines)
│ ├── tic_main.cpp # Standalone Tic-Tac-Toe entry point (11 lines)
│ ├── dot.cpp # Standalone "DOTS" board game (369 lines)
│ ├── viewer.cpp # Text file viewer (104 lines)
│ ├── options.cpp # Options menu (55 lines)
│ ├── opt_bkg.cpp # Background options + drawing (210 lines)
│ ├── opt_scr.cpp # Screen saver selector (69 lines)
│ ├── opt_time.cpp # Time setting (22 lines)
│ ├── cal_eval.cpp # Calculator expression evaluator (157 lines)
│ │
│ │ ── Screen Saver Source Files ──
│ ├── scr1.cpp # Screen saver 1: circle of life (25 lines)
│ ├── scr2.cpp # Screen saver 2: going around (33 lines)
│ ├── matrix.cpp # Screen saver 3: matrix text effect (27 lines)
│ ├── pipe.cpp # Screen saver 4: pipe line (38 lines)
│ ├── marquee.cpp # Screen saver 5: scrolling marquee (73 lines)
│ ├── marline.cpp # Screen saver 6: 3D building walkthrough (295 lines)
│ ├── marl_3d.cpp # 3D building drawing helper (83 lines)
│ │
│ │ ── Data Files ──
│ ├── system.icl # Icon image data (binary)
│ ├── lynx.ini # Settings file (binary, read/written by cookie.h)
│ ├── lynx.txt # Credits text
│ ├── egavga.bgi # Original BGI driver (unused in SDL port)
│ ├── goth.chr ... trip.chr # BGI font files (stubs in SDL port)
│ └── aparna.pic # Sample paint file
│
└── tests/
└── calc_button_test.cpp # Visual smoke test for calculator buttons
Build System
Native Linux Build (CMake + SDL2)
mkdir build && cd build
cmake ..
make
Produces:
lynx— main desktop shelltic— standalone Tic-Tac-Toedot— standalone DOTS gamecalc_button_test— visual smoke test
Dependencies (via pkg-config): sdl2, SDL2_ttf
WASM Build (Emscripten)
mkdir build-wasm && cd build-wasm
emcmake cmake .. -DCMAKE_BUILD_TYPE=Release
make
Produces lynx.html / lynx.js / lynx.wasm. Uses the WASM shim layer
(bgi_wasm.cpp, mouse_wasm.cpp) which replaces SDL2 entirely with direct
canvas rendering via Emscripten APIs.
Architecture
Abstraction Layers
The codebase has four distinct abstraction layers:
Layer 1: The BGI Shim (bgi.h + bgi.cpp)
The primary abstraction. Provides 100+ functions that reimplement the Borland
Graphics Interface, Borland <conio.h>, and Borland <dos.h> on top of SDL2.
No application code ever calls SDL functions directly (except mouse.cpp).
Layer 2: Application Widgets (grph.h, button.h, icon.h, windows.h, box.h)
Build on the BGI shim to provide higher-level UI constructs:
grph_settings: RAII-like save/restore of line/fill/text/color statebutton: Clickable GUI button with 3D shading and press/release animationicon: Desktop icon reading pixel data fromsystem.iclmsgbox()/input_box(): Modal dialog boxes usinggetimage/putimagefor save/restore
Layer 3: Mouse Abstraction (mouse.h + mouse.cpp)
Bridges between SDL events and the original DOS INT 0x33 mouse API. The public
API in mouse.h is unchanged from the original DOS version.
Layer 4: Cross-Module API (lynx_api.h)
Flat declarations of all application entry points for linker resolution.
SDL2 Rendering Pipeline
Only two files call SDL functions directly: bgi.cpp and mouse.cpp. The
rendering pipeline works as follows:
Application calls BGI function (e.g. line(), bar(), outtextxy())
│
▼
bgi.cpp writes pixels directly into E.gsurf (640×480 SDL_Surface)
- gput(x,y,color) writes one pixel via SDL_MapRGB()
- bar() fills rectangle rows in surface pixel memory
- line() uses Bresenham's algorithm with gput() per pixel
- circle() uses midpoint circle algorithm with gput() per pixel
- floodfill() uses stack-based 4-connected fill on raw pixels
- outtextxy() renders text via TTF_RenderUTF8_Blended() then SDL_BlitSurface()
│
▼
present_active() pushes the surface to screen:
SDL_UpdateTexture → SDL_RenderClear → SDL_RenderCopy → SDL_RenderPresent
Two-Window Architecture
The original DOS program switched between VGA graphics mode (640×480) and 80×25 text mode. This is emulated with two independent SDL windows:
| Mode | Window | Size | Shown When |
|---|---|---|---|
| Graphics | "LYNX" | 640×480 | setgraphmode() |
| Text | "LYNX (text mode)" | 640×400 | restorecrtmode() |
BGI Colour Palette
The 16-colour EGA palette is mapped to 24-bit RGB at drawing time:
| Index | Color | RGB |
|---|---|---|
| 0 | BLACK | (0, 0, 0) |
| 1 | BLUE | (0, 0, 170) |
| 2 | GREEN | (0, 170, 0) |
| 3 | CYAN | (0, 170, 170) |
| 4 | RED | (170, 0, 0) |
| 5 | MAGENTA | (170, 0, 170) |
| 6 | BROWN | (170, 85, 0) |
| 7 | LIGHTGRAY | (170, 170, 170) |
| 8 | DARKGRAY | (85, 85, 85) |
| 9 | LIGHTBLUE | (85, 85, 255) |
| 10 | LIGHTGREEN | (85, 255, 85) |
| 11 | LIGHTCYAN | (85, 255, 255) |
| 12 | LIGHTRED | (255, 85, 85) |
| 13 | LIGHTMAGENTA | (255, 85, 255) |
| 14 | YELLOW | (255, 255, 85) |
| 15 | WHITE | (255, 255, 255) |
Drawing Primitives
All primitives use software rasterization — no SDL2 draw calls:
- line(): Bresenham's line algorithm, writes via
gput() - circle(): Midpoint circle algorithm, writes via
gput() - ellipse(): Parametric angle stepping, calls
line()for segments - fillellipse(): Row-by-row horizontal span fill via
gput() - rectangle(): Four calls to
line() - bar(): Direct row-by-row pixel write into surface memory
- bar3d(): Calls
bar()+line()for 3D edges - fillpoly(): Even-odd scanline polygon fill
- drawpoly(): N calls to
line() - pieslice(): Arc + polygon fill
- floodfill(): Stack-based 4-connected seed fill on raw pixels
- putpixel() / getpixel(): Single pixel read/write
- getimage() / putimage(): Pixel-level blit with COPY/XOR/OR/AND/NOT operations
- outtextxy(): Text rendered via SDL_ttf, blitted with
SDL_BlitSurface()
Event Handling
pump_events() calls SDL_PollEvent() and translates:
| SDL Event | Internal Mapping |
|---|---|
SDL_KEYDOWN |
DOS scan codes pushed to E.keys deque |
SDL_MOUSEMOTION |
E.mx, E.my, E.moved |
SDL_MOUSEBUTTONDOWN/UP |
E.mb bitmask (using SDL_BUTTON()) |
SDL_QUIT / SDL_WINDOWEVENT_CLOSE |
E.quit = true |
Key Patterns
Pattern 1: Mouse visibility toggling Almost every drawing function hides the mouse cursor before drawing and shows it after, to prevent the OS cursor from corrupting rendered pixels.
Pattern 2: present_active() called selectively
Not after every draw call — batched until the next mouse read or explicit present.
Called after: putpixel(), putimage(), clearviewport() (full screen),
outtextxy(), pump_events().
Pattern 3: grph_settings RAII guard
Used in virtually every application function to save and restore drawing state:
grph_settings gr;
gr.get_settings();
gr.reset_settings();
// ... do work ...
gr.set_settings();
Pattern 4: Save/restore for modal dialogs
msgbox() and input_box() use imagesize() + getimage() to save the screen
region, draw the dialog, run the event loop, then putimage() to restore.
Font Rendering
BGI .chr font files are stubs — installuserfont() returns grOk without
loading them. All text rendering uses SDL_ttf with a system TrueType font,
selected at startup by probing standard font paths or $LYNX_FONT. Font size is
controlled by settextstyle() which scales the TTF point size (size × 8).
Text Mode
The 80×25 text mode maintains a character grid (E.cells[]) with per-cell
foreground/background colour arrays. text_redraw() renders this grid to a
surface by calling TTF_RenderText_Shaded() per cell and blitting with
SDL_BlitSurface().
Applications
| Application | Entry Point | Description |
|---|---|---|
| Desktop Shell | lynx.cpp:main() |
Icon-based desktop with clock, screen savers, options |
| Calculator | calculat.cpp:calculator() |
GUI calculator with expression evaluator |
| Clock | clock.cpp:lynx_clock() |
Analog clock with hour/minute/second hands |
| Paint | grafiti.cpp:paint() |
Drawing editor with pencil, line, rectangle, circle, spray tools |
| Stellar Evade | stellar.cpp:stellar() |
Text-mode space game |
| Tic-Tac-Toe | tic.cpp:tictactoe() |
Two-player tic-tac-toe with mouse input |
| Text Viewer | viewer.cpp:viewer() |
Text file reader with pagination |
| DOTS | dot.cpp:main() |
Standalone board game |
| Options | options.cpp:options() |
Background, screen saver, and time settings |
WASM Build Target
The WASM build replaces SDL2 entirely with a shim layer that draws to an HTML
canvas element directly via Emscripten APIs. All application source files are
shared unchanged — they only ever go through the bgi.h/mouse.h shim.
Key differences from the SDL2 build:
bgi_wasm.cppreplacesbgi.cpp— same software pixel buffer (ABGR), blitted to the<canvas>as RGBA viaputImageDatamouse_wasm.cppreplacesmouse.cpp— Emscripten event callbacks- No SDL2 or SDL2_ttf dependencies
- Uses Emscripten's
-sASYNCIFYfor blocking-call compatibility - Font rendering via the browser canvas
fillText()/measureText()API - Single canvas element (no dual-window mode switching)
Building
emcmake cmake -S . -B build-wasm -DCMAKE_BUILD_TYPE=Release
cmake --build build-wasm # produces lynx_wasm.html/.js/.wasm/.data
Running in a browser
Serve the build directory over HTTP (Emscripten data files and the .wasm
cannot be loaded from file://), then open lynx_wasm.html:
emrun build-wasm/lynx_wasm.html
# or
python3 -m http.server --directory build-wasm
# browse to http://localhost:8000/lynx_wasm.html
Docker
A multi-stage Dockerfile builds the WASM output using the official Emscripten image and serves it with nginx:alpine. No local Emscripten installation required.
docker build -t lynx-wasm .
docker run --rm -p 8080:80 lynx-wasm
# browse to http://localhost:8080
Custom HTML shell (shell.html)
A minimal hand-written shell (shell.html, wired via --shell-file=) is used
instead of the default Emscripten shell. It:
- sizes a 640x480 canvas that fits the graphics framebuffer
- omits the default "Lock/hide mouse pointer" checkbox, avoiding Emscripten's
automatic
requestPointerLock()call (which throwsWrongDocumentError) when the app blocks on input under ASYNCIFY - gives the canvas focus so keyboard events reach the app
The canvas 2D context is created with {willReadFrequently: true} (see
js_canvas_init in bgi_wasm.cpp) because the shim calls getImageData for
every glyph rendered; this removes the Canvas2D readback performance warning.