From 6a0a984310094ad2f73a6c047f2608762234b1a7 Mon Sep 17 00:00:00 2001 From: Zachary Levy Date: Tue, 12 May 2026 04:47:23 +0000 Subject: [PATCH] Added full clay border support to draw (#28) Co-authored-by: Zachary Levy Reviewed-on: https://git.bfpower.io/BFPOWER/levlib/pulls/28 --- .zed/tasks.json | 5 + draw/clay.odin | 806 ++++++++++++++++++++++++++++++++ draw/draw.odin | 411 +--------------- draw/draw_qr/draw_qr.odin | 66 +-- draw/examples/clay_borders.odin | 363 ++++++++++++++ draw/examples/examples.odin | 4 + draw/examples/textures.odin | 20 +- draw/textures.odin | 10 - 8 files changed, 1236 insertions(+), 449 deletions(-) create mode 100644 draw/clay.odin create mode 100644 draw/examples/clay_borders.odin diff --git a/.zed/tasks.json b/.zed/tasks.json index a9be01a..9b0b288 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -75,6 +75,11 @@ "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- textures", "cwd": "$ZED_WORKTREE_ROOT", }, + { + "label": "Run draw clay-borders example", + "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- clay-borders", + "cwd": "$ZED_WORKTREE_ROOT", + }, { "label": "Run draw gaussian-blur example", "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- gaussian-blur", diff --git a/draw/clay.odin b/draw/clay.odin new file mode 100644 index 0000000..e6abf8d --- /dev/null +++ b/draw/clay.odin @@ -0,0 +1,806 @@ +// 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) +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- 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(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(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(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(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(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 738ef2d..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 ------------ // --------------------------------------------------------------------------------------------------------------------- @@ -171,6 +168,7 @@ Global :: struct { // -- Clay (once per frame in prepare_clay_batch) -- clay_memory: [^]u8, // Raw memory block backing Clay's internal arena. + clay_merge_open_stack: [dynamic]Clay_Merge_Candidate, // Pending Rectangle/Image primitives waiting for a matching Border to merge with. // -- Text (occasional — font registration and text cache lookups) -- text_cache: Text_Cache, // Font registry, SDL_ttf engine, and cached TTF_Text objects. @@ -266,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 @@ -346,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 @@ -394,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, @@ -403,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 } @@ -447,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() @@ -467,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) @@ -482,6 +466,7 @@ clear_global :: proc() { clear(&GLOB.tmp_primitives) clear(&GLOB.tmp_sub_batches) clear(&GLOB.tmp_gaussian_blur_primitives) + clear_clay_per_frame() } // --------------------------------------------------------------------------------------------------------------------- @@ -725,394 +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, -} - -// 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 -} - -// 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)^ - cr := render_data.cornerRadius - radii := Rectangle_Radii { - top_left = cr.topLeft, - top_right = cr.topRight, - bottom_right = cr.bottomRight, - bottom_left = cr.bottomLeft, - } - - // Background color behind the image (Clay allows it) - bg := color_from_clay(render_data.backgroundColor) - if bg.a > 0 { - rectangle(layer, bounds, bg, radii = radii) - } - - // Compute fit UVs - uv, sampler, inner := fit_params(img_data.fit, bounds, img_data.texture_id) - - // Draw the image - rectangle( - layer, - inner, - Texture_Fill{id = img_data.texture_id, tint = img_data.tint, uv_rect = uv, sampler = sampler}, - radii = radii, - ) - 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 - cr := render_data.cornerRadius - color := color_from_clay(render_data.backgroundColor) - radii := Rectangle_Radii { - top_left = cr.topLeft, - top_right = cr.topRight, - bottom_right = cr.bottomRight, - bottom_left = cr.bottomLeft, - } - - rectangle(layer, bounds, color, radii = radii) - case clay.RenderCommandType.Border: - render_data := render_command.renderData.border - cr := render_data.cornerRadius - color := color_from_clay(render_data.color) - thickness := f32(render_data.width.top) - radii := Rectangle_Radii { - top_left = cr.topLeft, - top_right = cr.topRight, - bottom_right = cr.bottomRight, - bottom_left = cr.bottomLeft, - } - - rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii) - 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 - } - 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 - - 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 { - 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/draw_qr/draw_qr.odin b/draw/draw_qr/draw_qr.odin index f9e9e9d..9d8c0cf 100644 --- a/draw/draw_qr/draw_qr.odin +++ b/draw/draw_qr/draw_qr.odin @@ -1,54 +1,71 @@ package draw_qr +import "core:mem" +import "core:slice" + import draw ".." import "../../qrcode" DFT_QR_DARK :: draw.BLACK // Default QR code dark module color. DFT_QR_LIGHT :: draw.WHITE // Default QR code light module color. DFT_QR_BOOST_ECL :: true // Default QR error correction level boost. +DFT_QR_QUIET_ZONE :: 4 // Default light-pixel border on each side; 4 is the QR spec value. -// Returns the number of bytes to_texture will write for the given encoded -// QR buffer. Equivalent to size*size*4 where size = qrcode.get_size(qrcode_buf). -texture_size :: #force_inline proc(qrcode_buf: []u8) -> int { +// Returns the number of bytes to_texture will write. Equals dim*dim*4 where +// dim = qrcode.get_size(qrcode_buf) + 2*quiet_zone. +texture_size :: #force_inline proc(qrcode_buf: []u8, quiet_zone: int = DFT_QR_QUIET_ZONE) -> int { size := qrcode.get_size(qrcode_buf) - return size * size * 4 + if size == 0 || quiet_zone < 0 do return 0 + padded_size := size + 2 * quiet_zone + return padded_size * padded_size * 4 } // Decodes an encoded QR buffer into tightly-packed RGBA pixel data written to // texture_buf. No allocations, no GPU calls. Returns the Texture_Desc the // caller should pass to draw.register_texture alongside texture_buf. // +// quiet_zone adds that many `light` pixels on each side; the spec value is 4. +// Final dimension is qrcode.get_size + 2*quiet_zone on each axis. +// // Returns ok=false when: // - qrcode_buf is invalid (qrcode.get_size returns 0). -// - texture_buf is smaller than texture_size(qrcode_buf). +// - quiet_zone is negative. +// - texture_buf is smaller than texture_size(qrcode_buf, quiet_zone). @(require_results) to_texture :: proc( qrcode_buf: []u8, texture_buf: []u8, dark: draw.Color = DFT_QR_DARK, light: draw.Color = DFT_QR_LIGHT, + quiet_zone: int = DFT_QR_QUIET_ZONE, ) -> ( desc: draw.Texture_Desc, ok: bool, ) { size := qrcode.get_size(qrcode_buf) - if size == 0 do return {}, false - if len(texture_buf) < size * size * 4 do return {}, false + if size == 0 || quiet_zone < 0 do return + padded_size := size + 2 * quiet_zone + if len(texture_buf) < padded_size * padded_size * 4 do return + // Type-pun to []Color so each store is a single 32-bit write. + pixels := mem.slice_data_cast([]draw.Color, texture_buf[:padded_size * padded_size * 4]) + + // Bulk-fill with light: handles the border and every light QR module at once. + slice.fill(pixels, light) + + // Overwrite only the dark modules, offset by the quiet-zone border. for y in 0 ..< size { + row := (y + quiet_zone) * padded_size + quiet_zone for x in 0 ..< size { - i := (y * size + x) * 4 - c := dark if qrcode.get_module(qrcode_buf, x, y) else light - texture_buf[i + 0] = c[0] - texture_buf[i + 1] = c[1] - texture_buf[i + 2] = c[2] - texture_buf[i + 3] = c[3] + if qrcode.get_module(qrcode_buf, x, y) { + pixels[row + x] = dark + } } } return draw.Texture_Desc { - width = u32(size), - height = u32(size), + width = u32(padded_size), + height = u32(padded_size), depth_or_layers = 1, type = .D2, format = .R8G8B8A8_UNORM, @@ -71,19 +88,20 @@ register_texture_from_raw :: proc( qrcode_buf: []u8, dark: draw.Color = DFT_QR_DARK, light: draw.Color = DFT_QR_LIGHT, + quiet_zone: int = DFT_QR_QUIET_ZONE, temp_allocator := context.temp_allocator, ) -> ( texture: draw.Texture_Id, ok: bool, ) { - tex_size := texture_size(qrcode_buf) + tex_size := texture_size(qrcode_buf, quiet_zone) if tex_size == 0 do return draw.INVALID_TEXTURE, false pixels, alloc_err := make([]u8, tex_size, temp_allocator) if alloc_err != nil do return draw.INVALID_TEXTURE, false defer delete(pixels, temp_allocator) - desc := to_texture(qrcode_buf, pixels, dark, light) or_return + desc := to_texture(qrcode_buf, pixels, dark, light, quiet_zone) or_return return draw.register_texture(desc, pixels) } @@ -103,6 +121,7 @@ register_texture_from_text :: proc( boost_ecl: bool = DFT_QR_BOOST_ECL, dark: draw.Color = DFT_QR_DARK, light: draw.Color = DFT_QR_LIGHT, + quiet_zone: int = DFT_QR_QUIET_ZONE, temp_allocator := context.temp_allocator, ) -> ( texture: draw.Texture_Id, @@ -123,7 +142,7 @@ register_texture_from_text :: proc( temp_allocator, ) or_return - return register_texture_from_raw(qrcode_buf, dark, light, temp_allocator) + return register_texture_from_raw(qrcode_buf, dark, light, quiet_zone, temp_allocator) } // Encodes arbitrary binary data as a QR Code and registers the result as an RGBA texture. @@ -142,6 +161,7 @@ register_texture_from_binary :: proc( boost_ecl: bool = DFT_QR_BOOST_ECL, dark: draw.Color = DFT_QR_DARK, light: draw.Color = DFT_QR_LIGHT, + quiet_zone: int = DFT_QR_QUIET_ZONE, temp_allocator := context.temp_allocator, ) -> ( texture: draw.Texture_Id, @@ -162,18 +182,10 @@ register_texture_from_binary :: proc( temp_allocator, ) or_return - return register_texture_from_raw(qrcode_buf, dark, light, temp_allocator) + return register_texture_from_raw(qrcode_buf, dark, light, quiet_zone, temp_allocator) } register_texture_from :: proc { register_texture_from_text, register_texture_from_binary, } - -// Default fit=.Fit preserves the QR's square aspect; override as needed. -clay_image :: #force_inline proc( - texture: draw.Texture_Id, - tint: draw.Color = draw.DFT_TINT, -) -> draw.Clay_Image_Data { - return draw.clay_image_data(texture, fit = .Fit, tint = tint) -} diff --git a/draw/examples/clay_borders.odin b/draw/examples/clay_borders.odin new file mode 100644 index 0000000..7d210c8 --- /dev/null +++ b/draw/examples/clay_borders.odin @@ -0,0 +1,363 @@ +package examples + +import "core:os" +import sdl "vendor:sdl3" + +import "../../draw" +import "../../vendor/clay" +import cyber "../cybersteel" + +// Clay border debug example. +// +// Lays out a grid of bordered Clay elements that exercise every code path in +// `clay_emit_partial_border` and `try_dispatch_clay_rect_border_pair`: +// +// 1. Uniform borders (fast path) — sharp, rounded, and the border-thicker-than-radius +// edge case (inner corner clamps to 0). +// 2. Background + border combinations — opaque bg + opaque uniform border MERGES into one +// SDF primitive; translucent border DECLINES the merge to preserve blend fidelity; +// non-uniform border declines and falls through to the slow path; translucent bg with +// opaque border still merges (bg alpha doesn't affect merge correctness). +// 3. Single-side borders — top / right / bottom / left individually. +// 4. Two-side borders — parallel pairs (no corners drawn) and adjacent pairs (one corner +// rounds, others stay square). +// 5. Three-side borders + asymmetric widths. +// 6. Layout correctness — a vertical list with bottom-border separators (each border +// lives inside its own item, no bleed between siblings) and a row of adjacent fully +// bordered siblings (no border overlap, each in its own bounds). +clay_borders :: proc() { + if !sdl.Init({.VIDEO}) do os.exit(1) + window := sdl.CreateWindow("Clay Borders Debug", 1200, 900, {.HIGH_PIXEL_DENSITY}) + gpu := sdl.CreateGPUDevice(draw.PLATFORM_SHADER_FORMAT, true, nil) + if !sdl.ClaimWindowForGPUDevice(gpu, window) do os.exit(1) + if !draw.init(gpu, window) do os.exit(1) + PLEX_SANS_REGULAR = draw.register_font(cyber.SANS_REGULAR_RAW) + + // Distinct colors so the fill, border, and translucent variants are visually unambiguous. + BG_PAGE :: draw.Color{25, 25, 30, 255} + FILL_OPAQUE :: draw.Color{80, 120, 200, 255} + FILL_TRANSLUCENT :: draw.Color{80, 120, 200, 128} + BORDER_OPAQUE :: draw.Color{255, 200, 100, 255} + BORDER_TRANSLUCENT :: draw.Color{255, 200, 100, 128} + + label_config := clay.TextElementConfig { + fontId = PLEX_SANS_REGULAR, + fontSize = 12, + textColor = {220, 220, 220, 255}, + } + header_config := clay.TextElementConfig { + fontId = PLEX_SANS_REGULAR, + fontSize = 16, + textColor = {255, 255, 255, 255}, + } + title_config := clay.TextElementConfig { + fontId = PLEX_SANS_REGULAR, + fontSize = 22, + textColor = {255, 255, 255, 255}, + } + + for { + defer free_all(context.temp_allocator) + ev: sdl.Event + for sdl.PollEvent(&ev) { + if ev.type == .QUIT do return + } + + base_layer := draw.begin({width = 1200, height = 900}) + clay.SetLayoutDimensions({width = base_layer.bounds.width, height = base_layer.bounds.height}) + clay.BeginLayout() + + if clay.UI(clay.ID("borders_page"))( + { + layout = { + sizing = {clay.SizingGrow({}), clay.SizingGrow({})}, + padding = clay.PaddingAll(20), + childGap = 14, + layoutDirection = .TopToBottom, + }, + backgroundColor = clay_color(BG_PAGE), + }, + ) { + clay.Text("Clay Borders Debug", title_config) + + //----- Section 1: Uniform borders (fast path) ----------------------------------- + clay.Text("Uniform borders (fast path)", header_config) + if clay.UI(clay.ID("row_uniform"))(border_row_layout()) { + border_test_card( + "1px sharp", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {left = 1, right = 1, top = 1, bottom = 1}, + {}, + ) + border_test_card( + "2px, radius 8", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {left = 2, right = 2, top = 2, bottom = 2}, + {topLeft = 8, topRight = 8, bottomRight = 8, bottomLeft = 8}, + ) + border_test_card( + "8px, radius 20", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {left = 8, right = 8, top = 8, bottom = 8}, + {topLeft = 20, topRight = 20, bottomRight = 20, bottomLeft = 20}, + ) + border_test_card( + "10px > radius 5 (inner clamps)", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {left = 10, right = 10, top = 10, bottom = 10}, + {topLeft = 5, topRight = 5, bottomRight = 5, bottomLeft = 5}, + ) + } + + //----- Section 2: Background + border (merge optimization) ---------------------- + clay.Text("Background + border (merge optimization)", header_config) + if clay.UI(clay.ID("row_bg_border"))(border_row_layout()) { + border_test_card( + "opaque bg + opaque (MERGES: 1 prim)", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {left = 2, right = 2, top = 2, bottom = 2}, + {topLeft = 6, topRight = 6, bottomRight = 6, bottomLeft = 6}, + ) + border_test_card( + "translucent bg + opaque (MERGES)", + label_config, + FILL_TRANSLUCENT, + BORDER_OPAQUE, + {left = 3, right = 3, top = 3, bottom = 3}, + {topLeft = 6, topRight = 6, bottomRight = 6, bottomLeft = 6}, + ) + border_test_card( + "opaque bg + translucent (NO merge)", + label_config, + FILL_OPAQUE, + BORDER_TRANSLUCENT, + {left = 4, right = 4, top = 4, bottom = 4}, + {topLeft = 8, topRight = 8, bottomRight = 8, bottomLeft = 8}, + ) + border_test_card( + "opaque bg + non-uniform (NO merge)", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {left = 1, right = 4, top = 2, bottom = 3}, + {topLeft = 6, topRight = 6, bottomRight = 6, bottomLeft = 6}, + ) + } + + //----- Section 3: Single side borders ------------------------------------------- + clay.Text("Single side", header_config) + if clay.UI(clay.ID("row_single_side"))(border_row_layout()) { + border_test_card("top only (4px)", label_config, FILL_OPAQUE, BORDER_OPAQUE, {top = 4}, {}) + border_test_card("right only (4px)", label_config, FILL_OPAQUE, BORDER_OPAQUE, {right = 4}, {}) + border_test_card( + "bottom only (4px, divider)", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {bottom = 4}, + {}, + ) + border_test_card("left only (4px)", label_config, FILL_OPAQUE, BORDER_OPAQUE, {left = 4}, {}) + } + + //----- Section 4: Two side borders ---------------------------------------------- + clay.Text("Two sides", header_config) + if clay.UI(clay.ID("row_two_sides"))(border_row_layout()) { + border_test_card( + "T+B parallel (no corners)", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {top = 3, bottom = 3}, + {topLeft = 8, topRight = 8, bottomRight = 8, bottomLeft = 8}, + ) + border_test_card( + "L+R parallel (no corners)", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {left = 3, right = 3}, + {topLeft = 8, topRight = 8, bottomRight = 8, bottomLeft = 8}, + ) + border_test_card( + "T+L adjacent (TL rounds)", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {top = 3, left = 3}, + {topLeft = 12, topRight = 12, bottomRight = 12, bottomLeft = 12}, + ) + border_test_card( + "B+R adjacent (BR rounds)", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {bottom = 3, right = 3}, + {topLeft = 12, topRight = 12, bottomRight = 12, bottomLeft = 12}, + ) + } + + //----- Section 5: Three sides + asymmetric widths ------------------------------- + clay.Text("Three sides + asymmetric widths", header_config) + if clay.UI(clay.ID("row_advanced"))(border_row_layout()) { + border_test_card( + "T+R+B (no L), rounded", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {top = 3, right = 3, bottom = 3}, + {topLeft = 8, topRight = 8, bottomRight = 8, bottomLeft = 8}, + ) + border_test_card( + "T+L+R (no B), rounded", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {top = 3, left = 3, right = 3}, + {topLeft = 8, topRight = 8, bottomRight = 8, bottomLeft = 8}, + ) + border_test_card( + "asym 1/2/3/4 T/R/B/L", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {top = 1, right = 2, bottom = 3, left = 4}, + {}, + ) + border_test_card( + "asym + rounded", + label_config, + FILL_OPAQUE, + BORDER_OPAQUE, + {top = 2, right = 4, bottom = 2, left = 4}, + {topLeft = 10, topRight = 10, bottomRight = 10, bottomLeft = 10}, + ) + } + + //----- Section 6: Layout correctness -------------------------------------------- + clay.Text("Layout correctness", header_config) + if clay.UI(clay.ID("row_correctness"))( + {layout = {sizing = {clay.SizingGrow({}), clay.SizingFit({})}, childGap = 14}}, + ) { + // 6a: vertical list with per-item bottom-border separator. Each item's + // border draws INSIDE its own bounds, so adjacent items don't bleed. + if clay.UI(clay.ID("list_demo"))( + { + layout = { + sizing = {clay.SizingFixed(300), clay.SizingFit({})}, + padding = clay.PaddingAll(6), + childGap = 6, + layoutDirection = .TopToBottom, + }, + }, + ) { + clay.Text("List with bottom-border separators", label_config) + if clay.UI(clay.ID("list_outer"))( + { + layout = {sizing = {clay.SizingGrow({}), clay.SizingFit({})}, layoutDirection = .TopToBottom}, + backgroundColor = clay_color(FILL_OPAQUE), + }, + ) { + for index in 0 ..< 5 { + if clay.UI(clay.ID("list_item", u32(index)))( + { + layout = {sizing = {clay.SizingGrow({}), clay.SizingFixed(28)}, padding = clay.PaddingAll(6)}, + border = {color = clay_color(BORDER_OPAQUE), width = {bottom = 1}}, + }, + ) { + clay.Text("Item", label_config) + } + } + } + } + + // 6b: row of adjacent fully bordered siblings. With borders rendered + // INSIDE each element's bounds, the boundary between two siblings shows + // the natural 2*width sum (no overlap, no bleed). + if clay.UI(clay.ID("adj_demo"))( + { + layout = { + sizing = {clay.SizingFixed(380), clay.SizingFit({})}, + padding = clay.PaddingAll(6), + childGap = 6, + layoutDirection = .TopToBottom, + }, + }, + ) { + clay.Text("Adjacent bordered siblings (no gap)", label_config) + if clay.UI(clay.ID("adj_row"))({layout = {sizing = {clay.SizingGrow({}), clay.SizingFit({})}}}) { + for index in 0 ..< 4 { + if clay.UI(clay.ID("adj_item", u32(index)))( + { + layout = {sizing = {clay.SizingFixed(80), clay.SizingFixed(60)}}, + backgroundColor = clay_color(FILL_OPAQUE), + border = {color = clay_color(BORDER_OPAQUE), width = {left = 2, right = 2, top = 2, bottom = 2}}, + }, + ) {} + } + } + } + } + } + + clay_batch := draw.ClayBatch { + bounds = base_layer.bounds, + cmds = clay.EndLayout(0), + } + draw.prepare_clay_batch(base_layer, &clay_batch, {0, 0}) + draw.end(gpu, window) + } +} + +// Helper: convert a draw.Color (RGBA u8) to clay.Color (RGBA float in 0-255 range). +clay_color :: proc(c: draw.Color) -> clay.Color { + return clay.Color{f32(c[0]), f32(c[1]), f32(c[2]), f32(c[3])} +} + +// Helper: shared row container declaration for the test sections. +border_row_layout :: proc() -> clay.ElementDeclaration { + return clay.ElementDeclaration{layout = {sizing = {clay.SizingGrow({}), clay.SizingFit({})}, childGap = 12}} +} + +// One labeled test card: a fixed-width column with a caption above and a sample bordered +// rectangle below. Uses `clay.ID_LOCAL` for the inner element so each card gets a unique +// child ID without the caller passing one explicitly. +border_test_card :: proc( + label: string, + label_config: clay.TextElementConfig, + fill_color: draw.Color, + border_color: draw.Color, + border_width: clay.BorderWidth, + corner_radii: clay.CornerRadius, +) { + if clay.UI(clay.ID(label))( + { + layout = { + sizing = {clay.SizingFixed(275), clay.SizingFit({})}, + padding = clay.PaddingAll(4), + childGap = 6, + layoutDirection = .TopToBottom, + }, + }, + ) { + clay.Text(label, label_config) + if clay.UI(clay.ID_LOCAL("test_inner"))( + { + layout = {sizing = {clay.SizingGrow({}), clay.SizingFixed(64)}}, + backgroundColor = clay_color(fill_color), + border = clay.BorderElementConfig{color = clay_color(border_color), width = border_width}, + cornerRadius = corner_radii, + }, + ) {} + } +} diff --git a/draw/examples/examples.odin b/draw/examples/examples.odin index c865437..4e5ab1a 100644 --- a/draw/examples/examples.odin +++ b/draw/examples/examples.odin @@ -9,6 +9,7 @@ EX_HELLOPE_SHAPES :: "hellope-shapes" EX_HELLOPE_TEXT :: "hellope-text" EX_HELLOPE_CLAY :: "hellope-clay" EX_HELLOPE_CUSTOM :: "hellope-custom" +EX_CLAY_BORDERS :: "clay-borders" EX_TEXTURES :: "textures" EX_GAUSSIAN_BLUR :: "gaussian-blur" EX_GAUSSIAN_BLUR_DEBUG :: "gaussian-blur-debug" @@ -23,6 +24,8 @@ AVAILABLE_EXAMPLES_MSG :: ", " + EX_HELLOPE_CUSTOM + ", " + + EX_CLAY_BORDERS + + ", " + EX_TEXTURES + ", " + EX_GAUSSIAN_BLUR + @@ -81,6 +84,7 @@ main :: proc() { case EX_HELLOPE_CUSTOM: hellope_custom() case EX_HELLOPE_SHAPES: hellope_shapes() case EX_HELLOPE_TEXT: hellope_text() + case EX_CLAY_BORDERS: clay_borders() case EX_TEXTURES: textures() case EX_GAUSSIAN_BLUR: gaussian_blur() case EX_GAUSSIAN_BLUR_DEBUG: gaussian_blur_debug() diff --git a/draw/examples/textures.odin b/draw/examples/textures.odin index 9a3c6d9..7244f6c 100644 --- a/draw/examples/textures.odin +++ b/draw/examples/textures.odin @@ -166,12 +166,14 @@ textures :: proc() { ROW2_Y :: f32(190) - // QR code (RGBA texture with baked colors, nearest sampling) + // QR code (RGBA texture with baked colors, nearest sampling) + thin framing border. draw.rectangle(base_layer, {COL1, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, draw.Color{255, 255, 255, 255}) // white bg draw.rectangle( base_layer, {COL1, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, draw.Texture_Fill{id = qr_texture, tint = draw.WHITE, uv_rect = {0, 0, 1, 1}, sampler = .Nearest_Clamp}, + outline_color = draw.WHITE, + outline_width = 2, ) draw.text( base_layer, @@ -182,7 +184,7 @@ textures :: proc() { color = draw.WHITE, ) - // Rounded corners + // Rounded corners + outline traces the rounded shape. draw.rectangle( base_layer, {COL2, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, @@ -192,6 +194,8 @@ textures :: proc() { uv_rect = {0, 0, 1, 1}, sampler = .Nearest_Clamp, }, + outline_color = draw.Color{255, 200, 100, 255}, + outline_width = 3, radii = draw.uniform_radii({COL2, ROW2_Y, ITEM_SIZE, ITEM_SIZE}, 0.3), ) draw.text( @@ -203,7 +207,7 @@ textures :: proc() { color = draw.WHITE, ) - // Rotating + // Rotating + outline rotates with the texture. rot_rect := draw.Rectangle{COL3, ROW2_Y, ITEM_SIZE, ITEM_SIZE} draw.rectangle( base_layer, @@ -214,6 +218,8 @@ textures :: proc() { uv_rect = {0, 0, 1, 1}, sampler = .Nearest_Clamp, }, + outline_color = draw.WHITE, + outline_width = 2, origin = draw.center_of(rot_rect), rotation = spin_angle, ) @@ -282,7 +288,7 @@ textures :: proc() { color = draw.WHITE, ) - // Per-corner radii + // Per-corner radii + outline traces the asymmetric corner shape. draw.rectangle( base_layer, {COL4, ROW3_Y, FIT_SIZE, FIT_SIZE}, @@ -292,6 +298,8 @@ textures :: proc() { uv_rect = {0, 0, 1, 1}, sampler = .Nearest_Clamp, }, + outline_color = draw.Color{255, 100, 100, 255}, + outline_width = 3, radii = {20, 0, 20, 0}, ) draw.text( @@ -321,12 +329,14 @@ textures :: proc() { sampler = .Nearest_Clamp, } - // Textured circle + // Textured circle + outline (textured shape with built-in border). draw.circle( base_layer, {SHAPE_COL1 + SHAPE_SIZE / 2, ROW4_Y + SHAPE_SIZE / 2}, SHAPE_SIZE / 2, checker_fill, + outline_color = draw.WHITE, + outline_width = 2, ) draw.text( base_layer, 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 ------------- // ---------------------------------------------------------------------------------------------------------------------