diff --git a/.zed/tasks.json b/.zed/tasks.json index be18c0b..34251b1 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -60,6 +60,11 @@ "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- hellope-text", "cwd": "$ZED_WORKTREE_ROOT", }, + { + "label": "Run draw hellope-custom example", + "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- hellope-custom", + "cwd": "$ZED_WORKTREE_ROOT", + }, // --------------------------------------------------------------------------------------------------------------------- // ----- Other ------------------------ // --------------------------------------------------------------------------------------------------------------------- diff --git a/draw/draw.odin b/draw/draw.odin index 5247692..66dc384 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -352,6 +352,11 @@ prepare_text :: proc(layer: ^Layer, text: Text) { scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + // Snap base position to integer physical pixels to avoid atlas sub-pixel + // sampling blur (and the off-by-one bottom-row clip that comes with it). + base_x := math.round(text.position[0] * GLOB.dpi_scaling) + base_y := math.round(text.position[1] * GLOB.dpi_scaling) + for data != nil { vertex_start := u32(len(GLOB.tmp_text_verts)) index_start := u32(len(GLOB.tmp_text_indices)) @@ -363,7 +368,7 @@ prepare_text :: proc(layer: ^Layer, text: Text) { append( &GLOB.tmp_text_verts, Vertex { - position = {pos.x + text.position[0] * GLOB.dpi_scaling, -pos.y + text.position[1] * GLOB.dpi_scaling}, + position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = text.color, }, @@ -471,6 +476,19 @@ clay_error_handler :: proc "c" (errorData: clay.ErrorData) { log.error("Clay error:", errorData.errorType, errorData.errorText) } +// Called for each Clay `RenderCommandType.Custom` render command that +// `prepare_clay_batch` encounters. +// +// - `layer` is the layer the command belongs to (post-z-index promotion). +// - `bounds` is already translated into the active layer's coordinate system +// and pre-DPI, matching what the built-in shape procs expect. +// - `render_data` is Clay's `CustomRenderData` for the element, exposing +// `backgroundColor`, `cornerRadius`, and the `customData` pointer the caller +// attached to `clay.CustomElementConfig.customData`. +// +// The callback must not call `new_layer` or `prepare_clay_batch`. +Custom_Draw :: #type proc(layer: ^Layer, bounds: Rectangle, render_data: clay.CustomRenderData) + ClayBatch :: struct { bounds: Rectangle, cmds: clay.ClayArray(clay.RenderCommand), @@ -482,6 +500,7 @@ prepare_clay_batch :: proc( batch: ^ClayBatch, mouse_wheel_delta: [2]f32, frame_time: f32 = 0, + custom_draw: Custom_Draw = nil, ) { mouse_pos: [2]f32 mouse_flags := sdl.GetMouseState(&mouse_pos.x, &mouse_pos.y) @@ -522,10 +541,10 @@ prepare_clay_batch :: proc( render_data := render_command.renderData.text txt := string(render_data.stringContents.chars[:render_data.stringContents.length]) c_text := strings.clone_to_cstring(txt, context.temp_allocator) - // Clay's render_command.id is already hashed with the same Jenkins algorithm - // as text_cache_hash, so it shares the same keyspace. + // Clay render-command IDs are derived via Clay's internal HashNumber (Jenkins-family) + // and namespaced with .Clay so they can never collide with user-provided custom text IDs. sdl_text := cache_get_or_update( - render_command.id, + Cache_Key{render_command.id, .Clay}, c_text, get_font(render_data.fontId, render_data.fontSize), ) @@ -581,7 +600,9 @@ prepare_clay_batch :: proc( } else { rectangle_corners_lines(layer, bounds, radii, color, thickness) } - case clay.RenderCommandType.Custom: + case clay.RenderCommandType.Custom: if custom_draw != nil { + custom_draw(layer, bounds, render_command.renderData.custom) + } } } } diff --git a/draw/examples/hellope.odin b/draw/examples/hellope.odin index 3a6082b..d90aee8 100644 --- a/draw/examples/hellope.odin +++ b/draw/examples/hellope.odin @@ -2,6 +2,7 @@ package examples import "../../draw" import "../../vendor/clay" +import "core:math" import "core:os" import sdl "vendor:sdl3" @@ -108,6 +109,11 @@ hellope_shapes :: proc() { } hellope_text :: proc() { + HELLOPE_ID :: 1 + ROTATING_SENTENCE_ID :: 2 + MEASURED_ID :: 3 + CORNER_SPIN_ID :: 4 + if !sdl.Init({.VIDEO}) do os.exit(1) window := sdl.CreateWindow("Hellope!", 600, 600, {.HIGH_PIXEL_DENSITY}) gpu := sdl.CreateGPUDevice({.MSL}, true, nil) @@ -141,7 +147,7 @@ hellope_text :: proc() { FONT_SIZE, color = draw.WHITE, origin = draw.center_of("Hellope!", JETBRAINS_MONO_REGULAR, FONT_SIZE), - id = "hellope", + id = HELLOPE_ID, ) // Rotating sentence — verifies multi-word text rotation around center @@ -154,7 +160,7 @@ hellope_text :: proc() { color = {255, 200, 50, 255}, origin = draw.center_of("Hellope World!", JETBRAINS_MONO_REGULAR, FONT_SIZE), rotation = spin_angle, - id = "rotating_sentence", + id = ROTATING_SENTENCE_ID, ) // Uncached text (no id) — created and destroyed each frame, simplest usage @@ -178,7 +184,7 @@ hellope_text :: proc() { FONT_SIZE, color = draw.WHITE, origin = draw.top_of("Measured!", JETBRAINS_MONO_REGULAR, FONT_SIZE), - id = "measured", + id = MEASURED_ID, ) // Rotating text anchored at top-left (no origin offset) — spins around top-left corner @@ -190,7 +196,7 @@ hellope_text :: proc() { FONT_SIZE, color = {100, 200, 255, 255}, rotation = spin_angle, - id = "corner_spin", + id = CORNER_SPIN_ID, ) draw.end(gpu, window) @@ -207,7 +213,7 @@ hellope_clay :: proc() { text_config := clay.TextElementConfig { fontId = JETBRAINS_MONO_REGULAR, - fontSize = 24, + fontSize = 36, textColor = {255, 255, 255, 255}, } @@ -240,3 +246,106 @@ hellope_clay :: proc() { draw.end(gpu, window) } } + +hellope_custom :: proc() { + if !sdl.Init({.VIDEO}) do os.exit(1) + window := sdl.CreateWindow("Hellope Custom!", 600, 400, {.HIGH_PIXEL_DENSITY}) + gpu := sdl.CreateGPUDevice({.MSL}, true, nil) + if !sdl.ClaimWindowForGPUDevice(gpu, window) do os.exit(1) + if !draw.init(gpu, window) do os.exit(1) + JETBRAINS_MONO_REGULAR = draw.register_font(JETBRAINS_MONO_REGULAR_RAW) + + text_config := clay.TextElementConfig { + fontId = JETBRAINS_MONO_REGULAR, + fontSize = 24, + textColor = {255, 255, 255, 255}, + } + + gauge := Gauge { + value = 0.73, + color = {50, 200, 100, 255}, + } + gauge2 := Gauge { + value = 0.45, + color = {200, 100, 50, 255}, + } + spin_angle: f32 = 0 + + for { + defer free_all(context.temp_allocator) + ev: sdl.Event + for sdl.PollEvent(&ev) { + if ev.type == .QUIT do return + } + + spin_angle += 1 + gauge.value = (math.sin(spin_angle * 0.02) + 1) * 0.5 + gauge2.value = (math.cos(spin_angle * 0.03) + 1) * 0.5 + + base_layer := draw.begin({width = 600, height = 400}) + clay.SetLayoutDimensions({width = base_layer.bounds.width, height = base_layer.bounds.height}) + clay.BeginLayout() + + if clay.UI()( + { + id = clay.ID("outer"), + layout = { + sizing = {clay.SizingGrow({}), clay.SizingGrow({})}, + childAlignment = {x = .Center, y = .Center}, + layoutDirection = .TopToBottom, + childGap = 20, + }, + backgroundColor = {50, 50, 50, 255}, + }, + ) { + if clay.UI()({id = clay.ID("title"), layout = {sizing = {clay.SizingFit({}), clay.SizingFit({})}}}) { + clay.Text("Custom Draw Demo", &text_config) + } + + if clay.UI()( + { + id = clay.ID("gauge"), + layout = {sizing = {clay.SizingFixed(300), clay.SizingFixed(30)}}, + custom = {customData = &gauge}, + backgroundColor = {80, 80, 80, 255}, + }, + ) {} + + if clay.UI()( + { + id = clay.ID("gauge2"), + layout = {sizing = {clay.SizingFixed(300), clay.SizingFixed(30)}}, + custom = {customData = &gauge2}, + backgroundColor = {80, 80, 80, 255}, + }, + ) {} + } + + clay_batch := draw.ClayBatch { + bounds = base_layer.bounds, + cmds = clay.EndLayout(), + } + draw.prepare_clay_batch(base_layer, &clay_batch, {0, 0}, custom_draw = draw_custom) + draw.end(gpu, window) + } + + Gauge :: struct { + value: f32, + color: draw.Color, + } + + draw_custom :: proc(layer: ^draw.Layer, bounds: draw.Rectangle, render_data: clay.CustomRenderData) { + gauge := cast(^Gauge)render_data.customData + + // Background from clay's backgroundColor + draw.rectangle(layer, bounds, draw.color_from_clay(render_data.backgroundColor), roundness = 0.25) + + // Fill bar + fill := bounds + fill.width *= gauge.value + draw.rectangle(layer, fill, gauge.color, roundness = 0.25) + + // Border + draw.rectangle_lines(layer, bounds, draw.WHITE, thickness = 2, roundness = 0.25) + } +} diff --git a/draw/examples/main.odin b/draw/examples/main.odin index 75ebd48..f8107eb 100644 --- a/draw/examples/main.odin +++ b/draw/examples/main.odin @@ -57,17 +57,18 @@ main :: proc() { args := os.args if len(args) < 2 { fmt.eprintln("Usage: examples ") - fmt.eprintln("Available examples: hellope-shapes, hellope-text, hellope-clay") + fmt.eprintln("Available examples: hellope-shapes, hellope-text, hellope-clay, hellope-custom") os.exit(1) } switch args[1] { case "hellope-clay": hellope_clay() + case "hellope-custom": hellope_custom() case "hellope-shapes": hellope_shapes() case "hellope-text": hellope_text() case: fmt.eprintf("Unknown example: %v\n", args[1]) - fmt.eprintln("Available examples: hellope-shapes, hellope-text, hellope-clay") + fmt.eprintln("Available examples: hellope-shapes, hellope-text, hellope-clay, hellope-custom") os.exit(1) } } diff --git a/draw/text.odin b/draw/text.odin index fae7f61..5ff7265 100644 --- a/draw/text.odin +++ b/draw/text.odin @@ -1,7 +1,6 @@ package draw import "core:c" -import "core:hash" import "core:log" import "core:strings" import sdl "vendor:sdl3" @@ -14,11 +13,21 @@ Font_Key :: struct { 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[u32]^sdl_ttf.Text, + cache: map[Cache_Key]^sdl_ttf.Text, } // Internal for fetching SDL TTF font pointer for rendering @@ -75,27 +84,20 @@ Text :: struct { } // --------------------------------------------------------------------------------------------------------------------- -// ----- Text cache hashing ------------ +// ----- Text cache lookup ------------- // --------------------------------------------------------------------------------------------------------------------- -// 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(text_string: string) -> u32 { - return hash.jenkins(transmute([]u8)text_string) + 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] +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[cache_id] = sdl_text + GLOB.text_cache.cache[key] = sdl_text return sdl_text } else { if !sdl_ttf.SetTextString(existing, c_str, 0) { @@ -114,11 +116,12 @@ cache_get_or_update :: proc(cache_id: u32, c_str: cstring, font: ^sdl_ttf.Font) // 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. +// 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. @@ -132,7 +135,7 @@ text :: proc( color: Color = BLACK, origin: [2]f32 = {0, 0}, rotation: f32 = 0, - id: Maybe(string) = nil, + id: Maybe(u32) = nil, temp_allocator := context.temp_allocator, ) { c_str := strings.clone_to_cstring(text_string, temp_allocator) @@ -140,9 +143,9 @@ text :: proc( sdl_text: ^sdl_ttf.Text cached := false - if id_string, ok := id.?; ok { + if cache_id, ok := id.?; ok { cached = true - sdl_text = cache_get_or_update(text_cache_hash(id_string), c_str, get_font(font_id, font_size)) + 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 { @@ -236,8 +239,8 @@ bottom_right_of_text :: proc(text_string: string, font_id: Font_Id, font_size: u // ----- 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. +// 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 { @@ -246,12 +249,12 @@ clear_text_cache :: proc() { 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("..."). +// 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: string) { - key := text_cache_hash(id) +clear_text_cache_entry :: proc(id: u32) { + key := Cache_Key{id, .Custom} sdl_text, ok := GLOB.text_cache.cache[key] if ok { sdl_ttf.DestroyText(sdl_text) @@ -287,7 +290,7 @@ init_text_cache :: proc( text_cache = Text_Cache { engine = engine, - cache = make(map[u32]^sdl_ttf.Text, allocator = allocator), + cache = make(map[Cache_Key]^sdl_ttf.Text, allocator = allocator), } log.debug("Done initializing text cache")