From 9ab44338b7f06cee65b4fc0fad6e16d664a13353 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Mon, 13 Jul 2026 19:39:54 -0500 Subject: [PATCH] feat(gfx): proportional antialiased text via SDL2_ttf (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DeslanStudio side-by-side against its Qt prototype read a decade old at feature parity; the dominant gap was the 5x7 bitmap monospace font. gfx_text now lazily dlopens libSDL2_ttf-2.0.so.0 (the same no-headers / no-hard-dependency pattern as SDL2/SDL2_mixer) and renders TTF_RenderUTF8_Blended through the texture path when a font is available. EIGS_GFX_FONT overrides discovery; else DejaVu / Liberation / Noto Sans are probed. TTF_Font* cached per size; the gfx_text signature is unchanged (scale maps to pixel size 6*scale+2). THE FALLBACK IS LOAD-BEARING: without the library or a font the bitmap path is byte-identical to before (CI containers may have neither), and a bogus EIGS_GFX_FONT is the deterministic off-switch, pinned by the new suite section [120] (forced-fallback exact-math run + default-env either-renderer run, SDL dummy video for the render smoke). New metrics builtins gfx_text_width of [text, scale?] and gfx_text_height of scale? return pixel metrics under the active text renderer (bitmap math in fallback; usable before gfx_open). lib/ui text_width / text_height / _draw_text_clipped route through them when present — probed once with the catch-undefined pattern so binaries without the gfx extension and headless tests that stub gfx_* keep the historical bitmap math (test_ui gains faithful metric stubs). Output-only: no trace-tape records in either mode (gfx_rect class); the metrics builtins are untraced, gfx_ticks class. Gates: release suite 2863/2863; ASan+UBSan detect_leaks=1 2867/2867 (leak tally 0); gfx-build suite 2904/2904 incl. [62]+[120]; visual proof on the real X display in both modes. Closes #593 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 +++++ docs/BUILTINS.md | 19 +++- lib/ui.eigs | 2 +- lib/ui_draw.eigs | 36 +++++-- lib/ui_w_basic.eigs | 2 +- src/ext_gfx.c | 204 ++++++++++++++++++++++++++++++++++++++- src/ext_names.h | 2 + tests/run_all_tests.sh | 36 +++++++ tests/test_gfx_text.eigs | 73 ++++++++++++++ tests/test_ui.eigs | 9 ++ 10 files changed, 393 insertions(+), 13 deletions(-) create mode 100644 tests/test_gfx_text.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a16391..a88165d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to EigenScript are documented here. ## [Unreleased] +### Added +- **gfx: proportional antialiased text via SDL2_ttf (#593).** The + DeslanStudio side-by-side against its Qt prototype showed feature + parity reading a decade old, and the dominant gap was the 5x7 bitmap + monospace font. `gfx_text` now lazily dlopens `libSDL2_ttf-2.0.so.0` + (mirroring the SDL2/SDL2_mixer pattern — no headers, no hard + dependency) and renders `TTF_RenderUTF8_Blended` through the texture + path when a font is available; `EIGS_GFX_FONT` overrides discovery, + else DejaVu/Liberation/Noto Sans are probed. `TTF_Font*` cached per + size; signature unchanged (`scale` maps to a comparable pixel size). + **The fallback is load-bearing**: without the library or a font the + bitmap path is byte-identical to before (CI containers may have + neither), and a bogus `EIGS_GFX_FONT` is the deterministic off-switch + (pinned by suite section [120]). New metrics builtins + `gfx_text_width of [text, scale?]` / `gfx_text_height of scale?` + return real pixel metrics under the active renderer (bitmap math in + fallback); `lib/ui` measurement (`text_width`/`text_height`/ + `_draw_text_clipped`) routes through them when present — probed once + with the catch-undefined pattern so headless stubs keep working — so + buttons, labels, menus, tabs, and dialogs center and clip + proportional text correctly. Output-only: no trace-tape records in + either mode. + ## [0.30.0] - 2026-07-13 ### Added diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index cdd4119a..98f63256 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -539,7 +539,9 @@ libSDL2 at runtime — no SDL2 headers needed at build time. | `gfx_line` | `gfx_line of [x1, y1, x2, y2, r, g, b]` | Line segment | | `gfx_point` | `gfx_point of [x, y, r, g, b]` | Single pixel | | `gfx_circle` | `gfx_circle of [cx, cy, radius, r, g, b]` | Filled circle (midpoint) | -| `gfx_text` | `gfx_text of [x, y, text, r, g, b]` or `[..., scale]` | Bitmap-font text | +| `gfx_text` | `gfx_text of [x, y, text, r, g, b]` or `[..., scale]` | Text. Proportional antialiased TTF when libSDL2_ttf + a font are available (#593); the 5x7 bitmap font otherwise — see the font note below the table | +| `gfx_text_width` | `gfx_text_width of [text, scale?]` or `of "text"` | Pixel width of `text` under the active text renderer: TTF metrics when active, `len * 6 * scale` in bitmap mode. Works before `gfx_open` | +| `gfx_text_height` | `gfx_text_height of scale?` | Pixel line height under the active text renderer: TTF font height when active, `7 * scale` in bitmap mode | | `gfx_present` | `gfx_present of null` | Flip backbuffer to screen | | `gfx_poll` | `gfx_poll of null` | Return next event as dict (`quit`, `keydown`, `keyup`, `mousemove`, `mousedown`, `mouseup`, `wheel`, `resize`), or null. Key, mouse, and wheel events carry `shift`/`ctrl`/`alt` (0/1); wheel `x`/`y` are scroll deltas | | `gfx_ticks` | `gfx_ticks of null` | Milliseconds since `SDL_Init` | @@ -548,6 +550,21 @@ libSDL2 at runtime — no SDL2 headers needed at build time. | `gfx_fb` | `gfx_fb of [buf, w, h, x, y, scale]` | Blit buffer (palette indices 0-3) as scaled texture | | `ppu_render_frame` | `ppu_render_frame of [mem_buf, fb_buf]` | Full Game Boy PPU render (BG/window/sprites) into framebuffer | +**Text rendering and fonts (#593).** `gfx_text` lazily loads +`libSDL2_ttf-2.0.so.0` on first use and renders proportional antialiased +text (`TTF_RenderUTF8_Blended`) when both the library and a font file are +present. Font selection: the `EIGS_GFX_FONT` environment variable +(absolute path to a `.ttf`) wins; when it is set but unreadable the +runtime warns once and stays on the bitmap font (a nonexistent path is +the deterministic off-switch). Otherwise a short list of common system +fonts is probed (DejaVu Sans, Liberation Sans, Noto Sans under +`/usr/share/fonts/truetype/`). Without SDL2_ttf or a font, `gfx_text` +renders through the built-in 5x7 bitmap font exactly as before — the +fallback is load-bearing (CI containers may have neither). Layout code +should measure through `gfx_text_width`/`gfx_text_height` (as `lib/ui` +does) rather than assuming the `6 * scale` monospace advance. Text +rendering is output-only: no trace-tape records in either mode. + ## Optional: Database Extension Requires full build with libpq. PostgreSQL client. diff --git a/lib/ui.eigs b/lib/ui.eigs index 1b9b41e4..01d358f6 100644 --- a/lib/ui.eigs +++ b/lib/ui.eigs @@ -174,7 +174,7 @@ define _render_toasts() as: if alpha < 0: alpha is 0 tw is (text_width of [t.text, _theme.font_scale]) + 16 - th is _theme.font_scale * 7 + 12 + th is (text_height of _theme.font_scale) + 12 tx is _ui.window_w - tw - 12 # Background gfx_rrect of [tx, ty, tw, th, 4, 40, 40, 55, alpha] diff --git a/lib/ui_draw.eigs b/lib/ui_draw.eigs index 935f1081..f318587b 100644 --- a/lib/ui_draw.eigs +++ b/lib/ui_draw.eigs @@ -13,12 +13,28 @@ define _point_in_rect(px, py, x, y, w, h) as: return 0 # ---- Text measurement ---- -# Based on the 5x7 bitmap font: advance = (5+1) * scale +# Real pixel metrics via gfx_text_width / gfx_text_height when the runtime +# has them (#593: proportional TTF metrics when SDL2_ttf + a font are +# active, bitmap math otherwise). Probed once at load with the +# catch-undefined pattern so a binary without the gfx extension — and +# headless tests that stub only the drawing calls — keep the 5x7 +# bitmap-font math: advance = (5+1) * scale, height = 7 * scale. + +_has_text_metrics is 0 +try: + if (gfx_text_width of ["m", 1]) != null: + _has_text_metrics is 1 +catch _e: + _has_text_metrics is 0 define text_width(text, scale) as: + if _has_text_metrics == 1: + return gfx_text_width of [text, scale] return (len of text) * 6 * scale define text_height(scale) as: + if _has_text_metrics == 1: + return gfx_text_height of scale return 7 * scale # ---- Drawing primitives ---- @@ -36,13 +52,17 @@ define _draw_rbox(x, y, w, h, rad, bg, border) as: gfx_rrect of [x, y, w, h, rad, bg[0], bg[1], bg[2]] define _draw_text_clipped(x, y, w, text, color, scale) as: - cw is 6 * scale - max_chars is floor of (w / cw) - if max_chars < 1: - return null - display is text - if (len of text) > max_chars: - display is substr of [text, 0, max_chars] + # Truncate by MEASURED width (proportional under TTF, len*6*scale in + # bitmap mode — where this reduces to the old floor(w / (6*scale)) + # character clip). Widget text is short; the shrink loop is fine. + local display is text + if (text_width of [display, scale]) > w: + local n is len of display + loop while n > 0 and (text_width of [(substr of [display, 0, n]), scale]) > w: + n is n - 1 + if n < 1: + return null + display is substr of [display, 0, n] gfx_text of [x, y, display, color[0], color[1], color[2], scale] # ---- Focus ring ---- diff --git a/lib/ui_w_basic.eigs b/lib/ui_w_basic.eigs index 01ac5b8e..99f4b250 100644 --- a/lib/ui_w_basic.eigs +++ b/lib/ui_w_basic.eigs @@ -49,7 +49,7 @@ define color_dot(id, x, y, size, color) as: define badge(id, x, y, text, color) as: bw is (text_width of [text, _theme.font_scale]) + 12 - bh is _theme.font_scale * 7 + 6 + bh is (text_height of _theme.font_scale) + 6 return { "type": "badge", "id": id, diff --git a/src/ext_gfx.c b/src/ext_gfx.c index e57f032c..2b986a1f 100644 --- a/src/ext_gfx.c +++ b/src/ext_gfx.c @@ -3,7 +3,9 @@ * Dynamically loads libSDL2 at runtime (no dev headers needed). * * Builtins: gfx_open, gfx_close, gfx_clear, gfx_rect, gfx_line, - * gfx_point, gfx_circle, gfx_present, gfx_poll, gfx_ticks, gfx_delay + * gfx_point, gfx_circle, gfx_present, gfx_poll, gfx_ticks, gfx_delay, + * gfx_text, gfx_text_width, gfx_text_height (proportional + * antialiased text via SDL2_ttf when available, #593) */ #include "eigenscript.h" @@ -106,6 +108,9 @@ static SDL_Texture* (*p_SDL_CreateTexture)(SDL_Renderer*, Uint32, int, int, int) static void (*p_SDL_DestroyTexture)(SDL_Texture*); static int (*p_SDL_UpdateTexture)(SDL_Texture*, const SDL_Rect*, const void*, int); static int (*p_SDL_RenderCopy)(SDL_Renderer*, SDL_Texture*, const SDL_Rect*, const SDL_Rect*); +static SDL_Texture* (*p_SDL_CreateTextureFromSurface)(SDL_Renderer*, void*); +static void (*p_SDL_FreeSurface)(void*); +static int (*p_SDL_QueryTexture)(SDL_Texture*, Uint32*, int*, int*, int*); /* Framebuffer texture cache */ static SDL_Texture *g_fb_texture = NULL; @@ -155,6 +160,9 @@ static int load_sdl2(void) { p_SDL_DestroyTexture = dlsym(g_sdl_lib, "SDL_DestroyTexture"); p_SDL_UpdateTexture = dlsym(g_sdl_lib, "SDL_UpdateTexture"); p_SDL_RenderCopy = dlsym(g_sdl_lib, "SDL_RenderCopy"); + p_SDL_CreateTextureFromSurface = dlsym(g_sdl_lib, "SDL_CreateTextureFromSurface"); + p_SDL_FreeSurface = dlsym(g_sdl_lib, "SDL_FreeSurface"); + p_SDL_QueryTexture = dlsym(g_sdl_lib, "SDL_QueryTexture"); p_SDL_OpenAudioDevice = dlsym(g_sdl_lib, "SDL_OpenAudioDevice"); p_SDL_CloseAudioDevice = dlsym(g_sdl_lib, "SDL_CloseAudioDevice"); p_SDL_QueueAudio = dlsym(g_sdl_lib, "SDL_QueueAudio"); @@ -215,6 +223,125 @@ static int load_sdl_mixer(void) { return 1; } +/* ---- SDL2_ttf (proportional antialiased text, #593) ---- + * Loaded separately and lazily (first gfx_text / gfx_text_width / + * gfx_text_height call) so the core graphics binary keeps no hard + * dependency on libSDL2_ttf. THE FALLBACK IS LOAD-BEARING: when the lib + * or a usable font file is missing (CI containers may have neither), + * gfx_text renders through the 5x7 bitmap path exactly as before, and + * the metrics builtins return the bitmap math (len*6*scale / 7*scale). + * + * Font discovery: EIGS_GFX_FONT (absolute path to a .ttf) wins; when it + * is set but unreadable we warn once and stay on the bitmap font — a + * nonexistent path is the deterministic off-switch the tests pin. + * Otherwise a short list of common system fonts is probed. No + * fontconfig dependency. + * + * Text rendering is output-only — no trace-tape records (same class as + * gfx_rect); the metrics builtins are environment-dependent (which font + * is installed) but untraced, same class as gfx_ticks. */ +typedef struct { Uint8 r, g, b, a; } SDL_Color; + +static void *g_ttf_lib = NULL; +static int g_ttf_state = 0; /* 0 unprobed, 1 active, -1 unavailable */ +static char g_ttf_font_path[512]; + +static int (*p_TTF_Init)(void); +static void (*p_TTF_Quit)(void); +static void *(*p_TTF_OpenFont)(const char*, int); +static void (*p_TTF_CloseFont)(void*); +static int (*p_TTF_SizeUTF8)(void*, const char*, int*, int*); +static int (*p_TTF_FontHeight)(void*); +static void *(*p_TTF_RenderUTF8_Blended)(void*, const char*, SDL_Color); + +/* TTF_Font* cache, one entry per pixel size actually used. */ +#define TTF_FONT_CACHE_MAX 16 +static struct { int px; void *font; } g_ttf_fonts[TTF_FONT_CACHE_MAX]; +static int g_ttf_font_count = 0; + +/* Map the gfx_text bitmap `scale` to a TTF pixel size. 6*scale + 2 + * keeps the rendered line height close to the bitmap cell (7*scale) + * and the average advance narrower than the bitmap's 6*scale, so + * existing fixed-width layouts don't overflow (scale 2 → 14 px). */ +static int ttf_scale_to_px(int scale) { + if (scale < 1) scale = 1; + return 6 * scale + 2; +} + +static int ttf_available(void) { + if (g_ttf_state) return g_ttf_state > 0; + g_ttf_state = -1; + + const char *env = getenv("EIGS_GFX_FONT"); + if (env && *env) { + if (access(env, R_OK) != 0) { + fprintf(stderr, "gfx_text: EIGS_GFX_FONT '%s' is not readable; " + "using the bitmap font\n", env); + return 0; + } + snprintf(g_ttf_font_path, sizeof g_ttf_font_path, "%s", env); + } else { + static const char *candidates[] = { + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + NULL + }; + const char *found = NULL; + for (int i = 0; candidates[i]; i++) + if (access(candidates[i], R_OK) == 0) { found = candidates[i]; break; } + if (!found) return 0; /* silent: bitmap is the normal fallback */ + snprintf(g_ttf_font_path, sizeof g_ttf_font_path, "%s", found); + } + + g_ttf_lib = dlopen("libSDL2_ttf-2.0.so.0", RTLD_LAZY); + if (!g_ttf_lib) g_ttf_lib = dlopen("libSDL2_ttf.so", RTLD_LAZY); + if (!g_ttf_lib) return 0; /* silent: bitmap is the normal fallback */ + + int ok = 1; + #define TLOAD(name) do { p_##name = dlsym(g_ttf_lib, #name); \ + if (!p_##name) { fprintf(stderr, "gfx_text: missing %s\n", #name); ok = 0; } } while(0) + TLOAD(TTF_Init); TLOAD(TTF_Quit); + TLOAD(TTF_OpenFont); TLOAD(TTF_CloseFont); + TLOAD(TTF_SizeUTF8); TLOAD(TTF_FontHeight); + TLOAD(TTF_RenderUTF8_Blended); + #undef TLOAD + if (!ok || p_TTF_Init() != 0) { + dlclose(g_ttf_lib); + g_ttf_lib = NULL; + return 0; + } + g_ttf_state = 1; + return 1; +} + +/* Cached TTF_Font* for a bitmap scale; NULL when the font can't open + * (callers fall back to the bitmap path). */ +static void* ttf_font_for_scale(int scale) { + int px = ttf_scale_to_px(scale); + for (int i = 0; i < g_ttf_font_count; i++) + if (g_ttf_fonts[i].px == px) return g_ttf_fonts[i].font; + if (g_ttf_font_count >= TTF_FONT_CACHE_MAX) + return g_ttf_fonts[0].font; /* pathological size churn: reuse */ + void *font = p_TTF_OpenFont(g_ttf_font_path, px); + g_ttf_fonts[g_ttf_font_count].px = px; + g_ttf_fonts[g_ttf_font_count].font = font; /* cache NULL too */ + g_ttf_font_count++; + return font; +} + +static void ttf_teardown(void) { + if (!g_ttf_lib) { g_ttf_state = 0; g_ttf_font_count = 0; return; } + for (int i = 0; i < g_ttf_font_count; i++) + if (g_ttf_fonts[i].font) p_TTF_CloseFont(g_ttf_fonts[i].font); + g_ttf_font_count = 0; + p_TTF_Quit(); + dlclose(g_ttf_lib); + g_ttf_lib = NULL; + g_ttf_state = 0; +} + /* Scancode to key name — SDL2 scancodes */ static const char* scancode_name(int sc) { switch (sc) { @@ -291,6 +418,7 @@ Value* builtin_gfx_open(Value *arg) { /* gfx_close of null */ Value* builtin_gfx_close(Value *arg) { (void)arg; + ttf_teardown(); if (g_fb_texture && p_SDL_DestroyTexture) { p_SDL_DestroyTexture(g_fb_texture); g_fb_texture = NULL; g_fb_w = 0; g_fb_h = 0; } if (g_audio_device) { p_SDL_CloseAudioDevice(g_audio_device); g_audio_device = 0; } if (g_capture_device) { p_SDL_CloseAudioDevice(g_capture_device); g_capture_device = 0; } @@ -650,7 +778,10 @@ static const unsigned char font5x7[95][7] = { {0x00,0x04,0x02,0x1F,0x02,0x04,0x00}, /* '~' */ }; -/* gfx_text of [x, y, text, r, g, b] or [x, y, text, r, g, b, scale] */ +/* gfx_text of [x, y, text, r, g, b] or [x, y, text, r, g, b, scale] + * Proportional antialiased TTF text when SDL2_ttf + a font are available + * (#593); the 5x7 bitmap path below is the exact pre-#593 behavior and + * runs whenever any part of the TTF path is missing or fails. */ Value* builtin_gfx_text(Value *arg) { if (!g_renderer || !arg || arg->type != VAL_LIST || arg->data.list.count < 6) return make_null(); int x = (int)arg->data.list.items[0]->data.num; @@ -662,6 +793,29 @@ Value* builtin_gfx_text(Value *arg) { int scale = (arg->data.list.count >= 7) ? (int)arg->data.list.items[6]->data.num : 1; if (scale < 1) scale = 1; + if (*text && ttf_available() && p_SDL_CreateTextureFromSurface + && p_SDL_FreeSurface && p_SDL_QueryTexture && p_SDL_DestroyTexture + && p_SDL_RenderCopy) { + void *font = ttf_font_for_scale(scale); + if (font) { + SDL_Color col = { (Uint8)r, (Uint8)g, (Uint8)b, 255 }; + void *surf = p_TTF_RenderUTF8_Blended(font, text, col); + if (surf) { + SDL_Texture *tex = p_SDL_CreateTextureFromSurface(g_renderer, surf); + p_SDL_FreeSurface(surf); + if (tex) { + int tw = 0, th = 0; + p_SDL_QueryTexture(tex, NULL, NULL, &tw, &th); + SDL_Rect dst = { x, y, tw, th }; + p_SDL_RenderCopy(g_renderer, tex, NULL, &dst); + p_SDL_DestroyTexture(tex); + return make_null(); + } + } + } + /* fall through to the bitmap path on any failure */ + } + p_SDL_SetRenderDrawColor(g_renderer, r, g, b, 255); int cx = x; for (const char *p = text; *p; p++) { @@ -686,6 +840,52 @@ Value* builtin_gfx_text(Value *arg) { return make_null(); } +/* gfx_text_width of [text, scale?] (or of "text") — pixel width of `text` + * under the ACTIVE text renderer: TTF metrics when SDL2_ttf + a font are + * loaded, the bitmap advance (len * 6 * scale) otherwise. lib/ui layout + * routes through this so proportional text doesn't break centering (#593). + * Works without an open window (layout can be computed before gfx_open). */ +Value* builtin_gfx_text_width(Value *arg) { + const char *text = NULL; + int scale = 1; + if (arg && arg->type == VAL_STR) { + text = arg->data.str; + } else if (arg && arg->type == VAL_LIST && arg->data.list.count >= 1 + && arg->data.list.items[0]->type == VAL_STR) { + text = arg->data.list.items[0]->data.str; + if (arg->data.list.count >= 2 && arg->data.list.items[1]->type == VAL_NUM) + scale = (int)arg->data.list.items[1]->data.num; + } + if (!text) return make_num(0); + if (scale < 1) scale = 1; + if (*text && ttf_available()) { + void *font = ttf_font_for_scale(scale); + int w = 0, h = 0; + if (font && p_TTF_SizeUTF8(font, text, &w, &h) == 0) + return make_num((double)w); + } + return make_num((double)strlen(text) * 6.0 * (double)scale); +} + +/* gfx_text_height of scale? (number, [scale], or null → 1) — pixel line + * height under the ACTIVE text renderer: TTF font height when active, + * the bitmap glyph height (7 * scale) otherwise. */ +Value* builtin_gfx_text_height(Value *arg) { + int scale = 1; + if (arg && arg->type == VAL_NUM) { + scale = (int)arg->data.num; + } else if (arg && arg->type == VAL_LIST && arg->data.list.count >= 1 + && arg->data.list.items[0]->type == VAL_NUM) { + scale = (int)arg->data.list.items[0]->data.num; + } + if (scale < 1) scale = 1; + if (ttf_available()) { + void *font = ttf_font_for_scale(scale); + if (font) return make_num((double)p_TTF_FontHeight(font)); + } + return make_num(7.0 * (double)scale); +} + /* ---- Audio Builtins ---- */ /* ================================================================ diff --git a/src/ext_names.h b/src/ext_names.h index 5aaa631c..10a5168d 100644 --- a/src/ext_names.h +++ b/src/ext_names.h @@ -40,6 +40,8 @@ X(gfx_delay, builtin_gfx_delay) \ X(gfx_title, builtin_gfx_title) \ X(gfx_text, builtin_gfx_text) \ + X(gfx_text_width, builtin_gfx_text_width) \ + X(gfx_text_height, builtin_gfx_text_height) \ X(gfx_fb, builtin_gfx_fb) \ X(ppu_render_frame, builtin_ppu_render_frame) \ X(audio_open, builtin_audio_open) \ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index ac5b5f5c..4191a02b 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2102,6 +2102,42 @@ else echo "" fi +# [120] gfx text metrics (#593 — probe-gated: needs a gfx build). Two runs: +# a forced-fallback run (EIGS_GFX_FONT pointing nowhere — the deterministic +# off-switch) must report fallback-mode: 1 and pin the bitmap math exactly; +# the default-env run must pass under EITHER text renderer (the machine may +# or may not have libSDL2_ttf + a system font). Both use the dummy video +# driver for the windowed gfx_text render smoke. +GT_PROBE_FILE=$(mktemp /tmp/eigs_gt_probe_XXXXXX.eigs) +cat > "$GT_PROBE_FILE" <<'PROBE' +print of (gfx_text_width of ["m", 1]) +PROBE +GT_PROBE_OUT=$(./eigenscript "$GT_PROBE_FILE" 2>&1) +rm -f "$GT_PROBE_FILE" + +if ! echo "$GT_PROBE_OUT" | grep -q "undefined variable"; then + echo "[120] Gfx Text Metrics (2 runs)" + GT_FB=$(SDL_VIDEODRIVER=dummy EIGS_GFX_FONT=/nonexistent/eigs-no-font.ttf ./eigenscript ../tests/test_gfx_text.eigs 2>&1); GT_FB_RC=$? + GT_DEF=$(SDL_VIDEODRIVER=dummy ./eigenscript ../tests/test_gfx_text.eigs 2>&1); GT_DEF_RC=$? + if rc_ok "$GT_FB_RC" "$GT_FB" && echo "$GT_FB" | grep -q "All tests passed" \ + && echo "$GT_FB" | grep -q "fallback-mode: 1" \ + && rc_ok "$GT_DEF_RC" "$GT_DEF" && echo "$GT_DEF" | grep -q "All tests passed"; then + TOTAL=$((TOTAL + 2)) + PASS=$((PASS + 2)) + echo " PASS: fallback pinned + active-renderer invariants" + else + TOTAL=$((TOTAL + 2)) + FAIL=$((FAIL + 2)) + echo " FAIL: gfx text metrics" + echo "$GT_FB" | grep -iE "assert|error|FAIL" | head -3 + echo "$GT_DEF" | grep -iE "assert|error|FAIL" | head -3 + fi + echo "" +else + echo "[120] Gfx text metrics SKIPPED (binary built without EIGENSCRIPT_EXT_GFX)" + echo "" +fi + # [64] list_truncate builtin echo "[64] List Truncate (9 checks)" LT_OUTPUT=$(./eigenscript ../tests/test_list_truncate.eigs 2>&1); LT_OUTPUT_RC=$? diff --git a/tests/test_gfx_text.eigs b/tests/test_gfx_text.eigs new file mode 100644 index 00000000..860fa4e8 --- /dev/null +++ b/tests/test_gfx_text.eigs @@ -0,0 +1,73 @@ +# ============================================================ +# gfx_text_width / gfx_text_height metric tests (#593) +# ============================================================ +# Runs against a gfx build (probe-gated in the runner). Passes in BOTH +# text-renderer modes: +# - bitmap fallback (no SDL2_ttf, no usable font, or a bogus +# EIGS_GFX_FONT): widths are exactly len * 6 * scale, heights +# exactly 7 * scale — byte-identical to pre-#593 behavior +# - TTF active: proportional pixel metrics from the loaded font +# The invariants below hold in both; the mode line lets the runner's +# forced-fallback run (EIGS_GFX_FONT=/nonexistent/...) additionally +# require "fallback-mode: 1", pinning the off-switch. + +load_file of "lib/test.eigs" + +# ---- Monotonic in text length (both modes) ---- +w_a is gfx_text_width of ["a", 1] +w_aa is gfx_text_width of ["aa", 1] +w_aaa is gfx_text_width of ["aaa", 1] +assert_true of [w_a > 0, "one char has positive width"] +assert_true of [w_aa > w_a, "width monotonic in length (2 > 1)"] +assert_true of [w_aaa > w_aa, "width monotonic in length (3 > 2)"] + +# ---- Monotonic in scale (both modes) ---- +w_s1 is gfx_text_width of ["hello", 1] +w_s2 is gfx_text_width of ["hello", 2] +w_s3 is gfx_text_width of ["hello", 3] +assert_true of [w_s2 > w_s1, "width monotonic in scale (2 > 1)"] +assert_true of [w_s3 > w_s2, "width monotonic in scale (3 > 2)"] + +h1 is gfx_text_height of 1 +h2 is gfx_text_height of 2 +assert_true of [h1 >= 7, "height at least the bitmap glyph height"] +assert_true of [h2 > h1, "height monotonic in scale"] + +# ---- Degenerate inputs (both modes) ---- +assert_eq of [gfx_text_width of ["", 1], 0, "empty string has width 0"] +assert_eq of [gfx_text_width of (5), 0, "non-string arg yields width 0"] +assert_eq of [gfx_text_width of ("x"), gfx_text_width of ["x", 1], "scale defaults to 1"] + +# ---- Mode detection ---- +# The bitmap renderer is monospace with exact known math; no proportional +# font matches all four values simultaneously. +fb is 0 +if w_a == 6 and (gfx_text_width of ["W", 1]) == 6 and w_s2 == 60 and h1 == 7: + fb is 1 +print of f"fallback-mode: {fb}" + +if fb == 1: + # Bitmap fallback pinned exactly (the pre-#593 metrics) + assert_eq of [gfx_text_width of ["iiii", 2], 48, "bitmap: iiii@2 is 4*6*2"] + assert_eq of [gfx_text_width of ["MMMM", 2], 48, "bitmap: MMMM@2 is 4*6*2"] + assert_eq of [gfx_text_height of 3, 21, "bitmap: height@3 is 7*3"] +else: + # TTF active: metrics must actually be proportional + assert_true of [(gfx_text_width of ["iiii", 2]) < (gfx_text_width of ["MMMM", 2]), "ttf: iiii narrower than MMMM"] + assert_true of [(gfx_text_width of ["il", 2]) < (gfx_text_width of ["WM", 2]), "ttf: il narrower than WM"] + assert_true of [h2 >= 10, "ttf: scale-2 line height is a real font height"] + +# ---- Windowed render smoke (SDL dummy driver in the runner) ---- +# gfx_text must not crash in either mode; skipped when no video driver. +opened is gfx_open of [64, 48, "ttf-smoke"] +if opened == 1: + gfx_clear of [0, 0, 0] + gfx_text of [2, 2, "Smoke", 255, 255, 255, 2] + gfx_text of [2, 20, "", 255, 255, 255, 1] + gfx_present of null + gfx_close of null + print of "smoke: drew text" +else: + print of "smoke: no video driver (render skipped)" + +test_summary of null diff --git a/tests/test_ui.eigs b/tests/test_ui.eigs index f3cf7438..fc0d3f03 100644 --- a/tests/test_ui.eigs +++ b/tests/test_ui.eigs @@ -49,6 +49,15 @@ define gfx_delay(args) as: define gfx_ticks(args) as: return 1000 +# Metrics stubs mirror the bitmap-font math (#593) so every layout +# assertion below keeps its historical value regardless of the fonts +# installed on the machine running the suite. +define gfx_text_width(text, scale) as: + return (len of text) * 6 * scale + +define gfx_text_height(scale) as: + return 7 * scale + # ---- Load UI toolkit ---- load_file of "lib/ui.eigs"