310 lines
11 KiB
Odin
310 lines
11 KiB
Odin
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()
|
|
}
|