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 db995df..d2f5919 100644 Binary files a/goldens/last_frame.png and b/goldens/last_frame.png differ 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);