Added improved non-clay text handling along with consistent origin and rotation API

This commit is contained in:
Zachary Levy
2026-04-19 18:28:42 -07:00
parent 30b72128b2
commit 7a21d6f253
12 changed files with 1157 additions and 388 deletions

View File

@@ -1,6 +1,9 @@
package draw
import "core:c"
import "core:hash"
import "core:log"
import "core:strings"
import sdl "vendor:sdl3"
import sdl_ttf "vendor:sdl3/ttf"
@@ -71,32 +74,195 @@ Text :: struct {
color: Color,
}
text :: proc(
id: u32,
txt: cstring,
pos: [2]f32,
font_id: Font_Id,
font_size: u16 = 44,
color: Color = {0, 0, 0, 255},
) -> Text {
sdl_text := GLOB.text_cache.cache[id]
if sdl_text == nil {
sdl_text = sdl_ttf.CreateText(GLOB.text_cache.engine, get_font(font_id, font_size), txt, 0)
// ---------------------------------------------------------------------------------------------------------------------
// ----- 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[id] = sdl_text
GLOB.text_cache.cache[cache_id] = sdl_text
return sdl_text
} else {
//TODO if IDs are always unique and never change the underlying text
// can get rid of this
if !sdl_ttf.SetTextString(sdl_text, txt, 0) {
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())
}
}
return Text{sdl_text, pos, color}
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,
@@ -132,6 +298,9 @@ 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)