Files
levlib/draw/clay.odin
T
2026-05-11 20:30:31 -07:00

816 lines
36 KiB
Odin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Clay UI integration for the `draw` package.
//
// All code in this file is dedicated to bridging Clay's render command stream into `draw`'s
// primitive/sub-batch pipeline. Nothing outside this file should reference the `clay` package
// directly; everything Clay-related (types, lifecycle helpers, render-command dispatch, the
// border-merge stack, the Clay backdrop bracket walker, the text measure/error callbacks,
// and the `Clay_Image_Data` user-facing helper) lives here. `draw.odin`'s lifecycle procs
// call `init_clay`, `destroy_clay`, and `clear_clay_per_frame` to drive the bits of state
// that necessarily live on the shared `Global` struct.
package draw
import "base:runtime"
import "core:c"
import "core:log"
import "core:strings"
import sdl "vendor:sdl3"
import sdl_ttf "vendor:sdl3/ttf"
import clay "../vendor/clay"
// ---------------------------------------------------------------------------------------------------------------------
// ----- Lifecycle ------------
// ---------------------------------------------------------------------------------------------------------------------
// Allocate the Clay arena, build the merge-candidate stack, hand the arena to Clay, and
// register the text-measurement and error callbacks. Called by `init` once `GLOB` has been
// populated with the device/window state Clay's callbacks read from.
//INTERNAL
init_clay :: proc(window: ^sdl.Window, allocator: runtime.Allocator) {
min_memory_size: c.size_t = cast(c.size_t)clay.MinMemorySize()
GLOB.clay_merge_open_stack = make([dynamic]Clay_Merge_Candidate, 0, 16, allocator = allocator)
GLOB.clay_memory = make([^]u8, min_memory_size, allocator = allocator)
arena := clay.CreateArenaWithCapacityAndMemory(min_memory_size, GLOB.clay_memory)
window_width, window_height: c.int
sdl.GetWindowSize(window, &window_width, &window_height)
clay.Initialize(arena, {f32(window_width), f32(window_height)}, {handler = clay_error_handler})
clay.SetMeasureTextFunction(measure_text_clay, nil)
}
// Free the Clay arena memory allocated in `init_clay`. Called by `destroy`. The merge stack
// is left to the package allocator's normal teardown to preserve historical behavior.
//INTERNAL
destroy_clay :: proc(allocator: runtime.Allocator) {
free(GLOB.clay_memory, allocator)
}
// Reset Clay per-frame state: the z-index high-water mark and the border-merge stack.
// Called by `clear_global` at the start of every frame.
//INTERNAL
clear_clay_per_frame :: proc() {
GLOB.clay_z_index = 0
clear(&GLOB.clay_merge_open_stack)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Conversion helpers ------------
// ---------------------------------------------------------------------------------------------------------------------
// Convert clay.Color ([4]c.float in 0255 range) to Color.
color_from_clay :: #force_inline proc(clay_color: clay.Color) -> Color {
return Color{u8(clay_color[0]), u8(clay_color[1]), u8(clay_color[2]), u8(clay_color[3])}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Image data (Clay RenderCommandType.Image payload) ------------
// ---------------------------------------------------------------------------------------------------------------------
Clay_Image_Data :: struct {
texture_id: Texture_Id,
fit: Fit_Mode,
tint: Color,
}
clay_image_data :: proc(id: Texture_Id, fit: Fit_Mode = .Stretch, tint: Color = WHITE) -> Clay_Image_Data {
return {texture_id = id, fit = fit, tint = tint}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Callbacks (clay -> draw) ------------
// ---------------------------------------------------------------------------------------------------------------------
@(private = "file")
clay_error_handler :: proc "c" (errorData: clay.ErrorData) {
context = GLOB.odin_context
log.error("Clay error:", errorData.errorType, errorData.errorText)
}
@(private = "file")
measure_text_clay :: proc "c" (
text: clay.StringSlice,
config: ^clay.TextElementConfig,
user_data: rawptr,
) -> clay.Dimensions {
context = GLOB.odin_context
text := string(text.chars[:text.length])
c_text := strings.clone_to_cstring(text, context.temp_allocator)
defer delete(c_text, context.temp_allocator)
width, height: c.int
if !sdl_ttf.GetStringSize(get_font(config.fontId, config.fontSize), c_text, 0, &width, &height) {
log.panicf("Failed to measure text: %s", sdl.GetError())
}
return clay.Dimensions{width = f32(width) / GLOB.dpi_scaling, height = f32(height) / GLOB.dpi_scaling}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Custom draw + customData envelope ------------
// ---------------------------------------------------------------------------------------------------------------------
// Called for each Clay `RenderCommandType.Custom` render command that
// `prepare_clay_batch` encounters and which is NOT a levlib-managed variant
// (e.g. `Backdrop_Marker`).
//
// - `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` and `cornerRadius`. Its `customData` field has been
// unwrapped from the `Clay_Custom` envelope: it points at the user's own
// data (the value the user wrote into the `rawptr` variant), not at the
// `Clay_Custom` itself. If the union was zero-init (no variant set) or
// `customData` was originally nil, the callback receives nil.
//
// 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),
}
// Discriminated sum of everything `clay.CustomElementConfig.customData` is allowed to point
// at. levlib-defined variants (currently just `Backdrop_Marker`) are recognized by
// `prepare_clay_batch` and routed to the appropriate internal path; the `rawptr` variant is
// the escape hatch for user-defined custom drawing — `prepare_clay_batch` unwraps it before
// invoking `custom_draw` so the callback sees the user's pointer in `render_data.customData`
// exactly as if no wrapper were involved.
//
// Contract: `customData`, when non-nil, MUST point at storage holding a `Clay_Custom`
// value. The user owns that storage; its lifetime must span the Clay layout call and the
// matching `prepare_clay_batch` call. Pointing `customData` at a bare user struct violates
// the contract — the dispatcher will read its first bytes as a union tag and either route
// the draw incorrectly or panic on type assertion. There is no recovery path; this is a
// strict-discipline API by design.
//
// Construction notes (Odin implicit-conversion rules):
// - Backdrop variant: `bd: Clay_Custom = Backdrop_Marker{...}` works directly.
// Variant-to-union conversion is implicit.
// - User pointer: `up: Clay_Custom = rawptr(&my_struct)` — the explicit `rawptr(...)` is
// required because Odin does not chain `^T -> rawptr -> Clay_Custom` implicitly. A bare
// `up: Clay_Custom = &my_struct` is a compile error.
Clay_Custom :: union {
Backdrop_Marker,
rawptr,
}
// Per-primitive parameters for a backdrop blur dispatched through the Clay integration.
// Embedded as a `Clay_Custom` variant; `prepare_clay_batch` walks the command stream,
// opens/closes a backdrop scope around contiguous backdrop runs, and feeds these to
// `backdrop_blur` via `dispatch_clay_backdrop`. The discriminant is the union tag — no
// in-band magic field needed (compiler-enforced).
Backdrop_Marker :: struct {
sigma: f32,
tint: Color,
radii: Rectangle_Radii,
feather_ppx: f32,
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Border-merge stack ------------
// ---------------------------------------------------------------------------------------------------------------------
// One entry on the Clay merge stack. Pushed by `dispatch_clay_command` when emitting a
// Rectangle or an Image primitive, then popped by a matching Border to retroactively add
// the outline. See `try_dispatch_clay_border_merge` for the matching semantics.
//INTERNAL
Clay_Merge_Candidate :: struct {
primitive_index: u32, // Index into `GLOB.tmp_primitives` of the candidate primitive.
outer_bounds: Rectangle, // Clay's bounding box — keyed on for the bounds match check.
corner_radii: clay.CornerRadius, // Clay's corner radii — also keyed on for the match check.
image_data: Clay_Image_Data, // Only read when kind == .Fill_Texture (needed to refit UVs to inner_bounds).
kind: Clay_Merge_Candidate_Kind,
}
//INTERNAL
Clay_Merge_Candidate_Kind :: enum u8 {
// Solid Color brush. Used for Rectangle commands and for the bg primitive of an Image
// command that has `backgroundColor.a > 0`. Merge mutation: shrink shape + add outline.
Fill_Color,
// Texture_Fill brush. Used for the image primitive of an Image command with no bg, where
// `fit_params` returned `fit_rect == outer_bounds` (the image fully covers Clay's bounds).
// Merge mutation: shrink shape + add outline + refit UV against inner_bounds.
Fill_Texture,
}
// Returns true if this Clay render command represents a backdrop primitive — i.e. its
// `customData` points at a `Clay_Custom` whose active variant is `Backdrop_Marker`.
is_clay_backdrop :: proc(cmd: ^clay.RenderCommand) -> bool {
if cmd.commandType != .Custom do return false
p := cmd.renderData.custom.customData
if p == nil do return false
_, ok := (^Clay_Custom)(p).(Backdrop_Marker)
return ok
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Border emission ------------
// ---------------------------------------------------------------------------------------------------------------------
// Emit a Clay border drawn INSIDE `bounds` — the outer edge of each side aligns with
// `bounds`, the inner edge is `border_width.*` pixels inset. Matches Clay's layout model
// (CSS border-box) so the visible element occupies exactly Clay's allocated space.
//
// The fast path (uniform widths) uses `rectangle()` with the built-in SDF outline, which
// always extends outward from the shape it's given — we pre-shrink the shape by
// `border_width` so the outline lands precisely at Clay's bounds. The slow path (non-uniform
// widths) emits per-side rectangles and per-corner arcs directly, all positioned inside
// `bounds`. All-zero widths is a no-op.
//
// A corner is rounded iff its radius is positive AND both adjacent sides have positive
// width. Top corners take their thickness from `border_width.top`, bottom corners from
// `border_width.bottom`. When the two widths meeting at a corner differ there is a step at
// the side/corner junction (acceptable for the rare mixed-width case).
//
// When `border_width > corner_radius`, the inner corner clamps to zero (sharp inside, still
// rounded outside) — matches CSS-standard behavior.
//INTERNAL
clay_emit_partial_border :: proc(
layer: ^Layer,
bounds: Rectangle,
border_color: Color,
border_width: clay.BorderWidth,
corner_radii: clay.CornerRadius,
) {
// All-zero: nothing to draw.
if border_width.top == 0 && border_width.right == 0 && border_width.bottom == 0 && border_width.left == 0 {
return
}
// Convert side widths once (u16 -> f32) and cache for reuse.
width_top := f32(border_width.top)
width_right := f32(border_width.right)
width_bottom := f32(border_width.bottom)
width_left := f32(border_width.left)
// Fast path: all four sides have the same nonzero width. Pre-shrink the shape by the
// uniform width so the SDF outline (which always extends outward from the shape) lands
// exactly at Clay's `bounds` — the visible border ends up INSIDE Clay's allocation while
// the SDF mechanism keeps doing outward outlining. Single SDF primitive, exact curves,
// analytical AA.
if border_width.left == border_width.top &&
border_width.top == border_width.right &&
border_width.right == border_width.bottom {
uniform_width := width_top
inner_bounds := Rectangle {
x = bounds.x + uniform_width,
y = bounds.y + uniform_width,
width = bounds.width - 2 * uniform_width,
height = bounds.height - 2 * uniform_width,
}
inner_radii := Rectangle_Radii {
top_left = max(0, corner_radii.topLeft - uniform_width),
top_right = max(0, corner_radii.topRight - uniform_width),
bottom_right = max(0, corner_radii.bottomRight - uniform_width),
bottom_left = max(0, corner_radii.bottomLeft - uniform_width),
}
rectangle(
layer,
inner_bounds,
BLANK,
outline_color = border_color,
outline_width = uniform_width,
radii = inner_radii,
)
return
}
// A corner is drawn rounded only if its radius is positive AND both adjacent sides are present.
top_left_rounded := corner_radii.topLeft > 0 && border_width.top > 0 && border_width.left > 0
top_right_rounded := corner_radii.topRight > 0 && border_width.top > 0 && border_width.right > 0
bottom_left_rounded := corner_radii.bottomLeft > 0 && border_width.bottom > 0 && border_width.left > 0
bottom_right_rounded := corner_radii.bottomRight > 0 && border_width.bottom > 0 && border_width.right > 0
// Horizontal x-coordinates where the top/bottom side rectangles start/end. When the
// adjacent corner is rounded, the side stops at `bounds.x + radius` (where the corner
// arc takes over). When not rounded, the side runs to the bounds edge; the perpendicular
// side handles the inset to avoid overlap.
top_left_x: f32 = top_left_rounded ? bounds.x + corner_radii.topLeft : bounds.x
top_right_x: f32 =
top_right_rounded ? bounds.x + bounds.width - corner_radii.topRight : bounds.x + bounds.width
bottom_left_x: f32 = bottom_left_rounded ? bounds.x + corner_radii.bottomLeft : bounds.x
bottom_right_x: f32 =
bottom_right_rounded ? bounds.x + bounds.width - corner_radii.bottomRight : bounds.x + bounds.width
// Vertical y-coordinates where the left/right side rectangles start/end. When the
// adjacent corner is rounded, inset by the corner radius. When not rounded, inset by the
// adjacent horizontal width — the horizontal side owns the corner area (extending through
// it to the bounds edge), so the vertical side starts below it to avoid overdraw of
// translucent colors.
top_left_y: f32 = top_left_rounded ? bounds.y + corner_radii.topLeft : bounds.y + width_top
top_right_y: f32 = top_right_rounded ? bounds.y + corner_radii.topRight : bounds.y + width_top
bottom_left_y: f32 =
bottom_left_rounded ? bounds.y + bounds.height - corner_radii.bottomLeft : bounds.y + bounds.height - width_bottom
bottom_right_y: f32 =
bottom_right_rounded ? bounds.y + bounds.height - corner_radii.bottomRight : bounds.y + bounds.height - width_bottom
// Side rectangles drawn INSIDE `bounds`. Sharp corners, solid fill, no outline. Each
// gated on its own width — skipping zero-width sides saves the primitive upload.
if border_width.top > 0 {
top_side := Rectangle {
x = top_left_x,
y = bounds.y,
width = top_right_x - top_left_x,
height = width_top,
}
rectangle(layer, top_side, border_color)
}
if border_width.bottom > 0 {
bottom_side := Rectangle {
x = bottom_left_x,
y = bounds.y + bounds.height - width_bottom,
width = bottom_right_x - bottom_left_x,
height = width_bottom,
}
rectangle(layer, bottom_side, border_color)
}
if border_width.left > 0 {
left_side := Rectangle {
x = bounds.x,
y = top_left_y,
width = width_left,
height = bottom_left_y - top_left_y,
}
rectangle(layer, left_side, border_color)
}
if border_width.right > 0 {
right_side := Rectangle {
x = bounds.x + bounds.width - width_right,
y = top_right_y,
width = width_right,
height = bottom_right_y - top_right_y,
}
rectangle(layer, right_side, border_color)
}
// Corner arcs (90° quadrants) drawn INSIDE bounds: outer radius matches Clay's
// `corner_radii`, inner radius is the outer radius minus the relevant border thickness
// (clamped to 0 for thick borders — produces a filled pie slice when border > radius,
// matching CSS). Angle convention matches ring(): 0° = +x (right), 90° = +y (down),
// 180° = -x (left), 270° = -y (up).
if top_left_rounded {
radius := corner_radii.topLeft
inner_radius := max(0, radius - width_top)
center := Vec2{bounds.x + radius, bounds.y + radius}
ring(layer, center, inner_radius, radius, border_color, start_angle = 180, end_angle = 270)
}
if top_right_rounded {
radius := corner_radii.topRight
inner_radius := max(0, radius - width_top)
center := Vec2{bounds.x + bounds.width - radius, bounds.y + radius}
ring(layer, center, inner_radius, radius, border_color, start_angle = 270, end_angle = 360)
}
if bottom_right_rounded {
radius := corner_radii.bottomRight
inner_radius := max(0, radius - width_bottom)
center := Vec2{bounds.x + bounds.width - radius, bounds.y + bounds.height - radius}
ring(layer, center, inner_radius, radius, border_color, start_angle = 0, end_angle = 90)
}
if bottom_left_rounded {
radius := corner_radii.bottomLeft
inner_radius := max(0, radius - width_bottom)
center := Vec2{bounds.x + radius, bounds.y + bounds.height - radius}
ring(layer, center, inner_radius, radius, border_color, start_angle = 90, end_angle = 180)
}
}
// Try to retroactively merge this Border into a pending Rectangle/Image candidate on the
// merge stack. Returns true on success so the caller can skip the standalone Border emission.
//
// Clay emits a parent element's bg and border bracketing all the children's commands, so a
// simple "is the next command a Border?" check (the previous approach) only catches leaf
// elements. The stack approach lets us pair them across arbitrary nesting: every Rectangle/
// Image push registers itself; every Border pops down until it finds a geometric match.
//
// Pop semantics: non-matching candidates above the match are discarded — their elements had
// no border anyway, so their primitives stay in `tmp_primitives` as plain Rectangles. A
// Border that finds no match at all falls back to standalone `clay_emit_partial_border`.
//
// Predicates that decline a candidate:
// - non-uniform or zero border widths (can't be a single uniform outline)
// - translucent border (the unmerged path's bg-under-border blending differs)
// - mismatched bounds or cornerRadius (the candidate isn't from the same element)
//
// False-match risk: two unrelated elements with bit-identical bounds and corner radii.
// Requires geometric coincidence (rare in practice), and even when it fires, the misattributed
// outline still lands at the correct screen position with the correct color — the pixels
// match the unmerged ground truth for opaque borders (the only kind we merge).
//INTERNAL
try_dispatch_clay_border_merge :: proc(bounds: Rectangle, border_data: clay.BorderRenderData) -> bool {
border_width := border_data.width
uniform_nonzero :=
border_width.left == border_width.top &&
border_width.top == border_width.right &&
border_width.right == border_width.bottom &&
border_width.top > 0
if !uniform_nonzero do return false
if border_data.color[3] < 255 do return false
for len(GLOB.clay_merge_open_stack) > 0 {
candidate := pop(&GLOB.clay_merge_open_stack)
if candidate.outer_bounds != bounds do continue
if candidate.corner_radii != border_data.cornerRadius do continue
apply_clay_border_merge_to_primitive(candidate, border_data)
return true
}
return false
}
// Mutates `tmp_primitives[candidate.primitive_index]` in place: shrinks the SDF shape by
// the uniform border width so the (outward) outline lands at the outer bounds, sets the
// outline flag and params, and — for `Fill_Texture` candidates — refits the texture's UV
// against `inner_bounds` so the image doesn't overflow into the border strip.
//
// The primitive's `bounds` field stays at the outer bounds: the rasterized quad already
// covers the area the outline now occupies. Skipping the bounds expansion that
// `apply_brush_and_outline` would normally do is intentional — expanding here would push the
// rasterized quad past Clay's outer edge.
//INTERNAL
apply_clay_border_merge_to_primitive :: proc(
candidate: Clay_Merge_Candidate,
border_data: clay.BorderRenderData,
) {
prim := &GLOB.tmp_primitives[candidate.primitive_index]
uniform_width := f32(border_data.width.top)
dpi_scale := GLOB.dpi_scaling
inner_half_width := candidate.outer_bounds.width * 0.5 - uniform_width
inner_half_height := candidate.outer_bounds.height * 0.5 - uniform_width
prim.params.rrect.half_size_ppx = {inner_half_width * dpi_scale, inner_half_height * dpi_scale}
prim.params.rrect.radii_ppx = {
max(0, candidate.corner_radii.topLeft - uniform_width) * dpi_scale,
max(0, candidate.corner_radii.topRight - uniform_width) * dpi_scale,
max(0, candidate.corner_radii.bottomRight - uniform_width) * dpi_scale,
max(0, candidate.corner_radii.bottomLeft - uniform_width) * dpi_scale,
}
// Set the outline bit in the packed flags field (low byte = Shape_Kind, bits 8+ = Shape_Flags).
prim.flags |= u32(transmute(u8)Shape_Flags{.Outline}) << 8
prim.effects.outline_color = color_from_clay(border_data.color)
prim.effects.outline_packed = pack_f16_pair(f16(uniform_width * dpi_scale), 0)
if candidate.kind == .Fill_Texture {
// The candidate was only pushed if its `fit_rect == outer_bounds` at emission time, so the
// image fills the rasterized quad. Refit UVs against `inner_bounds` so the image is scoped
// to the area inside the new outline rather than overflowing into the border strip.
inner_bounds := Rectangle {
x = candidate.outer_bounds.x + uniform_width,
y = candidate.outer_bounds.y + uniform_width,
width = candidate.outer_bounds.width - 2 * uniform_width,
height = candidate.outer_bounds.height - 2 * uniform_width,
}
uv_rect, _, _ := fit_params(candidate.image_data.fit, inner_bounds, candidate.image_data.texture_id)
prim.uv_rect = {uv_rect.x, uv_rect.y, uv_rect.width, uv_rect.height}
}
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Command dispatch ------------
// ---------------------------------------------------------------------------------------------------------------------
// Dispatch a single non-backdrop Clay render command to the appropriate `draw` primitive.
// Extracted from the main `prepare_clay_batch` walk so that the deferred-buffer flush path
// can replay commands accumulated during an open backdrop scope without duplicating the
// per-command lowering code.
//INTERNAL
dispatch_clay_command :: proc(
layer: ^Layer,
render_command: ^clay.RenderCommand,
custom_draw: Custom_Draw,
temp_allocator: runtime.Allocator,
) {
// Translate bounding box of the primitive by the layer position
bounds := Rectangle {
x = render_command.boundingBox.x + layer.bounds.x,
y = render_command.boundingBox.y + layer.bounds.y,
width = render_command.boundingBox.width,
height = render_command.boundingBox.height,
}
switch render_command.commandType {
case clay.RenderCommandType.None:
log.errorf(
"Received render command with type None. This generally means we're in some kind of fucked up state.",
)
case clay.RenderCommandType.Text:
render_data := render_command.renderData.text
txt := string(render_data.stringContents.chars[:render_data.stringContents.length])
c_text := strings.clone_to_cstring(txt, temp_allocator)
defer delete(c_text, temp_allocator)
// 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(
Cache_Key{render_command.id, .Clay},
c_text,
get_font(render_data.fontId, render_data.fontSize),
)
prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)})
case clay.RenderCommandType.Image:
// Any texture
render_data := render_command.renderData.image
if render_data.imageData == nil do return
img_data := (^Clay_Image_Data)(render_data.imageData)^
corner_radii_clay := render_data.cornerRadius
radii := Rectangle_Radii {
top_left = corner_radii_clay.topLeft,
top_right = corner_radii_clay.topRight,
bottom_right = corner_radii_clay.bottomRight,
bottom_left = corner_radii_clay.bottomLeft,
}
background_color := color_from_clay(render_data.backgroundColor)
uv_rect, sampler, fit_rect := fit_params(img_data.fit, bounds, img_data.texture_id)
if background_color.a > 0 {
// Bg behind image. Push the bg primitive as the merge candidate so a matching Border
// turns into a bg+border-merged primitive plus a separate image draw on top.
rectangle(layer, bounds, background_color, radii = radii)
bg_primitive_index := u32(len(GLOB.tmp_primitives) - 1)
rectangle(
layer,
fit_rect,
Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv_rect, sampler = sampler},
radii = radii,
)
append(
&GLOB.clay_merge_open_stack,
Clay_Merge_Candidate {
primitive_index = bg_primitive_index,
outer_bounds = bounds,
corner_radii = corner_radii_clay,
kind = .Fill_Color,
},
)
} else {
// No bg: the image itself can host the outline if its fit fully covers Clay's bounds.
// `Fit_Mode.Fit` with aspect mismatch returns a sub-rect, which can't host an outline
// (the rasterized quad wouldn't reach Clay's outer edge), so we skip pushing.
rectangle(
layer,
fit_rect,
Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv_rect, sampler = sampler},
radii = radii,
)
if fit_rect == bounds {
img_primitive_index := u32(len(GLOB.tmp_primitives) - 1)
append(
&GLOB.clay_merge_open_stack,
Clay_Merge_Candidate {
primitive_index = img_primitive_index,
outer_bounds = bounds,
corner_radii = corner_radii_clay,
image_data = img_data,
kind = .Fill_Texture,
},
)
}
}
case clay.RenderCommandType.ScissorStart:
if bounds.width == 0 || bounds.height == 0 do return
curr_scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
if curr_scissor.sub_batch_len != 0 {
// Scissor has some content, need to make a new scissor
new := Scissor {
sub_batch_start = curr_scissor.sub_batch_start + curr_scissor.sub_batch_len,
bounds = sdl.Rect {
c.int(bounds.x * GLOB.dpi_scaling),
c.int(bounds.y * GLOB.dpi_scaling),
c.int(bounds.width * GLOB.dpi_scaling),
c.int(bounds.height * GLOB.dpi_scaling),
},
}
append(&GLOB.scissors, new)
layer.scissor_len += 1
} else {
curr_scissor.bounds = sdl.Rect {
c.int(bounds.x * GLOB.dpi_scaling),
c.int(bounds.y * GLOB.dpi_scaling),
c.int(bounds.width * GLOB.dpi_scaling),
c.int(bounds.height * GLOB.dpi_scaling),
}
}
case clay.RenderCommandType.ScissorEnd:
case clay.RenderCommandType.OverlayColorStart, clay.RenderCommandType.OverlayColorEnd:
unimplemented("Clay overlays not supported yet...")
case clay.RenderCommandType.Rectangle:
render_data := render_command.renderData.rectangle
corner_radii_clay := render_data.cornerRadius
background_color := color_from_clay(render_data.backgroundColor)
radii := Rectangle_Radii {
top_left = corner_radii_clay.topLeft,
top_right = corner_radii_clay.topRight,
bottom_right = corner_radii_clay.bottomRight,
bottom_left = corner_radii_clay.bottomLeft,
}
rectangle(layer, bounds, background_color, radii = radii)
// Register this primitive as a merge candidate. If the element has a matching Border
// later in the stream (after its children's commands), `try_dispatch_clay_border_merge`
// will pop this candidate and mutate the primitive in-place to add the outline.
primitive_index := u32(len(GLOB.tmp_primitives) - 1)
append(
&GLOB.clay_merge_open_stack,
Clay_Merge_Candidate {
primitive_index = primitive_index,
outer_bounds = bounds,
corner_radii = corner_radii_clay,
kind = .Fill_Color,
},
)
case clay.RenderCommandType.Border:
render_data := render_command.renderData.border
if try_dispatch_clay_border_merge(bounds, render_data) do return
clay_emit_partial_border(
layer,
bounds,
color_from_clay(render_data.color),
render_data.width,
render_data.cornerRadius,
)
case clay.RenderCommandType.Custom:
// Copy the CustomRenderData by value so we can patch its `customData` field for the
// user callback without mutating Clay-owned memory. After unwrapping, the callback
// sees its own pointer in `render_data.customData`, identical to what it would see
// if `Clay_Custom` did not exist as an intermediary.
patched := render_command.renderData.custom
// Default to nil so a zero-init `Clay_Custom` (no variant set) and an originally-nil
// `customData` both surface to the callback as `customData = nil`.
patched.customData = nil
if custom_data_pointer := render_command.renderData.custom.customData; custom_data_pointer != nil {
switch custom_value in (^Clay_Custom)(custom_data_pointer)^ {
case Backdrop_Marker: // The walker pre-filters backdrops into `dispatch_clay_backdrop` and never feeds
// them here; reaching this branch means either the walker logic is broken or the
// `Clay_Custom` variant tag mutated between the walker's `is_clay_backdrop` check
// and this re-check (heap corruption / lifetime bug in user-managed customData
// memory). Both are renderer-level bugs that warrant a hard failure rather than a
// silently-dropped panel.
log.panicf(
"backdrop marker reached dispatch_clay_command; either the prepare_clay_batch walker is misrouting commands or the customData pointee at %p was mutated mid-frame",
render_command.renderData.custom.customData,
)
case rawptr: patched.customData = custom_value
}
}
if custom_draw != nil {
custom_draw(layer, bounds, patched)
} else if patched.customData != nil {
log.panicf(
"Received clay render command of type custom with non-nil user data but no custom_draw proc provided.",
)
}
}
}
// Dispatch a single backdrop Clay render command to `backdrop_blur` on the active layer.
// Caller guarantees:
// - a backdrop scope is open on `layer` so the underlying `append_or_extend_sub_batch`
// contract assertion is satisfied;
// - the command's `customData` points at a `Clay_Custom` whose active variant is
// `Backdrop_Marker` (the walker has already verified this via `is_clay_backdrop`).
//INTERNAL
dispatch_clay_backdrop :: proc(layer: ^Layer, cmd: ^clay.RenderCommand) {
bounds := Rectangle {
x = cmd.boundingBox.x + layer.bounds.x,
y = cmd.boundingBox.y + layer.bounds.y,
width = cmd.boundingBox.width,
height = cmd.boundingBox.height,
}
// Type-asserting form (no `, ok`): panics loudly if the variant tag changed since
// `is_clay_backdrop`, which is the desired tripwire for a heap-corruption bug in
// user-managed customData.
marker := (^Clay_Custom)(cmd.renderData.custom.customData).(Backdrop_Marker)
backdrop_blur(
layer,
bounds,
gaussian_sigma = marker.sigma,
tint = marker.tint,
radii = marker.radii,
feather_ppx = marker.feather_ppx,
)
}
// Close the in-flight backdrop scope (if open) and replay every command accumulated in the
// deferred index buffer. Ordering: end_backdrop first so deferred non-backdrop draws land
// at submission position relative to the bracket they followed (the bracket is now closed,
// so these draws render after it). Used at every zIndex transition and at end of stream.
//INTERNAL
flush_deferred_and_close_backdrop_scope :: proc(
layer: ^Layer,
batch: ^ClayBatch,
deferred_indices: ^[dynamic]i32,
backdrop_scope_open: ^bool,
custom_draw: Custom_Draw,
temp_allocator: runtime.Allocator,
) {
if backdrop_scope_open^ {
end_backdrop(layer)
backdrop_scope_open^ = false
}
// Clear the merge stack at scope/stratum boundaries: any pending candidates from the
// pre-scope (or pre-transition) commands stay as plain primitives — they can't merge
// with Borders on the far side of the boundary because that would change draw order.
clear(&GLOB.clay_merge_open_stack)
for index in deferred_indices^ {
cmd := clay.RenderCommandArray_Get(&batch.cmds, index)
dispatch_clay_command(layer, cmd, custom_draw, temp_allocator)
}
clear(deferred_indices)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Main entry point ------------
// ---------------------------------------------------------------------------------------------------------------------
// Process Clay render commands into shape, text, and backdrop primitives.
//
// Single-walk dispatcher with a deferred buffer. The walk does three things per command:
// 1. zIndex transitions: close the in-flight scope, flush any deferred non-backdrop
// commands into the current layer, then open a new layer seeded with `base_layer.bounds`
// (NOT the bumping element's bounds — Clay's floating elements with `clipTo = .None`
// should not be over-clipped, and `clipTo = .AttachedParent` floating elements get a
// Clay-emitted ScissorStart immediately afterward that narrows correctly).
// 2. Backdrop commands: open a scope on first encounter (extending it on subsequent ones),
// then dispatch the backdrop_blur call.
// 3. Non-backdrop commands during an open scope: append to the deferred buffer for replay
// after the scope closes. The buffer holds command indices, not pointers, so it stays
// valid even if the underlying ClayArray reallocates.
// At end of stream, flush whatever remains.
prepare_clay_batch :: proc(
base_layer: ^Layer,
batch: ^ClayBatch,
mouse_wheel_delta: [2]f32,
frame_time: f32 = 0,
custom_draw: Custom_Draw = nil,
temp_allocator := context.temp_allocator,
) {
mouse_pos: [2]f32
mouse_flags := sdl.GetMouseState(&mouse_pos.x, &mouse_pos.y)
// Update clay internals
clay.SetPointerState(
clay.Vector2{mouse_pos.x - base_layer.bounds.x, mouse_pos.y - base_layer.bounds.y},
.LEFT in mouse_flags,
)
clay.UpdateScrollContainers(true, mouse_wheel_delta, frame_time)
layer := base_layer
command_count := int(batch.cmds.length)
deferred_indices := make([dynamic]i32, 0, 16, temp_allocator)
backdrop_scope_open := false
// Seed from GLOB.clay_z_index so multi-batch frames preserve the original semantics: a
// later call to `prepare_clay_batch` doesn't re-trigger layer splits for zIndex values
// the previous batch already saw.
previous_z_index := GLOB.clay_z_index
// Start with a clean merge stack. The stack is also cleared by
// `flush_deferred_and_close_backdrop_scope` at every stratum boundary; both clears together
// ensure merge candidates never pair across a boundary that would shift draw order.
clear(&GLOB.clay_merge_open_stack)
for i in 0 ..< command_count {
cmd := clay.RenderCommandArray_Get(&batch.cmds, i32(i))
// zIndex transition: close out current stratum, create new layer, continue.
if cmd.zIndex > previous_z_index {
log.debug("Higher zIndex found, creating new layer & setting z_index to", cmd.zIndex)
flush_deferred_and_close_backdrop_scope(
layer,
batch,
&deferred_indices,
&backdrop_scope_open,
custom_draw,
temp_allocator,
)
layer = new_layer(layer, base_layer.bounds)
previous_z_index = cmd.zIndex
// Keep GLOB.clay_z_index in sync for any external readers (debug tooling, etc.).
GLOB.clay_z_index = cmd.zIndex
}
if is_clay_backdrop(cmd) {
if !backdrop_scope_open {
begin_backdrop(layer)
backdrop_scope_open = true
}
dispatch_clay_backdrop(layer, cmd)
} else if backdrop_scope_open {
append(&deferred_indices, i32(i))
} else {
// Rectangle/Image dispatches push merge candidates; Border dispatches pop the stack
// to retroactively add an outline to a matching candidate. See
// `try_dispatch_clay_border_merge` for the matching semantics.
dispatch_clay_command(layer, cmd, custom_draw, temp_allocator)
}
}
// End-of-stream: flush whatever remains.
flush_deferred_and_close_backdrop_scope(
layer,
batch,
&deferred_indices,
&backdrop_scope_open,
custom_draw,
temp_allocator,
)
}