From 343ea4bf084caa7493cc6fac84724ec61e3f83f3 Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 3 Mar 2026 13:25:25 +0200 Subject: [PATCH] ... --- build.sx | 22 ++ goldens/last_frame.png | Bin 25526 -> 212792 bytes main.sx | 76 +++-- shell.html | 54 ++++ ui/font.sx | 92 +----- ui/glyph_cache.sx | 532 +++++++++++++++++++++++++++++++ ui/pipeline.sx | 8 +- ui/renderer.sx | 106 +++--- vendors/kb_text_shape/kbts_api.h | 15 + 9 files changed, 736 insertions(+), 169 deletions(-) create mode 100644 build.sx create mode 100644 shell.html create mode 100644 ui/glyph_cache.sx create mode 100644 vendors/kb_text_shape/kbts_api.h diff --git a/build.sx b/build.sx new file mode 100644 index 0000000..8ba5658 --- /dev/null +++ b/build.sx @@ -0,0 +1,22 @@ +#import "modules/compiler.sx"; + +configure_build :: () { + opts := build_options(); + inline if OS == { + case .wasm: { + output := if POINTER_SIZE == 4 + then "sx-out/wasm32/index.html" + else "sx-out/wasm64/index.html"; + opts.set_output_path(output); + opts.set_wasm_shell("shell.html"); + opts.add_link_flag("-sUSE_SDL=3"); + opts.add_link_flag("-sMAX_WEBGL_VERSION=2"); + opts.add_link_flag("-sFULL_ES3=1"); + opts.add_link_flag("--preload-file assets"); + opts.add_link_flag("-sALLOW_MEMORY_GROWTH=1"); + } + case .macos: { + opts.set_output_path("main"); + } + } +} \ No newline at end of file diff --git a/goldens/last_frame.png b/goldens/last_frame.png index db995df0f9b8019fe1bdcfff8cd2e8442fa6de40..d2f5919f556907ac8b8bea674c422eec5ce0a123 100644 GIT binary patch literal 212792 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 literal 25526 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 diff --git a/main.sx b/main.sx index dbf8cac..934cc62 100644 --- a/main.sx +++ b/main.sx @@ -1,40 +1,24 @@ #import "modules/std.sx"; +#import "build.sx"; #import "modules/compiler.sx"; #import "modules/sdl3.sx"; #import "modules/opengl.sx"; #import "modules/math"; #import "modules/stb.sx"; #import "modules/stb_truetype.sx"; +#import "modules/wasm.sx"; #import "ui"; -configure_build :: () { - opts := build_options(); - inline if OS == { - case .wasm: { - opts.set_output_path(if POINTER_SIZE == 4 then "sx-out/wasm32/index.html" else "sx-out/wasm32/index.html"); - opts.add_link_flag("-sUSE_SDL=3"); - opts.add_link_flag("-sMAX_WEBGL_VERSION=2"); - opts.add_link_flag("-sFULL_ES3=1"); - opts.add_link_flag("--preload-file assets"); - opts.add_link_flag("-sALLOW_MEMORY_GROWTH=1"); - } - case .macos: { - opts.set_output_path("sx-out/macos/game"); - } - } -} #run configure_build(); -libc :: #library "c"; -emscripten_set_main_loop :: (func: *void, fps: s32, sim_infinite: s32) #foreign libc; - -WIDTH :f32: 800; -HEIGHT :f32: 600; - // --- Frame state (globals for emscripten callback) --- g_window : *void = ---; g_pipeline : *UIPipeline = ---; g_running : bool = true; +g_width : s32 = 800; // logical window size +g_height : s32 = 600; +g_pixel_w : s32 = 800; // physical pixel size +g_pixel_h : s32 = 600; load_texture :: (path: [:0]u8) -> u32 { w : s32 = 0; @@ -141,13 +125,13 @@ run_ui_tests :: (pipeline: *UIPipeline) { // Render after tests and save snapshot to see scrolled state glClear(GL_COLOR_BUFFER_BIT); pipeline.tick(); - save_snapshot("goldens/test_after_drag.png", xx WIDTH, xx HEIGHT); + save_snapshot("goldens/test_after_drag.png", g_pixel_w, g_pixel_h); } // One frame of the main loop — called repeatedly by emscripten or desktop while-loop frame :: () { sdl_event : SDL_Event = .none; - while SDL_PollEvent(sdl_event) { + while SDL_PollEvent(@sdl_event) { print("SDL event: {}\n", sdl_event.tag); if sdl_event == { @@ -155,17 +139,24 @@ frame :: () { case .key_up: (e) { if e.key == { case .escape: { g_running = false; } } } + case .window_resized: (data) { + g_width = data.data1; + g_height = data.data2; + SDL_GetWindowSizeInPixels(g_window, @g_pixel_w, @g_pixel_h); + g_pipeline.resize(xx g_width, xx g_height); + } } ui_event := translate_sdl_event(@sdl_event); if ui_event != .none { print(" ui event dispatched\n"); - g_pipeline.*.dispatch_event(@ui_event); + g_pipeline.dispatch_event(@ui_event); } else { print(" -> .none\n"); } } + glViewport(0, 0, g_pixel_w, g_pixel_h); glClearColor(0.12, 0.12, 0.15, 1.0); glClear(GL_COLOR_BUFFER_BIT); @@ -189,17 +180,42 @@ main :: () -> void { SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); - window := SDL_CreateWindow("SX UI Demo", xx WIDTH, xx HEIGHT, SDL_WINDOW_OPENGL); + // Auto-size: on desktop use 75% of usable display, on WASM SDL picks up canvas size + init_w : s32 = 800; + init_h : s32 = 600; + inline if OS == .wasm { + init_w = emscripten_run_script_int("window.innerWidth"); + init_h = emscripten_run_script_int("window.innerHeight"); + } else { + display_id := SDL_GetPrimaryDisplay(); + bounds : SDL_Rect = ---; + if SDL_GetDisplayUsableBounds(display_id, @bounds) { + init_w = bounds.w * 3 / 4; + init_h = bounds.h * 3 / 4; + } + } + + window := SDL_CreateWindow("SX UI Demo", init_w, init_h, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY); gl_ctx := SDL_GL_CreateContext(window); SDL_GL_MakeCurrent(window, gl_ctx); SDL_GL_SetSwapInterval(1); load_gl(xx SDL_GL_GetProcAddress); + // Query actual window size (may differ from requested, especially on WASM) + SDL_GetWindowSize(window, @g_width, @g_height); + SDL_GetWindowSizeInPixels(window, @g_pixel_w, @g_pixel_h); + width_f : f32 = xx g_width; + height_f : f32 = xx g_height; + dpi_scale : f32 = if width_f > 0.0 then xx g_pixel_w / width_f else 1.0; + + // Set viewport to physical pixel dimensions + glViewport(0, 0, g_pixel_w, g_pixel_h); + // --- Build UI --- pipeline : UIPipeline = ---; - pipeline.init(WIDTH, HEIGHT); - pipeline.init_font("assets/fonts/default.ttf", 32.0); + pipeline.init(width_f, height_f); + pipeline.init_font("assets/fonts/default.ttf", 32.0, dpi_scale); scroll_content := VStack.{ spacing = 10.0, alignment = .center } { self.add( @@ -220,7 +236,7 @@ main :: () -> void { }); self.add( RectView.{ color = COLOR_DARK_GRAY, preferred_height = 60.0 } - |> padding(EdgeInsets.symmetric(16.0, 8.0)) + |> padding(.symmetric(16.0, 8.0)) |> background(COLOR_BLUE, 8.0) ); self.add(RectView.{ color = COLOR_ORANGE, preferred_height = 120.0, corner_radius = 12.0 }); @@ -243,6 +259,8 @@ main :: () -> void { } } + save_snapshot("goldens/last_frame.png", g_pixel_w, g_pixel_h); + SDL_GL_DestroyContext(gl_ctx); SDL_DestroyWindow(window); SDL_Quit(); diff --git a/shell.html b/shell.html new file mode 100644 index 0000000..7e79830 --- /dev/null +++ b/shell.html @@ -0,0 +1,54 @@ + + + + + +sx + + + +
+
+
Loading…
+
+ + +{{{ SCRIPT }}} + + diff --git a/ui/font.sx b/ui/font.sx index 32f8893..14f10a6 100644 --- a/ui/font.sx +++ b/ui/font.sx @@ -1,95 +1,11 @@ #import "modules/std.sx"; #import "ui/types.sx"; +#import "ui/glyph_cache.sx"; -FIRST_CHAR :s32: 32; -NUM_CHARS :s32: 96; -ATLAS_W :s32: 512; -ATLAS_H :s32: 512; +// Global glyph cache pointer for views (Label, Button) to access +g_font : *GlyphCache = xx 0; -// Matches stbtt_bakedchar memory layout -BakedChar :: struct { - x0, y0, x1, y1: u16; - xoff, yoff, xadvance: f32; -} - -// Matches stbtt_aligned_quad memory layout -AlignedQuad :: struct { - x0, y0, s0, t0: f32; - x1, y1, s1, t1: f32; -} - -FontAtlas :: struct { - texture_id: u32; - font_size: f32; - char_data: [*]BakedChar; - bitmap: [*]u8; - ascent: f32; - descent: f32; - line_height: f32; - - // Bake font glyphs into a bitmap. Call upload_texture() after GL is ready. - init :: (self: *FontAtlas, path: [:0]u8, size: f32) { - file_size : s32 = 0; - font_data := read_file_bytes(path, @file_size); - if xx font_data == 0 { - out("Failed to load font: "); - out(path); - out("\n"); - return; - } - - self.font_size = size; - - // Allocate baked char data (96 entries for ASCII 32..127) - self.char_data = xx context.allocator.alloc(xx NUM_CHARS * size_of(BakedChar)); - - // Bake font bitmap (512x512 single-channel alpha) - bitmap_size : s64 = xx ATLAS_W * xx ATLAS_H; - self.bitmap = xx context.allocator.alloc(bitmap_size); - stbtt_BakeFontBitmap(font_data, 0, size, self.bitmap, ATLAS_W, ATLAS_H, FIRST_CHAR, NUM_CHARS, xx self.char_data); - - // Get font vertical metrics - fontinfo : [256]u8 = ---; - stbtt_InitFont(xx @fontinfo, font_data, 0); - ascent_i : s32 = 0; - descent_i : s32 = 0; - linegap_i : s32 = 0; - stbtt_GetFontVMetrics(xx @fontinfo, @ascent_i, @descent_i, @linegap_i); - scale := stbtt_ScaleForPixelHeight(xx @fontinfo, size); - self.ascent = xx ascent_i * scale; - self.descent = xx descent_i * scale; - self.line_height = self.ascent - self.descent + xx linegap_i * scale; - - font_data_ptr : *void = xx font_data; - free(font_data_ptr); - - out("Font loaded: "); - out(path); - out("\n"); - } - - measure_text :: (self: *FontAtlas, text: string, font_size: f32) -> Size { - if self.char_data == null { return Size.zero(); } - scale := font_size / self.font_size; - xpos : f32 = 0.0; - ypos : f32 = 0.0; - q : AlignedQuad = ---; - i : s64 = 0; - while i < text.len { - ch : s32 = xx text[i]; - if ch >= FIRST_CHAR and ch < FIRST_CHAR + NUM_CHARS { - stbtt_GetBakedQuad(xx self.char_data, ATLAS_W, ATLAS_H, ch - FIRST_CHAR, @xpos, @ypos, xx @q, 1); - } - i += 1; - } - Size.{ width = xpos * scale, height = self.line_height * scale }; - } -} - -// Global font atlas pointer for views (Label, Button) to access -g_font : *FontAtlas = xx 0; - -set_global_font :: (font: *FontAtlas) { +set_global_font :: (font: *GlyphCache) { g_font = font; } diff --git a/ui/glyph_cache.sx b/ui/glyph_cache.sx new file mode 100644 index 0000000..f3af409 --- /dev/null +++ b/ui/glyph_cache.sx @@ -0,0 +1,532 @@ +#import "modules/std.sx"; +#import "modules/opengl.sx"; +#import "ui/types.sx"; + +// Cached glyph data with UV coordinates into the atlas texture +CachedGlyph :: struct { + uv_x: f32; + uv_y: f32; + uv_w: f32; + uv_h: f32; + width: f32; + height: f32; + offset_x: f32; + offset_y: f32; + advance: f32; +} + +// Cache entry: key + glyph data +GlyphEntry :: struct { + key: u32; + glyph: CachedGlyph; +} + +// Quantize font size to half-point increments to limit cache entries. +// e.g., 13.0 -> 26, 13.5 -> 27, 14.0 -> 28 +quantize_size :: (font_size: f32) -> u16 { + xx (font_size * 2.0 + 0.5); +} + +dequantize_size :: (q: u16) -> f32 { + xx q / 2.0; +} + +// Pack (glyph_index, size_quantized) into a single u32 for fast comparison +make_glyph_key :: (glyph_index: u16, size_quantized: u16) -> u32 { + (xx glyph_index << 16) | xx size_quantized; +} + +// Shaped glyph — output of text shaping (positioned glyph with index) +ShapedGlyph :: struct { + glyph_index: u16; + x: f32; // horizontal position (logical units, cumulative) + y: f32; // vertical offset (logical units) + advance: f32; // advance width (logical units) +} + +is_ascii :: (text: string) -> bool { + i : s64 = 0; + while i < text.len { + if text[i] >= 128 { return false; } + i += 1; + } + true; +} + +// kbts constants (C enum values) +KBTS_DIRECTION_DONT_KNOW :u32: 0; +KBTS_LANGUAGE_DONT_KNOW :u32: 0; +KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX :u32: 0; + +// SX structs matching kbts C struct layouts (64-bit). +// We define these in SX to access fields directly, casting from opaque C pointers. + +KbtsGlyphIterator :: struct { + glyph_storage: *void; + current_glyph: *void; + last_advance_x: s32; + x: s32; + y: s32; +} + +KbtsRun :: struct { + font: *void; + script: u32; + paragraph_direction: u32; + direction: u32; + flags: u32; + glyphs: KbtsGlyphIterator; +} + +KbtsGlyph :: struct { + prev: *void; + next: *void; + codepoint: u32; + id: u16; + uid: u16; + user_id_or_codepoint_index: s32; + offset_x: s32; + offset_y: s32; + advance_x: s32; + advance_y: s32; +} + +// kbts_font_info2 base (simplified — we only need the Size field for dispatch) +KBTS_FONT_INFO_STRING_ID_COUNT :s32: 7; + +KbtsFontInfo2 :: struct { + size: u32; + strings: [7]*void; // char* array + string_lengths: [7]u16; + style_flags: u32; + weight: u32; + width: u32; +} + +KbtsFontInfo2_1 :: struct { + base: KbtsFontInfo2; + units_per_em: u16; + x_min: s16; + y_min: s16; + x_max: s16; + y_max: s16; + ascent: s16; + descent: s16; + line_gap: s16; +} + +GLYPH_ATLAS_W :s32: 1024; +GLYPH_ATLAS_H :s32: 1024; +FONTINFO_SIZE :s64: 256; + +PackResult :: struct { + x, y: s32; +} + +// Dynamic glyph cache with on-demand rasterization and texture atlas packing. +GlyphCache :: struct { + // Font data + font_info: *void; // heap-allocated stbtt_fontinfo (256 bytes) + font_data: *void; // raw TTF file bytes (kept alive for stbtt) + + // Atlas texture (GPU) + texture_id: u32; + atlas_width: s32; + atlas_height: s32; + + // Atlas bitmap (CPU-side for updates) + bitmap: [*]u8; + + // Shelf packer state + shelf_y: s32; + shelf_height: s32; + cursor_x: s32; + padding: s32; + + // Glyph lookup cache (flat list, linear scan) + entries: List(GlyphEntry); + + // Dirty tracking for texture upload + dirty: bool; + + // Font vertical metrics (at reference size 1.0 — scale by font_size) + ascent: f32; + descent: f32; + line_gap: f32; + + // HiDPI: physical pixels per logical pixel (e.g. 2.0 on Retina) + dpi_scale: f32; + inv_dpi: f32; + + // Text shaping (kb_text_shape) + shape_ctx: *void; + shape_font: *void; + units_per_em: u16; + font_data_size: s32; + shaped_buf: List(ShapedGlyph); + + init :: (self: *GlyphCache, path: [:0]u8, default_size: f32) { + // Zero out the entire struct first (parent may be uninitialized with = ---) + memset(self, 0, size_of(GlyphCache)); + + // Load font file + file_size : s32 = 0; + font_data := read_file_bytes(path, @file_size); + if xx font_data == 0 { + out("Failed to load font: "); + out(path); + out("\n"); + return; + } + self.font_data = xx font_data; + self.font_data_size = file_size; + + // Init stbtt_fontinfo + self.font_info = context.allocator.alloc(FONTINFO_SIZE); + memset(self.font_info, 0, FONTINFO_SIZE); + stbtt_InitFont(self.font_info, font_data, 0); + + // Get font vertical metrics (in unscaled font units) + ascent_i : s32 = 0; + descent_i : s32 = 0; + linegap_i : s32 = 0; + stbtt_GetFontVMetrics(self.font_info, @ascent_i, @descent_i, @linegap_i); + + // Store unscaled metrics — we'll scale per font_size in measure_text + self.ascent = xx ascent_i; + self.descent = xx descent_i; + self.line_gap = xx linegap_i; + + // Init text shaping context + self.shape_ctx = xx kbts_CreateShapeContext(xx 0, xx 0); + if xx self.shape_ctx != 0 { + self.shape_font = xx kbts_ShapePushFontFromMemory(xx self.shape_ctx, self.font_data, file_size, 0); + // Get font metrics (units_per_em) from kbts + kb_info : KbtsFontInfo2_1 = ---; + memset(@kb_info, 0, size_of(KbtsFontInfo2_1)); + kb_info.base.size = xx size_of(KbtsFontInfo2_1); + kbts_GetFontInfo2(xx self.shape_font, xx @kb_info); + self.units_per_em = kb_info.units_per_em; + } + + // Allocate atlas bitmap + self.atlas_width = GLYPH_ATLAS_W; + self.atlas_height = GLYPH_ATLAS_H; + bitmap_size : s64 = xx self.atlas_width * xx self.atlas_height; + self.bitmap = xx context.allocator.alloc(bitmap_size); + memset(self.bitmap, 0, bitmap_size); + + // Shelf packer init + self.shelf_y = 0; + self.shelf_height = 0; + self.cursor_x = 0; + self.padding = 1; + + self.dirty = false; + self.dpi_scale = 1.0; + self.inv_dpi = 1.0; + + // Create OpenGL texture + glGenTextures(1, @self.texture_id); + glBindTexture(GL_TEXTURE_2D, self.texture_id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, self.atlas_width, self.atlas_height, 0, GL_RED, GL_UNSIGNED_BYTE, self.bitmap); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE); + + out("GlyphCache initialized: "); + out(path); + out("\n"); + } + + // Look up or rasterize a glyph, returning a pointer to its cached entry. + // Returns null for glyphs with no outline AND zero advance (shouldn't happen for valid chars). + get_or_rasterize :: (self: *GlyphCache, glyph_index: u16, font_size: f32) -> *CachedGlyph { + size_q := quantize_size(font_size); + key := make_glyph_key(glyph_index, size_q); + + // Cache lookup (linear scan) + i : s64 = 0; + while i < self.entries.len { + if self.entries.items[i].key == key { + return @self.entries.items[i].glyph; + } + i += 1; + } + + // Cache miss — rasterize + actual_size := dequantize_size(size_q); + scale := stbtt_ScaleForPixelHeight(self.font_info, actual_size); + + // Get glyph bounding box + x0 : s32 = 0; + y0 : s32 = 0; + x1 : s32 = 0; + y1 : s32 = 0; + stbtt_GetGlyphBitmapBox(self.font_info, xx glyph_index, scale, scale, @x0, @y0, @x1, @y1); + + glyph_w := if x1 > x0 then x1 - x0 else 0; + glyph_h := if y1 > y0 then y1 - y0 else 0; + + // Get horizontal metrics + advance_i : s32 = 0; + lsb_i : s32 = 0; + stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i); + advance : f32 = xx advance_i * scale; + + // Zero-size glyph (e.g. space) — cache with advance only + if glyph_w == 0 or glyph_h == 0 { + entry := GlyphEntry.{ + key = key, + glyph = CachedGlyph.{ + uv_x = 0.0, uv_y = 0.0, uv_w = 0.0, uv_h = 0.0, + width = 0.0, height = 0.0, + offset_x = xx x0, offset_y = xx y0, + advance = advance + } + }; + self.entries.append(entry); + return @self.entries.items[self.entries.len - 1].glyph; + } + + // Pack into atlas + pack := self.try_pack(glyph_w, glyph_h); + if pack.x < 0 { + // Atlas full — grow and retry + self.grow(); + return self.get_or_rasterize(glyph_index, font_size); + } + + // Rasterize directly into atlas bitmap + dest_offset : s64 = xx pack.y * xx self.atlas_width + xx pack.x; + stbtt_MakeGlyphBitmap( + self.font_info, + @self.bitmap[dest_offset], + glyph_w, glyph_h, + self.atlas_width, + scale, scale, + xx glyph_index + ); + self.dirty = true; + + // Compute normalized UV coordinates + atlas_wf : f32 = xx self.atlas_width; + atlas_hf : f32 = xx self.atlas_height; + + entry := GlyphEntry.{ + key = key, + glyph = CachedGlyph.{ + uv_x = xx pack.x / atlas_wf, + uv_y = xx pack.y / atlas_hf, + uv_w = xx glyph_w / atlas_wf, + uv_h = xx glyph_h / atlas_hf, + width = xx glyph_w, + height = xx glyph_h, + offset_x = xx x0, + offset_y = xx y0, + advance = advance + } + }; + self.entries.append(entry); + return @self.entries.items[self.entries.len - 1].glyph; + } + + // Upload dirty atlas to GPU + flush :: (self: *GlyphCache) { + if self.dirty == false { return; } + glBindTexture(GL_TEXTURE_2D, self.texture_id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, self.atlas_width, self.atlas_height, GL_RED, GL_UNSIGNED_BYTE, self.bitmap); + self.dirty = false; + } + + // Shelf-based rectangle packer. + // Returns PackResult with x >= 0 on success, x = -1 if no space. + try_pack :: (self: *GlyphCache, w: s32, h: s32) -> PackResult { + padded_w := w + self.padding; + padded_h := h + self.padding; + + // Try fitting on the current shelf + eff_h := if self.shelf_height > padded_h then self.shelf_height else padded_h; + if self.cursor_x + padded_w <= self.atlas_width and self.shelf_y + eff_h <= self.atlas_height { + result := PackResult.{ x = self.cursor_x, y = self.shelf_y }; + self.cursor_x += padded_w; + if padded_h > self.shelf_height { + self.shelf_height = padded_h; + } + return result; + } + + // Start a new shelf + new_shelf_y := self.shelf_y + self.shelf_height; + if new_shelf_y + padded_h <= self.atlas_height and padded_w <= self.atlas_width { + self.shelf_y = new_shelf_y; + self.shelf_height = padded_h; + self.cursor_x = padded_w; + return PackResult.{ x = 0, y = new_shelf_y }; + } + + // No space + PackResult.{ x = 0 - 1, y = 0 - 1 }; + } + + // Grow the atlas by doubling dimensions + grow :: (self: *GlyphCache) { + new_w := self.atlas_width * 2; + new_h := self.atlas_height * 2; + new_size : s64 = xx new_w * xx new_h; + new_bitmap : [*]u8 = xx context.allocator.alloc(new_size); + memset(new_bitmap, 0, new_size); + + // Copy old rows into new bitmap + y : s32 = 0; + while y < self.atlas_height { + old_off : s64 = xx y * xx self.atlas_width; + new_off : s64 = xx y * xx new_w; + memcpy(@new_bitmap[new_off], @self.bitmap[old_off], xx self.atlas_width); + y += 1; + } + + context.allocator.dealloc(self.bitmap); + self.bitmap = new_bitmap; + self.atlas_width = new_w; + self.atlas_height = new_h; + + // Recreate GL texture + glDeleteTextures(1, @self.texture_id); + glGenTextures(1, @self.texture_id); + glBindTexture(GL_TEXTURE_2D, self.texture_id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, new_w, new_h, 0, GL_RED, GL_UNSIGNED_BYTE, new_bitmap); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE); + + // Recompute UV coordinates for all cached glyphs + atlas_wf : f32 = xx new_w; + atlas_hf : f32 = xx new_h; + i : s64 = 0; + while i < self.entries.len { + g := @self.entries.items[i].glyph; + if g.width > 0.0 { + g.uv_x = g.uv_x / 2.0; + g.uv_y = g.uv_y / 2.0; + g.uv_w = g.width / atlas_wf; + g.uv_h = g.height / atlas_hf; + } + i += 1; + } + + self.dirty = false; + out("GlyphCache atlas grown\n"); + } + + set_dpi_scale :: (self: *GlyphCache, scale: f32) { + self.dpi_scale = scale; + self.inv_dpi = 1.0 / scale; + } + + // Get the scale factor for a logical font size + scale_for_size :: (self: *GlyphCache, font_size: f32) -> f32 { + stbtt_ScaleForPixelHeight(self.font_info, font_size); + } + + // Get scaled ascent for a logical font size + get_ascent :: (self: *GlyphCache, font_size: f32) -> f32 { + self.ascent * self.scale_for_size(font_size); + } + + // Get scaled line height for a logical font size + get_line_height :: (self: *GlyphCache, font_size: f32) -> f32 { + s := self.scale_for_size(font_size); + (self.ascent - self.descent + self.line_gap) * s; + } + + // Shape text into positioned glyphs. + // Uses ASCII fast-path (stbtt byte-by-byte) for pure ASCII, + // full kb_text_shape pipeline for Unicode/complex scripts. + // Results stored in self.shaped_buf (reused across calls). + shape_text :: (self: *GlyphCache, text: string, font_size: f32) { + self.shaped_buf.len = 0; + if text.len == 0 { return; } + + if is_ascii(text) { + self.shape_ascii(text, font_size); + } else { + self.shape_with_kb(text, font_size); + } + } + + shape_ascii :: (self: *GlyphCache, text: string, font_size: f32) { + scale := stbtt_ScaleForPixelHeight(self.font_info, font_size); + total : f32 = 0.0; + i : s64 = 0; + while i < text.len { + ch : s32 = xx text[i]; + glyph_index : u16 = xx stbtt_FindGlyphIndex(self.font_info, ch); + + advance_i : s32 = 0; + lsb_i : s32 = 0; + stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i); + adv : f32 = xx advance_i * scale; + + self.shaped_buf.append(ShapedGlyph.{ + glyph_index = glyph_index, + x = total, + y = 0.0, + advance = adv + }); + total += adv; + i += 1; + } + } + + shape_with_kb :: (self: *GlyphCache, text: string, font_size: f32) { + if xx self.shape_ctx == 0 { + self.shape_ascii(text, font_size); + return; + } + + scale : f32 = font_size / xx self.units_per_em; + total : f32 = 0.0; + + kbts_ShapeBegin(xx self.shape_ctx, KBTS_DIRECTION_DONT_KNOW, KBTS_LANGUAGE_DONT_KNOW); + kbts_ShapeUtf8(xx self.shape_ctx, xx text.ptr, xx text.len, KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX); + kbts_ShapeEnd(xx self.shape_ctx); + + run : KbtsRun = ---; + while kbts_ShapeRun(xx self.shape_ctx, xx @run) != 0 { + glyph_ptr : *KbtsGlyph = xx 0; + while kbts_GlyphIteratorNext(xx @run.glyphs, xx @glyph_ptr) != 0 { + if xx glyph_ptr == 0 { continue; } + gx := total + xx glyph_ptr.offset_x * scale; + gy : f32 = xx glyph_ptr.offset_y * scale; + adv : f32 = xx glyph_ptr.advance_x * scale; + + self.shaped_buf.append(ShapedGlyph.{ + glyph_index = glyph_ptr.id, + x = gx, + y = gy, + advance = adv + }); + total += adv; + } + } + } + + // Measure text at a logical font size using text shaping. + // Rasterizes at physical resolution (font_size * dpi_scale), returns logical dimensions. + measure_text :: (self: *GlyphCache, text: string, font_size: f32) -> Size { + self.shape_text(text, font_size); + width : f32 = 0.0; + i : s64 = 0; + while i < self.shaped_buf.len { + width += self.shaped_buf.items[i].advance; + i += 1; + } + Size.{ width = width, height = self.get_line_height(font_size) }; + } +} diff --git a/ui/pipeline.sx b/ui/pipeline.sx index 0eb23ca..026bbc5 100644 --- a/ui/pipeline.sx +++ b/ui/pipeline.sx @@ -9,7 +9,7 @@ UIPipeline :: struct { renderer: UIRenderer; render_tree: RenderTree; - font: FontAtlas; + font: GlyphCache; screen_width: f32; screen_height: f32; root: ViewChild; @@ -23,9 +23,10 @@ UIPipeline :: struct { self.has_root = false; } - init_font :: (self: *UIPipeline, path: [:0]u8, size: f32) { + init_font :: (self: *UIPipeline, path: [:0]u8, size: f32, dpi_scale: f32) { self.font.init(path, size); - upload_font_texture(@self.font); + self.font.set_dpi_scale(dpi_scale); + self.renderer.dpi_scale = dpi_scale; set_global_font(@self.font); } @@ -58,7 +59,6 @@ UIPipeline :: struct { origin = Point.zero(), size = root_size }; - print("tick: computed_frame=({},{},{},{})\n", self.root.computed_frame.origin.x, self.root.computed_frame.origin.y, self.root.computed_frame.size.width, self.root.computed_frame.size.height); self.root.view.layout(self.root.computed_frame); // Render to tree diff --git a/ui/renderer.sx b/ui/renderer.sx index 09150f9..fd460ca 100644 --- a/ui/renderer.sx +++ b/ui/renderer.sx @@ -4,6 +4,8 @@ #import "modules/math"; #import "ui/types.sx"; #import "ui/render.sx"; +#import "ui/glyph_cache.sx"; +#import "ui/font.sx"; // Vertex: pos(2) + uv(2) + color(4) + params(4) = 12 floats UI_VERTEX_FLOATS :s64: 12; @@ -20,6 +22,7 @@ UIRenderer :: struct { vertex_count: s64; screen_width: f32; screen_height: f32; + dpi_scale: f32; white_texture: u32; current_texture: u32; @@ -61,6 +64,8 @@ UIRenderer :: struct { glBindVertexArray(0); + self.dpi_scale = 1.0; + // 1x1 white texture for solid rects self.white_texture = create_white_texture(); } @@ -137,7 +142,7 @@ UIRenderer :: struct { self.push_quad(node.frame, node.fill_color, node.corner_radius, node.stroke_width); } case .text: { - if xx g_font != 0 and g_font.char_data != null { + if xx g_font != 0 { self.render_text(node); } } @@ -149,11 +154,12 @@ UIRenderer :: struct { case .clip_push: { self.flush(); glEnable(GL_SCISSOR_TEST); + dpi := self.dpi_scale; glScissor( - xx node.frame.origin.x, - xx (self.screen_height - node.frame.origin.y - node.frame.size.height), - xx node.frame.size.width, - xx node.frame.size.height + xx (node.frame.origin.x * dpi), + xx ((self.screen_height - node.frame.origin.y - node.frame.size.height) * dpi), + xx (node.frame.size.width * dpi), + xx (node.frame.size.height * dpi) ); } case .clip_pop: { @@ -195,8 +201,13 @@ UIRenderer :: struct { render_text :: (self: *UIRenderer, node: RenderNode) { font := g_font; - scale := node.font_size / font.font_size; + if xx font == 0 { return; } + // Shape text into positioned glyphs + font.shape_text(node.text, node.font_size); + + // Flush any new glyphs to the atlas texture before rendering + font.flush(); self.bind_texture(font.texture_id); r := node.text_color.rf(); @@ -204,56 +215,51 @@ UIRenderer :: struct { b := node.text_color.bf(); a := node.text_color.af(); - // stbtt_GetBakedQuad works at baked size; we scale output positions - xpos : f32 = 0.0; - ypos : f32 = 0.0; - q : AlignedQuad = ---; + ascent := font.get_ascent(node.font_size); + raster_size := node.font_size * font.dpi_scale; + inv_dpi := font.inv_dpi; + i : s64 = 0; - while i < node.text.len { - ch : s32 = xx node.text[i]; - if ch >= FIRST_CHAR and ch < FIRST_CHAR + NUM_CHARS { - stbtt_GetBakedQuad(xx font.char_data, ATLAS_W, ATLAS_H, ch - FIRST_CHAR, @xpos, @ypos, xx @q, 1); + while i < font.shaped_buf.len { + shaped := font.shaped_buf.items[i]; + cached := font.get_or_rasterize(shaped.glyph_index, raster_size); - // Scale and offset to frame position - // ypos=0 means baseline is at y=0; glyphs go above (negative yoff) - // Add ascent so top of text aligns with frame top - gx0 := node.frame.origin.x + q.x0 * scale; - gy0 := node.frame.origin.y + font.ascent * scale + q.y0 * scale; - gx1 := node.frame.origin.x + q.x1 * scale; - gy1 := node.frame.origin.y + font.ascent * scale + q.y1 * scale; + if xx cached != 0 { + if cached.width > 0.0 { + // Scale physical pixel dimensions back to logical units + gx0 := node.frame.origin.x + shaped.x + cached.offset_x * inv_dpi; + gy0 := node.frame.origin.y + ascent + shaped.y + cached.offset_y * inv_dpi; + gx1 := gx0 + cached.width * inv_dpi; + gy1 := gy0 + cached.height * inv_dpi; - if self.vertex_count + 6 > MAX_UI_VERTICES { - self.flush(); + u0 := cached.uv_x; + v0 := cached.uv_y; + u1 := cached.uv_x + cached.uv_w; + v1 := cached.uv_y + cached.uv_h; + + if self.vertex_count + 6 > MAX_UI_VERTICES { + self.flush(); + } + + // corner_radius = -1.0 signals "text mode" to the fragment shader + neg1 : f32 = 0.0 - 1.0; + self.write_vertex(gx0, gy0, u0, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy0, u1, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx0, gy1, u0, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy0, u1, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy1, u1, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx0, gy1, u0, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); } - - // corner_radius = -1.0 signals "text mode" to the fragment shader - self.write_vertex(gx0, gy0, q.s0, q.t0, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0); - self.write_vertex(gx1, gy0, q.s1, q.t0, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0); - self.write_vertex(gx0, gy1, q.s0, q.t1, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0); - self.write_vertex(gx1, gy0, q.s1, q.t0, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0); - self.write_vertex(gx1, gy1, q.s1, q.t1, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0); - self.write_vertex(gx0, gy1, q.s0, q.t1, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0); } i += 1; } + // Flush any glyphs rasterized during this text draw + font.flush(); self.bind_texture(self.white_texture); } } -// Upload font atlas bitmap as GL texture (called after GL init) -upload_font_texture :: (font: *FontAtlas) { - if font.bitmap == null { return; } - glGenTextures(1, @font.texture_id); - glBindTexture(GL_TEXTURE_2D, font.texture_id); - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, ATLAS_W, ATLAS_H, 0, GL_RED, GL_UNSIGNED_BYTE, font.bitmap); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR); - context.allocator.dealloc(font.bitmap); - font.bitmap = null; -} - create_white_texture :: () -> u32 { tex : u32 = 0; glGenTextures(1, @tex); @@ -311,8 +317,10 @@ void main() { vec2 rectSize = vParams.zw; if (radius < 0.0) { - float textAlpha = texture(uTex, vUV).r; - FragColor = vec4(vColor.rgb, vColor.a * textAlpha); + float alpha = texture(uTex, vUV).r; + float ew = fwidth(alpha) * 0.7; + alpha = smoothstep(0.5 - ew, 0.5 + ew, alpha); + FragColor = vec4(vColor.rgb, vColor.a * pow(alpha, 0.9)); } else if (radius > 0.0 || border > 0.0) { vec4 texColor = texture(uTex, vUV); vec2 half_size = rectSize * 0.5; @@ -381,8 +389,10 @@ void main() { vec2 rectSize = vParams.zw; if (radius < 0.0) { - float textAlpha = texture(uTex, vUV).r; - FragColor = vec4(vColor.rgb, vColor.a * textAlpha); + float alpha = texture(uTex, vUV).r; + float ew = fwidth(alpha) * 0.7; + alpha = smoothstep(0.5 - ew, 0.5 + ew, alpha); + FragColor = vec4(vColor.rgb, vColor.a * pow(alpha, 0.9)); } else if (radius > 0.0 || border > 0.0) { vec4 texColor = texture(uTex, vUV); vec2 half_size = rectSize * 0.5; diff --git a/vendors/kb_text_shape/kbts_api.h b/vendors/kb_text_shape/kbts_api.h new file mode 100644 index 0000000..9ae0657 --- /dev/null +++ b/vendors/kb_text_shape/kbts_api.h @@ -0,0 +1,15 @@ +// Minimal API declarations for SX import. +// Only the functions/types we actually use — avoids parsing the full 30k-line header. + +typedef struct kbts_shape_context kbts_shape_context; +typedef struct kbts_font kbts_font; + +kbts_shape_context *kbts_CreateShapeContext(void *Allocator, void *AllocatorData); +void kbts_DestroyShapeContext(kbts_shape_context *Context); +kbts_font *kbts_ShapePushFontFromMemory(kbts_shape_context *Context, void *Memory, int Size, int FontIndex); +void kbts_GetFontInfo2(kbts_font *Font, void *Info); +void kbts_ShapeBegin(kbts_shape_context *Context, unsigned int ParagraphDirection, unsigned int Language); +void kbts_ShapeUtf8(kbts_shape_context *Context, const char *Utf8, int Length, unsigned int UserIdGenerationMode); +void kbts_ShapeEnd(kbts_shape_context *Context); +int kbts_ShapeRun(kbts_shape_context *Context, void *Run); +int kbts_GlyphIteratorNext(void *It, void **Glyph);