ascii-chat
Binary v0.15.83-53-gb711d8720
Library lib/v0.3.2-701-gb711d8720
Video chat in your terminal
Loading...
Searching...
No Matches
media/render/terminal.c
Go to the documentation of this file.
15
16/* Storage capacities in elements; text buffers include the terminating NUL. */
17#define RENDER_DEBUG_SIGNATURE_ROWS 52
18#define RENDER_DEBUG_SIGNATURE_COLUMNS 4
19#define RENDER_DEBUG_SIGNATURE_BUFFER_SIZE (RENDER_DEBUG_SIGNATURE_COLUMNS + 1)
20
25#include <libplatform/terminal.h>
26#include <ascii-chat/log/log.h>
28#include <ascii-chat/uthash.h>
29#include <ascii-chat/font.h>
31#include <vterm.h>
32#include <ft2build.h>
33#include FT_FREETYPE_H
34#include FT_GLYPH_H
35#include FT_FONT_FORMATS_H
36#include FT_TRUETYPE_TABLES_H
37#include <string.h>
38#include <stddef.h>
39#include <stdint.h>
40#include <limits.h>
41
42// ============================================================================
43// Glyph Cache
44// ============================================================================
45
58typedef struct {
59 uint32_t codepoint; // Hash key (character code)
60 uint8_t *bitmap_buf; // Normalized 8-bit alpha bitmap
61 int width; // Bitmap dimensions (in pixels)
62 int rows; // Bitmap height (in rows)
63 int pitch; // Bytes per row
64 int bitmap_left; // Glyph positioning offsets
66 UT_hash_handle hh; // uthash handle
68
72static void glyph_cache_destroy(glyph_cache_entry_t **cache) {
73 glyph_cache_entry_t *curr, *tmp;
74 HASH_ITER(hh, *cache, curr, tmp) {
75 HASH_DEL(*cache, curr);
76 SAFE_FREE(curr->bitmap_buf);
77 SAFE_FREE(curr);
78 }
79}
80
81// Cache complete cells so repeated glyph/color combinations need only row copies.
82#define CELL_CACHE_SIZE 512
88
106
112static FT_Bitmap *glyph_cache_get(terminal_renderer_t *r, FT_Face face, uint32_t codepoint) {
113 glyph_cache_entry_t *entry = NULL;
114
115 // Lookup in renderer's per-font cache
116 HASH_FIND_INT(r->glyph_cache, &codepoint, entry);
117 if (entry) {
118 // Return cached bitmap
119 static FT_Bitmap cached_result;
120 cached_result.width = entry->width;
121 cached_result.rows = entry->rows;
122 cached_result.pitch = entry->pitch;
123 cached_result.buffer = entry->bitmap_buf;
124 return &cached_result;
125 }
126
127 // Not in cache — load and render glyph
128 FT_UInt gi = FT_Get_Char_Index(face, codepoint);
129 if (!gi || FT_Load_Glyph(face, gi, FT_LOAD_RENDER) != 0) {
130 return NULL; // Glyph not found or load failed
131 }
132
133 FT_Bitmap *src_bitmap = &face->glyph->bitmap;
134 if (!src_bitmap->buffer || src_bitmap->width == 0 || src_bitmap->rows == 0 || src_bitmap->width > INT_MAX ||
135 src_bitmap->rows > INT_MAX) {
136 return NULL;
137 }
138
139 // FreeType bitmaps may use packed mono/gray pixels and either pitch
140 // direction. Normalize them once so blit_glyph can safely read one alpha
141 // byte per pixel for every cached glyph.
142 int64_t signed_pitch = src_bitmap->pitch;
143 size_t source_pitch = (size_t)(signed_pitch < 0 ? -signed_pitch : signed_pitch);
144 size_t minimum_source_pitch = 0;
145 switch (src_bitmap->pixel_mode) {
146 case FT_PIXEL_MODE_MONO:
147 minimum_source_pitch = ((size_t)src_bitmap->width + 7) / 8;
148 break;
149 case FT_PIXEL_MODE_GRAY2:
150 minimum_source_pitch = ((size_t)src_bitmap->width + 3) / 4;
151 break;
152 case FT_PIXEL_MODE_GRAY4:
153 minimum_source_pitch = ((size_t)src_bitmap->width + 1) / 2;
154 break;
155 case FT_PIXEL_MODE_GRAY:
156 minimum_source_pitch = src_bitmap->width;
157 break;
158 case FT_PIXEL_MODE_BGRA:
159 minimum_source_pitch = (size_t)src_bitmap->width * 4;
160 break;
161 default:
162 return NULL;
163 }
164 if (source_pitch == 0 || source_pitch < minimum_source_pitch || src_bitmap->rows > SIZE_MAX / source_pitch ||
165 src_bitmap->rows > SIZE_MAX / (size_t)src_bitmap->width) {
166 return NULL;
167 }
168
169 size_t bitmap_size = (size_t)src_bitmap->width * src_bitmap->rows;
170
171 // Create and populate cache entry
173 if (!entry) {
174 return NULL;
175 }
176
177 entry->codepoint = codepoint;
178 entry->bitmap_buf = SAFE_MALLOC(bitmap_size, uint8_t *);
179 if (!entry->bitmap_buf) {
180 SAFE_FREE(entry);
181 return NULL;
182 }
183
184 for (unsigned row = 0; row < src_bitmap->rows; row++) {
185 unsigned source_row = signed_pitch < 0 ? src_bitmap->rows - row - 1 : row;
186 const uint8_t *source = src_bitmap->buffer + (size_t)source_row * source_pitch;
187 uint8_t *destination = entry->bitmap_buf + (size_t)row * src_bitmap->width;
188 for (unsigned col = 0; col < src_bitmap->width; col++) {
189 switch (src_bitmap->pixel_mode) {
190 case FT_PIXEL_MODE_MONO:
191 destination[col] = (source[col / 8] & (0x80U >> (col % 8))) ? 255 : 0;
192 break;
193 case FT_PIXEL_MODE_GRAY2:
194 destination[col] = (uint8_t)(((source[col / 4] >> (6 - (col % 4) * 2)) & 0x03U) * 85U);
195 break;
196 case FT_PIXEL_MODE_GRAY4:
197 destination[col] = (uint8_t)(((source[col / 2] >> (col % 2 == 0 ? 4 : 0)) & 0x0fU) * 17U);
198 break;
199 case FT_PIXEL_MODE_GRAY:
200 destination[col] = src_bitmap->num_grays > 1 && src_bitmap->num_grays != 256
201 ? (uint8_t)((source[col] * 255U) / (src_bitmap->num_grays - 1))
202 : source[col];
203 break;
204 case FT_PIXEL_MODE_BGRA:
205 destination[col] = source[col * 4 + 3];
206 break;
207 default:
208 break;
209 }
210 }
211 }
212 entry->width = src_bitmap->width;
213 entry->rows = src_bitmap->rows;
214 entry->pitch = (int)src_bitmap->width;
215 entry->bitmap_left = face->glyph->bitmap_left;
216 entry->bitmap_top = face->glyph->bitmap_top;
217
218 // Add to renderer's per-font hash table
219 HASH_ADD_INT(r->glyph_cache, codepoint, entry);
220
221 // Return the cached bitmap
222 static FT_Bitmap new_result;
223 new_result.width = entry->width;
224 new_result.rows = entry->rows;
225 new_result.pitch = entry->pitch;
226 new_result.buffer = entry->bitmap_buf;
227 return &new_result;
228}
229
230static int screen_damage(VTermRect r, void *u) {
231 (void)r;
232 (void)u;
233 return 1;
234}
235
236static VTermScreenCallbacks g_vterm_cbs = {.damage = screen_damage};
237
243static uint32_t matrix_char_map(uint32_t ascii_char) {
244 // Matrix font glyphs: U+E900 to U+E91A (27 glyphs total)
245 const uint32_t matrix_start = 0xE900;
246 const uint32_t matrix_count = 27;
247
248 // If it's already in the PUA range, return as-is
249 if (ascii_char >= 0xE900 && ascii_char <= 0xE91A) {
250 return ascii_char;
251 }
252
253 // Map printable ASCII (32-126) to matrix glyphs
254 if (ascii_char >= 32 && ascii_char <= 126) {
255 // Cycle through available glyphs
256 // Coverage order measured from the bundled Matrix-Resurrected font at 10px.
257 // Adjacent alternatives preserve image shading when the rain mutates symbols.
258 static const uint8_t glyphs[6][4] = {{15, 18, 19, 15}, {20, 1, 26, 25}, {23, 0, 3, 13},
259 {12, 16, 24, 17}, {9, 4, 22, 21}, {2, 11, 14, 5}};
260 int group = digital_rain_glyph_group(ascii_char);
261 if (group >= 0)
262 return matrix_start + glyphs[group][ascii_char % 4];
263 return matrix_start + (ascii_char - 32) % matrix_count;
264 }
265
266 // Return unmapped characters as-is (will have no glyph)
267 return ascii_char;
268}
269
273static void blit_glyph(terminal_renderer_t *r, FT_Bitmap *bm, int px, int py, uint8_t fr, uint8_t fg, uint8_t fb,
274 uint8_t br, uint8_t bg, uint8_t bb) {
275 // Clip once, outside the pixel loop, so WASM can vectorize alpha blending.
276 int first_col = px < 0 ? -px : 0;
277 int last_col = (int)bm->width;
278 if (px + last_col > r->width_px)
279 last_col = r->width_px - px;
280 int first_row = py < 0 ? -py : 0;
281 int last_row = (int)bm->rows;
282 if (py + last_row > r->height_px)
283 last_row = r->height_px - py;
284 if (first_col >= last_col || first_row >= last_row)
285 return;
286 for (int row = first_row; row < last_row; row++) {
287 const uint8_t *alpha = bm->buffer + row * bm->pitch + first_col;
288 uint8_t *line = r->framebuffer + (py + row) * r->pitch + (px + first_col) * 4;
289 for (int col = 0; col < last_col - first_col; col++) {
290 unsigned a = alpha[col];
291 if (r->is_matrix_font && fr > 64 && fg > 64 && fb > 64 && (fr > 200 || fg > 200 || fb > 200)) {
292 // A small local bloom softens pale rain heads without blurring the body.
293 int x = first_col + col;
294 unsigned glow = 0;
295 if (x > 0)
296 glow += bm->buffer[row * bm->pitch + x - 1];
297 if (x + 1 < (int)bm->width)
298 glow += bm->buffer[row * bm->pitch + x + 1];
299 if (row > 0)
300 glow += bm->buffer[(row - 1) * bm->pitch + x];
301 if (row + 1 < (int)bm->rows)
302 glow += bm->buffer[(row + 1) * bm->pitch + x];
303 a += (255 - a) * glow / (255 * 24);
304 }
305 uint8_t *dst = line + col * 4;
306 dst[0] = (uint8_t)((fr * a + br * (255 - a)) / 255);
307 dst[1] = (uint8_t)((fg * a + bg * (255 - a)) / 255);
308 dst[2] = (uint8_t)((fb * a + bb * (255 - a)) / 255);
309 dst[3] = 255;
310 }
311 }
312}
313
315 if (!cfg || !out || cfg->cols <= 0 || cfg->rows <= 0)
316 return SET_ERRNO(ERROR_INVALID_PARAM, "Missing font renderer configuration or output");
317 *out = NULL;
318 if (cfg->font_data_size > LONG_MAX || (cfg->font_data_size && !cfg->font_data))
319 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid font data or size");
321 r->cols = cfg->cols;
322 r->rows = cfg->rows;
323 r->theme = cfg->theme;
324
325 if (FT_Init_FreeType(&r->ft_lib)) {
326 SAFE_FREE(r);
327 return SET_ERRNO(ERROR_INIT, "FreeType init failed");
328 }
329
330 FT_Error face_error;
331 if (cfg->font_data && cfg->font_data_size > 0) {
332 face_error = FT_New_Memory_Face(r->ft_lib, cfg->font_data, (FT_Long)cfg->font_data_size, 0, &r->ft_face);
333 } else {
334 face_error = FT_New_Face(r->ft_lib, cfg->font_spec, 0, &r->ft_face);
335 }
336 if (face_error) {
337 FT_Done_FreeType(r->ft_lib);
338 SAFE_FREE(r);
339 if (face_error == FT_Err_Cannot_Open_Resource)
340 return SET_ERRNO(ERROR_NOT_FOUND, "Cannot open font '%s' (FreeType error %d)", cfg->font_spec, face_error);
341 // Older FreeType versions recognize an HVF SFNT wrapper, then reject its
342 // missing TrueType outline tables instead of reporting an unknown format.
343 if (face_error == FT_Err_Unknown_File_Format || face_error == FT_Err_Unimplemented_Feature ||
344 face_error == FT_Err_Locations_Missing)
346 "Unsupported font format: '%s' (FreeType error %d). HVF fonts require HVF-enabled FreeType "
347 "2.15+ on macOS 15.4+ or iOS 18.4+",
348 cfg->font_spec, face_error);
349 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid font '%s' (FreeType error %d)", cfg->font_spec, face_error);
350 }
351
352 // Some older FreeType drivers can open an SFNT container without supporting
353 // its outlines. Check the parsed table directory before accepting that face.
354 FT_ULong hvf_size = 0;
355 bool is_hvf = FT_IS_SFNT(r->ft_face) &&
356 FT_Load_Sfnt_Table(r->ft_face, FT_MAKE_TAG('h', 'v', 'g', 'l'), 0, NULL, &hvf_size) == 0;
357 const char *font_format = FT_Get_Font_Format(r->ft_face);
358 if (is_hvf) {
359 bool supported = false;
360#ifdef __APPLE__
361 if (__builtin_available(macOS 15.4, iOS 18.4, *))
362 supported = font_format && strcmp(font_format, "HVF") == 0;
363#endif
364 if (!supported) {
365 FT_Done_Face(r->ft_face);
366 FT_Done_FreeType(r->ft_lib);
367 SAFE_FREE(r);
369 "Unsupported HVF font '%s': requires HVF-enabled FreeType 2.15+ on macOS 15.4+ or iOS 18.4+",
370 cfg->font_spec);
371 }
372 }
373 log_debug("Loaded font '%s' using FreeType format %s", cfg->font_spec, font_format ? font_format : "unknown");
374
375 // Detect if this is the matrix font (needs character mapping to Private Use Area)
376 r->is_matrix_font = (strstr(cfg->font_spec, "matrix") != NULL || strstr(cfg->font_spec, "Matrix") != NULL) ||
377 (cfg->font_data != NULL && GET_OPTION(matrix_rain));
378 if (r->is_matrix_font) {
379 log_debug("Detected matrix font - will use Private Use Area character mapping");
380 }
381
382 // Handle scalable vs bitmap fonts differently
383 // Bitmap fonts (like matrix) don't respond to FT_Set_Char_Size
384 // Instead, use FT_Set_Pixel_Sizes to request a specific pixel size
385 if (r->ft_face->num_fixed_sizes > 0) {
386 // Bitmap font: use FT_Set_Pixel_Sizes to select appropriate size (12px is reasonable for ASCII)
387 int err = FT_Set_Pixel_Sizes(r->ft_face, 12, 12);
388 log_debug("[BITMAP] FT_Set_Pixel_Sizes(12, 12) returned %d", err);
389
390 // If that fails, try FT_Select_Size as fallback
391 if (err != 0) {
392 err = FT_Select_Size(r->ft_face, 0); // Use first available strike
393 log_debug("[BITMAP FALLBACK] FT_Select_Size(0) returned %d", err);
394 }
395 if (err != 0) {
396 FT_Done_Face(r->ft_face);
397 FT_Done_FreeType(r->ft_lib);
398 SAFE_FREE(r);
399 return SET_ERRNO(ERROR_NOT_SUPPORTED, "Font '%s' has no usable bitmap size (FreeType error %d)", cfg->font_spec,
400 err);
401 }
402 } else {
403 // Scalable font: use FT_Set_Char_Size
404 // FT_Set_Char_Size takes 1/64pt units and DPI — supports fractional point sizes.
405 // 96 DPI is the standard screen DPI used here; the 64 factor is the 26.6 fixed-point scale.
406 int err = FT_Set_Char_Size(r->ft_face, 0, (FT_F26Dot6)(cfg->font_size_pt * 64.0), 96, 96);
407 log_debug("[SCALABLE] FT_Set_Char_Size(size_pt=%.1f → %ld 1/64pt) returned %d", cfg->font_size_pt,
408 (long)(cfg->font_size_pt * 64.0), err);
409 if (err != 0) {
410 FT_Done_Face(r->ft_face);
411 FT_Done_FreeType(r->ft_lib);
412 SAFE_FREE(r);
413 return SET_ERRNO(ERROR_NOT_SUPPORTED, "Font '%s' cannot be sized (FreeType error %d)", cfg->font_spec, err);
414 }
415 }
416
417 // For matrix fonts, try PUA glyph first (U+E900) since ASCII chars might not have bitmap data
418 // For other fonts, start with 'M'
419 int load_err;
420 if (r->is_matrix_font) {
421 log_debug("Matrix font detected: trying PUA glyph U+E900 first");
422 load_err = FT_Load_Char(r->ft_face, 0xE900, FT_LOAD_RENDER);
423 log_debug("FT_Load_Char(U+E900) returned %d", load_err);
424
425 // If PUA glyph fails, try ASCII fallback
426 if (load_err != 0) {
427 log_debug("U+E900 failed, trying fallback character 'M'");
428 load_err = FT_Load_Char(r->ft_face, 'M', FT_LOAD_RENDER);
429 log_debug("FT_Load_Char('M') returned %d", load_err);
430 }
431 } else {
432 // For non-matrix fonts, standard approach
433 load_err = FT_Load_Char(r->ft_face, 'M', FT_LOAD_RENDER);
434 log_debug("FT_Load_Char('M', FT_LOAD_RENDER) returned %d", load_err);
435
436 // For bitmap fonts (like matrix), FT_Load_Char may fail if the character doesn't exist.
437 // Try a fallback character if 'M' fails.
438 if (load_err != 0 && r->ft_face->num_fixed_sizes > 0) {
439 log_debug("FT_Load_Char('M') failed for bitmap font, trying fallback character 'A'");
440 load_err = FT_Load_Char(r->ft_face, 'A', FT_LOAD_RENDER);
441 log_debug("FT_Load_Char('A') returned %d", load_err);
442
443 if (load_err != 0) {
444 // Last resort: try a PUA glyph for unknown bitmap fonts
445 log_debug("FT_Load_Char('A') also failed, trying PUA glyph U+E900");
446 load_err = FT_Load_Char(r->ft_face, 0xE900, FT_LOAD_RENDER);
447 log_debug("FT_Load_Char(U+E900) returned %d", load_err);
448 }
449 }
450 }
451
452 if (load_err != 0) {
453 FT_Done_Face(r->ft_face);
454 FT_Done_FreeType(r->ft_lib);
455 SAFE_FREE(r);
456 return SET_ERRNO(ERROR_NOT_SUPPORTED, "Font '%s' cannot render glyphs (FreeType error %d)", cfg->font_spec,
457 load_err);
458 }
459
460 // For monospace ASCII grid: use advance.x (proper character spacing) and rendered height
461 FT_Pos advance_x_26_6 = r->ft_face->glyph->advance.x;
462 log_debug("advance.x (26.6pt)=%ld", advance_x_26_6);
463
464 // Calculate cell width from font metrics
465 r->cell_w = (int)(advance_x_26_6 >> 6);
466
467 // Safeguard: for bitmap fonts, cell_w might be wrong if the font metrics are off
468 // Ensure we have a reasonable minimum width
469 if (r->cell_w <= 0) {
470 log_warn("cell_w is %d (invalid), using default 10px", r->cell_w);
471 r->cell_w = 10;
472 }
473
474 // Only add spacing to fill entire frame if --stretch is specified
475 bool stretch_mode = GET_OPTION(stretch);
476 if (stretch_mode) {
477 r->cell_w++;
478 log_debug("ADVANCE_X: value=%ld (26.6pt) → cell_w=%d (advance + 1 for stretch), cols=%d → width_px=%d",
479 advance_x_26_6, r->cell_w, r->cols, r->cols * r->cell_w);
480 } else {
481 log_debug("ADVANCE_X: value=%ld (26.6pt) → cell_w=%d (preserve aspect ratio), cols=%d → width_px=%d",
482 advance_x_26_6, r->cell_w, r->cols, r->cols * r->cell_w);
483 }
484
485 // For both bitmap and scalable fonts, use the actual glyph bitmap height
486 // This ensures text doesn't overflow cells. Using size->metrics.height (line spacing)
487 // instead of bitmap.rows causes cells to be too large and text to overflow.
488 r->cell_h = r->ft_face->glyph->bitmap.rows;
489 int raw_bitmap_top = r->ft_face->glyph->bitmap_top;
490 log_debug("cell_h=%d (from bitmap.rows), raw_bitmap_top=%d", r->cell_h, raw_bitmap_top);
491
492 // Fallback: if cell_h is 0 (bitmap failed to load), use aspect ratio calculation
493 if (r->cell_h <= 0) {
494 log_warn("cell_h is 0 (FT_Load_Char failed or no bitmap), using aspect ratio fallback");
495 r->cell_h = r->cell_w * 2;
496 log_debug("FALLBACK: cell_h set to %d (2x width=%d)", r->cell_h, r->cell_w);
497 }
498
499 // Apply aspect ratio correction unless --stretch is specified
500 // Terminal characters are typically 2:1 (height:width) to appear normal
501 // BUT: only correct if the loaded glyph height is reasonably close (±30%)
502 // This prevents huge jumps from small glyphs (e.g., 8px → 32px)
503 if (!stretch_mode && r->cell_h > 0) {
504 int corrected_h = r->cell_w * 2;
505 int min_acceptable = (corrected_h * 70) / 100; // 70% of target
506 int max_acceptable = (corrected_h * 130) / 100; // 130% of target
507
508 if (r->cell_h < min_acceptable || r->cell_h > max_acceptable) {
509 // Loaded height is far from expected, apply correction
510 log_debug("ASPECT_RATIO: Correcting cell_h from %d to %d (out of range [%d,%d], 2x width=%d)", r->cell_h,
511 corrected_h, min_acceptable, max_acceptable, r->cell_w);
512 r->cell_h = corrected_h;
513 } else if (corrected_h != r->cell_h) {
514 // Loaded height is close to expected, fine-tune by small amount only
515 int delta = corrected_h - r->cell_h;
516 if (delta > 4 || delta < -4) {
517 // Only apply if difference is more than 4px
518 log_debug("ASPECT_RATIO: Fine-tuning cell_h from %d to %d (within acceptable range, 2x width=%d)", r->cell_h,
519 corrected_h, r->cell_w);
520 r->cell_h = corrected_h;
521 } else {
522 log_debug("ASPECT_RATIO: Keeping cell_h=%d (close to target %d, delta=%d)", r->cell_h, corrected_h, delta);
523 }
524 }
525 }
526
527 // Position baseline vertically within the cell.
528 // Baseline should be positioned at approximately 75% of cell height to allow proper
529 // glyph rendering with space both above and below. This prevents glyphs from being
530 // clipped at the cell boundaries.
531 //
532 // Baseline position: y_glyph = py + r->baseline - g->bitmap_top
533 // We want: 0 <= baseline - bitmap_top < cell_h (glyph fits within cell)
534 // Choose: baseline = (3 * cell_h) / 4 (positions at 75% height)
535 r->baseline = (3 * r->cell_h) / 4;
536 log_debug("baseline set to %d (75%% of cell_h=%d)", r->baseline, r->cell_h);
537
538 r->width_px = r->cols * r->cell_w;
539 r->height_px = r->rows * r->cell_h;
540 log_debug("calculated dimensions: width_px=%d (cols=%d * cell_w=%d), height_px=%d (rows=%d * cell_h=%d)", r->width_px,
541 r->cols, r->cell_w, r->height_px, r->rows, r->cell_h);
542 // Pitch in bytes: each pixel is 4 bytes (RGBA), already aligned to 4-byte boundary
543 int base_pitch = r->width_px * 4;
544 r->pitch = base_pitch;
545 log_debug("PITCH_CALC: width_px=%d, base_pitch=%d, final_pitch=%d (RGBA, no padding needed)", r->width_px, base_pitch,
546 r->pitch);
547 log_debug("Allocating framebuffer: %d bytes total (%d pitch * %d height)", (int)((size_t)r->pitch * r->height_px),
548 r->pitch, r->height_px);
549 r->framebuffer = SAFE_MALLOC((size_t)r->pitch * r->height_px, uint8_t *);
550
551 r->vt = vterm_new(r->rows, r->cols);
552 r->vts = vterm_obtain_screen(r->vt);
553
554 // Enable UTF-8 encoding so vterm correctly parses multi-byte characters
555 vterm_set_utf8(r->vt, 1);
556
557 // Set size BEFORE reset to ensure screen is allocated with correct dimensions
558 vterm_set_size(r->vt, r->rows, r->cols);
559 log_debug("vterm_set_size called with rows=%d cols=%d", r->rows, r->cols);
560
561 vterm_screen_set_callbacks(r->vts, &g_vterm_cbs, r);
562 // Reset MUST come before any input writing to initialize state correctly
563 vterm_screen_reset(r->vts, 1);
564
565 // Verify the size was set correctly after reset
566 int vt_rows, vt_cols;
567 vterm_get_size(r->vt, &vt_rows, &vt_cols);
568 log_debug("vterm_get_size returned rows=%d cols=%d (expected %d,%d)", vt_rows, vt_cols, r->rows, r->cols);
569
570 // CRITICAL: Set size AGAIN after reset, as reset may have affected it
571 vterm_set_size(r->vt, r->rows, r->cols);
572 log_debug("vterm_set_size called AGAIN after reset");
573
574 // Initialize per-renderer glyph cache (empty, will populate on demand)
575 r->glyph_cache = NULL;
576
577 *out = r;
578 return ASCIICHAT_OK;
579}
580
582 if (!r || level < 0 || level > 10)
583 return SET_ERRNO(ERROR_INVALID_PARAM, "Zalgo raster level must be 0-10");
584 if (level && !r->zalgo_face) {
585 if (FT_New_Memory_Face(r->ft_lib, g_font_default, (FT_Long)g_font_default_size, 0, &r->zalgo_face))
586 return SET_ERRNO(ERROR_INIT, "Cannot load Zalgo combining font");
587 if (FT_Set_Pixel_Sizes(r->zalgo_face, 0, (FT_UInt)(r->cell_w * 2))) {
588 FT_Done_Face(r->zalgo_face);
589 r->zalgo_face = NULL;
590 return SET_ERRNO(ERROR_INIT, "Cannot size Zalgo combining font");
591 }
592 }
593 r->zalgo_level = level;
594 return ASCIICHAT_OK;
595}
596
598static void render_zalgo(terminal_renderer_t *r, int offset_x, int offset_y) {
599 if (!r->zalgo_level)
600 return;
601 // Reuse the glyph cache machinery with a separate cache for the combining font.
602 glyph_cache_entry_t *base_cache = r->glyph_cache;
603 r->glyph_cache = r->zalgo_cache;
604 for (int row = 0; row < r->rows; row++) {
605 for (int col = 0; col < r->cols; col++) {
606 VTermScreenCell cell;
607 vterm_screen_get_cell(r->vts, (VTermPos){row, col}, &cell);
608 if (cell.chars[0] <= ' ' || cell.chars[0] >= 0x7f)
609 continue;
611 unsigned count = zalgo_cell_marks((uint32_t)row, (uint32_t)col, r->zalgo_level, marks);
612 VTermColor color = cell.fg;
613 vterm_screen_convert_color_to_rgb(r->vts, &color);
614 int above = 0, below = 0;
615 for (unsigned i = 0; i < count; i++) {
616 uint32_t cp = marks[i];
617 FT_Bitmap *bitmap = glyph_cache_get(r, r->zalgo_face, cp);
618 glyph_cache_entry_t *entry = NULL;
619 HASH_FIND_INT(r->glyph_cache, &cp, entry);
620 if (!bitmap || !entry)
621 continue;
622 int x = offset_x + col * r->cell_w + (r->cell_w - (int)bitmap->width) / 2;
623 int y = offset_y + row * r->cell_h + r->baseline - entry->bitmap_top;
624 if (i && cp < 0x0320) {
625 y -= above;
626 above += (int)bitmap->rows + 1;
627 } else if (i) {
628 y += below;
629 below += (int)bitmap->rows + 1;
630 }
631 for (unsigned by = 0; by < bitmap->rows; by++) {
632 int dy = y + (int)by;
633 if (dy < 0 || dy >= r->height_px)
634 continue;
635 for (unsigned bx = 0; bx < bitmap->width; bx++) {
636 int dx = x + (int)bx;
637 if (dx < 0 || dx >= r->width_px)
638 continue;
639 unsigned alpha = bitmap->buffer[by * bitmap->pitch + bx];
640 uint8_t *dst = r->framebuffer + dy * r->pitch + dx * 4;
641 dst[0] = (uint8_t)((color.rgb.red * alpha + dst[0] * (255 - alpha)) / 255);
642 dst[1] = (uint8_t)((color.rgb.green * alpha + dst[1] * (255 - alpha)) / 255);
643 dst[2] = (uint8_t)((color.rgb.blue * alpha + dst[2] * (255 - alpha)) / 255);
644 }
645 }
646 }
647 }
648 }
649 r->zalgo_cache = r->glyph_cache;
650 r->glyph_cache = base_cache;
651}
652
653asciichat_error_t term_renderer_feed(terminal_renderer_t *r, const char *ansi_frame, size_t len) {
654 if (!r || (!ansi_frame && len > 0)) {
655 return SET_ERRNO(ERROR_INVALID_PARAM, "renderer or frame is invalid");
656 }
657
658 uint64_t stage_start_ns = time_get_ns();
659 // libvterm natively implements CSI REP. Expanding those runs here turns a
660 // compact network frame into synchronous browser-main-thread work before
661 // every paint.
662 const char *frame = ansi_frame;
663 const size_t frame_len = len;
664
665 // Clear framebuffer to ensure no leftover pixels from previous frames
666 uint8_t def_fg_r, def_fg_g, def_fg_b;
667 uint8_t def_bg_r, def_bg_g, def_bg_b;
668 terminal_get_default_foreground_color(r->theme, &def_fg_r, &def_fg_g, &def_fg_b);
669 terminal_get_default_background_color(r->theme, &def_bg_r, &def_bg_g, &def_bg_b);
670 uint8_t def_bg = def_bg_r;
671 memset(r->framebuffer, def_bg, (size_t)r->pitch * r->height_px);
672
673 // Reset scrolling region and cursor position to prevent vertical shifts
674 static const char reset_sequence[] = "\033[2J\033[H\033[r\033[?7l";
675 vterm_input_write(r->vt, reset_sequence, sizeof(reset_sequence) - 1);
676
677 // Count newlines to determine CRLF conversion size
678 int newline_count = 0;
679 for (size_t i = 0; i < frame_len; i++) {
680 if (frame[i] == '\n')
681 newline_count++;
682 }
683
684 // Convert LF-only line endings to CRLF (vterm expects CRLF)
685 // This fixes cursor positioning on alternate rows
686 char *fixed_frame = SAFE_MALLOC(frame_len + (size_t)newline_count, char *);
687 if (!fixed_frame) {
688 log_error("failed to allocate memory for CRLF conversion");
689 return SET_ERRNO(ERROR_MEMORY, "CRLF conversion allocation failed");
690 }
691
692 size_t fixed_pos = 0;
693 int frame_row = 0;
694 for (size_t i = 0; i < frame_len; i++) {
695 // A video frame is a fixed grid, not a scrolling terminal transcript.
696 // A trailing newline on the bottom row must not scroll the whole image.
697 if (frame[i] == '\n' && frame_row >= r->rows - 1) {
698 continue;
699 }
700 fixed_frame[fixed_pos++] = frame[i];
701 if (frame[i] == '\n' && (i == 0 || frame[i - 1] != '\r')) {
702 // Insert carriage return before LF if not already preceded by CR
703 fixed_frame[fixed_pos - 1] = '\r';
704 fixed_frame[fixed_pos++] = '\n';
705 }
706 if (frame[i] == '\n')
707 frame_row++;
708 }
709
710 vterm_input_write(r->vt, fixed_frame, fixed_pos);
711 uint64_t parsed_ns = time_get_ns();
712 SAFE_FREE(fixed_frame);
713
714 // Calculate centering offsets to position grid in center of canvas
715 int grid_width_px = r->cols * r->cell_w;
716 int grid_height_px = r->rows * r->cell_h;
717 int offset_x = (r->width_px - grid_width_px) / 2;
718 int offset_y = (r->height_px - grid_height_px) / 2;
719
720 static int frame_count = 0;
722 0}; // Store signatures of previous frame's rows
723 frame_count++;
724
725 // Log if any row signature changed to detect vertical shifts
726 if (frame_count % 10 == 0) {
728 for (int row = 0; row < r->rows && row < RENDER_DEBUG_SIGNATURE_ROWS; row++) {
729 // Get first 4 chars as signature
730 for (int col = 0; col < RENDER_DEBUG_SIGNATURE_COLUMNS && col < r->cols; col++) {
731 VTermScreenCell cell;
732 vterm_screen_get_cell(r->vts, (VTermPos){row, col}, &cell);
733 curr_row_sigs[row][col] = (cell.chars[0] >= 32 && cell.chars[0] <= 126) ? cell.chars[0] : '.';
734 }
735 curr_row_sigs[row][RENDER_DEBUG_SIGNATURE_COLUMNS] = 0;
736 }
737 memcpy(prev_row_sigs, curr_row_sigs, sizeof(curr_row_sigs));
738 }
739
740 int glyph_rendered_count = 0;
741 int occupied_cell_count = 0;
742 // ANSI 16/256 colors are indexed. Converting them through libvterm for
743 // every cell is especially expensive in the single-threaded WASM renderer.
744 // A terminal palette has at most 256 entries and is stable for a frame.
745 uint32_t indexed_rgb[ASCII_LUMINANCE_LEVELS] = {0};
746 bool indexed_rgb_valid[ASCII_LUMINANCE_LEVELS] = {false};
747 for (int row = 0; row < r->rows; row++) {
748 for (int col = 0; col < r->cols; col++) {
749 VTermScreenCell cell;
750 vterm_screen_get_cell(r->vts, (VTermPos){row, col}, &cell);
751
752 uint8_t fr, fg, fb, br, bg, bb;
753 if (VTERM_COLOR_IS_RGB(&cell.fg)) {
754 fr = cell.fg.rgb.red;
755 fg = cell.fg.rgb.green;
756 fb = cell.fg.rgb.blue;
757 } else if (VTERM_COLOR_IS_INDEXED(&cell.fg)) {
758 const uint8_t index = cell.fg.indexed.idx;
759 if (!indexed_rgb_valid[index]) {
760 VTermColor foreground = cell.fg;
761 vterm_screen_convert_color_to_rgb(r->vts, &foreground);
762 indexed_rgb[index] =
763 ((uint32_t)foreground.rgb.red << 16) | ((uint32_t)foreground.rgb.green << 8) | foreground.rgb.blue;
764 indexed_rgb_valid[index] = true;
765 }
766 fr = (uint8_t)(indexed_rgb[index] >> 16);
767 fg = (uint8_t)(indexed_rgb[index] >> 8);
768 fb = (uint8_t)indexed_rgb[index];
769 } else {
770 fr = def_fg_r;
771 fg = def_fg_g;
772 fb = def_fg_b;
773 }
774
775 if (VTERM_COLOR_IS_RGB(&cell.bg)) {
776 br = cell.bg.rgb.red;
777 bg = cell.bg.rgb.green;
778 bb = cell.bg.rgb.blue;
779 } else if (VTERM_COLOR_IS_INDEXED(&cell.bg)) {
780 const uint8_t index = cell.bg.indexed.idx;
781 if (!indexed_rgb_valid[index]) {
782 VTermColor background = cell.bg;
783 vterm_screen_convert_color_to_rgb(r->vts, &background);
784 indexed_rgb[index] =
785 ((uint32_t)background.rgb.red << 16) | ((uint32_t)background.rgb.green << 8) | background.rgb.blue;
786 indexed_rgb_valid[index] = true;
787 }
788 br = (uint8_t)(indexed_rgb[index] >> 16);
789 bg = (uint8_t)(indexed_rgb[index] >> 8);
790 bb = (uint8_t)indexed_rgb[index];
791 } else {
792 br = def_bg_r;
793 bg = def_bg_g;
794 bb = def_bg_b;
795 }
796
797 int px = offset_x + col * r->cell_w, py = offset_y + row * r->cell_h;
798 uint32_t foreground = ((uint32_t)fr << 16) | ((uint32_t)fg << 8) | fb;
799 uint32_t background = ((uint32_t)br << 16) | ((uint32_t)bg << 8) | bb;
800 // Retain the low 32 bits without overflowing the intermediate products.
801 uint32_t cell_hash = (uint32_t)((uint64_t)cell.chars[0] * UINT64_C(2654435761) ^
802 (uint64_t)foreground * UINT64_C(2246822519) ^ background);
803 cell_hash ^= cell_hash >> 16;
804 rendered_cell_t *cached_cell = &r->cells[cell_hash % CELL_CACHE_SIZE];
805 bool contained = px >= 0 && py >= 0 && px + r->cell_w <= r->width_px && py + r->cell_h <= r->height_px;
806 if (contained && cached_cell->valid && cached_cell->character == cell.chars[0] &&
807 cached_cell->foreground == foreground && cached_cell->background == background) {
808 for (int dy = 0; dy < r->cell_h; dy++) {
809 memcpy(r->framebuffer + (py + dy) * r->pitch + px * 4, cached_cell->pixels + dy * r->cell_w * 4,
810 (size_t)r->cell_w * 4);
811 }
812 if (cell.chars[0] && cell.chars[0] != ' ') {
813 occupied_cell_count++;
814 glyph_rendered_count++;
815 }
816 continue;
817 }
818 // Block elements represent cell geometry, independent of font bearings and line spacing.
819 bool block = cell.chars[0] == 0x2580 || cell.chars[0] == 0x2584 || cell.chars[0] == 0x2588;
820 for (int dy = 0; dy < r->cell_h; dy++) {
821 bool block_foreground =
822 block && (cell.chars[0] == 0x2588 || (cell.chars[0] == 0x2580 ? dy < r->cell_h / 2 : dy >= r->cell_h / 2));
823 int y = py + dy;
824 if (y < 0 || y >= r->height_px)
825 continue;
826 uint8_t *line = r->framebuffer + y * r->pitch + px * 4;
827 for (int dx = 0; dx < r->cell_w; dx++) {
828 int x = px + dx;
829 if (x < 0 || x >= r->width_px)
830 continue;
831 line[dx * 4] = block_foreground ? fr : br;
832 line[dx * 4 + 1] = block_foreground ? fg : bg;
833 line[dx * 4 + 2] = block_foreground ? fb : bb;
834 line[dx * 4 + 3] = 255;
835 }
836 }
837
838 if (block) {
839 occupied_cell_count++;
840 glyph_rendered_count++;
841 } else if (cell.chars[0] && cell.chars[0] != ' ') {
842 occupied_cell_count++;
843 // For matrix font, map ASCII characters to Private Use Area glyphs (U+E900-U+E91A)
844 uint32_t char_to_render = r->is_matrix_font ? matrix_char_map(cell.chars[0]) : cell.chars[0];
845
846 // Use per-renderer glyph cache to avoid rasterizing every glyph every frame
847 FT_Bitmap *cached_bitmap = glyph_cache_get(r, r->ft_face, char_to_render);
848 if (cached_bitmap && cached_bitmap->width > 0 && cached_bitmap->rows > 0) {
849 // Find cache entry to get positioning offsets
850 glyph_cache_entry_t *cache_entry = NULL;
851 HASH_FIND_INT(r->glyph_cache, &char_to_render, cache_entry);
852 if (cache_entry) {
853 // Overhanging glyphs must retain the normal cross-cell drawing order.
854 contained = contained && cache_entry->bitmap_left >= 0 &&
855 cache_entry->bitmap_left + cache_entry->width <= r->cell_w &&
856 r->baseline - cache_entry->bitmap_top >= 0 &&
857 r->baseline - cache_entry->bitmap_top + cache_entry->rows <= r->cell_h;
858 blit_glyph(r, cached_bitmap, px + cache_entry->bitmap_left, py + r->baseline - cache_entry->bitmap_top, fr,
859 fg, fb, br, bg, bb);
860 glyph_rendered_count++;
861 }
862 } else if (cell.chars[0] >= 0x2800 && cell.chars[0] <= 0x28ff) {
863 // The bundled font lacks Braille. Draw its eight-dot pattern in the
864 // cell when the selected font cannot supply the requested glyph.
865 static const unsigned dot_bits[4][2] = {{0, 3}, {1, 4}, {2, 5}, {6, 7}};
866 unsigned pattern = cell.chars[0] - 0x2800;
867 int dot_w = r->cell_w / 4 > 0 ? r->cell_w / 4 : 1;
868 int dot_h = r->cell_h / 8 > 0 ? r->cell_h / 8 : 1;
869 for (int row = 0; row < 4; row++) {
870 for (int col = 0; col < 2; col++) {
871 if (!(pattern & (1U << dot_bits[row][col])))
872 continue;
873 int left = px + (2 * col + 1) * r->cell_w / 4 - dot_w / 2;
874 int top = py + (2 * row + 1) * r->cell_h / 8 - dot_h / 2;
875 for (int dy = 0; dy < dot_h; dy++) {
876 for (int dx = 0; dx < dot_w; dx++) {
877 int x = left + dx, y = top + dy;
878 if (x < px || x >= px + r->cell_w || y < py || y >= py + r->cell_h || x < 0 || x >= r->width_px ||
879 y < 0 || y >= r->height_px)
880 continue;
881 uint8_t *pixel = r->framebuffer + y * r->pitch + x * 4;
882 pixel[0] = fr;
883 pixel[1] = fg;
884 pixel[2] = fb;
885 pixel[3] = 255;
886 }
887 }
888 }
889 }
890 glyph_rendered_count++;
891 } else {
892 return SET_ERRNO(ERROR_NOT_SUPPORTED, "Font cannot render Unicode character U+%04X", cell.chars[0]);
893 }
894 }
895 if (contained) {
896 if (!cached_cell->pixels)
897 cached_cell->pixels = SAFE_MALLOC((size_t)r->cell_w * r->cell_h * 4, uint8_t *);
898 if (cached_cell->pixels) {
899 for (int dy = 0; dy < r->cell_h; dy++) {
900 memcpy(cached_cell->pixels + dy * r->cell_w * 4, r->framebuffer + (py + dy) * r->pitch + px * 4,
901 (size_t)r->cell_w * 4);
902 }
903 cached_cell->character = cell.chars[0];
904 cached_cell->foreground = foreground;
905 cached_cell->background = background;
906 cached_cell->valid = true;
907 }
908 }
909 }
910 }
911 render_zalgo(r, offset_x, offset_y);
912 uint64_t rendered_ns = time_get_ns();
913 log_debug_every(US_PER_SEC_INT, "RECORD_RASTER: input_bytes=%zu parse_ms=%.2f paint_ms=%.2f cells=%dx%d", len,
914 (double)(parsed_ns - stage_start_ns) / NS_PER_MS_INT,
915 (double)(rendered_ns - parsed_ns) / NS_PER_MS_INT, r->cols, r->rows);
916 log_debug_every(5 * US_PER_SEC_INT, "Rendered %d glyphs from %d occupied cells out of %d cells", glyph_rendered_count,
917 occupied_cell_count, r->rows * r->cols);
918
919 return ASCIICHAT_OK;
920}
921
932 return r->pitch;
933}
934
936 if (!r)
937 return 0;
938 return r->cols;
939}
940
942 if (!r)
943 return 0;
944 return r->rows;
945}
946
948 if (!r)
949 return;
950 vterm_free(r->vt);
951 if (r->zalgo_face)
952 FT_Done_Face(r->zalgo_face);
953 glyph_cache_destroy(&r->zalgo_cache);
954 FT_Done_Face(r->ft_face);
955 FT_Done_FreeType(r->ft_lib);
957 glyph_cache_destroy(&r->glyph_cache);
958 for (size_t i = 0; i < CELL_CACHE_SIZE; i++)
959 SAFE_FREE(r->cells[i].pixels);
960 SAFE_FREE(r);
961}
#define SET_ERRNO(code,...)
Matrix-style digital rain effect for ASCII frames.
audio_visualization_source_t source
Definition fft.c:24
unsigned short uint16_t
Definition common.h:61
unsigned int uint32_t
Definition common.h:62
#define SAFE_FREE(ptr)
Definition common.h:377
#define SAFE_MALLOC(size, cast)
Definition common.h:265
#define SAFE_CALLOC(count, size, cast)
Definition common.h:275
unsigned long long uint64_t
Definition common.h:63
unsigned char uint8_t
Definition common.h:60
asciichat_error_t
Error and exit codes - unified status values (0-255).
Definition error_codes.h:44
@ ERROR_NOT_FOUND
@ ERROR_MEMORY
Definition error_codes.h:51
@ ASCIICHAT_OK
Definition error_codes.h:46
@ ERROR_INIT
Definition error_codes.h:56
@ ERROR_INVALID_PARAM
@ ERROR_NOT_SUPPORTED
Definition error_codes.h:69
#define log_warn(...)
Log a WARN message.
Definition log/log.h:621
#define log_error(...)
Log an ERROR message.
Definition log/log.h:634
#define log_debug(...)
Log a DEBUG message.
Definition log/log.h:595
#define NS_PER_MS_INT
Definition time.h:168
#define US_PER_SEC_INT
Definition time.h:173
#define GET_OPTION(field)
Safely get a specific option field (lock-free read).
Definition options.h:1336
#define ASCII_LUMINANCE_LEVELS
Number of luminance levels supported (256).
Definition ascii.h:440
int digital_rain_glyph_group(uint32_t codepoint)
Font resolution and bundled font data.
const size_t g_font_default_size
const unsigned char g_font_default[]
📝 Logging API with multiple log levels and terminal output control
#define log_debug_every(interval_us, fmt,...)
Rate-limited DEBUG logging.
Definition log/log.h:749
int term_renderer_get_rows(terminal_renderer_t *r)
int term_renderer_get_cols(terminal_renderer_t *r)
int term_renderer_width_px(terminal_renderer_t *r)
const uint8_t * term_renderer_pixels(terminal_renderer_t *r)
asciichat_error_t term_renderer_create(const term_renderer_config_t *cfg, terminal_renderer_t **out)
void term_renderer_destroy(terminal_renderer_t *r)
#define RENDER_DEBUG_SIGNATURE_ROWS
#define RENDER_DEBUG_SIGNATURE_COLUMNS
#define CELL_CACHE_SIZE
#define RENDER_DEBUG_SIGNATURE_BUFFER_SIZE
asciichat_error_t term_renderer_set_zalgo(terminal_renderer_t *r, int level)
asciichat_error_t term_renderer_feed(terminal_renderer_t *r, const char *ansi_frame, size_t len)
int term_renderer_pitch(terminal_renderer_t *r)
int term_renderer_height_px(terminal_renderer_t *r)
⚙️ Unified options parsing system for ascii-chat with builder pattern and lock-free access
static bool size_t index
Definition queries.h:8
static bool size_t gotel_query_t * out
Definition queries.h:8
Terminal-to-pixel renderer and render-file output (audio + video).
struct terminal_renderer_s terminal_renderer_t
Definition renderer.h:18
term_renderer_theme_t
Terminal rendering theme.
Definition renderer.h:27
🔤 String Manipulation and Shell Escaping Utilities
Owns the temporary storage used by glyph cache entry. The data pointers and size or cursor fields des...
UT_hash_handle hh
uint32_t codepoint
int bitmap_left
uint8_t * bitmap_buf
int bitmap_top
int rows
int width
int pitch
const uint8_t * font_data
Definition renderer.h:40
term_renderer_theme_t theme
Definition renderer.h:37
char font_spec[PLATFORM_MAX_PATH_LENGTH]
Definition renderer.h:38
rendered_cell_t cells[512]
term_renderer_theme_t theme
glyph_cache_entry_t * zalgo_cache
glyph_cache_entry_t * glyph_cache
⏱️ High-precision timing utilities using sokol_time.h and uthash
SIMD-optimized ASCII conversion interface.
unsigned zalgo_cell_marks(uint32_t row, uint32_t col, int level, uint16_t marks[ZALGO_MAX_MARKS])
Definition zalgo.c:19
#define ZALGO_MAX_MARKS
Definition zalgo.h:4