package draw import "core:c" 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, } Cache_Source :: enum u8 { Custom, Clay, } Cache_Key :: struct { id: u32, source: Cache_Source, } Text_Cache :: struct { engine: ^sdl_ttf.TextEngine, font_bytes: [dynamic][]u8, sdl_fonts: map[Font_Key]^sdl_ttf.Font, cache: map[Cache_Key]^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 { sdl_text: ^sdl_ttf.Text, position: [2]f32, color: Color, } // --------------------------------------------------------------------------------------------------------------------- // ----- Text cache lookup ------------- // --------------------------------------------------------------------------------------------------------------------- // 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(key: Cache_Key, c_str: cstring, font: ^sdl_ttf.Font) -> ^sdl_ttf.Text { existing, found := GLOB.text_cache.cache[key] 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[key] = 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 keyed by the provided u32. // This avoids per-frame HarfBuzz shaping and allocation, which matters for text-heavy apps // (editors, terminals, chat). The user is responsible for choosing unique IDs per logical text // element and calling `clear_text_cache` or `clear_text_cache_entry` when cached entries are // no longer needed. Custom text IDs occupy a separate namespace from Clay text IDs, so // collisions between the two are impossible. // // `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, text_string: string, position: [2]f32, font_id: Font_Id, font_size: u16 = 44, color: Color = BLACK, origin: [2]f32 = {0, 0}, rotation: f32 = 0, id: Maybe(u32) = nil, temp_allocator := context.temp_allocator, ) { c_str := strings.clone_to_cstring(text_string, temp_allocator) defer delete(c_str, temp_allocator) sdl_text: ^sdl_ttf.Text cached := false if cache_id, ok := id.?; ok { cached = true sdl_text = cache_get_or_update(Cache_Key{cache_id, .Custom}, 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_scale := GLOB.dpi_scaling transform := build_pivot_rotation(position * dpi_scale, origin * dpi_scale, rotation) prepare_text_transformed(layer, Text{sdl_text, {0, 0}, color}, transform) } else { prepare_text(layer, Text{sdl_text, position, 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( text_string: string, font_id: Font_Id, font_size: u16 = 44, allocator := context.temp_allocator, ) -> [2]f32 { c_str := strings.clone_to_cstring(text_string, allocator) defer delete(c_str, allocator) width, height: c.int if !sdl_ttf.GetStringSize(get_font(font_id, font_size), c_str, 0, &width, &height) { log.panicf("Failed to measure text: %s", sdl.GetError()) } return {f32(width) / GLOB.dpi_scaling, f32(height) / GLOB.dpi_scaling} } // --------------------------------------------------------------------------------------------------------------------- // ----- Text anchor helpers ----------- // --------------------------------------------------------------------------------------------------------------------- center_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(text_string, font_id, font_size) return size * 0.5 } top_left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { return {0, 0} } top_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(text_string, font_id, font_size) return {size.x * 0.5, 0} } top_right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(text_string, font_id, font_size) return {size.x, 0} } left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(text_string, font_id, font_size) return {0, size.y * 0.5} } right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(text_string, font_id, font_size) return {size.x, size.y * 0.5} } bottom_left_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(text_string, font_id, font_size) return {0, size.y} } bottom_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(text_string, font_id, font_size) return {size.x * 0.5, size.y} } bottom_right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { size := measure_text(text_string, font_id, font_size) return size } // --------------------------------------------------------------------------------------------------------------------- // ----- Cache management -------------- // --------------------------------------------------------------------------------------------------------------------- // Destroy all cached text objects (both custom and Clay entries). 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 { append(&GLOB.pending_text_releases, sdl_text) } clear(&GLOB.text_cache.cache) } // Destroy a specific cached custom text entry by its u32 id (the same value passed to the // `text` proc's `id` parameter). This only affects custom text entries — Clay text entries // are managed internally and are not addressable by the user. // No-op if the id is not in the cache. clear_text_cache_entry :: proc(id: u32) { key := Cache_Key{id, .Custom} sdl_text, ok := GLOB.text_cache.cache[key] if ok { append(&GLOB.pending_text_releases, 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[Cache_Key]^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() }