package draw import "core:c" import "core:hash" import "core:log" import "core:strings" import sdl "vendor:sdl3" import sdl_ttf "vendor:sdl3/ttf" Font_Id :: u16 Font_Key :: struct { id: Font_Id, size: u16, } Text_Cache :: struct { engine: ^sdl_ttf.TextEngine, font_bytes: [dynamic][]u8, sdl_fonts: map[Font_Key]^sdl_ttf.Font, cache: map[u32]^sdl_ttf.Text, } // Internal for fetching SDL TTF font pointer for rendering get_font :: proc(id: Font_Id, size: u16) -> ^sdl_ttf.Font { assert(int(id) < len(GLOB.text_cache.font_bytes), "Invalid font ID.") key := Font_Key{id, size} font := GLOB.text_cache.sdl_fonts[key] if font == nil { log.debug("Font with id:", id, "and size:", size, "not found. Adding..") font_bytes := GLOB.text_cache.font_bytes[id] if font_bytes == nil { log.panicf("Font must first be registered with register_font before using (id=%d)", id) } font_io := sdl.IOFromConstMem(raw_data(font_bytes[:]), len(font_bytes)) if font_io == nil { log.panicf("Failed to create IOStream for font id=%d: %s", id, sdl.GetError()) } sdl_font := sdl_ttf.OpenFontIO(font_io, true, f32(size)) if sdl_font == nil { log.panicf("Failed to create SDL font for font id=%d size=%d: %s", id, size, sdl.GetError()) } if !sdl_ttf.SetFontSizeDPI(sdl_font, f32(size), 72 * i32(GLOB.dpi_scaling), 72 * i32(GLOB.dpi_scaling)) { log.panicf("Failed to set font DPI for font id=%d size=%d: %s", id, size, sdl.GetError()) } GLOB.text_cache.sdl_fonts[key] = sdl_font return sdl_font } else { return font } } // Returns `false` if there are more than max(u16) fonts register_font :: proc(bytes: []u8) -> (id: Font_Id, ok: bool) #optional_ok { if GLOB.text_cache.engine == nil { log.panicf("Cannot register font: text system not initialized. Call init() first.") } if len(GLOB.text_cache.font_bytes) > int(max(Font_Id)) do return 0, false log.debug("Registering font...") append(&GLOB.text_cache.font_bytes, bytes) return Font_Id(len(GLOB.text_cache.font_bytes) - 1), true } Text :: struct { ref: ^sdl_ttf.Text, position: [2]f32, color: Color, } // --------------------------------------------------------------------------------------------------------------------- // ----- Text cache hashing ------------ // --------------------------------------------------------------------------------------------------------------------- // Hash a string to a u32 cache key using the same Jenkins one-at-a-time algorithm as Clay. // This means Clay element IDs and user-chosen string IDs share the same keyspace — same // string produces the same cache key regardless of whether it came from Clay or user code. text_cache_hash :: #force_inline proc(s: string) -> u32 { return hash.jenkins(transmute([]u8)s) + 1 // +1 reserves 0 as "no entry" (matches Clay convention) } // Shared cache lookup/create/update logic used by both the `text` proc and the Clay render path. // Returns the cached (or newly created) TTF_Text pointer. @(private) cache_get_or_update :: proc(cache_id: u32, c_str: cstring, font: ^sdl_ttf.Font) -> ^sdl_ttf.Text { existing, found := GLOB.text_cache.cache[cache_id] if !found { sdl_text := sdl_ttf.CreateText(GLOB.text_cache.engine, font, c_str, 0) if sdl_text == nil { log.panicf("Failed to create SDL text: %s", sdl.GetError()) } GLOB.text_cache.cache[cache_id] = sdl_text return sdl_text } else { if !sdl_ttf.SetTextString(existing, c_str, 0) { log.panicf("Failed to update SDL text string: %s", sdl.GetError()) } return existing } } // --------------------------------------------------------------------------------------------------------------------- // ----- Text drawing ------------------ // --------------------------------------------------------------------------------------------------------------------- // Draw text at a position with optional rotation and origin. // // When `id` is nil (the default), the text is created and destroyed each frame — simple and // leak-free, appropriate for HUDs and moderate UI (up to ~50 text elements per frame). // // When `id` is set, the TTF_Text object is cached across frames using a hash of the provided // string (same algorithm as Clay's element IDs). This avoids per-frame HarfBuzz shaping and // allocation, which matters for text-heavy apps (editors, terminals, chat). The user is // responsible for choosing unique ID strings per logical text element and calling // `clear_text_cache` or `clear_text_cache_entry` when cached entries are no longer needed. // // `origin` is in pixels from the text block's top-left corner (raylib convention). // The point whose local coords equal `origin` lands at `pos` in world space. // `rotation` is in degrees, counter-clockwise. text :: proc( layer: ^Layer, str: string, pos: [2]f32, font_id: Font_Id, font_size: u16 = 44, color: Color = BLACK, origin: [2]f32 = {0, 0}, rotation: f32 = 0, id: Maybe(string) = nil, temp_allocator := context.temp_allocator, ) { c_str := strings.clone_to_cstring(str, temp_allocator) sdl_text: ^sdl_ttf.Text cached := false if id_str, ok := id.?; ok { cached = true sdl_text = cache_get_or_update(text_cache_hash(id_str), c_str, get_font(font_id, font_size)) } else { sdl_text = sdl_ttf.CreateText(GLOB.text_cache.engine, get_font(font_id, font_size), c_str, 0) if sdl_text == nil { log.panicf("Failed to create SDL text: %s", sdl.GetError()) } } if needs_transform(origin, rotation) { dpi := GLOB.dpi_scaling xform := build_pivot_rot(pos * dpi, origin * dpi, rotation) prepare_text_transformed(layer, Text{sdl_text, {0, 0}, color}, xform) } else { prepare_text(layer, Text{sdl_text, pos, color}) } if !cached { // Don't destroy now — the draw data (atlas texture, vertices) is still referenced // by the batch buffers until end() submits to the GPU. Deferred to clear_global(). append(&GLOB.tmp_uncached_text, sdl_text) } } // --------------------------------------------------------------------------------------------------------------------- // ----- Public text measurement ------- // --------------------------------------------------------------------------------------------------------------------- // Measure a string in logical pixels (pre-DPI-scaling) using the same font backend as the renderer. measure_text :: proc( str: string, font_id: Font_Id, font_size: u16 = 44, allocator := context.temp_allocator, ) -> [2]f32 { c_str := strings.clone_to_cstring(str, allocator) w, h: c.int if !sdl_ttf.GetStringSize(get_font(font_id, font_size), c_str, 0, &w, &h) { log.panicf("Failed to measure text: %s", sdl.GetError()) } return {f32(w) / GLOB.dpi_scaling, f32(h) / GLOB.dpi_scaling} } // --------------------------------------------------------------------------------------------------------------------- // ----- Text anchor helpers ----------- // --------------------------------------------------------------------------------------------------------------------- center_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(str, font_id, font_size) return size * 0.5 } top_left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { return {0, 0} } top_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(str, font_id, font_size) return {size.x * 0.5, 0} } top_right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(str, font_id, font_size) return {size.x, 0} } left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(str, font_id, font_size) return {0, size.y * 0.5} } right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(str, font_id, font_size) return {size.x, size.y * 0.5} } bottom_left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(str, font_id, font_size) return {0, size.y} } bottom_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(str, font_id, font_size) return {size.x * 0.5, size.y} } bottom_right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(str, font_id, font_size) return size } // --------------------------------------------------------------------------------------------------------------------- // ----- Cache management -------------- // --------------------------------------------------------------------------------------------------------------------- // Destroy all cached text objects. Call on scene transitions, view changes, or periodically // in apps that produce many distinct cached text entries over time. // After calling this, subsequent text draws with an `id` will re-create their cache entries. clear_text_cache :: proc() { for _, sdl_text in GLOB.text_cache.cache { sdl_ttf.DestroyText(sdl_text) } clear(&GLOB.text_cache.cache) } // Destroy a specific cached text entry by its string id (same string used when drawing). // Uses the same hash as Clay's element IDs, so this also works for clearing Clay text entries // by passing the same string used in clay.ID("..."). // No-op if the id is not in the cache. clear_text_cache_entry :: proc(id: string) { key := text_cache_hash(id) sdl_text, ok := GLOB.text_cache.cache[key] if ok { sdl_ttf.DestroyText(sdl_text) delete_key(&GLOB.text_cache.cache, key) } } // --------------------------------------------------------------------------------------------------------------------- // ----- Internal cache lifecycle ------ // --------------------------------------------------------------------------------------------------------------------- @(private, require_results) init_text_cache :: proc( device: ^sdl.GPUDevice, allocator := context.allocator, ) -> ( text_cache: Text_Cache, ok: bool, ) { log.debug("Initializing text state") if !sdl_ttf.Init() { log.errorf("Failed to initialize SDL_ttf: %s", sdl.GetError()) return text_cache, false } engine := sdl_ttf.CreateGPUTextEngine(device) if engine == nil { log.errorf("Failed to create GPU text engine: %s", sdl.GetError()) sdl_ttf.Quit() return text_cache, false } sdl_ttf.SetGPUTextEngineWinding(engine, .COUNTER_CLOCKWISE) text_cache = Text_Cache { engine = engine, cache = make(map[u32]^sdl_ttf.Text, allocator = allocator), } log.debug("Done initializing text cache") return text_cache, true } destroy_text_cache :: proc() { for _, font in GLOB.text_cache.sdl_fonts { sdl_ttf.CloseFont(font) } for _, sdl_text in GLOB.text_cache.cache { sdl_ttf.DestroyText(sdl_text) } delete(GLOB.text_cache.sdl_fonts) delete(GLOB.text_cache.font_bytes) delete(GLOB.text_cache.cache) sdl_ttf.DestroyGPUTextEngine(GLOB.text_cache.engine) sdl_ttf.Quit() }