From a6f1c3701ac2b9aede37446ffe9933908dde677c Mon Sep 17 00:00:00 2001 From: Zachary Levy Date: Mon, 11 May 2026 16:26:09 -0700 Subject: [PATCH] Added full clay border support to draw --- .zed/tasks.json | 5 + draw/draw.odin | 412 +++++++++++++++++++++++++++++--- draw/examples/clay_borders.odin | 363 ++++++++++++++++++++++++++++ draw/examples/examples.odin | 4 + draw/examples/textures.odin | 20 +- 5 files changed, 760 insertions(+), 44 deletions(-) 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/draw.odin b/draw/draw.odin index 738ef2d..a5ffac6 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -171,6 +171,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. @@ -381,6 +382,7 @@ 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, @@ -482,6 +484,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) } // --------------------------------------------------------------------------------------------------------------------- @@ -812,6 +815,29 @@ Backdrop_Marker :: struct { 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 { @@ -822,6 +848,263 @@ is_clay_backdrop :: proc(cmd: ^clay.RenderCommand) -> bool { 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 @@ -864,30 +1147,61 @@ dispatch_clay_command :: proc( 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 + corner_radii_clay := render_data.cornerRadius radii := Rectangle_Radii { - top_left = cr.topLeft, - top_right = cr.topRight, - bottom_right = cr.bottomRight, - bottom_left = cr.bottomLeft, + 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 behind the image (Clay allows it) - bg := color_from_clay(render_data.backgroundColor) - if bg.a > 0 { - rectangle(layer, bounds, bg, radii = radii) + 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, + }, + ) + } } - - // 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 @@ -919,29 +1233,38 @@ dispatch_clay_command :: proc( 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) + corner_radii_clay := render_data.cornerRadius + background_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, + 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, color, radii = radii) + 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 - 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) + 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 @@ -1021,6 +1344,10 @@ flush_deferred_and_close_backdrop_scope :: proc( 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) @@ -1069,6 +1396,10 @@ prepare_clay_batch :: proc( // 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)) @@ -1098,6 +1429,9 @@ prepare_clay_batch :: proc( } 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) } } 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,