Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/ui.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
36 changes: 28 additions & 8 deletions lib/ui_draw.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----
Expand All @@ -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 ----
Expand Down
2 changes: 1 addition & 1 deletion lib/ui_w_basic.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
204 changes: 202 additions & 2 deletions src/ext_gfx.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand All @@ -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++) {
Expand All @@ -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 ---- */

/* ================================================================
Expand Down
Loading
Loading