diff --git a/draw/clay.odin b/draw/clay.odin new file mode 100644 index 0000000..6134dd3 --- /dev/null +++ b/draw/clay.odin @@ -0,0 +1,815 @@ +// 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 0–255 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, + ) +} diff --git a/draw/draw.odin b/draw/draw.odin index a5ffac6..01e9652 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -67,12 +67,9 @@ import "base:runtime" import "core:c" import "core:log" import "core:math" -import "core:strings" import sdl "vendor:sdl3" import sdl_ttf "vendor:sdl3/ttf" -import clay "../vendor/clay" - // --------------------------------------------------------------------------------------------------------------------- // ----- Shader format ------------ // --------------------------------------------------------------------------------------------------------------------- @@ -267,11 +264,6 @@ Brush :: union { Texture_Fill, } -// Convert clay.Color ([4]c.float in 0–255 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])} -} - // Convert Color to [4]f32 in 0.0–1.0 range. Useful for SDL interop (e.g. clear color). color_to_f32 :: proc(color: Color) -> [4]f32 { INV :: 1.0 / 255.0 @@ -347,8 +339,6 @@ init :: proc( ) -> ( ok: bool, ) { - min_memory_size: c.size_t = cast(c.size_t)clay.MinMemorySize() - core, core_ok := create_core_2d(device, window) if !core_ok { return false @@ -382,7 +372,6 @@ init :: proc( ), tmp_sub_batches = make([dynamic]Sub_Batch, 0, BUFFER_INIT_SIZE, allocator = allocator), tmp_uncached_text = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator), - clay_merge_open_stack = make([dynamic]Clay_Merge_Candidate, 0, 16, allocator = allocator), tmp_gaussian_blur_primitives = make( [dynamic]Gaussian_Blur_Primitive, 0, @@ -396,7 +385,6 @@ init :: proc( pending_text_releases = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator), odin_context = odin_context, dpi_scaling = sdl.GetWindowDisplayScale(window), - clay_memory = make([^]u8, min_memory_size, allocator = allocator), core_2d = core, backdrop = backdrop, text_cache = text_cache, @@ -405,12 +393,7 @@ init :: proc( // Reserve slot 0 for INVALID_TEXTURE append(&GLOB.texture_slots, Texture_Slot{}) log.debug("Window DPI scaling:", GLOB.dpi_scaling) - 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) + init_clay(window, allocator) return true } @@ -449,7 +432,7 @@ destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) { delete(GLOB.tmp_gaussian_blur_primitives) for ttf_text in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(ttf_text) delete(GLOB.tmp_uncached_text) - free(GLOB.clay_memory, allocator) + destroy_clay(allocator) process_pending_texture_releases() destroy_all_textures() destroy_sampler_pool() @@ -469,7 +452,6 @@ clear_global :: proc() { clear(&GLOB.pending_text_releases) GLOB.curr_layer_index = 0 - GLOB.clay_z_index = 0 GLOB.cleared = false GLOB.open_backdrop_layer = nil // Destroy uncached TTF_Text objects from the previous frame (after end() has submitted draw data) @@ -484,7 +466,7 @@ clear_global :: proc() { clear(&GLOB.tmp_primitives) clear(&GLOB.tmp_sub_batches) clear(&GLOB.tmp_gaussian_blur_primitives) - clear(&GLOB.clay_merge_open_stack) + clear_clay_per_frame() } // --------------------------------------------------------------------------------------------------------------------- @@ -728,725 +710,6 @@ append_or_extend_sub_batch :: proc( layer.sub_batch_len += 1 } -// --------------------------------------------------------------------------------------------------------------------- -// ----- Clay ------------ -// --------------------------------------------------------------------------------------------------------------------- - -@(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} -} - -// 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, -} - -// 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 -} - -// 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} - } -} - -// 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) -} - -// 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, - ) -} - // --------------------------------------------------------------------------------------------------------------------- // ----- Buffer ------------ // --------------------------------------------------------------------------------------------------------------------- diff --git a/draw/textures.odin b/draw/textures.odin index a48ab57..8d5dac5 100644 --- a/draw/textures.odin +++ b/draw/textures.odin @@ -56,16 +56,6 @@ Texture_Slot :: struct { // GLOB.pending_texture_releases : [dynamic]Texture_Id // GLOB.samplers : [SAMPLER_PRESET_COUNT]^sdl.GPUSampler -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} -} - // --------------------------------------------------------------------------------------------------------------------- // ----- Registration ------------- // ---------------------------------------------------------------------------------------------------------------------